How to Implement Soft Scroll Restoration and Preserve Scroll Position in Next.js App Router

How to Implement Soft Scroll Restoration and Preserve Scroll Position in Next.js App Router

by | Nov 13, 2026 | Uncategorized | 0 comments

If you have ever clicked a product, pressed the browser back button and landed at the very top of a 3000px listing page, you already know the problem. Next.js scroll restoration in the App Router is one of those features that looks like it works until your app grows a sticky header, a filter bar driven by search params, an infinite list or a modal route.

This guide is a hands-on walkthrough: why the scroll position breaks, how to store it, how to restore it before the browser paints, and how to reset it when it actually should be reset. All the code below is framework-native (no extra dependency) and tested against the App Router in Next.js 15 and Next.js 16.

TL;DR: the short answer

  • Set history.scrollRestoration = ‘manual’ so the browser stops fighting with React.
  • Save window.scrollY into sessionStorage, keyed by the full URL (pathname + search params), throttled with requestAnimationFrame.
  • Restore inside useLayoutEffect, not useEffect, so the jump happens before paint and the user never sees a flash.
  • Use <Link scroll={false}> (or router.push(url, { scroll: false })) for navigations where you take over, and scroll to top yourself on forward navigations.
  • For infinite lists, retry the restore with rAF until the document is tall enough.

Now the details, because the edge cases are where projects actually lose time.

scrolling web page laptop

Why scroll position breaks in the Next.js App Router

The default behavior

By default the App Router scrolls to the top of the page on every navigation to a new route, and it tries to restore the previous position on back/forward navigations using the History API. That sounds correct, but the implementation makes assumptions that break in real applications:

  1. Soft navigations stream in. With React Server Components, the new page can render in several passes. The router decides where to scroll before the final layout height exists, so the restore target is clamped to a document that is still short.
  2. Sticky and fixed elements confuse the heuristic. This is a documented Next.js issue (NEXT-1130): the router inspects the top element of the new page, finds a position: fixed or position: sticky node that is already in the viewport, and concludes that no scrolling is needed. Result: the page does not reset to the top.
  3. Search param updates are treated as navigations. Updating a filter with router.push or router.replace resets the scroll to the top unless you explicitly pass scroll: false (issue NEXT-1147).
  4. Cached vs fresh segments behave differently. When the Router Cache serves an instant render, the restore works. When the segment refetches, the restore runs against an empty shell.

The four breakages you will actually hit

Symptom Root cause Fix in this article
Back button lands at the top of a listing Document not tall enough when the router restores Manual restoration + rAF retry loop
Filter change jumps to the top Search param update counted as a new navigation scroll: false on push/replace
New page opens mid-page instead of at the top Sticky/fixed element heuristic Explicit reset on forward navigation
Closing a modal scrolls the page to the top body overflow lock without position capture Scroll lock helper that restores offset

Step 1: take control of native scroll restoration

The browser has its own restoration engine. Leaving it on auto while React is also moving the scroll produces a visible double jump. Switch it to manual once, as early as possible on the client.

'use client';

import { useEffect } from 'react';

export function useManualScrollRestoration() {
  useEffect(() => {
    if ('scrollRestoration' in window.history) {
      const previous = window.history.scrollRestoration;
      window.history.scrollRestoration = 'manual';
      return () => {
        window.history.scrollRestoration = previous;
      };
    }
  }, []);
}

Important: if your global CSS contains html { scroll-behavior: smooth; }, every programmatic restore will animate over several hundred milliseconds and look broken. Either scope smooth scrolling to anchor links only, or pass behavior: 'instant' when you restore.

scrolling web page laptop

Step 2: store the scroll position in sessionStorage

sessionStorage is the right store here: it is per-tab, it survives a hard reload, and it is cleared when the tab closes. localStorage would leak positions across tabs and sessions, which is exactly what you do not want.

// lib/scroll-store.ts
const PREFIX = 'nsr:'; // next scroll restoration

export function scrollKey(url: string) {
  return PREFIX + url;
}

export function saveScroll(url: string, y = window.scrollY) {
  try {
    sessionStorage.setItem(scrollKey(url), String(Math.round(y)));
  } catch {
    // quota exceeded or privacy mode: fail silently
  }
}

export function readScroll(url: string): number | null {
  try {
    const raw = sessionStorage.getItem(scrollKey(url));
    return raw === null ? null : Number(raw);
  } catch {
    return null;
  }
}

export function clearScroll(url: string) {
  try {
    sessionStorage.removeItem(scrollKey(url));
  } catch {}
}

Key design decision: include the search params in the key. /products?page=2&sort=price is a different scroll context from /products. If you key only on pathname, your users will be teleported to the position of a list they are no longer looking at.

