DEV Community

Cover image for Next.js Scroll Resets When searchParams Change
Mahdi BEN RHOUMA
Mahdi BEN RHOUMA

Posted on Originally published at iloveblogs.blog

Next.js Scroll Resets When searchParams Change

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.

Case 1 — you navigate with <Link>

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

import Link from 'next/link'

export function FilterChip({ label, href }: { label: string; href: string }) {
  return (
    <Link href={href} scroll={false}>
      {label}
    </Link>
  )
}
Enter fullscreen mode Exit fullscreen mode

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.

'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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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:

'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])
}
Enter fullscreen mode Exit fullscreen mode

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 Incidents


Originally published at https://www.iloveblogs.blog

Top comments (0)