Implementing Skip Links in Next.js App Router
Skip links are a foundational accessibility requirement that lets keyboard and screen reader users bypass repetitive navigation and jump straight to the content they came for. On a static page they are almost trivial, but inside the Next.js App Router they collide with client-side hydration, focus management, and the server/client component boundary. This guide builds a production-ready skip link that works before hydration, after hydration, and across every subsequent route change. It satisfies WCAG 2.4.1 Bypass Blocks, 2.1.1 Keyboard, and 2.4.3 Focus Order.
Why skip links are harder in the App Router
On a traditional multi-page site a skip link is simple: an anchor pointing at #main-content works because activating it moves the browser's focus and scroll position to that fragment natively. The App Router breaks this assumption in two ways. First, client-side navigation between routes never re-runs the browser's fragment handling, so the skip target's focus does not reset when the user moves to a new page. Second, the layout is assembled from a mix of streamed Server Component output and hydrated client islands, which means the skip link can momentarily exist in the DOM before its click handler has hydrated.
The result is a component that must behave correctly in three distinct states: before hydration (native anchor fallback), after hydration (managed focus), and across every subsequent client-side route change. Get any one of those wrong and you produce a skip link that passes a static audit but fails a real keyboard user mid-session.
Mapped WCAG 2.2 criteria
2.4.1 Bypass Blocks(Level A)2.1.1 Keyboard(Level A)2.4.3 Focus Order(Level A)
Prerequisites
Before implementing this pattern, confirm the following are in place:
- A Next.js project using the App Router (the
app/directory), version 13.4 or later. - A single, unique
<main id="main-content">landmark — ideally defined inapp/layout.tsxso it is shared across routes. Using the correct landmark element here matters; see semantic HTML vs ARIA in component trees for why the native<main>element beatsrole="main"on a<div>. - A global stylesheet imported in the root layout for the visually-hidden focus styles.
- A baseline understanding of the server/client boundary; the skip link straddles it deliberately, with the static markup on the server and the focus logic on the client.
If you are also managing focus globally on navigation, coordinate this skip link with that logic so the two do not fight over document.activeElement. The route-change focus pattern is covered in announcing client-side route changes in React.
DOM placement and Server Component constraints
The skip link must be the first interactive element in the document flow. Placing it inside <header> or after navigation menus violates WCAG 2.4.1 and forces keyboard users to tab through irrelevant links before reaching primary content.
Render the skip link directly in app/layout.tsx before the <main> element. Keep it as a Server Component to avoid hydration mismatch and minimize client-side JavaScript payload. Use a semantic <a> tag with href="#main-content" to ensure native anchor behavior when JavaScript is disabled.
// app/layout.tsx
import SkipLink from "@/components/SkipLink";
import "@/styles/globals.css";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{/* Must be first focusable element in DOM */}
<SkipLink />
<main id="main-content" tabIndex={-1}>
{children}
</main>
</body>
</html>
);
}
Testing note: Verify DOM order via the DevTools Elements panel. Ensure no <nav>, <header>, or interactive elements precede the skip link.
CSS for visual hiding and focus states
Do not use display: none or visibility: hidden. These properties remove elements from the accessibility tree and prevent screen readers and keyboard navigation from detecting the link. Use absolute positioning with a transform offset to hide the link visually while preserving its DOM presence.
/* styles/globals.css */
.skip-link {
position: absolute;
top: 0;
left: 0;
padding: 0.75rem 1.5rem;
background: #005fcc;
color: #ffffff;
font-weight: 600;
font-size: 1rem;
z-index: 9999;
transform: translateY(-100%);
transition: transform 0.2s ease-in-out;
border-radius: 0 0 0.25rem 0;
}
.skip-link:focus-visible {
transform: translateY(0);
outline: 3px solid #003d82;
outline-offset: 2px;
}
One refinement worth adding: respect motion preferences. The slide-in transition is decorative, and users who request reduced motion should see the link appear without animation. Wrap the transition in a guard so it only applies when motion is acceptable:
@media (prefers-reduced-motion: reduce) {
.skip-link {
transition: none;
}
}
Testing note: Test with the Tab key. Verify zero layout shift occurs when the link becomes visible, and confirm the link's contrast against its background meets WCAG AA (4.5:1 minimum for the text).
Focus management on route changes
Next.js App Router handles navigation client-side, which bypasses native browser hash-scroll and focus behaviors. You must programmatically move focus to the main content container after each route transition, or the skip link's promise — "you are now at the start of meaningful content" — silently breaks on every page after the first.
Create a client component that listens to usePathname changes. Target the #main-content container and apply tabIndex={-1} to allow programmatic focus without adding the container to the natural tab order.
// components/SkipLink.tsx
"use client";
import { useEffect } from "react";
import { usePathname } from "next/navigation";
export default function SkipLink() {
const pathname = usePathname();
useEffect(() => {
// Move focus to main content on client-side navigation
const mainContent = document.getElementById("main-content");
if (mainContent) {
mainContent.focus({ preventScroll: true });
}
}, [pathname]);
const handleSkip = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
const target = document.getElementById("main-content");
target?.focus({ preventScroll: true });
};
return (
<a href="#main-content" className="skip-link" onClick={handleSkip}>
Skip to main content
</a>
);
}
Two details make this robust. First, the onClick handler calls preventDefault and focuses the target manually rather than relying on the hash. This guarantees focus lands on <main> even though <main> is not natively focusable without its tabIndex={-1}. Second, the usePathname effect resets focus on every navigation, so the guarantee holds on the new page too, not just the first one the user loads. If you already run a global route-focus hook from the route-change announcer, drop the effect here to avoid double-focusing and let the dedicated announcer own the transition.
Integration with global layouts and multiple landmarks
Centralize the skip link in the root layout to guarantee consistency across all route segments. Avoid duplicate id="main-content" attributes in nested layouts or page components, as duplicate IDs break anchor targeting and focus management.
In apps with multiple landmarks per route — say a primary article and a complementary sidebar, or a page with its own in-content search — consider offering more than one skip target ("Skip to main content", "Skip to search"). Each target must have a unique id, and the links should appear in the same order as the regions they jump to so the focus order stays logical under WCAG 2.4.3. If any of those targets is an interactive widget rather than a static region, mind the keyboard navigation patterns for modals and overlays so a skip target does not drop the user inside a focus trap.
Testing note: Audit nested routes for duplicate id="main-content". Test with VoiceOver (macOS/iOS) and NVDA (Windows) to verify focus is not trapped after activating the link.
How to verify
Skip links fail quietly, so verify across the same three states they must support:
- Automated (axe): Run axe-core against the rendered route. It flags a missing or misplaced bypass mechanism and duplicate IDs, but it cannot confirm that focus actually moves — treat it as a floor, not proof.
- Keyboard (manual): Load the page, press
Tabonce, and confirm the skip link is the first thing focused. PressEnterand confirm the nextTablands inside<main>, not back at the top navigation. - Screen reader (manual): With NVDA or VoiceOver running, activate the skip link and confirm the reader begins announcing main content, then navigate to another route and confirm focus resets there too.
- No-JS (manual): Disable JavaScript in DevTools and reload. The native anchor must still move focus to
#main-content, proving the progressive-enhancement fallback works.
The keyboard check is the one worth locking into CI. The Playwright assertion below encodes it so it runs on every push; wire it into your pipeline alongside the rest of your end-to-end accessibility tests.
import { test, expect } from "@playwright/test";
test("skip link is first focusable and moves focus to main", async ({ page }) => {
await page.goto("/");
// Tab to the first focusable element
await page.keyboard.press("Tab");
const activeClass = await page.evaluate(() => document.activeElement?.className ?? "");
expect(activeClass).toContain("skip-link");
// Activate the skip link and verify focus lands on main content
await page.keyboard.press("Enter");
const activeId = await page.evaluate(() => document.activeElement?.id ?? "");
expect(activeId).toBe("main-content");
});
Common a11y mistakes
- Incorrect DOM placement: Nesting the skip link inside
<header>or after navigation menus, so keyboard users still tab through the nav first. - Accessibility-tree removal: Using
display: none,visibility: hidden, oropacity: 0instead of off-screen positioning, which hides the link from assistive technology entirely. - Missing
tabIndex={-1}: Forgetting to settabIndex={-1}on the target container, sofocus()silently does nothing and focus never moves. - Hash-only fallback: Relying solely on
href="#main-content"without handling client-side transitions, so the link works on first load but goes dead after any in-app navigation. - Over-engineering: Wrapping the component in unnecessary state managers or context providers, increasing hydration overhead for a static link.
- Duplicated focus logic: Running both a skip-link effect and a global route-focus hook, so
<main>is focused twice and the screen reader announcement stutters.
Conclusion
A correct App Router skip link is small but unforgiving: it must be first in the DOM, hidden without leaving the accessibility tree, focus its target manually because <main> is not natively focusable, and survive client-side route changes. Define it once in the root layout, keep the static markup server-rendered and the focus logic client-side, and lock the behavior in with a Playwright keyboard assertion. Done this way, every keyboard and screen reader user can bypass your navigation on every page, not just the first one they load.
Frequently Asked Questions
Do I need a client component for skip links in the App Router?
Only if you are handling dynamic focus management on route changes. The visual link itself can and should be a Server Component to minimize client-side JavaScript; the usePathname focus logic is the only part that needs "use client".
Why does my skip link cause a hydration error? It is usually caused by a mismatched DOM structure between the server render and client hydration. Keep the link outside conditional rendering and avoid wrapping it in client-only providers so the server and client trees match.
How do I test skip links without a screen reader?
Use keyboard-only navigation (Tab then Enter), the DevTools accessibility inspector to confirm the computed role is link, and an automated tool like axe-core to verify DOM order and duplicate IDs. Automation is a floor, not proof — still run one manual keyboard pass.
Should the skip link still work with JavaScript disabled?
Yes. The native href="#main-content" anchor provides a progressive-enhancement fallback that moves focus without any client JavaScript. Test it by disabling JS in DevTools and confirming the anchor still targets <main>.
Can I have more than one skip link per page?
Yes, when a route has multiple meaningful landmarks. Give each target a unique id and order the links to match the regions they jump to, preserving a logical focus order under WCAG 2.4.3.