Implement custom error pages, error boundaries, and global error handling strategies.
// app/blog/error.tsx
"use client";
import { useEffect } from "react";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<div>
<h2>Something went wrong!</h2>
<button onClick={() => reset()}>Try again</button>
</div>
);
}app/
error.tsx # catches app-level errors
dashboard/
error.tsx # catches only dashboard errors
settings/
error.tsx # catches only settings errors// app/blog/[slug]/not-found.tsx
export default function NotFound() {
return (
<div>
<h1>Post not found</h1>
<p>The post you're looking for doesn't exist.</p>
</div>
);
}import { notFound } from "next/navigation";
export default async function Post({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) {
notFound();
}
return <article>{post.content}</article>;
}// app/not-found.tsx
import Link from "next/link";
export default function RootNotFound() {
return (
<div>
<h1>Page not found</h1>
<Link href="/">Go home</Link>
</div>
);
}export const metadata = {
title: "404 — Page not found",
description: "This page does not exist."
};"use server";
export async function createPost(formData: FormData) {
try {
const title = formData.get("title") as string;
if (!title) {
return { error: "Title is required" };
}
const post = await db.post.create({ data: { title } });
return { success: true, post };
} catch (error) {
return { error: "Failed to create post" };
}
}"use client";
export default function Form() {
const [error, setError] = useState<string | null>(null);
async function handleSubmit(formData: FormData) {
const result = await createPost(formData);
if (result.error) {
setError(result.error);
}
}
return (
<form action={handleSubmit}>
{error && <p>{error}</p>}
</form>
);
}useActionState to wire server action errors directly into a form — the action receives prevState as its first argument (React 19 / Next.js 15).// actions.ts
"use server";
export async function createPost(prevState: unknown, formData: FormData) {
try {
await db.post.create({ data: { title: formData.get("title") } });
return { success: true };
} catch {
return { error: "Failed to create post" };
}
}
// Form.tsx
"use client";
import { useActionState } from "react";
export default function Form() {
const [state, action] = useActionState(createPost, null);
return (
<form action={action}>
{state?.error && <p>{state.error}</p>}
<button type="submit">Create</button>
</form>
);
}"use server";
export async function deletePost(id: string) {
try {
await db.post.delete({ where: { id } });
return { success: true };
} catch (error) {
console.error("[deletePost] failed:", error); // full error server-side only
return { error: "Could not delete post" };// safe message to client
}
}// app/api/posts/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
try {
const post = await getPost(id);
if (!post) {
return Response.json(
{ error: "Post not found" },
{ status: 404 }
);
}
return Response.json(post);
} catch (error) {
return Response.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}export async function POST(request: Request) {
const body = await request.json();
if (!body.title) {
return Response.json({ error: "Title is required" }, { status: 400 });
}
return Response.json({ ok: true });
}const token = request.headers.get("authorization");
if (!token || !isValid(token)) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}Create app/global-error.tsx to catch errors in the root layout — it replaces the layout entirely, so it must render <html> and <body> tags.
// app/global-error.tsx — replaces root layout; MUST include <html><body>
"use client";
export default function GlobalError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
return (
<html>
<body>
<h1>Application Error</h1>
<button onClick={reset}>Try again</button>
</body>
</html>
);
}
Regular error.tsx renders inside the existing layout — never add <html> or <body> tags there.
// app/dashboard/error.tsx — renders inside layout, no html/body tags
"use client";
export default function Error({ reset }: { reset: () => void }) {
return <div><p>Something went wrong.</p><button onClick={reset}>Retry</button></div>;
}
Use the error digest to correlate client errors with server log entries.
export default function Error({ error }: { error: Error & { digest?: string } }) {
return <p>Error ID: {error.digest}</p>; // matches server-side error log
}
Add error monitoring in the useEffect of any error boundary component.
useEffect(() => {
Sentry.captureException(error);
console.error("digest:", error.digest);
}, [error]);
Nest error.tsx files at each route segment for targeted recovery — a dashboard error won't break the nav or sidebar.
app/
error.tsx ← catches app-level errors (inside root layout)
global-error.tsx ← catches root layout errors (replaces layout)
dashboard/
error.tsx ← catches only dashboard errors
error.tsx creates a React error boundary that catches rendering errors in a route segment and its children, while try/catch handles runtime errors in async server components or server actions. Use both: error.tsx for UI recovery, try/catch for data-fetching logic.
Place error.tsx files at each route segment level where you want isolated error recovery — for example, app/dashboard/error.tsx catches errors only in the dashboard segment without breaking the rest of the layout. Nesting them gives users more targeted recovery options.
Return a Response with an appropriate HTTP status code and a JSON body: return Response.json({ error: 'Not found' }, { status: 404 }). Always use 4xx for client errors and 5xx for server errors so consuming clients can handle them correctly.
Wrap your server action logic in a try/catch and return a structured result object like { success: false, error: 'message' } rather than rethrowing, since uncaught server action errors surface as generic failures to the client. Use the error only to inform the UI, never to expose stack traces or DB details.
Add a not-found.tsx file in the app directory (or a specific route segment) and call the notFound() function from 'next/navigation' anywhere in a server component to trigger it. This replaces the default Next.js 404 page for that segment.