Skip to content
All posts

3 min read

Scroll restoration in the App Router

Why links in a Next.js 16 App Router site landed mid-page, what scroll-padding had to do with it, and the small fix that made Back and Forward exact.

  • Next.js
  • React
  • Browser APIs

After shipping a gallery page on this site, two bug reports came in on the same day: clicking the logo dropped people into the middle of the Experience section, and opening the gallery landed them halfway down the photos instead of at the top. On a phone it was worse — almost every page change landed mid-page.

None of it reproduced on a fresh load. It only happened on client-side navigations, which pointed straight at how the Next.js App Router decides where to scroll.

What the App Router does after a navigation

When a route changes, the App Router renders the new segment and then runs a small scroll handler. Reading it in next/dist/client/components/layout-router.js, the logic is roughly:

  1. Find the top edge of the new page's content.
  2. If that edge is already inside the viewport, do nothing.
  3. Otherwise set scrollTop = 0, and if the content is still not visible, call scrollIntoView() on it.
Flow chart of the App Router's scroll decision: if the page top is visible it does nothing, otherwise it scrolls to the top and, if still hidden, calls scrollIntoView.
The decision Next.js makes after every client-side navigation.

The important detail is how "inside the viewport" is measured. The check doesn't start at 0 — it starts at the root element's scroll-padding-top:

layout-router.js (simplified)
js
const scrollPaddingTop = parseFloat(getComputedStyle(html).scrollPaddingTop);
const visible = elementTop >= scrollPaddingTop && elementTop <= viewportHeight;

The two settings that caused it

scroll-padding-top on html

The site has a sticky header (56px, plus a 40px section bar on phones). To stop anchor links like /#experience from hiding headings under it, the global CSS had:

globals.css (before)
css
html {
  scroll-padding-top: calc(var(--header-height) + var(--subnav-height) + 1rem);
}

That's 72px on desktop and 112px on phones. But every page's content starts directly under the 56px header — so its top edge (56px) was always less than the scroll padding. Next.js concluded the top wasn't visible, skipped the "do nothing" branch, and fell through to scrollIntoView() on the page fragment, which landed wherever that fragment's first box happened to be.

Smooth scrolling during route changes

The site also sets scroll-behavior: smooth on html. Next.js 16 changed how it treats that: it no longer turns smooth scrolling off while it scrolls a new route into place, unless you opt in. Without the opt-in, its jump to the top animates — and the follow-up measurement reads the page halfway through the animation.

The fix, part one: two lines of configuration

Move the header offset off the root element and onto the elements that anchors actually target:

globals.css (after)
css
[id] {
  scroll-margin-top: calc(var(--header-height) + var(--subnav-height) + 1rem);
}

Anchor links still stop below the header (scroll-margin-top is honoured by scrollIntoView and by fragment navigation), but the root's scroll padding is back to 0, so the App Router sees the real page top.

Then opt back into Next.js handling smooth scrolling, as the Next.js 16 upgrade guide describes:

app/layout.tsx
tsx
return (
  <html lang="en" data-scroll-behavior="smooth">
    <body>{children}</body>
  </html>
);

With just these two changes, every navigation to a different page started at the top.

The fix, part two: Back and Forward

Testing harder turned up two more cases:

  • Clicking a link to the page you're already on (the logo on the home page) did nothing, because there's no new segment to render, so the scroll handler never runs.
  • Back and Forward restored the wrong position. The App Router leaves history traversal to the browser, and the browser restores the old scroll offset immediately — before React has rendered the previous page. The offset gets applied to the wrong document and clamped.

Both are handled by one small client component in the root layout. It takes over scroll restoration, remembers the offset for each history entry, and only restores it once the right page is actually on screen:

components/scroll-manager.tsx
tsx
"use client";
 
import { usePathname } from "next/navigation";
import { useEffect, useLayoutEffect, useRef } from "react";
 
const currentKey = () => window.navigation?.currentEntry?.key ?? location.pathname + location.search + location.hash;
 
export function ScrollManager() {
  const pathname = usePathname();
  const renderedPath = useRef(pathname);
  const restoring = useRef(false);
 
  useLayoutEffect(() => {
    renderedPath.current = pathname;
  }, [pathname]);
 
  useEffect(() => {
    history.scrollRestoration = "manual";
    const positions: Record<string, number> = {};
 
    const save = () => {
      if (!restoring.current) positions[currentKey()] = scrollY;
    };
 
    const restore = () => {
      const target = positions[currentKey()] ?? 0;
      const deadline = performance.now() + 2500;
      restoring.current = true;
      const step = () => {
        const rendered = renderedPath.current === location.pathname;
        const room = document.documentElement.scrollHeight - innerHeight;
        if ((rendered && room >= target) || performance.now() > deadline) {
          scrollTo({ top: target, behavior: "instant" });
          restoring.current = false;
          return;
        }
        requestAnimationFrame(step);
      };
      requestAnimationFrame(step);
    };
 
    addEventListener("scroll", save, { passive: true });
    addEventListener("popstate", restore);
    return () => {
      removeEventListener("scroll", save);
      removeEventListener("popstate", restore);
    };
  }, []);
 
  return null;
}

The version running on this site adds three things on top: positions are kept in sessionStorage so a refresh keeps your place, same-page links (and "Back to top") smooth-scroll to the top without adding a history entry, and any forward navigation to a new page without a #hash is pinned to exactly y = 0.

Why the key matters

The first version keyed positions by URL. A test immediately broke it: visit /, open a project, click the logo back to /, then press Back twice. Both visits to / share a URL, so the second (at the top) overwrote the first.

Three history entries, two of them for the home page. Keyed by URL, the second overwrites the first; keyed by navigation entry, each keeps its own position.
Two entries can share a URL. They never share a Navigation API key.

The Navigation API (opens in a new tab) gives every history entry a unique navigation.currentEntry.key, which is exactly the identity a scroll position belongs to. Browsers without it fall back to the URL.

How it was tested

Bugs like this hide in sequences, not single clicks, so the check was a scripted run in a real browser: home ↔ gallery ↔ project pages, the logo, every nav and section link, "Back to top", and Back/Forward — with a random scroll before every step — at 1440, 1024, 768, 390 and 320 pixels wide. Each step asserts one rule:

ActionExpected
Link to a different pagelands at y = 0
Link to the current pagesmooth-scrolls to the top
Section link (/#work)heading sits just below the header
Back / Forwardexact previous position
Refreshkeeps the position

All 159 steps pass. The takeaway: scroll-padding-top on the root element isn't just an anchor-link nicety — in the App Router it also decides whether your page counts as "already visible".