Eerlijk? Foutafhandeling in Next.js 15 App Router voelt voor veel ontwikkelaars als een doolhof. Je hebt error.tsx, global-error.tsx, not-found.tsx, plus de notFound()- en redirect()-functies, en dan ook nog die scheiding tussen Server Components en Client Components. Wanneer gebruik je nu wat? Welke fout vangt welk bestand op? En hoe combineer je dit alles met Server Actions en streaming zonder gek te worden?
In deze gids beantwoord ik al die vragen aan de hand van werkende code uit Next.js 15.4. Je leert de hiërarchie van foutgrenzen, hoe React Error Boundaries onder de motorkap werken, en — misschien wel het belangrijkste — hoe je productieklare foutafhandeling bouwt die zowel UX-vriendelijk is als observability biedt via Sentry of OpenTelemetry.
Laten we erin duiken.
De foutgrens-hiërarchie in Next.js 15
Next.js 15 gebruikt React Error Boundaries om fouten te isoleren. Elk speciaal bestand creëert een grens rondom een routesegment. De volgorde van afhandeling is, van binnen naar buiten:
- error.tsx — vangt fouten op binnen één routesegment (de pagina, layout-children en geneste segmenten)
- global-error.tsx — vangt fouten op die in de root layout zelf optreden
- not-found.tsx — gerenderd wanneer
notFound()wordt aangeroepen of een route simpelweg niet bestaat
En nu de cruciale regel die veel ontwikkelaars over het hoofd zien: error.tsx wikkelt een routesegment, maar niet de layout van datzelfde segment. Een fout in app/dashboard/layout.tsx wordt opgevangen door app/error.tsx (de bovenliggende), niet door app/dashboard/error.tsx. Klinkt als een detail, maar het is precies waar projecten op vastlopen.
Visuele weergave van de hiërarchie
app/
├── layout.tsx ← root layout
├── global-error.tsx ← vangt fouten in root layout
├── error.tsx ← vangt fouten in app/page.tsx
├── not-found.tsx ← globale 404
├── page.tsx
└── dashboard/
├── layout.tsx ← fouten hier → app/error.tsx
├── error.tsx ← vangt fouten in dashboard/page.tsx
├── not-found.tsx ← 404 binnen dashboard scope
└── page.tsx
error.tsx: de standaardfoutgrens
Het bestand error.tsx moet een Client Component zijn omdat het de interactieve reset()-functie gebruikt. Dit is een veelvoorkomende verwarring — zelfs als je server-side fouten vangt, het component zelf rendert in de browser. Ik heb dit zelf eens een halve middag uitgezocht voordat het kwartje viel.
'use client';
import { useEffect } from 'react';
import * as Sentry from '@sentry/nextjs';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
Sentry.captureException(error, {
tags: { source: 'error-boundary', digest: error.digest },
});
}, [error]);
return (
<div className="error-container">
<h2>Er ging iets mis</h2>
<p>We hebben deze fout vastgelegd. Probeer het opnieuw of ga terug.</p>
{error.digest && (
<p className="error-digest">
Foutreferentie: <code>{error.digest}</code>
</p>
)}
<button onClick={() => reset()}>Opnieuw proberen</button>
</div>
);
}
Wat is error.digest precies?
Wanneer een fout op de server optreedt, verbergt Next.js in productie de originele foutmelding om gevoelige informatie te beschermen. In plaats daarvan krijg je error.digest — een gehashte identifier die je kunt correleren met serverlogs. Stuur deze digest dus altijd mee naar je observability-tool, anders sta je in productie compleet in het donker.
global-error.tsx: het laatste vangnet
Wanneer de root layout zelf crasht, kan app/error.tsx de fout niet renderen — het leeft immers binnen die layout. Daarvoor bestaat global-error.tsx. En let op: deze component moet zijn eigen <html> en <body> tags renderen, omdat het de root layout volledig vervangt.
'use client';
import { useEffect } from 'react';
import * as Sentry from '@sentry/nextjs';
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
Sentry.captureException(error, {
level: 'fatal',
tags: { source: 'global-error' },
});
}, [error]);
return (
<html lang="nl">
<body>
<div style={{ padding: '2rem', fontFamily: 'system-ui' }}>
<h1>Kritieke fout</h1>
<p>De applicatie kon niet correct worden geladen.</p>
<button onClick={() => reset()}>Herlaad de pagina</button>
</div>
</body>
</html>
);
}
Belangrijk detail: in development-modus toont Next.js het standaard error overlay in plaats van global-error.tsx. Test deze grens dus altijd met next build && next start voordat je naar productie gaat. Anders kom je er pas achter als het te laat is.
not-found.tsx en de notFound()-functie
Voor 404-scenario's is er een aparte stroom. Roep gewoon notFound() aan vanuit een Server Component, Server Action of Route Handler — dat gooit een NEXT_NOT_FOUND-fout die door de dichtstbijzijnde not-found.tsx wordt opgevangen.
// app/posts/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { db } from '@/lib/db';
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await db.query.posts.findFirst({
where: (p, { eq }) => eq(p.slug, slug),
});
if (!post) {
notFound();
}
return <article>{post.content}</article>;
}
Let op: in Next.js 15 zijn params en searchParams Promises geworden. Je móet ze nu altijd awaiten. Dit is een breaking change ten opzichte van Next.js 14, en eentje die nogal eens stille bugs veroorzaakt bij de upgrade.
not-found.tsx kan een Server Component zijn
// app/posts/[slug]/not-found.tsx
import Link from 'next/link';
export default function PostNotFound() {
return (
<div>
<h2>Bericht niet gevonden</h2>
<p>Het opgevraagde bericht bestaat niet of is verwijderd.</p>
<Link href="/posts">Terug naar overzicht</Link>
</div>
);
}
Fouten in Server Actions afhandelen
Server Actions hebben een eigen patroon, en het is anders dan je misschien zou denken. Een gegooide fout binnen een Server Action wordt niet opgevangen door error.tsx tenzij de fout tijdens rendering optreedt. Voor formulieren met validatie gebruik je daarom typische return-waarden, vaak gecombineerd met useActionState.
// app/actions/create-post.ts
'use server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';
const PostSchema = z.object({
title: z.string().min(3, 'Titel moet minstens 3 tekens hebben'),
content: z.string().min(10, 'Inhoud is te kort'),
});
export type CreatePostState = {
errors?: { title?: string[]; content?: string[]; _form?: string[] };
success?: boolean;
};
export async function createPost(
_prevState: CreatePostState,
formData: FormData
): Promise<CreatePostState> {
const parsed = PostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
});
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors };
}
try {
await db.insert(posts).values(parsed.data);
revalidatePath('/posts');
return { success: true };
} catch (error) {
console.error('[createPost] DB-fout:', error);
return { errors: { _form: ['Kon bericht niet opslaan. Probeer opnieuw.'] } };
}
}
Aan de clientzijde verbruik je deze state met useActionState uit React 19:
'use client';
import { useActionState } from 'react';
import { createPost, type CreatePostState } from '@/app/actions/create-post';
const initialState: CreatePostState = {};
export function CreatePostForm() {
const [state, formAction, pending] = useActionState(createPost, initialState);
return (
<form action={formAction}>
<input name="title" />
{state.errors?.title && <p className="error">{state.errors.title[0]}</p>}
<textarea name="content" />
{state.errors?.content && <p className="error">{state.errors.content[0]}</p>}
{state.errors?._form && <p className="error">{state.errors._form[0]}</p>}
<button type="submit" disabled={pending}>
{pending ? 'Bezig...' : 'Plaatsen'}
</button>
</form>
);
}
Fouten in Route Handlers (API Routes)
Route Handlers in app/api/*/route.ts hebben geen error.tsx-grens. Hier ben je gewoon ouderwets bezig: je retourneert handmatig een NextResponse met een passende statuscode.
// app/api/posts/route.ts
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/db';
const Body = z.object({ title: z.string(), content: z.string() });
export async function POST(request: Request) {
try {
const json = await request.json();
const parsed = Body.safeParse(json);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Ongeldige invoer', details: parsed.error.flatten() },
{ status: 422 }
);
}
const [post] = await db.insert(posts).values(parsed.data).returning();
return NextResponse.json(post, { status: 201 });
} catch (error) {
console.error('[POST /api/posts]', error);
return NextResponse.json(
{ error: 'Interne serverfout' },
{ status: 500 }
);
}
}
Streaming en foutgrenzen combineren
In Next.js 15 met Partial Prerendering (PPR) of standaard streaming kun je gerichte error- en loading-grenzen plaatsen rondom langzame Server Components. Wikkel ze in <Suspense> én een dichterbij gelegen error grens. Deze combinatie is wat mij betreft een van de beste features van de App Router.
// app/dashboard/page.tsx
import { Suspense } from 'react';
import { ErrorBoundary } from '@/components/error-boundary';
import { RevenueChart } from './revenue-chart';
import { LatestOrders } from './latest-orders';
export default function Dashboard() {
return (
<div>
<ErrorBoundary fallback={<p>Omzetgrafiek niet beschikbaar</p>}>
<Suspense fallback={<p>Omzet laden...</p>}>
<RevenueChart />
</Suspense>
</ErrorBoundary>
<ErrorBoundary fallback={<p>Bestellingen niet beschikbaar</p>}>
<Suspense fallback={<p>Bestellingen laden...</p>}>
<LatestOrders />
</Suspense>
</ErrorBoundary>
</div>
);
}
Met deze opzet sleurt één widget niet de hele pagina mee, en zien gebruikers per sectie of er iets misgaat. Veel beter dan dat ene generieke "er ging iets mis"-scherm.
redirect() en foutgrenzen
Een veelvoorkomende valkuil (en eentje die mij ooit een productie-incident heeft gekost): redirect() uit next/navigation gooit intern een speciale NEXT_REDIRECT-fout. Als je deze in een try/catch wikkelt zonder hem opnieuw te gooien, breekt de redirect gewoon stilletjes.
'use server';
import { redirect } from 'next/navigation';
import { isRedirectError } from 'next/dist/client/components/redirect';
export async function saveAndGo(formData: FormData) {
try {
await db.insert(items).values({ /* ... */ });
redirect('/items'); // gooit NEXT_REDIRECT
} catch (error) {
if (isRedirectError(error)) throw error; // doorlaten!
console.error(error);
throw new Error('Opslaan mislukt');
}
}
Hetzelfde geldt overigens voor notFound(): gebruik isNotFoundError om die fout door te laten in catch-blokken. Onthoud dit goed.
Productie-observability met Sentry
Vanaf Sentry SDK 8.x is er native Next.js 15 ondersteuning. Installeren is gelukkig een eitje:
npx @sentry/wizard@latest -i nextjs
De wizard maakt drie configuratiebestanden aan: sentry.client.config.ts, sentry.server.config.ts, en sentry.edge.config.ts. Belangrijk in 2026: voeg de onRequestError-hook toe aan instrumentation.ts om server-side fouten van React Server Components te vangen.
// instrumentation.ts
import * as Sentry from '@sentry/nextjs';
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
await import('./sentry.server.config');
}
if (process.env.NEXT_RUNTIME === 'edge') {
await import('./sentry.edge.config');
}
}
export const onRequestError = Sentry.captureRequestError;
Veelgemaakte fouten en hoe je ze voorkomt
- error.tsx vergeten als 'use client' te markeren — geeft een runtime error over
useEffect - global-error.tsx zonder <html> en <body> — pagina toont een blanco scherm in productie
- notFound() in een Client Component — werkt niet, gebruik het uitsluitend serverzijdig
- redirect() in try/catch zonder rethrow — redirect wordt geslikt en de gebruiker blijft hangen
- Async params niet awaiten — Next.js 15 vereist
const { slug } = await params - error.digest negeren — zonder digest is debuggen in productie bijna onmogelijk
Veelgestelde vragen
Wat is het verschil tussen error.tsx en global-error.tsx?
error.tsx vangt fouten binnen routesegmenten (pagina's en geneste layouts), maar werkt alleen als de root layout zelf intact blijft. global-error.tsx is het laatste vangnet en wordt gebruikt wanneer de root layout zelf crasht. Het moet zijn eigen <html> en <body> renderen omdat het de root layout vervangt.
Waarom werkt mijn error.tsx niet voor fouten in mijn layout?
Omdat een error.tsx geen fouten in de layout van hetzelfde segment afvangt, alleen in de pagina en kindersegmenten. Voor fouten in een layout moet je dus een error.tsx in het bovenliggende segment plaatsen, of de logica naar de pagina verplaatsen.
Moet error.tsx echt een Client Component zijn?
Ja. Het bestand gebruikt de interactieve reset()-functie en heeft vaak useEffect nodig voor logging. Begin het bestand altijd met 'use client', anders krijg je gegarandeerd een runtime error.
Hoe vang ik fouten in Server Actions?
Gooi geen fouten vanuit Server Actions wanneer je gevalideerde formulierfouten wilt tonen. Retourneer in plaats daarvan een state-object met errors en consumeer dit met useActionState. Echte uitzonderingen (zoals databasefouten) kun je loggen en als _form-fout teruggeven.
Werkt notFound() in een Client Component?
Nee. notFound() werkt alleen in Server Components, Server Actions en Route Handlers. In Client Components moet je useRouter gebruiken om naar een 404-pagina te navigeren, of de check naar een Server Component verplaatsen.
Hoe zie ik de echte foutmelding in productie?
Next.js verbergt productiefoutmeldingen om beveiligingsredenen. Gebruik error.digest om de fout te correleren met serverlogs of een tool zoals Sentry. De digest verschijnt in zowel error.tsx als in je serveroutput met dezelfde waarde — handig voor debugging dus.