Accessible Error & Loading Boundaries in Next.js
loading.tsx and error.tsx are two of the App Router's best features and two of its quietest. Each replaces part of the page without a navigation, so nothing is announced; each can swap out the element that had focus, so focus lands on <body>; and the default skeleton content is a grid of grey rectangles that a screen reader will happily read as a list of empty items.
None of that is inherent to the pattern. This guide covers making both boundaries perceivable, extending Next.js App Router Accessibility.
WCAG Success Criteria Addressed:
2.4.3 Focus Order3.3.1 Error Identification4.1.3 Status Messages1.1.1 Non-text Content
Prerequisites
- Next.js App Router with
loading.tsxanderror.tsxfiles in at least one route segment. - A shared status region for polite announcements — see announcing client-side route changes in React.
Hide the Skeleton, Announce the State
A skeleton is decoration standing in for content. Read aloud, it is a series of empty containers.
Implementation Guidelines:
- Mark the skeleton itself
aria-hidden="true". There is nothing in it worth announcing. - Put the meaning in a polite status region that names the section: "Loading orders", not "Loading".
- Only announce loading when the wait is perceptible — around 500 ms. Faster loads should produce one announcement (the result), not two.
- Give the skeleton the same dimensions as the content it replaces, so nothing shifts when the real content arrives. Layout shift is disorienting for everyone and disabling for magnifier users.
// app/orders/loading.tsx
export default function Loading() {
return (
<>
<div aria-hidden="true" className="skeleton-list">
{Array.from({ length: 6 }, (_, index) => (
<div key={index} className="skeleton-row" />
))}
</div>
{/* A11y rationale: 4.1.3 Status Messages — the skeleton is decoration, so
the meaning has to be carried by a status region instead. */}
<p role="status" className="visually-hidden">Loading orders</p>
</>
);
}
Do Not Move Focus When Content Arrives
Streaming replaces part of the page while the user may be reading or typing elsewhere. Moving focus to the new content is the most disruptive thing a boundary can do.
Implementation Guidelines:
- Leave focus alone when a suspense boundary resolves. Announce the arrival politely instead.
- If the boundary contained the focused element — a control inside the region being replaced — restore focus to a stable ancestor rather than letting it fall to
<body>. - Never focus the skeleton. It is about to be destroyed, and focus will fall to the document when it is.
- For a full-page navigation, focus belongs on the new page's heading — that is a route change, not a boundary resolution, and the two are handled differently.
Error Boundaries Need a Real Recovery Path
error.tsx receives a reset function, and the default template renders it as an unstyled button with no heading and no explanation.
Implementation Guidelines:
- Render a heading that names what failed, in the user's terms. It also gives the region a name in the headings rotor.
- Move focus to the error region or the retry button when the boundary mounts, so the failure is announced rather than sitting silently in a replaced section.
- Explain in plain language and offer a second route out — a link back to a working page — because retrying does not always help.
- Never render the raw error message or a stack trace. It is meaningless to most users and can leak internals; log it instead.
'use client';
// app/orders/error.tsx
import { useEffect, useRef } from 'react';
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
const headingRef = useRef<HTMLHeadingElement>(null);
useEffect(() => {
logToService(error); // the details go to logs, not to the user
// A11y rationale: 3.3.1 Error Identification — focusing the heading makes
// the failure audible in a region that was swapped in without navigation.
headingRef.current?.focus();
}, [error]);
return (
<section aria-labelledby="error-heading">
<h2 id="error-heading" ref={headingRef} tabIndex={-1}>
We could not load your orders
</h2>
<p>Something went wrong on our side. Your data is safe and nothing was lost.</p>
<button type="button" onClick={reset}>Try again</button>
<a href="/dashboard">Back to dashboard</a>
</section>
);
}
Scope Boundaries to Meaningful Regions
Where you put loading.tsx and error.tsx determines how much of the page disappears, and how disruptive the swap is.
Implementation Guidelines:
- Put boundaries around a region that makes sense on its own — a list, a panel — rather than the whole page, so navigation and headings survive the swap.
- Keep landmarks outside the boundary. A skeleton that replaces
<main>removes the landmark structure until content arrives. - Prefer several small boundaries over one large one: less content is replaced, less focus is at risk, and announcements can be specific.
- Give each boundary's status message a distinct name, so two concurrent loads do not both announce "Loading".
How to Verify
- Screen reader pass. Trigger a slow load. You should hear one message naming the section, then one naming the result — never a stream of empty list items.
- Focus test. Focus a control inside a region that is about to be replaced, then trigger the swap. Focus must not fall to
<body>. - Error path. Force a throw. The heading should be announced, the retry button reachable, and no stack trace visible.
- Layout-shift check. Compare skeleton and content dimensions; a jump means the placeholder is the wrong size.
- Automated scan. Run axe on both the loading and error states, not just the happy path — they are separate DOMs and separate scans.
- Trigger two boundaries at once. Concurrent loads should produce two distinguishable announcements, not two identical ones, so the user can tell which section is still pending.
- Retry after a failure. Pressing the retry button should restore the content and leave focus somewhere real; a second failure should re-announce rather than sitting silently on an already-rendered error panel.
Common Accessibility Mistakes
- Skeletons exposed to assistive technology. Read as a list of empty items, which is worse than silence.
- No announcement at all. The section changes twice — to skeleton, then to content — and nothing is said either time.
- Moving focus when a boundary resolves. Interrupts whatever the user was doing elsewhere on the page.
- The default
error.tsxtemplate. No heading, no explanation, no focus move, occasionally a stack trace. - A boundary that replaces the whole page. Landmarks, navigation and heading structure all vanish while loading.
- Identical "Loading" messages. Two concurrent boundaries produce two identical announcements and no information.
Conclusion
Both boundaries are silent DOM swaps, and everything that makes them perceivable is something you add: aria-hidden on the skeleton, a polite status naming the section, focus left alone on resolution and deliberately moved on failure, and error UI with a heading, an explanation and a way out. Scope each boundary to a region that makes sense on its own, and scan the loading and error states as separate pages — because that is exactly what they are.
There is a testing implication worth planning for: the loading and error states are separate DOMs that most test suites never render. Add a story or a test that forces each one — a deliberately delayed promise, a thrown error — so they are scanned, screenshotted and reviewed like any other view. States that only appear when something goes wrong are exactly the states that ship unverified, and they are also the ones a user meets on their worst day.
Frequently Asked Questions
Should the loading status use role="status" or aria-busy?
Both, for different purposes. aria-busy="true" on the region tells assistive technology that content is in flux and may suppress partial announcements; role="status" carries the human-readable message. aria-busy alone announces nothing.
Does loading.tsx need a status message if the load is fast?
No, and adding one makes fast loads worse: the user hears "Loading orders, 18 orders loaded" back to back. Delay the loading message by around 500 ms and cancel it if the content arrives first.
Where should focus go after reset() succeeds?
Nowhere by default — the content is restored and the user was already on the retry button, which no longer exists. Move focus to the region's heading if the button is removed, so focus does not fall to <body>.
Should error boundaries announce assertively? No. Moving focus to the heading announces it without hijacking the speech queue. Reserve assertive announcements for time-critical events such as a session expiring in ten seconds.
How does this interact with route-level announcements? They are separate events. A route change announces the new page; a boundary resolving announces a section within it. Use the same status region for both, but make the messages distinguishable so the user can tell a navigation from a partial update.
Related guides
- Next.js App Router Accessibility — the parent guide covering routing and focus.
- Announcing Client-Side Route Changes in React — the shared status region these boundaries use.
- Accessible Loading Skeletons & Spinners — the skeleton itself, including motion preferences.
- Preventing Hydration Mismatches That Break ARIA — the other silent failure mode in server-rendered React.