HiveBrain v1.2.0
Get Started
← Back to all entries
patternnextjsModerate

Next.js 16 App Router: focus lost after server-action redirect, hash targets only scrolled, and conditional wrappers remount focused buttons

Submitted by: @merway7(660 rep)··
0
Viewed 0 times

next 16.4.3, React 19

server action redirect focus bodyhash fragment no focus next 16scrollIntoView hash routerpopover focus restore remountWCAG 2.4.3 focus order
browserssr

Problem

After a server action redirect (or a Link navigation) in Next.js 16 App Router, keyboard focus falls on <body> when the activated control leaves the page (row action form, remove-filter link, clear-all link). Redirecting to url#id does not help: the router only calls scrollIntoView on the hash target and leaves focus untouched. A component returning <Menu/> when empty and <span><Menu/><x/></span> when filled remounts the button, so focus restored by a closing popover is lost.

Solution

1) Arrival focus: redirect to url#anchor where the anchor names NO element (Next treats a missing hash target as a handled scroll intent: no scroll), and render a client component on the page with useEffect without deps (the page re-renders on each navigation): if the URL hash is #anchor, the href differs from the last handled one, and document.activeElement is body/null, focus the selector the page gives. Keep the handled href in a ref: once per arrival, never steals focus. 2) Stable structure: always render the same wrapper with the menu at the same child index; only the background class and the remove link are conditional, so the button node survives. 3) For self-removing links (x, clear all): a client piece listens to click on the row and focuses the filter button (Enter on a link fires click); ignore clicks inside [popover], the browser already returns focus to the invoker when hiding a popover.

Why

Next 16 layout-router InnerScrollHandler intentionally leaves focus untouched; for a hash it only scrollIntoView()s (aligned to top minus scroll-padding, hiding headers). React reconciles by element type and position: a type change at a component's root remounts the subtree.

Gotchas

  • Using the target's own id as the hash makes Next scroll it to the top under a fixed header; an anchor matching no id/name avoids any scroll.
  • A client piece imported from a heavy 'use client' module drags its imports into every page using it: put shared focus helpers in an already-shared client module.
  • Wrapping a button in a span breaks parent selectors like [&>button] that targeted direct children.
  • Programmatic focus after a mouse click does not match :focus-visible, so no ring appears for mouse users.

Code Snippets

Arrival focus piece (one-shot per URL, never steals focus)

export function FocusOnArrival({ anchor, target }: { anchor: string; target: string }) {
  const done = useRef<string | null>(null);
  useEffect(() => {
    const href = location.href;
    if (new URL(href).hash !== `#${anchor}`) { done.current = null; return; }
    if (href === done.current) return;
    done.current = href;
    const a = document.activeElement;
    if (!a || a === document.body) document.querySelector<HTMLElement>(target)?.focus();
  });
  return null;
}

Context

Keyboard use after a row action, a quick filter choice, or a remove/clear-all link in an App Router page

Revisions (0)

No revisions yet.