skip to content

In a Next.js App Router app, a filter panel calls `router.push('?sort=price')` on every change and the page jumps back to the top each time. Why does that happen, and how do you keep the scroll position?

level: seniorimportance: should knowfreq 40%

answer

  1. a forward navigation resets scroll by default
  2. a query change is still a navigation
  3. there is an option on both APIs
  4. Link has the declarative twin
  5. history spam is the other half of the bug

basics

~20 s

The App Router scrolls to the top on a new navigation by default, and changing the query string counts as one. Pass the scroll option — router.push(url, { scroll: false }) — or use scroll={false} on a Link, to keep the position.

solid answer

~40 s

By default the App Router treats every forward navigation as a new page view and restores the scroll position to the top; back and forward navigations restore the previous position instead. A query-string change through `router.push` is a forward navigation, so a filter panel that rewrites `?sort=` on every interaction yanks the user to the top mid-list. Both navigation APIs take an opt-out: `router.push(url, { scroll: false })` and `router.replace(url, { scroll: false })`, and declaratively `<Link href={url} scroll={false}>`. For a filter or sort control I would also switch `push` to `replace`, otherwise every adjustment adds a history entry and the back button walks the user through a dozen intermediate filter states instead of returning to the previous page.

code

tsx · 18 lines
tsx
'use client';

import { usePathname, useRouter, useSearchParams } from 'next/navigation';

export function SortControl() {
  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();

  function apply(sort: string) {
    const params = new URLSearchParams(searchParams.toString());
    params.set('sort', sort);
    // replace: no extra history entry. scroll: false: stay where the user is.
    router.replace(`${pathname}?${params.toString()}`, { scroll: false });
  }

  return <button onClick={() => apply('price')}>Sort by price</button>;
}

go deeper

for a junior

Know that the App Router scrolls to the top on navigation by default and that { scroll: false } on router.push/router.replace, or scroll={false} on a <Link>, turns that off.

for a middle

Explain why a query-string change counts as a navigation and therefore triggers the reset, and contrast it with back/forward navigations, where the previous scroll position is restored instead.

for a senior

Treat it as a URL-as-state design problem: choose replace over push so history is not polluted, debounce writes so you are not re-rendering the route on every keystroke, and verify the container is not simply remounting.

for a principal

Own the convention for what belongs in the URL and who writes it. Decide which parameters are shareable state versus ephemeral UI, and make the push/replace and scroll rules explicit so every filter UI in the app behaves the same way.

## Why the jump happens When a user clicks a link to a genuinely different page, starting at the top is right — nobody wants to land halfway down an article they have not read. So the App Router's default for a forward navigation is to reset the scroll position to the top of the page, while browser back/forward navigations restore the position the user had there before. The router does not distinguish "a different page" from "the same page with a different query string". Both are navigations. So this innocent-looking handler produces a jump on every keystroke or click: ```tsx router.push(`${pathname}?sort=${value}`); // scrolls to top ``` In a long, filterable list that is a genuinely bad experience: the user scrolls to row 80, adjusts a facet, and is thrown back to row 1. ## The opt-out Both `push` and `replace` from the App Router's `useRouter` accept an options object with a `scroll` flag: ```tsx router.replace(`${pathname}?${params.toString()}`, { scroll: false }); ``` The declarative equivalent on a link is the `scroll` prop: ```tsx <Link href="/docs/api#responses" scroll={false}>Responses</Link> ``` `scroll: false` says "do not touch the scroll position", leaving the user exactly where they were while the content under them updates. ## push versus replace for URL-as-state The scroll jump is usually only half the bug. The other half is history pollution. Every `push` adds an entry, so a user who tries five filter combinations has to press Back five times to leave the page. For controls whose URL is *state* rather than *destination* — sort order, filters, pagination within a view, an open tab — `replace` is almost always the right verb: - **`push`** — the user made a navigational decision they may want to undo with Back. - **`replace`** — the URL is being kept in sync with UI state; Back should return to the previous page, not the previous filter. A reasonable middle ground on a paginated list is `replace` for filter tweaks and `push` for page changes, since page changes are the ones users do expect Back to step through. ## Debounce the writes A text filter that calls the router on every keystroke does more than churn history: each navigation re-renders the route on the server with the new query. Debounce the write so the URL is updated once the user pauses, and keep the input itself responsive from local state in the meantime. ## The purely client-side alternative When the query change drives nothing on the server — a UI-only tab index, a scroll anchor you want shareable — a router navigation is more machinery than the job needs. The App Router integrates with the native History API, so `window.history.replaceState` can update the URL and stay in sync with the router without triggering a server round trip. Reach for it only when you are sure no server-rendered content depends on that parameter; the moment it does, go back through the router so the server sees the change. ## Diagnosing it in the wild When a page "jumps to the top for no reason", check in this order: 1. Is something calling `router.push`/`replace` on interaction? That is the default scroll reset, not a CSS bug. 2. Is the component that owns the scroll container being unmounted and remounted by the navigation? Then `scroll: false` will not save you — the container is new and starts at zero. 3. Is there an anchor or a focus call moving the viewport instead? Only the first is fixed by the `scroll` option, which is why it is worth confirming before reaching for it.

  • You set scroll: false and the page still jumps. What else could be responsible?
    Most often the scroll container itself is being remounted — if the navigation unmounts and recreates the element that scrolls, it starts at position zero regardless of the router's setting. Also check for an anchor in the href, a programmatic `focus()` or `scrollIntoView()` running after the update, or a layout shift as new content replaces old.
  • Why prefer router.replace over router.push for filter controls?
    Because those URLs are UI state, not destinations. Every `push` adds a history entry, so five filter tweaks mean five Back presses before the user escapes the page. `replace` keeps the URL in sync with the controls while leaving Back pointing at the page the user actually came from.
  • When would you update the URL with the native History API instead of the router?
    When no server-rendered content depends on that parameter — a UI-only tab index, a shareable position marker. The App Router stays in sync with native `history.replaceState`, so you get a shareable URL without a server round trip. As soon as a Server Component reads that value, go back through the router so the server actually sees the change.

saying these in an interview costs you the question

  • Blames CSS or a scroll library for the jump
  • Does not know push and replace take a scroll option
  • Uses push for every filter change, flooding history
  • Fires a navigation on every keystroke with no debounce
  • Thinks scroll: false also prevents the server re-render

context