Next.js — Preserve Scroll History
A custom hook to always come back from where you started
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.
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.