Next.js — Preserve Scroll History

A custom hook to always come back from where you started

Jakob Chill
3 min readJun 21, 2021

While working on my first Next.js Project and testing it from a users perspective, I found it a little annoying, that when using the back button of the browser the user is always brought to the top of the page instead of the position they originally navigated from. After doing some research and not finding a satisfying solution I decided to create one myself.

Photo by Nick Tiemeyer on Unsplash

TLDR — The Result

The code neatly packed into a single hook that just has to be called once from your _app.tsx / _app.js:

A little more explanation

The premise

First of all, next/link does include an attribute called scroll which is set to true by default and makes it so, that the browser scrolls to the top when moving to a page via that link. It does not change the behavior on moving backwards to a page visited before.

Setup

const router = useRouter()const scrollPositions = useRef<{ [url: string]: number }>({})
const isBack = useRef(false)

useRouter() gives as access to the build-in router of Next.js

scrollPositions is an object, that will get entries with the visited URLs as keys and the corresponding scroll-position as value.

isBack is set to true, while a navigation via the browser-back-button (or similar means) is happening. More on that below.

Both scrollPositions and isBack are using the useRef-hook because they need to be preserved over rerenders.

useEffect

useEffect(() => {
...
}, [router])

We are using the React useEffect-hook, because the desired behavior is coupled to some events. The only external variable it needs access to is router so we put it in as a conditional. In practice router should not change, so the whole hook should never rerun after the app has loaded.

beforePopState

router.beforePopState(() => {
isBack.current = true
return true
})

The callback passed to router.beforePopState() is called when a popstate (e.g. using the browser back button) event is triggered. It has the potential to completely override the default behavior, but the only thing we are actually doing it setting isBack to true and returning true, so Next does handle the rest of the popstate.

onRouteChangeStart

const onRouteChangeStart = () => {
const url = router.pathname
scrollPositions.current[url] = window.scrollY }

A custom function to save the scroll position of the current page within the scrollPositions-object when navigating to another page.

onRouteChangeComplete

const onRouteChangeComplete = (url: any) => {
if (isBack.current) {
window.scroll({
top: scrollPositions.current[url],
behavior: "auto",
})
}
isBack.current = false
}

A custom function that checks on every navigation if we are currently in a popstate (and if there is a scroll-position for the target URL, which usually should be). If so, it does set the scroll-position of the target URL as the one saved. behavior: "auto" is needed so that the scroll is instantaneous and in effect not visible.

Setting up the event handlers

router.events.on("routeChangeStart", onRouteChangeStart)
router.events.on("routeChangeComplete", onRouteChangeComplete)
return () => {
router.events.off("routeChangeStart", onRouteChangeStart)
router.events.off("routeChangeComplete", onRouteChangeComplete)
}

The Next-router has it’s own event-subscription-system, which we can use to call our conveniently similar named functions.

As always with events in React we should cleanup our event-subscriptions on component removal. Since we call the hook inside our _app.tsx and the useEffect-hook is tied to the router-object we shouldn’t run into issues with leaking event-handlers, but as you know: better safe then sorry.

Finishing up

Put all that code into it’s own file, import it in your _app.tsx and call it within the App-Component with usePreserveScroll().

Final thoughts

There are two caveats I can think off at the moment: First of all it does only save the last scroll position for a specific page, so if someone is making rounds around your app, it might not work as expected. The other is, depending on your app the popstate-event might be triggered by something else then just the user clicking the back-button. The nice thing is, that if you are running into issues, you can easily comment out the hook-call and see if its the cause.

All in all I am quite pleased with the result. It’s not overly complicated and since it’s packaged into its own hook you can easily turn it off and on for debugging purposes.

--

--

Jakob Chill

Hi, I’m Jakob, a self-taught web-developer from Berlin. I write about tech, design and other stuff.