79777989

Date: 2025-09-29 10:16:40
Score: 5.5
Natty:
Report link

I ran into the same scroll restoration issue in my Next.js project and spent quite a bit of time figuring out a reliable solution. Since I didn’t find a complete answer elsewhere, I want to share what worked for me. In this post, I’ll first explain the desired behavior, then the problem and why it happens, what I tried that didn’t work, and finally the custom hook I ended up using that solves it.


My setup


How to persist and restore scroll position in Next.js (Page Router) reliably?

Desired behavior

When navigating back and forward between pages in my Next.js app, I want the browser to remember and restore the last scroll position. Example:


The problem

By default, Next.js does not restore scroll positions in a predictable way when using the Page Router.


Important Note: Scroll behavior in Next.js also depends on how you navigate:

Using <Link> from next/link or router.push → Next.js manages the scroll behavior as part of SPA routing. Using a native <a> tag → this triggers a full page reload, so scroll restoration works differently (the browser’s default kicks in).

Make sure you’re consistent with navigation methods, otherwise scroll persistence may behave differently across pages.


Why this happens

The problem is caused by the timing of rendering vs. scrolling.


What I tried (but failed)

  1. experimental.scrollRestoration: true in next.config.js

Works in some cases, but not reliable for long or infinite scroll pages. Sometimes it restores too early → content isn’t rendered yet → wrong position.

  1. Double requestAnimationFrame
requestAnimationFrame(() => {
  requestAnimationFrame(() => {
    window.scrollTo(x, y);
  });
});

Works for simple pages but fails when coming back without scrolling on the new page (lands at bottom).

3.Using setTimeout before scrolling

setTimeout(() => window.scrollTo(x, y), 25);

Fixes some cases, but creates a visible "jump" (page opens at 0,0 then scrolls).


The solution that works reliably in my case

I ended up writing a custom scroll persistence hook. I placed this hook on a higher level in my default page layout so it's triggered once for all the pages in my application.

It saves the scroll position before navigation and restores it only when user navigates back/forth and the page content is tall enough.

import { useRouter } from 'next/router';
import { useEffect } from 'react';

let isPopState = false;

export const useScrollPersistence = () => {
  const router = useRouter();

  useEffect(() => {
    if (!('scrollRestoration' in history)) return;

    history.scrollRestoration = 'manual';

    const getScrollKey = (url: string) => `scroll-position:${url}`;

    const saveScrollPosition = (url: string) => {
      sessionStorage.setItem(
        getScrollKey(url),
        JSON.stringify({ x: window.scrollX, y: window.scrollY }),
      );
    };

    const restoreScrollPosition = (url: string) => {
      const savedPosition = sessionStorage.getItem(getScrollKey(url));

      if (!savedPosition) return;

      const { x, y } = JSON.parse(savedPosition);

      const tryScroll = () => {
        const documentHeight = document.body.scrollHeight;

        // Wait until content is tall enough to scroll
        if (documentHeight >= y + window.innerHeight) {
          window.scrollTo(x, y);
        } else {
          requestAnimationFrame(tryScroll);
        }
      };

      tryScroll();
    };

    const onPopState = () => {
      isPopState = true;
    };

    const onBeforeHistoryChange = () => {
      saveScrollPosition(router.asPath);
    };

    const onRouteChangeComplete = (url: string) => {
      if (!isPopState) return;

      restoreScrollPosition(url);
      isPopState = false;
    };

    window.addEventListener('popstate', onPopState);
    router.events.on('beforeHistoryChange', onBeforeHistoryChange);
    router.events.on('routeChangeComplete', onRouteChangeComplete);

    return () => {
      window.removeEventListener('popstate', onPopState);
      router.events.off('beforeHistoryChange', onBeforeHistoryChange);
      router.events.off('routeChangeComplete', onRouteChangeComplete);
    };
  }, [router]);
};


Final note

I hope this solution helps fellow developers who are facing the same scroll restoration issue in Next.js. It definitely solved a big headache for me. But still I was wondering if anyone found a more “official” or simpler way to do this with Page Router, or is this kind of approach still the best workaround until Next.js adds first-class support?

Reasons:
  • Blacklisted phrase (2): anyone found
  • Blacklisted phrase (2): was wondering
  • Whitelisted phrase (-1): worked for me
  • RegEx Blacklisted phrase (1): I want
  • Long answer (-1):
  • Has code block (-0.5):
  • Ends in question mark (2):
  • Low reputation (1):
Posted by: Recep May