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>
)
}
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>
)
}
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])
}
Pair this with <Link scroll={false}> so the save/restore cycle is not fought by the router's own reset.
Verifying the fix
- Open your search page, scroll to the middle, and click a filter.
- The URL must update; the viewport must stay where it was.
- 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).
- 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
-
Next.js missing Suspense boundary around useSearchParams — the error you hit the moment you read
useSearchParamsin a statically rendered page. Read this first if your filter page throws instead of scrolling wrong. -
Next.js stale cache after revalidatePath not working — a different symptom on the same search page: filters update, results do not, because
revalidatePathdid not invalidate the tagged fetch. The scroll looks right but the data is wrong. - Next.js hydration mismatch fix — when a scroll restore fights with a hydration mismatch, the page visibly jumps twice. Fix the mismatch first, then the scroll.
-
Next.js
cookies()should be awaited — the same App Router contract change that breakscookies()also breaks any server-side read ofsearchParamsin 15 and 16. If your filter Server Component readssearchParamsand getsundefined, await it. - Fix barrel_optimize Build Warnings with MUI in Next.js
-
Next.js query string params:
searchParams+useRouter— the read side of the same API. Worth reading first if the filter values themselves are wrong, rather than the scroll position after they change. - Fix Next.js 'Cannot Have a Negative Time Stamp' Error
Originally published at https://www.iloveblogs.blog
Top comments (0)