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.
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 #
-
page.tsxwraps the data call intry/catch. Any exception from the database or network layer reaches thecatchblock, which re-throws so Next.js can propagate it up to the nearesterror.tsx. Do not callnotFound()in the catch block — that would misclassify infrastructure failures as 404s. -
if (!product) notFound()delegates to the resource-absent path.notFound()throws Next.js’s internalNEXT_NOT_FOUNDsymbol, which the routing layer intercepts and maps to the nearestnot-found.tsxin the directory tree. This is the only way to guarantee an HTTP 404 response status from a dynamic segment. -
error.tsxmust carry'use client'. React Error Boundaries require the client runtime. Theresetprop calls Next.js’s internal retry mechanism, which re-renders the server component subtree. Logerror.digestrather thanerror.messagein 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. -
not-found.tsxis 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. Exportmetadatawithrobots: { index: false }to prevent search engines from indexing the page. -
Middleware injects a correlation ID. Add
x-correlation-idto request headers inmiddleware.tsso 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.
Related #
- Next.js and Nuxt Routing Error Pages — parent cluster covering the full routing error surface for both frameworks
- React Error Boundary Implementation — foundational patterns for React Error Boundaries that
error.tsxextends - Fallback UI Rendering Patterns — avoiding layout shift when a boundary swaps content
- Managing State Reset After Uncaught Promise Rejections — safe state teardown when
reset()re-invokes the component tree