AccessScanRun a free scan

Guide

Nuxt Accessibility: Focus, Semantics, and Accessible Components in a Vue SSR App

Nuxt gives you universal rendering, file-based routing, and hydration for free, but Nuxt accessibility is still entirely your responsibility. The server renders exactly the markup your components emit, then the client takes over for navigation. That handoff is where the hardest accessibility bugs live: a screen reader announces the first server-rendered page fine, then goes silent on every soft navigation after.

This guide is for Nuxt 3/4 developers shipping a real Vue SSR app. It covers the four things scanners rarely catch on their own: route-change focus management, semantic markup that survives hydration, setting the lang attribute correctly through Nuxt's head management, and building components that expose the right roles, names, and keyboard behavior. The baseline throughout is WCAG 2.2 Level A and AA, the same bar the European Accessibility Act enforces across the EU.

Why SSR changes the accessibility problem

With universal rendering, the first page load is real HTML, so the document language, headings, and landmarks exist before JavaScript runs. That is genuinely good for accessibility and SEO. The trap is everything after: once Nuxt hydrates and Vue Router takes over, clicking a <NuxtLink> swaps page content without a full document load. The browser's native behavior, moving focus to a fresh document and announcing the new title, never fires.

So a Nuxt app has two accessibility surfaces. The server-rendered surface is about correct markup. The client-rendered surface is about reproducing the browser behaviors that a single-page app silently removes. Treat them separately, because the bugs and the fixes are different.

Hydration adds a third concern: any markup that differs between server and client triggers a hydration mismatch. Conditionally rendering an aria-live region or a skip link only on the client (behind import.meta.client) is a common cause of warnings and, worse, regions that exist in the DOM but were never present when assistive tech first parsed the page.

Route-change focus management in Nuxt

When a sighted user clicks a link in a Nuxt app, the new view paints and they see it. A keyboard or screen-reader user gets nothing: focus stays on the link they just activated, which often no longer exists, so it falls back to the body. Their reading position, their place in the tab order, and any announcement of where they are, all gone. This is the single most common WCAG failure in Vue SSR apps and it maps to 2.4.3 Focus Order and, in spirit, 4.1.3 Status Messages.

The fix is a global route hook plus a deliberate focus target. Use the router's afterEach in a client plugin so it only runs after navigation completes on the client:

plugins/route-focus.client.ts: export default defineNuxtPlugin((nuxtApp) => { const router = useRouter(); router.afterEach((to, from) => { if (to.path === from.path) return; nextTick(() => { const target = document.querySelector('h1') as HTMLElement | null; if (target) { target.setAttribute('tabindex', '-1'); target.focus({ preventScroll: false }); } }); }); });

Focusing the new page's <h1> with tabindex="-1" puts the screen reader at the top of the new content and reads the heading, which tells the user the page changed and what it is. Use focus, not just an aria-live announcement, because focus also fixes keyboard tab order. An aria-live region announcing the route name is a useful supplement, never a replacement.

  • Skip the focus move when only the hash or query changed (in-page anchors), or you will yank focus during pagination and filtering.
  • Wrap the focus call in nextTick (or onMounted in a layout) so the new DOM exists before you target it; SSR pages hydrate asynchronously.
  • Add a visible :focus-visible style to your h1 so the focus ring is not a surprise to mouse users; meeting 2.4.7 Focus Visible is non-negotiable at AA.
  • Respect prefers-reduced-motion if you animate page transitions, and never let a transition delay the focus move past about a second.

If you use Nuxt page transitions, the <NuxtPage> wrapper delays the swap. Move the focus logic into the transition's onAfterEnter hook, or the focus will land on the outgoing page. For the underlying patterns, see the deeper dive on keyboard accessibility.

Semantic markup that survives hydration

Vue templates make it trivially easy to build a page out of <div> and <span> with click handlers. Nuxt will render that to the server happily, and it will look identical to a real button until someone tries to reach it with a keyboard. Semantic HTML is the cheapest accessibility win you have, and because Nuxt renders your markup verbatim, getting it right server-side means it is correct before a single line of JS executes.

  • One <h1> per page, with headings nested in order. Nuxt's layout/page split makes it easy to accidentally render two h1s (one in the layout, one in the page) or skip from h2 to h4. Audit the rendered outline, not the component tree.
  • Wrap regions in real landmarks: <header>, <nav>, <main>, <footer>. Put exactly one <main> in your default layout, not in every page, or you ship duplicate main landmarks.
  • Use <button> for actions and <NuxtLink>/<a> for navigation. A <div @click> is invisible to keyboards and screen readers; native elements give you focus, Enter/Space, and a role for free.
  • Add a skip link as the first focusable element in your layout, server-rendered, pointing at #main. It must exist in the SSR output, not be injected on the client.

Because the markup is the same on server and client, a single correct template fixes both surfaces at once. That is the upside of SSR: get the semantics right and they are durable. Run a quick scan with the free AccessScan checker to catch missing landmarks and broken outlines before they reach users.

The lang attribute in a Nuxt app

WCAG 3.1.1 Language of Page (Level A) requires a valid lang on the <html> element so screen readers pick the correct pronunciation rules and voice. A French page read with an English voice is close to unusable. In Nuxt you do not edit a static index.html; the <html> tag is rendered by Nitro, so you set lang through head management.

