This page is part of the Next.js and Nuxt Routing Error Pages cluster, which sits inside the broader Framework-Specific Crash Recovery & Error Handlers section.

The exact problem #

A Next.js 14 App Router segment throws during a fetch call and renders the error.tsx fallback — but the response still carries HTTP 200, breaking search-engine signals and bypassing CDN caching rules meant for 404s. The inverse also occurs: a dynamic route with no matching database record calls notFound() yet developers mistakenly place only an error.tsx file in the segment directory, leaving the App Router with no not-found.tsx to render, so it falls back to the root app/not-found.tsx (or Next.js’s default) instead of the co-located one. Both mis-routings stem from conflating two distinct boundary types that the App Router deliberately keeps separate.

App Router error boundary routing: throw vs notFound() A flow diagram. A route segment box splits into two paths: an unhandled throw goes right to error.tsx (client boundary, HTTP 200), while a notFound() call goes right to not-found.tsx (server render, HTTP 404). Both paths show the file that handles each case and the HTTP status returned. Route Segment page.tsx / layout.tsx throw / unhandled rejection notFound() error.tsx 'use client' React Error Boundary HTTP 200 · reset() available not-found.tsx Server Component render HTTP 404 · robots: noindex Key: HTTP status is set by the boundary file, not the throwing code

Zero-to-working implementation #

The snippet below co-locates both files under app/(shop)/products/[slug]/. The page.tsx calls notFound() when the product is absent, and throws on a genuine data-layer failure — ensuring each boundary receives the right class of fault.

// app/(shop)/products/[slug]/page.tsx  (Server Component)
import { notFound } from 'next/navigation';
import { getProductBySlug } from '@/lib/db/products';

interface PageProps {
  params: { slug: string };
}

export default async function ProductPage({ params }: PageProps) {
  let product;

  try {
    product = await getProductBySlug(params.slug);
  } catch (err) {
    // Database / network failure → throw so error.tsx activates
    throw new Error('Product fetch failed', { cause: err });
  }

  // Resource truly absent → delegate to not-found.tsx (sets HTTP 404)
  if (!product) {
    notFound();
  }

  return <article>{/* render product */}</article>;
}
// app/(shop)/products/[slug]/error.tsx  (must be 'use client')
'use client';

import { useEffect } from 'react';

export default function ProductError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    // Forward structured payload to your observability platform
    // error.digest is Next.js's server-side hash — correlate with server logs
    console.error('[product-segment]', {
      digest: error.digest,
      message: error.message,
    });
  }, [error]);

  return (
    <div role="alert" aria-live="assertive">
      <h2>Unable to load product</h2>
      <p>A temporary error occurred. Your cart has not been affected.</p>
      {/* reset() re-invokes the server component, clearing the boundary */}
      <button type="button" onClick={() => reset()}>
        Try again
      </button>
    </div>
  );
}
// app/(shop)/products/[slug]/not-found.tsx  (Server Component — no 'use client')
import type { Metadata } from 'next';

// Next.js merges this with the segment's metadata; robots blocks indexing
export const metadata: Metadata = {
  title: 'Product Not Found',
  robots: { index: false, follow: true },
};

export default function ProductNotFound() {
  // Next.js automatically responds with HTTP 404 when this file renders
  return (
    <main>
      <h1>Product not found</h1>
      <p>
        This product may have been discontinued or the URL may have changed.
        Browse our <a href="/products">full catalogue</a>.
      </p>
    </main>
  );
}