Step 3: restore with useLayoutEffect

This is the part most tutorials get wrong. useEffect runs after the browser has painted, so the user sees the top of the page for one frame before the jump. useLayoutEffect runs synchronously after DOM mutations and before paint, so the restore is invisible.

Because useLayoutEffect warns during server rendering, wrap it:

// lib/use-isomorphic-layout-effect.ts
import { useEffect, useLayoutEffect } from 'react';

export const useIsomorphicLayoutEffect =
  typeof window !== 'undefined' ? useLayoutEffect : useEffect;

Now the restorer component. Mount it once in your root layout.

// components/scroll-restorer.tsx
'use client';

import { useEffect, useRef } from 'react';
import { usePathname, useSearchParams } from 'next/navigation';
import { useIsomorphicLayoutEffect } from '@/lib/use-isomorphic-layout-effect';
import { readScroll, saveScroll } from '@/lib/scroll-store';

export function ScrollRestorer() {
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const query = searchParams?.toString();
  const url = query ? pathname + '?' + query : pathname;

  const isPopNavigation = useRef(false);

  // Flag back/forward navigations
  useEffect(() => {
    const onPopState = () => {
      isPopNavigation.current = true;
    };
    window.addEventListener('popstate', onPopState);
    if ('scrollRestoration' in window.history) {
      window.history.scrollRestoration = 'manual';
    }
    return () => window.removeEventListener('popstate', onPopState);
  }, []);

  // Persist position while scrolling, throttled by frame
  useEffect(() => {
    let frame = 0;
    const onScroll = () => {
      if (frame) return;
      frame = requestAnimationFrame(() => {
        frame = 0;
        saveScroll(url);
      });
    };
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('pagehide', onScroll);
    return () => {
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('pagehide', onScroll);
      if (frame) cancelAnimationFrame(frame);
      saveScroll(url); // snapshot just before leaving this URL
    };
  }, [url]);

  // Restore (or reset) before paint
  useIsomorphicLayoutEffect(() => {
    const stored = readScroll(url);
    const shouldRestore = isPopNavigation.current && stored !== null;
    isPopNavigation.current = false;

    if (shouldRestore) {
      restoreWithRetry(stored as number);
    } else {
      window.scrollTo({ top: 0, left: 0, behavior: 'instant' as ScrollBehavior });
    }
  }, [url]);

  return null;
}

Because this component calls useSearchParams, it must sit inside a Suspense boundary, otherwise the whole route opts into client side rendering:

// app/layout.tsx
import { Suspense } from 'react';
import { ScrollRestorer } from '@/components/scroll-restorer';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang='en'>
      <body>
        <Suspense fallback={null}>
          <ScrollRestorer />
        </Suspense>
        {children}
      </body>
    </html>
  );
}

The retry loop that makes it reliable

On a streamed or paginated page, the target offset may not exist yet when the layout effect runs. Keep trying for a short window:

function restoreWithRetry(targetY: number, timeout = 2000) {
  const started = performance.now();

  const attempt = () => {
    const maxY = document.documentElement.scrollHeight - window.innerHeight;

    if (maxY >= targetY) {
      window.scrollTo({ top: targetY, behavior: 'instant' as ScrollBehavior });
      return;
    }
    if (performance.now() - started > timeout) {
      window.scrollTo({ top: Math.max(0, maxY), behavior: 'instant' as ScrollBehavior });
      return;
    }
    requestAnimationFrame(attempt);
  };

  attempt();
}

Two thousand milliseconds is a good default. Anything longer and a user who starts scrolling manually will be yanked back, so add an abort on the first user input:

  • Listen for wheel, touchstart and keydown with { once: true, passive: true }.
  • Set a cancel flag and stop the rAF loop.
scrolling web page laptop

Step 4: a custom Link handler for soft navigations

The unmount snapshot in the effect above covers most cases, but click time is the most accurate moment to capture the position, especially when a navigation is slow and the user keeps scrolling. Wrap next/link once and use it everywhere:

// components/soft-link.tsx
'use client';

import NextLink, { type LinkProps } from 'next/link';
import { usePathname, useSearchParams } from 'next/navigation';
import type { AnchorHTMLAttributes, MouseEvent, ReactNode } from 'react';
import { saveScroll } from '@/lib/scroll-store';

type SoftLinkProps = LinkProps &
  AnchorHTMLAttributes<HTMLAnchorElement> & { children: ReactNode };

