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.

Routing error boundary interception points A flow diagram showing where error.tsx, global-error.tsx, not-found.tsx, and Nuxt error.vue each intercept failures in the server-to-client rendering pipeline. Next.js App Router Nuxt 3 / Nitro Route request → Middleware RSC data loader (async) Segment render (RSC → HTML) Client hydration middleware abort → global-error throw → error.tsx notFound()→ not-found.tsx mismatch→ error.tsx error.tsx not-found.tsx Route request → Nitro server useAsyncData / useFetch Vue SSR render (Nuxt payload) Client hydration Nitro err → error.vue throw → error.vue mismatch→ ClientOnly error.vue + clearError()

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:

  • 4xx routing events must activate the fallback UI within 500 ms without layout shift in sibling segments.
  • 5xx routing 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:


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.