Set it globally in nuxt.config.ts so it lands in the server-rendered HTML: app: { head: { htmlAttrs: { lang: 'en' } } }. This guarantees the attribute is present on first paint, before hydration, which is exactly when assistive tech reads the document language.

For per-page or per-locale languages, set it reactively with useHead inside a page or composable: useHead({ htmlAttrs: { lang: locale.value } }). If you use @nuxtjs/i18n, it manages htmlAttrs.lang for you and also emits hreflang alternates; verify it is actually writing the attribute by viewing source, not just the hydrated DOM.

  • Use valid BCP 47 codes: 'en', 'en-GB', 'de', 'fr'. A bare 'english' or an empty lang fails 3.1.1.
  • Mark inline language changes with lang on the element itself (<span lang="fr">), which satisfies 3.1.2 Language of Parts at AA.
  • Check the raw server response (curl or view-source), since useHead changes that only appear after hydration mean the first announcement used the wrong language.

Accessible components: roles, names, and keyboard support

Most custom Nuxt components, modals, dropdowns, tabs, toggles, that fail accessibility do so for the same three reasons: no accessible name, no exposed state, no keyboard support. Vue's reactivity makes the fix clean because you can bind ARIA attributes to the same state that drives the visuals.

An icon-only button needs a name. <button :aria-label="open ? 'Close menu' : 'Open menu'" :aria-expanded="open" @click="open = !open"> binds both the name and the state to the ref, so a screen reader always hears the current state. Never leave an icon button nameless; that is a 4.1.2 Name, Role, Value failure.

  • Modals/dialogs: render role="dialog" with aria-modal="true", move focus into the dialog on open, trap Tab inside it, return focus to the trigger on close, and close on Escape. See the guide on accessible modals and dialogs.
  • Disclosure widgets (accordions, menus): the trigger is a <button> with aria-expanded reflecting state and aria-controls pointing at the panel id.
  • Custom toggles and switches: use a real <input type="checkbox"> or role="switch" with aria-checked bound to your ref, and make Space toggle it.
  • Status updates (form success, cart count, toast): announce via an aria-live region that exists in the server-rendered DOM, satisfying 4.1.3 Status Messages at AA. Inject the region on the client and the first announcement is lost.
  • Hit targets: interactive controls should be at least 24x24 CSS pixels to meet 2.5.8 Target Size (Minimum), new in WCAG 2.2.

Reach for a headless, accessibility-first library (Reka UI, Headless UI for Vue, or Nuxt UI built on top of them) before hand-rolling a combobox or dialog. They ship the focus management and ARIA wiring that take days to get right.

Testing and the compliance picture

Automated tools miss the failures that hurt most in an SSR app: route-change focus and a wrong language voice are exactly the kind of thing they cannot see. Combine both approaches: wire @axe-core/vue or run axe in Playwright/Vitest, then keyboard-test the SPA flows by hand, tabbing through a few route changes and confirming focus lands on the new h1 each time.

  • Run axe against the hydrated client and the raw SSR HTML; the two can differ and bugs hide in the gap.
  • Tab through the whole app with no mouse: every interactive element reachable, a visible focus ring, no traps, focus resets on navigation.
  • Test with a real screen reader (VoiceOver or NVDA) on at least one full task; nothing else surfaces silent route changes as reliably.
  • Scan key templates with AccessScan's free checker to catch missing names, broken outlines, and low-contrast text before release.

On compliance: the European Accessibility Act (Directive (EU) 2019/882) applies from 28 June 2025 and sets WCAG 2.2 Level A and AA via EN 301 549 as the baseline for covered products and services, including e-commerce, consumer banking, e-books and e-readers, electronic communications, and transport. National laws implement it, Germany's BFSG, France's RGAA, Italy's Legge Stanca, but the technical target is the same WCAG bar your Nuxt app should already meet. When you are ready to formalize it, publish an accessibility statement with the statement generator.

Check your site against AccessScan

See your issues ranked by impact in seconds — free.

Run a free accessibility scan

FAQ

How do I manage focus on route change in Nuxt?

Add a client plugin that registers router.afterEach, and inside it move focus to the new page's <h1> with tabindex="-1" wrapped in nextTick. Focus, rather than only an aria-live announcement, also fixes keyboard tab order. Skip the move when only the hash or query changed, and if you use page transitions, run the focus logic in the transition's onAfterEnter hook so it targets the page that actually rendered.

Does Nuxt set the lang attribute automatically?

No. The <html> tag is rendered by Nitro, so you set lang via head management. Use app.head.htmlAttrs.lang in nuxt.config.ts for a global default so it appears in the server HTML, or useHead({ htmlAttrs: { lang } }) for per-locale pages. The @nuxtjs/i18n module manages it for you. Always verify it in view-source, not just the hydrated DOM, since the first announcement uses the server output.

Is server-side rendering enough to make my Nuxt app accessible?

No. SSR helps with first-paint markup, correct language, headings, and landmarks before JavaScript runs, but it does nothing for client-side navigation. Soft navigations via <NuxtLink> remove the browser's native focus and announcement behavior, so you still have to manage focus and status messages yourself.

Does the European Accessibility Act apply to my Nuxt app?

If you sell or serve EU consumers in a covered sector such as e-commerce, consumer banking, e-books, electronic communications, or transport, the EAA applies from 28 June 2025, with WCAG 2.2 Level A and AA via EN 301 549 as the baseline. The framework you build with is irrelevant to regulators; what ships in the browser is what gets judged.

More guides