export function SoftLink({ children, onClick, ...props }: SoftLinkProps) {
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const query = searchParams?.toString();
  const currentUrl = query ? pathname + '?' + query : pathname;

  const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
    saveScroll(currentUrl);
    onClick?.(event);
  };

  return (
    <NextLink {...props} scroll={false} onClick={handleClick}>
      {children}
    </NextLink>
  );
}

Note the scroll={false}: you are now the single source of truth for scroll behavior, so the router should not touch it. The same applies to imperative navigation:

const router = useRouter();

// keep the user where they are while a filter updates
router.replace(`/products?${params.toString()}`, { scroll: false });

// go to a detail page, your restorer will reset to top
saveScroll(currentUrl);
router.push(`/products/${id}`, { scroll: false });

Accessibility warning

When you disable the router scroll, you also disable the focus reset it performs. Move focus to the main landmark on forward navigations so keyboard and screen reader users are not stranded:

const main = document.querySelector('main');
if (main) {
  main.setAttribute('tabindex', '-1');
  main.focus({ preventScroll: true });
}

preventScroll: true is mandatory, otherwise focusing the element will scroll it into view and undo your restoration.

Step 5: edge cases that break naive implementations

Infinite lists and paginated feeds

Restoring an offset is useless if the items that created that offset were never rendered. On a 6-page infinite feed, going back means you need the first 6 pages again. Three viable strategies:

  1. Persist the page cursor in the URL. Write ?page=6 with router.replace(url, { scroll: false }) as the user scrolls, then refetch pages 1 to 6 on return. Best for SEO and shareable URLs.
  2. Cache the loaded items in sessionStorage or a client cache (TanStack Query, SWR with keepPreviousData) and rehydrate before restoring.
  3. Anchor based restore: store the id of the last visible item instead of a pixel value, then document.getElementById(id)?.scrollIntoView({ block: 'center' }). This survives layout shifts far better than a raw offset.
// anchor based restore, resilient to variable item heights
export function restoreToAnchor(itemId: string, fallbackY: number) {
  const el = document.getElementById(itemId);
  if (el) {
    const top = el.getBoundingClientRect().top + window.scrollY;
    window.scrollTo({ top: top - 120, behavior: 'instant' as ScrollBehavior });
    return true;
  }
  window.scrollTo({ top: fallbackY, behavior: 'instant' as ScrollBehavior });
  return false;
}

Also reserve space for images with explicit width/height or aspect-ratio. Cumulative Layout Shift is the number one cause of “the restore is off by 400 pixels”.

Modal routes and intercepting routes

With parallel and intercepting routes, opening a modal is a real navigation. Two rules:

  • Navigate to the modal with scroll={false} so the background page stays put.
  • Lock the body without the classic jump to top.
export function lockBodyScroll() {
  const y = window.scrollY;
  const { style } = document.body;
  const previous = { position: style.position, top: style.top, width: style.width };

  style.position = 'fixed';
  style.top = `-${y}px`;
  style.width = '100%';

  return () => {
    style.position = previous.position;
    style.top = previous.top;
    style.width = previous.width;
    window.scrollTo({ top: y, behavior: 'instant' as ScrollBehavior });
  };
}

Add html { scrollbar-gutter: stable; } to avoid the horizontal shift when the scrollbar disappears.

Finally, skip the restorer entirely for modal URLs. Detect them with a segment check and return early, otherwise closing the modal will trigger a reset to top.

Back and forward browser navigation

The popstate flag shown earlier is the simplest cross browser signal. If you target modern browsers only, the Navigation API gives you the direction explicitly:

if ('navigation' in window) {
  // @ts-expect-error experimental API
  window.navigation.addEventListener('navigate', (event: any) => {
    const isTraverse = event.navigationType === 'traverse';
    // isTraverse === true for back and forward
  });
}

Keep popstate as the fallback. Safari still lags on the Navigation API, and bfcache restores fire pageshow with event.persisted === true, which you should treat as “do nothing”, because the browser already restored everything.

Scrollable containers instead of the window

Dashboards often scroll an inner div, not the document. The same logic applies, you just swap the target:

'use client';

export function useContainerScrollRestoration(
  ref: React.RefObject<HTMLElement>,
  key: string
) {
  useIsomorphicLayoutEffect(() => {
    const el = ref.current;
    if (!el) return;

    const stored = readScroll('container:' + key);
    if (stored !== null) el.scrollTop = stored;

    let frame = 0;
    const onScroll = () => {
      if (frame) return;
      frame = requestAnimationFrame(() => {
        frame = 0;
        saveScroll('container:' + key, el.scrollTop);
      });
    };

    el.addEventListener('scroll', onScroll, { passive: true });
    return () => {
      el.removeEventListener('scroll', onScroll);
      if (frame) cancelAnimationFrame(frame);
    };
  }, [ref, key]);
}

