How to Fix: Scroll restoration issue.

5 min read

The scroll position breaks because the browser, the framework, and the page transition lifecycle are all trying to control restoration at the same time. On mobile, that conflict becomes much more visible when navigating from a deeply scrolled landing page section to a detail page and then going back.

Understanding the Root Cause

This issue typically appears when a user opens a page from a long, scroll-heavy route such as a marketing homepage, then navigates back and expects the previous position to be restored. In Next.js, scroll behavior depends on how navigation happens, whether the route is rendered through the App Router or Pages Router, and whether the browser’s own history.scrollRestoration behavior is competing with the framework.

On mobile devices, the problem is amplified by dynamic viewport changes, delayed content hydration, image loading, and layout shifts. If the original page height changes before restoration completes, the browser may restore to the wrong offset or reset to the top. This often happens when:

  • Client-side navigation pushes a new history entry before the previous page state is fully stabilized.
  • The browser attempts native scroll restoration while Next.js also applies its own scroll logic.
  • Content such as images, cards, or showcase sections changes layout after hydration.
  • A list page is reconstructed after back navigation, but the DOM height is temporarily different from the original render.

In short, the root cause is not just scrolling itself. It is the combination of history state, asynchronous rendering, and layout reflow during route transitions.

Step-by-Step Solution

The most reliable fix is to take explicit control of restoration for the affected route. That means saving scroll position before navigation, restoring it after back navigation, and disabling conflicting native behavior when needed.

1. Enable manual scroll restoration

If the browser restores scroll too early, switch to manual mode in a client component loaded near the root of your application.

'use client'

import { useEffect } from 'react'

export function ScrollRestorationMode() {
  useEffect(() => {
    if ('scrollRestoration' in window.history) {
      window.history.scrollRestoration = 'manual'
    }

    return () => {
      if ('scrollRestoration' in window.history) {
        window.history.scrollRestoration = 'auto'
      }
    }
  }, [])

  return null
}

Mount it in your top-level layout:

import { ScrollRestorationMode } from './ScrollRestorationMode'

export default function RootLayout({ children }) {
  return (
    <>
      <ScrollRestorationMode />
      {children}
    </>
  )
}

2. Save the current scroll position before leaving the page

For long pages such as a showcase listing, persist the Y offset in sessionStorage.

'use client'

import { useEffect } from 'react'

const STORAGE_KEY = 'showcase-scroll-y'

export function SaveScrollOnLeave() {
  useEffect(() => {
    const save = () => {
      sessionStorage.setItem(STORAGE_KEY, String(window.scrollY))
    }

    window.addEventListener('pagehide', save)
    window.addEventListener('beforeunload', save)

    return () => {
      window.removeEventListener('pagehide', save)
      window.removeEventListener('beforeunload', save)
    }
  }, [])

  return null
}

3. Restore scroll after the original page is ready

The key is restoring only after the page content has rendered. Using requestAnimationFrame avoids restoring too early.

'use client'

import { useEffect } from 'react'

const STORAGE_KEY = 'showcase-scroll-y'

export function RestoreScrollOnMount() {
  useEffect(() => {
    const raw = sessionStorage.getItem(STORAGE_KEY)
    if (!raw) return

    const y = Number(raw)
    if (Number.isNaN(y)) return

    const restore = () => {
      requestAnimationFrame(() => {
        requestAnimationFrame(() => {
          window.scrollTo({ top: y, behavior: 'auto' })
        })
      })
    }

    restore()
  }, [])

  return null
}

4. Use the components on the affected page

import { SaveScrollOnLeave } from './SaveScrollOnLeave'
import { RestoreScrollOnMount } from './RestoreScrollOnMount'

export default function ShowcasePage() {
  return (
    <>
      <SaveScrollOnLeave />
      <RestoreScrollOnMount />
      <section>
        <h1>Showcase</h1>
        {/* long list of cards */}
      </section>
    </>
  )
}

5. Prevent unwanted scroll resets during navigation

If you are linking from the listing page to a detail page, make sure navigation does not force a top reset unless you want that behavior.

import Link from 'next/link'

export function ShowcaseCard({ slug, title }) {
  return (
    <Link href={`/showcase/${slug}`} scroll={false}>
      {title}
    </Link>
  )
}

The scroll={false} option can reduce framework-driven scroll jumps during route transitions, especially when combined with manual restoration.

6. Stabilize layout before restoring

If images or dynamic content shift the layout after mount, reserve space up front. For example, use properly sized media and avoid rendering placeholders with mismatched heights. If your implementation uses next/image, define dimensions or responsive sizing so the page height is predictable before restoration runs.

For framework context and navigation behavior, review the official Next.js documentation and the project source linked in the issue via the Next.js repository.

Common Edge Cases

  • Infinite lists: If items are fetched incrementally, restoration may happen before enough content exists. Delay restoration until the expected page section or item count is present.
  • Hydration mismatch: If server-rendered markup differs from client output, the page can re-render and invalidate the restored position.
  • Sticky headers: A restored Y offset may look incorrect if a mobile sticky header overlays content. In those cases, adjust the final scroll target.
  • Hash navigation: If the URL includes an anchor, native anchor scrolling can fight your restoration logic.
  • Modal-based routing: If the detail page opens as an overlay on some breakpoints and a full page on others, restoration logic must account for both flows.
  • Virtualized content: Libraries that mount only visible rows need item-based restoration rather than raw pixel restoration.

FAQ

Why does this bug appear more often on mobile than desktop?

Mobile browsers frequently change the viewport height as browser chrome expands or collapses. That changes the effective scroll offset and makes scroll restoration timing much more fragile.

Is scroll={false} enough to fix the issue by itself?

Usually no. It can prevent one source of scroll resetting, but it does not solve delayed rendering, layout shifts, or conflicts with native browser restoration.

Should I use browser auto restoration or manual restoration?

If your page is simple and static, browser auto restoration may be enough. If the route contains dynamic sections, client rendering, or large responsive content, manual restoration is typically more reliable.

For this specific issue, the durable fix is to treat scroll position as part of route state: save it explicitly, restore it after content is stable, and prevent the browser and framework from racing each other.

Leave a Reply

Your email address will not be published. Required fields are marked *