This page is part of the Framework-Specific Crash Recovery & Error Handlers section, which covers how modern frameworks intercept failures at the boundary layer before they cascade into blank screens.
The problem: routing failures that silently corrupt the page #
When a Next.js App Router segment throws during RSC rendering, or when a Nuxt 3 route loader rejects with a non-Error value, the failure surface is not the component tree — it is the navigation layer itself. The user-visible consequence is a blank or frozen page rather than a scoped error fallback, because the framework has already committed to rendering a route that cannot complete. Server-side rendering amplifies this: a single uncaught exception in a data loader can corrupt the streamed HTML for all concurrent users on that route, not just the one who triggered the bad request.
Routing-level error boundaries differ from React Error Boundary Implementation in one critical respect: they must interoperate with the server rendering lifecycle. A client-side boundary catches synchronous throws during commit; a routing boundary must also handle async data-loader rejections, middleware aborts, and partial-render streaming interruptions before any React commit happens at all.
Prerequisites #
Architecture: how routing error boundaries sit in the render pipeline #
The diagram below shows where each boundary type intercepts in the server → client pipeline for both frameworks.
Core implementation: Next.js App Router #
Next.js uses file-based boundary conventions. error.tsx handles segment-level failures, global-error.tsx catches root-level crashes outside the root layout, and not-found.tsx activates when notFound() is explicitly called. All three must be Client Components — RSC rendering has already halted before they mount.
The following single file covers the production-hardened happy path: telemetry dispatch off the main thread, session state serialisation before the user sees the fallback, and a guarded reset() call that preserves the parent layout.
// app/dashboard/[id]/error.tsx
'use client';
import { useEffect, useRef } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { captureException } from '@/lib/telemetry'; // replace with your SDK
interface RoutingError extends Error {
digest?: string; // Next.js server-error fingerprint
}
interface ErrorProps {
error: RoutingError;
reset: () => void;
}
export default function SegmentErrorPage({ error, reset }: ErrorProps) {
const router = useRouter();
const pathname = usePathname();
const telemetrySent = useRef(false);
useEffect(() => {
// Serialise any in-flight form state before the fallback UI paints
try {
const draft = document.querySelector<HTMLFormElement>('[data-draft-key]');
if (draft) {
const key = draft.dataset.draftKey!;
sessionStorage.setItem(`draft:${key}`, JSON.stringify(
Object.fromEntries(new FormData(draft))
));
}
} catch {
// sessionStorage unavailable (private mode, storage quota)
}
// Dispatch telemetry off the main thread once per error instance
if (!telemetrySent.current) {
telemetrySent.current = true;
const payload = {
type: 'routing_segment_error' as const,
path: pathname,
digest: error.digest, // correlates server log to client event
message: error.message,
timestamp: Date.now(),
};
if ('requestIdleCallback' in window) {
requestIdleCallback(() => captureException(payload));
} else {
// Fallback: sendBeacon is non-blocking even without rIC
navigator.sendBeacon('/api/telemetry', JSON.stringify(payload));
}
}
}, [error, pathname]);
const handleReset = () => {
// router.refresh() re-fetches server data without full navigation;
// reset() remounts the segment subtree after the refresh completes
router.refresh();
reset();
};
return (
<section
role="alert"
aria-live="polite"
aria-atomic="true"
className="routing-error-fallback"
>
<h1>This section failed to load</h1>
<p>
Your session is intact. Use <strong>Retry</strong> to reload just this
part of the page, or go back to the dashboard.
</p>
{error.digest && (
<p className="error-id">
Reference: <code>{error.digest}</code>
</p>
)}
<div className="error-actions">
<button onClick={handleReset} type="button">
Retry
</button>
<button onClick={() => router.replace('/dashboard')} type="button">
Back to dashboard
</button>
</div>
</section>
);
}
Why this works #
error.tsx activates as a React subtree replacement inside the nearest layout boundary, so sibling segments and the shell layout continue rendering normally. The digest field is a deterministic hash Next.js computes server-side — it appears in both the client-side error event and the server logs, making cross-layer correlation instant without exposing raw stack traces to the browser. Calling router.refresh() before reset() forces the server cache to re-evaluate, preventing the segment from immediately re-throwing with stale data.
Errors thrown at the top level of a Server Component before any await — including inside generateStaticParams — bypass error.tsx entirely. Use notFound() or redirect() in those paths, and let not-found.tsx handle them. This is covered in depth on the Next.js 14 app router error.tsx vs not-found.tsx strategies deep-dive page.
Edge cases and failure modes #
| Failure mode | Symptom | Mitigation |
|---|---|---|
| Streaming SSR interruption | Partial layout renders; siblings collapse | Wrap every streaming segment in <Suspense fallback={<Skeleton/>}> — streaming errors trigger Suspense fallback, not error.tsx |
| Middleware abort with invalid rewrite target | Server returns 500 instead of 404 | Validate rewrite patterns inside middleware before NextResponse.rewrite(); catch invalid targets and return NextResponse.next() with a query flag |
generateStaticParams throws |
Build fails or 500 at runtime, bypasses error.tsx |
Wrap inside try/catch, return empty array and log; rely on ISR to regenerate |
| Hydration mismatch triggers reset loop | error.tsx remounts repeatedly |
Add suppressHydrationWarning to elements with intentional server/client divergence; wrap client-only nodes in <Suspense> |
Parallel route (@folder) error isolation |
One slot crashes the whole layout | Add a separate error.tsx inside each @folder directory |
global-error.tsx missing <html> wrapper |
Blank white screen on root crash | global-error.tsx replaces the full document — it must render its own <html> and <body> tags |
Advanced variant: parallel-route isolation with SegmentErrorBoundary #
When using @modal or @sidebar parallel routes, each slot needs its own boundary so one failing slot does not collapse the default slot. Next.js file conventions handle this automatically if you place error.tsx inside the slot directory. For composable reuse across multiple slots, a class-based Client Component boundary prevents the re-render overhead of hooks:
// components/SegmentErrorBoundary.tsx
'use client';
import {
Component,
type ErrorInfo,
type PropsWithChildren,
type ReactNode,
} from 'react';
import { captureException } from '@/lib/telemetry';
interface Props extends PropsWithChildren {
fallback: ReactNode;
slotName: string; // identifies which parallel route failed in telemetry
maxRetries?: number;
}
interface State {
errorCount: number;
lastError: Error | null;
}
export class SegmentErrorBoundary extends Component<Props, State> {
static defaultProps = { maxRetries: 2 };
state: State = { errorCount: 0, lastError: null };
static getDerivedStateFromError(error: Error): Partial<State> {
return { lastError: error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
const { slotName } = this.props;
// Increment before telemetry so the count is accurate if the
// telemetry call itself throws
this.setState((prev) => ({ errorCount: prev.errorCount + 1 }));
captureException({
type: 'parallel_slot_error',
slot: slotName,
message: error.message,
componentStack: info.componentStack ?? '',
});
}
handleRetry = () => {
this.setState({ lastError: null });
};
render() {
const { errorCount, lastError } = this.state;
const { maxRetries = 2, fallback, children } = this.props;
if (lastError && errorCount > maxRetries) {
// Hard fallback: exceeded retry budget, show static UI
return <>{fallback}</>;
}
if (lastError) {
return (
<button onClick={this.handleRetry} type="button">
Reload {this.props.slotName}
</button>
);
}
return <>{children}</>;
}
}
Nuxt 3: error.vue and the clearError() timing contract #
Nuxt 3 uses ~/error.vue as its global routing error page, activated whenever Nitro or the Vue renderer sets the application error state via createError() or an unhandled async rejection in useAsyncData. The Composition API useError() composable reads that state reactively; clearError() resets it and optionally redirects. This differs from the approach in Vue & Svelte Global Error Handlers, which covers app-level onErrorCaptured and Svelte’s svelte:boundary — those fire inside the component tree, whereas error.vue replaces the entire rendered document.
The critical constraint is the clearError() timing contract: calling it before telemetry has flushed silently drops the error context, because Nuxt will have already navigated away. The implementation below enforces flush-before-clear using a composable-backed queue:
// composables/useRouteErrorHandler.ts
import { ref } from 'vue';
import { useError, clearError } from '#app';
interface TelemetryEntry {
path: string;
statusCode: number;
message: string;
ts: number;
}
const queue = ref<TelemetryEntry[]>([]);
let flushInProgress = false;
async function flushQueue(): Promise<void> {
if (flushInProgress || queue.value.length === 0) return;
flushInProgress = true;
const batch = queue.value.splice(0);
try {
await fetch('/api/telemetry/routing', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ batch }),
keepalive: true, // survives page navigation
});
} finally {
flushInProgress = false;
}
}
export function useRouteErrorHandler() {
const error = useError();
const captureAndClear = async (redirectTo = '/') => {
if (!error.value) return;
queue.value.push({
path: window.location.pathname,
statusCode: error.value.statusCode ?? 0,
message: error.value.message ?? 'unknown',
ts: Date.now(),
});
// Flush first, navigate second — order is the contract
await flushQueue();
await clearError({ redirect: redirectTo });
};
return { error, captureAndClear };
}
<!-- error.vue (project root) -->
<script setup lang="ts">
import { onMounted } from 'vue';
import { useRouteErrorHandler } from '~/composables/useRouteErrorHandler';
import { useFormStateSync } from '~/composables/useFormStateSync';
const { error, captureAndClear } = useRouteErrorHandler();
const { restore } = useFormStateSync('checkout');
onMounted(() => {
// Restore any draft state that was serialised before the error
restore();
});
</script>
<template>
<main role="alert" aria-live="assertive">
<h1>
{{ error?.statusCode === 404 ? 'Page not found' : 'Something went wrong' }}
</h1>
<p>{{ error?.message ?? 'An error occurred during routing.' }}</p>
<p v-if="error?.statusCode">
Status: <code>{{ error.statusCode }}</code>
</p>
<nav>
<button type="button" @click="captureAndClear('/')">
Return home
</button>
<button
v-if="error?.statusCode !== 404"
type="button"
@click="captureAndClear()"
>
Try again
</button>
</nav>
</main>
</template>
The useFormStateSync composable that restore() comes from persists form drafts to sessionStorage via beforeunload. Because error.vue replaces the document rather than overlaying it, restoring state on onMounted gives the user a continuity signal even though the full page reloaded. For a deeper treatment of this pattern, see Draft Auto-Save Recovery Workflows.
Nuxt-specific edge cases #
| Failure mode | Symptom | Mitigation |
|---|---|---|
ssr: false component triggers hydration mismatch |
error.vue fires on first load, not a real route error |
Wrap client-only logic in <ClientOnly> or move to onMounted; never read window at module scope |
Infinite redirect loop in clearError() |
Browser shows “too many redirects” | Guard navigateTo() with a ref<number> retry counter; abort if retries >= 3 |
| Nitro plugin throws during initialisation | error.vue never mounts — white screen |
Add a Nitro error hook in server/plugins/*.ts to log and return a safe response before Vue attempts to render |
| Cross-origin iframe routing error | Iframe shows blank, host page unaffected | Add a postMessage listener in the host to catch { type: 'routeError' } events from the iframe |
useAsyncData with server: false throws on client |
Error lands in Vue’s onErrorCaptured, not Nuxt’s boundary |
Explicitly call useError() setter or throw createError(...) inside the data handler to route it to error.vue |
Testing and CI/CD validation #
Boundary code that is never exercised is dead code. The following Playwright snippet injects a 500 response into a specific segment, asserts the fallback activates within an acceptable latency window, and verifies state preservation on recovery:
// tests/routing-error-boundary.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Next.js segment error boundary', () => {
test('activates fallback within 500 ms and preserves form draft', async ({
page,
}) => {
// Pre-fill a form draft so we can verify it survives the error
await page.goto('/dashboard/new');
await page.fill('[name="title"]', 'Draft title');
// Confirm sessionStorage write (the page's beforeunload handler)
await page.evaluate(() =>
window.dispatchEvent(new Event('beforeunload'))
);
// Intercept the data route and force a 500
await page.route('**/api/dashboard/**', (route) =>
route.fulfill({ status: 500, body: 'Internal Server Error' })
);
const start = Date.now();
await page.goto('/dashboard/some-id');
// error.tsx should be visible promptly
const fallback = page.getByRole('alert');
await expect(fallback).toBeVisible({ timeout: 500 });
expect(Date.now() - start).toBeLessThan(500);
// The error digest reference code should be present
await expect(page.locator('.error-id')).toBeVisible();
// Unblock the route and click Retry
await page.unroute('**/api/dashboard/**');
await page.getByRole('button', { name: 'Retry' }).click();
// Confirm draft was restored after reset
const titleValue = await page
.locator('[name="title"]')
.inputValue();
expect(titleValue).toBe('Draft title');
});
});
Acceptance criteria for automated pipelines:
4xxrouting events must activate the fallback UI within 500 ms without layout shift in sibling segments.5xxrouting events must dispatch telemetry within 2 s and not block Time to Interactive.- Recovery (
reset()/clearError()) must restore serialised draft state and not trigger a second boundary activation. - Parallel route slots must isolate failures — one slot error must not crash the default slot.
Load testing: Simulate 500+ concurrent users hitting a broken route. Verify that sendBeacon / keepalive fetch telemetry payloads drop gracefully at saturation rather than queuing unboundedly, and monitor heap size across repeated boundary activation / clearError cycles to detect listener leaks.
Complement these tests with the fault-injection patterns described in Hydration Mismatch State Recovery to cover the SSR divergence cases that Playwright’s jsdom mode cannot simulate.
Deep-dives under this topic #
The scenario-specific pages below address the decision points that emerge once the boundary is wired up:
- Next.js 14 app router error.tsx vs not-found.tsx strategies — when each file activates, how
notFound()interacts with the layout boundary, and how to return the correct HTTP status from RSC without exposing stack traces.
FAQ #
How do I preserve form state when a Next.js routing error occurs?
Serialise form state to sessionStorage inside the useEffect in error.tsx, keyed by a data-draft-key attribute on the form element. On recovery, read and JSON.parse() the stored value, validate it with zod.safeParse() to guard against stale or tampered data, then rehydrate controlled inputs before calling reset(). Avoid full re-renders by updating state references directly via useSyncExternalStore for complex forms.
What is the difference between error.tsx and global-error.tsx in Next.js?
error.tsx operates at the segment level, inherits all ancestor layouts, and handles localised data-fetch failures or route validation errors. global-error.tsx runs outside the root layout — it activates only for unrecoverable root-level throws or middleware panics and must render its own <html> and <body> tags. Use error.tsx for the vast majority of production failures; global-error.tsx is a last-resort safety net.
How do I prevent infinite error loops in Nuxt’s error.vue?
Never call navigateTo() or clearError() synchronously inside setup(). Defer all navigation to onMounted, guard it with an isRecovering ref set to true before the call and back to false on completion, and track a retryCount ref that aborts navigation if it exceeds three attempts. The useRouteErrorHandler composable above implements this contract.
When does a Next.js routing error bypass error.tsx entirely?
Errors thrown synchronously in generateStaticParams, at the module level of a Server Component, or inside Middleware that does not catch its own exceptions bypass error.tsx. In those paths, use explicit notFound() or redirect() returns so the framework activates not-found.tsx or the redirect handler. Never let async data-fetching utilities throw to the RSC module scope — wrap them in try/catch and call notFound() in the catch block.
Should routing errors be tracked in frontend telemetry or backend logs?
Both. Frontend telemetry captures UX metrics, session context, and the client-side error.digest. Backend logs hold raw stack traces, Nitro/Next.js runtime diagnostics, and infrastructure context. Correlate them via the digest field or a shared traceId injected at request time. Scrub PII (emails, auth tokens, IP addresses) from frontend payloads before ingestion and use deduplication at the ingestion layer to avoid alert storms when many users hit the same broken route simultaneously.
Related #
- Framework-Specific Crash Recovery & Error Handlers — parent overview covering all framework boundary patterns
- React Error Boundary Implementation — client-side class and hook boundary patterns that underpin the App Router’s boundary model
- Vue & Svelte Global Error Handlers —
onErrorCaptured,app.config.errorHandler, and Svelte’ssvelte:boundaryfor in-tree error handling - Hydration Mismatch State Recovery — diagnosing and recovering from SSR/CSR divergence that triggers false boundary activations