//
Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Handle errors gracefully in Next.js 15+ App Router using error.tsx boundaries, global-error.tsx for root-level failures, not-found.tsx for 404s, and structured error logging.
// app/dashboard/error.tsx
"use client";
import { useEffect } from "react";
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Log to your error reporting service
console.error("Dashboard error:", error);
}, [error]);
return (
<div role="alert">
<h2>Something went wrong</h2>
<p>{error.message}</p>
{error.digest && (
<p className="text-sm text-gray-500">Error ID: {error.digest}</p>
)}
<button onClick={reset}>Try again</button>
</div>
);
}// app/global-error.tsx
"use client";
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<html>
<body>
<div role="alert">
<h1>Application Error</h1>
<p>An unexpected error occurred.</p>
<button onClick={reset}>Reload</button>
</div>
</body>
</html>
);
}// app/not-found.tsx
import Link from "next/link";
export default function NotFound() {
return (
<div>
<h1>404 - Page Not Found</h1>
<p>The page you are looking for does not exist.</p>
<Link href="/">Go home</Link>
</div>
);
}// app/posts/[slug]/page.tsx
import { notFound } from "next/navigation";
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await db.post.findUnique({ where: { slug } });
if (!post) {
notFound(); // Renders the nearest not-found.tsx
}
return <article>{post.content}</article>;
}// lib/logger.ts
type ErrorContext = {
userId?: string;
path?: string;
action?: string;
metadata?: Record<string, unknown>;
};
export function logError(error: unknown, context?: ErrorContext) {
const errorObj = error instanceof Error ? error : new Error(String(error));
const payload = {
message: errorObj.message,
stack: errorObj.stack,
timestamp: new Date().toISOString(),
...context,
};
// Replace with Sentry, Axiom, or your preferred service
if (process.env.NODE_ENV === "production") {
fetch("/api/log", {
method: "POST",
body: JSON.stringify(payload),
}).catch(() => {
// Swallow logging errors to prevent cascading failures
});
} else {
console.error("[Error]", payload);
}
}// app/actions.ts
"use server";
import { logError } from "@/lib/logger";
type ActionResult<T> =
| { success: true; data: T }
| { success: false; error: string };
export async function createPost(
formData: FormData
): Promise<ActionResult<{ id: string }>> {
try {
const title = formData.get("title") as string;
if (!title) {
return { success: false, error: "Title is required" };
}
const post = await db.post.create({ data: { title } });
return { success: true, data: { id: post.id } };
} catch (error) {
logError(error, { action: "createPost" });
return { success: false, error: "Failed to create post" };
}
}error.tsx is a Client Component that wraps the route segment's children in a React Error Boundary. It catches errors thrown during rendering, in Server Components, and during data fetching.global-error.tsx catches errors in the root layout. It must render its own <html> and <body> tags because it replaces the entire root layout when triggered.not-found.tsx is rendered when notFound() is called or when no route matches. The nearest not-found.tsx in the component tree is used.useEffect cleanup, or async code in Client Components. Use try/catch for those.digest property is a hash generated by Next.js for server-side errors. It allows correlating user-facing errors with server logs without exposing sensitive stack traces.reset function attempts to re-render the error boundary's children. It works for transient errors (network issues) but not for persistent bugs.Error Boundary with Retry and Fallback:
"use client";
import { useEffect, useState } from "react";
export default function ErrorWithRetry({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
const [retryCount, setRetryCount] = useState(0);
useEffect(() => {
if (retryCount > 0) {
reset();
}
}, [retryCount, reset]);
if (retryCount >= 3) {
return (
<div>
<h2>Persistent Error</h2>
<p>Please contact support. Error ID: {error.digest}</p>
</div>
);
}
return (
<div role="alert">
<h2>Error Occurred</h2>
<button onClick={() => setRetryCount((c) => c + 1)}>
Retry ({3 - retryCount} attempts remaining)
</button>
</div>
);
}Route Handler Error Handling:
// app/api/posts/route.ts
import { NextRequest, NextResponse } from "next/server";
import { logError } from "@/lib/logger";
export async function GET(request: NextRequest) {
try {
const posts = await db.post.findMany();
return NextResponse.json(posts);
} catch (error) {
logError(error, { path: "/api/posts", action: "GET" });
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}error prop type is Error & { digest?: string }. The digest is optional and only present for server-side errors.{ success: true; data: T } | { success: false; error: string }) for type-safe error handling on the client.throw in Server Actions for validation errors. Reserve throw for unexpected failures that should trigger the error boundary.error.tsx must be a Client Component. It requires the "use client" directive. Forgetting this produces a build error.error.tsx does not catch errors in the same-level layout.tsx. To catch layout errors, place error.tsx in the parent segment, or use global-error.tsx for the root layout.global-error.tsx only activates in production. In development, the Next.js error overlay is shown instead.redirect() throws a special error. If you wrap redirect() in a try/catch inside a Server Component, the redirect will be caught and swallowed. Either rethrow NEXT_REDIRECT errors or call redirect() outside try/catch.digest to correlate with server logs.| Approach | Pros | Cons |
|---|---|---|
error.tsx boundary | Built-in, automatic, per-route | Client Component only, no layout errors |
global-error.tsx | Catches root layout errors | Must render own html/body, production only |
| Try/catch in Server Actions | Granular control, return typed errors | Manual, no automatic boundary |
| Sentry or Datadog | Rich error tracking, alerts | External dependency, cost |
React ErrorBoundary class | Full control, reusable | Verbose, no Server Component errors |
"use client" directive is required for error.tsx to function as an error boundary.error.tsx only catches errors in the route segment's children.error.tsx in the parent segment.global-error.tsx.global-error.tsx activates, it replaces the entire root layout.<html> and <body>, the page would have no document structure.redirect() throws a special NEXT_REDIRECT error.NEXT_REDIRECT errors or call redirect() outside the try/catch block.throw for unexpected failures that should trigger the nearest error boundary.{ success: false; error: string }) for expected validation errors.reset() re-renders the error boundary's children, attempting recovery.type ActionResult<T> =
| { success: true; data: T }
| { success: false; error: string };
export async function createPost(
formData: FormData
): Promise<ActionResult<{ id: string }>> {
// ...
}success lets TypeScript narrow the type when checking the result.export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
// ...
}Error & { digest?: string } is the required type. The digest is optional..catch(() => {}) ensures logging failures are silent.digest hash is exposed, which you can use to find the full error in server logs.useEffect cleanup functions.layout.tsx.global-error.tsx for root layout failures.Reviewed by Chris St. John·Last updated Jul 7, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