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
pages/_app.tsx
and pages/_document.tsx
)<Link />
and router.push()
→ so the app runs as a SPA (single-page application).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)
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.
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?