Set overflow-anchor: none on containers where the browser’s scroll anchoring fights your restore.

Search params, filters and tabs

Every router.push or router.replace triggered by a filter must carry { scroll: false }. Decide deliberately whether a param change should keep or reset the position:

  • Keep position: sorting, view mode toggle, sidebar filters that change the list below the fold.
  • Reset position: changing pages in classic pagination, switching to a completely different tab panel.
scrolling web page laptop

Which approach should you pick?

Approach Effort Handles infinite lists Best for
Default App Router behavior None No Static sites, short pages, blogs
scroll={false} plus manual scrollTo Low Partially Filters and tab interfaces
sessionStorage + useLayoutEffect (this guide) Medium Yes, with the retry loop Ecommerce, feeds, catalogs
Anchor / item id restore Medium Yes, most accurate Variable height cards, masonry grids
Third party package Very low Depends Prototypes, internal tools

Packages such as next-restore-scroll-position exist, but most were written for the Pages Router. Before adding one, check the last release date and whether it ships a client component compatible with React Server Components. In our experience a 120 line hook you own beats an unmaintained dependency.

Debugging checklist

  1. Is html { scroll-behavior: smooth } set globally? Remove it or scope it.
  2. Is your restorer a client component with 'use client' and inside <Suspense>?
  3. Does your key include the search params?
  4. Are images and embeds reserving space (width/height or aspect-ratio)?
  5. Are you focusing an element without preventScroll: true?
  6. Does a position: sticky header sit as the first child of your page? Try wrapping it so it is not the first scrollable candidate.
  7. Check Dev Tools Performance: if the scroll jump happens after a layout shift, fix the shift first.
  8. Test in a real browser, not only the Next.js dev overlay. Fast Refresh re-mounts components and distorts the behavior.
scrolling web page laptop

Performance notes

  • Always use passive listeners for scroll, otherwise you block the compositor thread.
  • Throttle with requestAnimationFrame, never with a 10ms setInterval. One write per frame is plenty.
  • Prune old keys. A long session on a large catalog can accumulate hundreds of entries. Keep the last 30 and drop the rest on mount.
  • Guard every storage call in try/catch: Safari private mode and some embedded webviews throw on write.
export function pruneScrollKeys(max = 30) {
  try {
    const keys = Object.keys(sessionStorage).filter((k) => k.startsWith('nsr:'));
    if (keys.length <= max) return;
    keys.slice(0, keys.length - max).forEach((k) => sessionStorage.removeItem(k));
  } catch {}
}

FAQ

Does the Next.js App Router restore scroll position automatically?

Partially. It scrolls to the top on forward navigations and attempts a restore on back/forward. It fails when the destination page renders progressively, when sticky or fixed elements confuse the heuristic, or when the content below the fold is loaded client side. For listing pages and feeds you need the manual approach described above.

Is there still an experimental.scrollRestoration flag?

That flag belonged to the Pages Router. In the App Router there is no equivalent config switch: you control the behavior per navigation with the scroll option on <Link>, router.push and router.replace, plus your own restoration layer.

Why useLayoutEffect instead of useEffect?

useLayoutEffect runs synchronously after DOM mutations and before the browser paints, so the scroll jump is never visible. useEffect runs after paint, which produces a one frame flash at the top of the page. Wrap it in an isomorphic helper to avoid the server rendering warning.

sessionStorage or localStorage?

sessionStorage. It is scoped to the tab and cleared when the tab closes, which matches the lifetime of a browsing session. localStorage would restore a position from yesterday on a list that has completely changed.

How do I keep scroll position when updating search params?

Pass the scroll option: router.replace(`?${params}`, { scroll: false }). Without it the router treats the update as a navigation and resets to the top.

Does this work with streaming, Suspense and Partial Prerendering?

Yes, and that is exactly why the retry loop matters. With streamed segments the final document height arrives after the first paint, so a single scrollTo call will be clamped. The rAF loop waits until the page is tall enough or the timeout expires.

Will View Transitions solve this for me?

No. View transitions animate between two states, they do not decide where the scroll should land. You still need the save and restore logic, and you should run the restore before the transition starts to avoid animating a scroll jump.

Wrapping up

Reliable Next.js scroll restoration comes down to four decisions: take manual control from the browser, key your stored positions by full URL, restore before paint, and be explicit about when scroll should reset. Add the retry loop for streamed content and the anchor fallback for infinite lists and you cover roughly every real world scenario.

At Box Software we build and audit Next.js applications where these details decide whether a catalog converts or frustrates. If your App Router migration left you with broken navigation UX, get in touch and we will take a look at your routing layer.