← Back to Fixes

Next.js Scroll Resets When searchParams Change

Next.js App Router resets scroll position to top whenever searchParams update. Here is the root cause and the scroll-restoration fix that works.

What actually changed#

You built a search or filter page. The user scrolls down, clicks a filter chip, the URL updates from /search?q=react to /search?q=react&sort=recent, the results re-render — and the viewport snaps back to the top. The user has to scroll back down to find where they were.

This is not a hydration bug and not a cache bug. It is the App Router's scroll-restoration policy, which treats any URL change (including the query string) as a navigation worth resetting. The behavior has been reported since App Router shipped and is tracked as vercel/next.js#49087.

The fix#

The fix depends on how you trigger the navigation.

<Link> scrolls to the top of the new page by default. Opt out per-link:

tsx
import Link from 'next/link'
 
export function FilterChip({ label, href }: { label: string; href: string }) {
  return (
    <Link href={href} scroll={false}>
      {label}
    </Link>
  )
}

This is the documented, supported fix. Use it when the searchParams change re-renders results in place and the user's scroll position is still meaningful.

Case 2 — you navigate with router.push#

router.push does not auto-scroll. If you are seeing a jump with router.push, the cause is almost always a sibling <Link> or a <form> submission, not the push itself. Verify with a minimal reproduction before patching.

tsx
'use client'
 
import { useRouter, useSearchParams } from 'next/navigation'
 
export function SortControl() {
  const router = useRouter()
  const params = useSearchParams()
 
  return (
    <select
      value={params.get('sort') ?? 'recent'}
      onChange={(e) => {
        const next = new URLSearchParams(params)
        if (e.target.value === 'recent') next.delete('sort')
        else next.set('sort', e.target.value)
        // No scroll reset happens here — router.push does not scroll.
        router.push(`/search?${next.toString()}`)
      }}
    >
      <option value="recent">Recent</option>
      <option value="popular">Popular</option>
    </select>
  )
}

If you do want a reset with router.push, call window.scrollTo(0, 0) explicitly after — do not rely on implicit behavior.

Case 3 — you genuinely want scroll restoration after a filter change#

When the user clicks a filter that reorders the list they are reading, keeping them at the same pixel can be worse than resetting. But if you have a long sidebar and only the main column re-renders, restoration is correct:

tsx
'use client'
 
import { useEffect, useRef } from 'react'
import { useSearchParams } from 'next/navigation'
 
// Keep the viewport anchored when a filter change re-renders the list.
// Pair with <Link scroll={false}> so the router's own reset does not fight us.
export function useStableScroll() {
  const params = useSearchParams()
  const savedY = useRef(0)
 
  // Track scroll continuously, so we always have the latest position
  // before the navigation fires — not just the value at mount.
  useEffect(() => {
    const onScroll = () => { savedY.current = window.scrollY }
    window.addEventListener('scroll', onScroll, { passive: true })
    return () => window.removeEventListener('scroll', onScroll)
  }, [])
 
  // After the new searchParams render, restore. The 0-ms timeout lets
  // the new layout commit before we jump.
  useEffect(() => {
    const id = window.setTimeout(() => window.scrollTo(0, savedY.current), 0)
    return () => window.clearTimeout(id)
  }, [params])
}

Pair this with <Link scroll={false}> so the save/restore cycle is not fought by the router's own reset.

Verifying the fix#

  1. Open your search page, scroll to the middle, and click a filter.
  2. The URL must update; the viewport must stay where it was.
  3. Hard-reload the filtered URL — the page should mount at the top (restoration only applies to in-app navigation, not full reloads, which is the correct browser behavior).
  4. Tab through with the keyboard. Focus should not jump to the top either; if it does, an auto-focus on mount is competing with your restore.

If the page crashes with useSearchParams() should be wrapped in a suspense boundary, that is a separate fix — App Router requires a <Suspense> boundary around any component that reads useSearchParams during static rendering. The missing Suspense boundary fix walkthrough covers the exact boundary placement.

Related fixes & guides