Step-by-step walkthrough #

  1. page.tsx wraps the data call in try/catch. Any exception from the database or network layer reaches the catch block, which re-throws so Next.js can propagate it up to the nearest error.tsx. Do not call notFound() in the catch block — that would misclassify infrastructure failures as 404s.

  2. if (!product) notFound() delegates to the resource-absent path. notFound() throws Next.js’s internal NEXT_NOT_FOUND symbol, which the routing layer intercepts and maps to the nearest not-found.tsx in the directory tree. This is the only way to guarantee an HTTP 404 response status from a dynamic segment.

  3. error.tsx must carry 'use client'. React Error Boundaries require the client runtime. The reset prop calls Next.js’s internal retry mechanism, which re-renders the server component subtree. Log error.digest rather than error.message in production — the digest is a stable, non-sensitive hash you can match against server-side structured logs, as covered in managing state reset after uncaught promise rejections.

  4. not-found.tsx is a Server Component by default. Avoid adding 'use client' unless you need interactivity; keeping it server-rendered ensures the 404 status is set before the response stream opens. Export metadata with robots: { index: false } to prevent search engines from indexing the page.

  5. Middleware injects a correlation ID. Add x-correlation-id to request headers in middleware.ts so both boundaries can attach the same ID to their telemetry payloads, linking client-side error reports to server-side log lines.

// middleware.ts
import { NextResponse, type NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const correlationId = crypto.randomUUID();
  const headers = new Headers(request.headers);
  headers.set('x-correlation-id', correlationId);
  return NextResponse.next({ request: { headers } });
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

Edge cases #

Scenario Symptom Fix
error.tsx absent, fetch throws Next.js bubbles to the nearest ancestor error.tsx or crashes the full page render Co-locate error.tsx at every segment that owns async data fetching
not-found.tsx absent, notFound() called Falls back to app/not-found.tsx; segment-specific layout and breadcrumbs are lost Add a segment-level not-found.tsx; it does not need to duplicate global styles
'use client' on not-found.tsx HTTP 404 status may not be set before streaming begins in some Next.js 14 minor versions Remove 'use client'; use a client child component only if interactivity is required
Parallel route (@modal) calls notFound() Only the @modal slot renders its not-found.tsx; the default slot and page continue Intended behaviour — each slot boundary is independent; guard with a shared isValid() check if you need to 404 the whole page

Verification steps #

HTTP status in DevTools. Open Network → filter by Fetch/XHR and Doc. Navigate to a URL that triggers each boundary. The not-found.tsx response must show 404 Not Found in the Status column; error.tsx will show 200 OK — that is intentional, because the error occurred client-side after the initial response.

Console output. In development (next dev) the error.digest value appears in the terminal log alongside the full server-side stack trace. Confirm the digest from the client useEffect matches the server log entry.

Playwright assertion. Use page.route to intercept and fail the product API, then assert the [role="alert"] element is visible. For the 404 path, navigate to a non-existent slug and assert page.url() has not changed (no redirect) and page.locator('h1') contains “not found”:

// e2e/product-boundaries.spec.ts
import { test, expect } from '@playwright/test';

test('error.tsx activates on fetch failure', async ({ page }) => {
  await page.route('**/api/products/**', (route) =>
    route.fulfill({ status: 500 })
  );
  await page.goto('/products/some-slug');
  await expect(page.getByRole('alert')).toBeVisible();
  await expect(page.getByRole('button', { name: 'Try again' })).toBeVisible();
});

test('not-found.tsx returns 404 for absent product', async ({ page }) => {
  const response = await page.goto('/products/does-not-exist-xyz');
  expect(response?.status()).toBe(404);
  await expect(page.locator('h1')).toContainText('not found');
});

Lighthouse SEO audit. Run Lighthouse on a not-found.tsx route and confirm the SEO score does not flag a missing robots directive. The robots: noindex metadata export is the critical signal.

Frequently asked questions #

Can error.tsx ever return a 404 status? No. error.tsx is a client component React Error Boundary; it cannot set HTTP response status. Returning a 404 from a rendering failure requires calling notFound() inside the server component before throwing, which transfers control to not-found.tsx.

Why must error.tsx be a Client Component? React Error Boundaries require class component lifecycle methods (or the equivalent runtime hook) which are unavailable in React Server Components. The 'use client' directive forces the boundary into the client bundle, where React can intercept thrown errors during render and commit phases.

Does not-found.tsx suppress the 404 in Parallel Routes? A notFound() call in one parallel route slot (e.g. @modal) only triggers that slot’s not-found.tsx. The sibling slots continue rendering. The parent layout does not receive a 404 unless every slot invokes notFound() or the top-level route segment does.