Focus Management in the Next.js App Router: How We Handle the Route Change That Loses Focus, inert, and Native dialog
Table of Contents
About the Author

Ahmed Mahmoud
Author & Developer
Software engineer passionate about web development and user experience design.
Founder of Devya · eng-ahmed.com ↗Key takeaways
- Next.js injects a route announcer on every client-side navigation: a visually hidden aria-live region that reads the new document.title, falling back to the page's h1. It handles announcing; it does not handle focus.
- A client-side navigation in the Next.js App Router does not reset keyboard focus. When the activated link unmounts, document.activeElement falls back to the body element, so the user's next Tab press starts from the top of the page instead of the new content.
- The fix is a client component that watches usePathname() from next/navigation and calls .focus({ preventScroll: true }) on a container carrying tabIndex={-1}. tabIndex={-1} makes a non-interactive element focusable by script without adding it to the Tab order.
- inert is a global HTML attribute that removes a subtree from the Tab order, the accessibility tree, and pointer hit testing in one declaration. aria-hidden="true" removes it from the accessibility tree only and leaves every control inside it tabbable.
- The native dialog element traps focus and renders in the browser top layer only when it is opened imperatively with showModal(). Rendering a dialog with the open attribute in JSX produces a non-modal dialog with no focus trap and no backdrop.
We found this the way most teams find accessibility bugs: not with an audit tool, but by unplugging the mouse for an afternoon. Navigating our own dashboard with the keyboard alone, every link left the user stranded — the page had changed, and Tab went back to the site logo. Since the European Accessibility Act became applicable on 28 June 2025, that class of bug also stopped being purely a craft question for many of the products our clients ship.
What does the Next.js App Router already do for screen readers on navigation?
It announces the new page title, and that is all it does. Next.js renders a route announcer element into the DOM — a visually hidden region with aria-live — and writes the new page name into it on each client-side navigation. It reads document.title first and falls back to the text of the page's h1. A screen reader user therefore hears that something changed.
Two gaps are left for application code to close. The first is that the announcement is only as good as the title: a title resolved late by generateMetadata in a streamed segment can land after the announcer fires, so what gets read may be the previous page's name. A distinct, statically resolved title per route is the reliable version. The second gap is bigger, and it is focus.
Where should keyboard focus go after a client-side route change?
Focus belongs at the top of the new main content. A full browser navigation does this for free by resetting focus to the start of the document. A client-side navigation does not, because from the browser's point of view nothing navigated — React replaced some nodes. The link the user activated is one of those nodes, so focus falls to the body element and sequential navigation restarts at the top of the page, walking the entire header again on every click.
Our RouteFocus component is fifteen lines: it reads pathname from usePathname(), and inside a useEffect keyed on pathname it calls ref.current?.focus({ preventScroll: true }) on a div that carries tabIndex={-1}. A first-render guard skips the very first run.
Three details in those fifteen lines earn their place. Skipping the first render matters because on initial load the browser has already put focus at the document start, and stealing it again is noise. preventScroll: true matters because Next.js restores scroll position across navigations and an unguarded .focus() call scrolls the container into view and fights that restoration. And tabIndex={-1} is what makes a plain div focusable by script while staying out of the Tab sequence; without it, .focus() silently does nothing.
We wrap the layout's main region rather than the page, so the focus target still exists while a loading.tsx boundary is rendering: a skip link first, then the nav, then RouteFocus wrapping the main element.
The skip link solves the other half of the same problem, and it has two requirements: it must be the first focusable element in the DOM, and it must become visible when focused. A skip link pointing at #main only works if the target can receive focus — which is the tabIndex={-1} rule again.
How do we trap focus in a modal without a focus-trap library?
We use the native dialog element and open it with showModal(). HTMLDialogElement.showModal() puts the dialog in the browser's top layer, renders the ::backdrop pseudo-element, makes everything outside it inert, and closes on Escape by firing a cancel event. That is most of the feature set focus-trap packages were written to reimplement in JavaScript.
The trap comes from the method call, not from the markup. A dialog rendered declaratively with the open attribute is a non-modal dialog: no top layer, no backdrop, no focus trap. In React that means driving it from an effect: if (open && !el.open) el.showModal(); if (!open && el.open) el.close();
Two behaviours still need code. Initial focus goes to the first focusable descendant, which is usually a close button, so we put autoFocus on the element we actually want. And focus is not restored on close: we capture document.activeElement before calling showModal() and call .focus() on it after close().
Which dialog approach traps focus?
- dialog opened with showModal(): traps focus in the browser, Escape fires cancel, renders in the top layer with ::backdrop.
- dialog with the open attribute in JSX: no focus trap, no Escape handling, no top layer.
- The popover attribute: no focus trap by design, Escape and light dismiss work, renders in the top layer.
- A div with role="dialog": traps only what you write, closes only on handlers you write, and lives in z-index rather than the top layer.
The popover attribute is the right choice for menus, tooltips and non-blocking panels precisely because it does not trap focus. Reaching for it to build a confirmation dialog is the mistake we now see most often, because both features shipped close enough together to feel interchangeable.
When should we use inert instead of aria-hidden?
Use inert when the user must not reach the content at all, and aria-hidden only when the content is visible but redundant for assistive technology. They are not interchangeable. aria-hidden="true" hides a subtree from the accessibility tree while leaving every button inside it tabbable, which produces the worst available state: focus lands on a control the screen reader will not describe.
inert is a single global HTML attribute that removes a subtree from the Tab order, the accessibility tree, and pointer hit testing at once. React 19 treats it as a real boolean prop, so inert={isDrawerOpen} compiles to the right thing; React 18 expected a string and warned on a boolean.
- inert: background content behind a custom drawer, off-screen carousel slides, a form section that is temporarily unavailable.
- aria-hidden: decorative icons sitting next to a visible text label, content duplicated purely for layout.
- display: none: removes the element from layout and from both trees; the correct default when content is genuinely not present.
With showModal() none of the three is needed, because the browser applies inertness to everything outside the top layer itself.
How do we announce async updates like search results or form errors?
Put the live region in the DOM before the content arrives, then change its text. A screen reader announces changes inside a region it was already observing, so mounting an aria-live element at the same moment the results arrive typically announces nothing at all. We keep a visually hidden paragraph with aria-live="polite" mounted permanently and swap only its text between "Searching" and the result count.
Pick the politeness level deliberately. role="status" maps to aria-live="polite" and waits for a pause in speech, which is correct for result counts, saved indicators and toasts. role="alert" maps to aria-live="assertive" and interrupts immediately, which is correct only for validation failures and errors the user must handle now.
For form errors we skip the live region when we can. Moving focus to the first invalid input announces the message through that input's own aria-describedby, and it puts the caret where the fix has to happen. A live region tells the user something is wrong; focus tells them where.
How do we keep keyboard accessibility from regressing in CI?
Automated rule engines catch the markup mistakes and none of the focus mistakes. @axe-core/playwright finds missing labels, contrast failures and invalid ARIA, but it cannot tell you that focus went to the body element after a route change, because that state is perfectly valid HTML. Focus needs explicit assertions.
We keep two Playwright tests: one clicks a nav link and asserts expect(page.locator('main')).toBeFocused(), and one opens a modal, presses Escape, and asserts the trigger button is focused again. Two tests, one per bug class, covering the two bugs keyboard users actually report. An axe scan over the main templates runs in the same suite for the markup half.
FAQ
Q: Does Next.js move focus on a route change automatically?
A: No. The App Router injects a route announcer that reads the new document title into an aria-live region, but it does not change document.activeElement. Moving focus is application code.
Q: Should tabIndex={-1} go on the h1 or on a wrapper element?
A: A wrapper around main is the more robust target, because it survives pages that have no h1 or that stream one in late. Both are announced; the wrapper cannot go missing.
Q: Is inert safe to ship in 2026?
A: Yes. inert is supported in current Chrome, Edge, Safari and Firefox and has been for several release cycles. React 19 accepts it as a boolean prop, while React 18 required a string value.
Q: Do we still need a focus-trap library?
A: Not for modal dialogs, because a dialog opened with showModal() traps focus in the browser. A library is still useful for non-dialog patterns such as an embedded wizard step that must contain focus without entering the top layer.
Q: Does aria-hidden stop an element from being focusable?
A: No, and that combination is a bug. aria-hidden="true" removes an element from the accessibility tree while leaving it in the Tab order, so a keyboard user can focus a control no screen reader will describe. Use inert instead.
None of this required a dependency. A fifteen-line client component, one HTML attribute, one native element and two Playwright assertions covered every keyboard bug we could find in an afternoon of not touching the mouse. The App Router gives you the announcement for free. The focus is still yours to move.
Further Reading
Frontend Engineering
Temporal in Production: Replacing JavaScript Date, Fixing the Same-Day Bug, and the RSC Boundary Nobody Warns You About
Temporal is the TC39 API that replaces JavaScript's Date object with immutable, time-zone-aware types. We migrated a Next.js application's date handling onto Temporal, and these are our field notes: which Temporal type maps to which field, why Temporal objects cannot be passed as Server Component props, and how to keep the Postgres round-trip honest.
Frontend Engineering
Parallel Routes and Intercepting Routes in the Next.js App Router: Field Notes on the Photo-Modal Pattern
We use parallel routes and intercepting routes to build shareable photo modals in the Next.js App Router — named @slot folders, the (.) interception convention, and the default.tsx file that decides what happens on a hard refresh.