Error Handling & Observability in Next.js
Error handling is not a feature you add at the end of a project; it is the infrastructure that keeps a production application alive when things go wrong. In Next.js, the App Router introduces a file-system convention for errors: error.tsx, not-found.tsx, loading.tsx, and global-error.tsx turn failure states into first-class routing concerns. When an error occurs, the framework isolates the broken segment instead of taking down the whole page.
Observability is the counterpart to error handling. Catching errors is only useful if you can see them. Structured logs, distributed traces, real user monitoring, and exception trackers like Sentry give you the context to diagnose failures without reproducing them locally. In a server-rendered React application, errors can originate in Server Components, Server Actions, route handlers, middleware, or client-side code, so the observability strategy must cover every layer.
This guide covers the full Next.js error lifecycle: error boundaries, not-found and loading states, error handling in Server Components and Server Actions, route handler error patterns, client-side tracking, logging with pino and winston, OpenTelemetry fundamentals, and monitoring best practices. The goal is to build applications that fail gracefully, recover cleanly, and report clearly.
In the Next.js App Router, an error boundary is created by adding an error.tsx file inside a route segment. When a rendering error happens in that segment or any of its children, Next.js renders the boundary UI instead of the failed component tree. This is the React componentDidCatch mechanism, wrapped in a file-based convention.
Error boundaries are Client Components because they need React lifecycle methods. They must include the "use client" directive. The component receives an error prop containing the thrown error and a reset function that attempts to re-render the segment. Use reset for transient errors, not for permanent failures like missing data.
| 1 | "use client"; |
| 2 | |
| 3 | import { useEffect } from "react"; |
| 4 | |
| 5 | export default function ErrorBoundary({ |
| 6 | error, |
| 7 | reset, |
| 8 | }: { |
| 9 | error: Error & { digest?: string }; |
| 10 | reset: () => void; |
| 11 | }) { |
| 12 | useEffect(() => { |
| 13 | // Send to your error reporting service |
| 14 | console.error("Segment error:", error); |
| 15 | }, [error]); |
| 16 | |
| 17 | return ( |
| 18 | <div className="p-6 border border-red-500/30 rounded-lg bg-red-500/5"> |
| 19 | <h2 className="text-lg font-semibold text-red-400 mb-2"> |
| 20 | Something went wrong |
| 21 | </h2> |
| 22 | <p className="text-sm text-[#A0A0A0] mb-4"> |
| 23 | {error.message || "An unexpected error occurred in this section."} |
| 24 | </p> |
| 25 | <button |
| 26 | onClick={() => reset()} |
| 27 | className="px-4 py-2 text-sm font-medium bg-red-500/10 hover:bg-red-500/20 text-red-400 rounded transition-colors" |
| 28 | > |
| 29 | Try again |
| 30 | </button> |
| 31 | </div> |
| 32 | ); |
| 33 | } |
Error boundaries are nested by segment. A boundary in app/dashboard/error.tsx catches errors in the dashboard segment and its children, but it does not catch errors in sibling segments like app/settings. This is a feature: it keeps failures localized. Place boundaries at the right granularity. Too high and a small bug takes down a large page; too low and you lose useful isolation.
note
The error.digest property is a Next.js-generated hash that can be correlated with server logs. Include it in user-facing error messages and in your error reporting metadata. When a user reports an issue, the digest lets you find the exact server-side stack trace without exposing it to the client.
| 1 | "use client"; |
| 2 | |
| 3 | import { useEffect } from "react"; |
| 4 | import * as Sentry from "@sentry/nextjs"; |
| 5 | |
| 6 | export default function ErrorBoundary({ |
| 7 | error, |
| 8 | reset, |
| 9 | }: { |
| 10 | error: Error & { digest?: string }; |
| 11 | reset: () => void; |
| 12 | }) { |
| 13 | useEffect(() => { |
| 14 | Sentry.captureException(error, { |
| 15 | tags: { segment: "dashboard" }, |
| 16 | extra: { digest: error.digest }, |
| 17 | }); |
| 18 | }, [error]); |
| 19 | |
| 20 | return ( |
| 21 | <div className="p-6 rounded-lg border border-[#222222]"> |
| 22 | <h2 className="text-lg font-semibold text-[#E0E0E0] mb-2"> |
| 23 | Dashboard error |
| 24 | </h2> |
| 25 | <p className="text-sm text-[#A0A0A0] mb-2"> |
| 26 | We encountered an error while loading this section. |
| 27 | </p> |
| 28 | {error.digest && ( |
| 29 | <p className="text-xs font-mono text-[#525252] mb-4"> |
| 30 | Error ID: {error.digest} |
| 31 | </p> |
| 32 | )} |
| 33 | <button |
| 34 | onClick={() => reset()} |
| 35 | className="px-4 py-2 text-sm font-medium border border-[#222222] hover:border-[#00FF41]/30 text-[#E0E0E0] rounded transition-colors" |
| 36 | > |
| 37 | Retry |
| 38 | </button> |
| 39 | </div> |
| 40 | ); |
| 41 | } |
Next.js provides three related conventions for non-error failure states: not-found.tsx for missing resources, loading.tsx for Suspense fallbacks, and global-error.tsx for uncaught errors in the root layout. Together they form a complete state surface for the routing tree.
A not-found.tsx file renders when you call the notFound() function from next/navigation, or when a dynamic route segment does not match the expected data. It is useful for soft 404s: the URL is valid but the resource it represents does not exist. Unlike error.tsx, not-found.tsx can be a Server Component.
| 1 | // app/blog/[slug]/page.tsx |
| 2 | import { notFound } from "next/navigation"; |
| 3 | import { getPost } from "@/lib/posts"; |
| 4 | |
| 5 | export default async function BlogPostPage({ |
| 6 | params, |
| 7 | }: { |
| 8 | params: Promise<{ slug: string }>; |
| 9 | }) { |
| 10 | const { slug } = await params; |
| 11 | const post = await getPost(slug); |
| 12 | |
| 13 | if (!post) { |
| 14 | notFound(); |
| 15 | } |
| 16 | |
| 17 | return ( |
| 18 | <article> |
| 19 | <h1>{post.title}</h1> |
| 20 | <p>{post.excerpt}</p> |
| 21 | </article> |
| 22 | ); |
| 23 | } |
| 24 | |
| 25 | // app/blog/[slug]/not-found.tsx |
| 26 | import Link from "next/link"; |
| 27 | |
| 28 | export default function NotFound() { |
| 29 | return ( |
| 30 | <div className="p-6 text-center"> |
| 31 | <h2 className="text-2xl font-bold text-[#E0E0E0] mb-2">Post not found</h2> |
| 32 | <p className="text-sm text-[#A0A0A0] mb-4"> |
| 33 | We could not find the article you were looking for. |
| 34 | </p> |
| 35 | <Link |
| 36 | href="/blog" |
| 37 | className="text-sm text-[#00FF41] hover:underline" |
| 38 | > |
| 39 | Back to all posts |
| 40 | </Link> |
| 41 | </div> |
| 42 | ); |
| 43 | } |
A loading.tsxfile is a Suspense boundary. It renders immediately while the segment's data is fetching. Use it for any route that performs asynchronous work, because Next.js will automatically suspend the segment and show the loading UI. Keep loading states lightweight and avoid skeletons that flash for only a few milliseconds.
| 1 | // app/dashboard/loading.tsx |
| 2 | export default function DashboardLoading() { |
| 3 | return ( |
| 4 | <div className="p-6 space-y-4 animate-pulse"> |
| 5 | <div className="h-8 w-1/3 bg-[#222222] rounded" /> |
| 6 | <div className="grid grid-cols-3 gap-4"> |
| 7 | <div className="h-24 bg-[#222222] rounded" /> |
| 8 | <div className="h-24 bg-[#222222] rounded" /> |
| 9 | <div className="h-24 bg-[#222222] rounded" /> |
| 10 | </div> |
| 11 | <div className="h-64 bg-[#222222] rounded" /> |
| 12 | </div> |
| 13 | ); |
| 14 | } |
| 15 | |
| 16 | // app/dashboard/page.tsx |
| 17 | import { getDashboardData } from "@/lib/dashboard"; |
| 18 | |
| 19 | export default async function DashboardPage() { |
| 20 | // This suspends automatically while loading.tsx is shown |
| 21 | const data = await getDashboardData(); |
| 22 | |
| 23 | return ( |
| 24 | <main> |
| 25 | <h1>Dashboard</h1> |
| 26 | <pre>{JSON.stringify(data, null, 2)}</pre> |
| 27 | </main> |
| 28 | ); |
| 29 | } |
The root global-error.tsx is the last line of defense. It wraps the entire application and catches errors that bubble past every other boundary. It must export a component that accepts error and reset and render the <html> and <body>tags itself, because the error has escaped the root layout. Use it for a generic "application crashed" screen and always log the error to your monitoring service.
| 1 | "use client"; |
| 2 | |
| 3 | import { useEffect } from "react"; |
| 4 | import * as Sentry from "@sentry/nextjs"; |
| 5 | |
| 6 | export default function GlobalError({ |
| 7 | error, |
| 8 | reset, |
| 9 | }: { |
| 10 | error: Error & { digest?: string }; |
| 11 | reset: () => void; |
| 12 | }) { |
| 13 | useEffect(() => { |
| 14 | Sentry.captureException(error, { level: "fatal" }); |
| 15 | }, [error]); |
| 16 | |
| 17 | return ( |
| 18 | <html> |
| 19 | <body> |
| 20 | <div className="min-h-screen flex flex-col items-center justify-center p-6 text-center"> |
| 21 | <h1 className="text-3xl font-bold text-[#E0E0E0] mb-4"> |
| 22 | Application error |
| 23 | </h1> |
| 24 | <p className="text-sm text-[#A0A0A0] mb-2"> |
| 25 | A critical error occurred. Our team has been notified. |
| 26 | </p> |
| 27 | {error.digest && ( |
| 28 | <p className="text-xs font-mono text-[#525252] mb-6"> |
| 29 | Reference: {error.digest} |
| 30 | </p> |
| 31 | )} |
| 32 | <button |
| 33 | onClick={() => reset()} |
| 34 | className="px-4 py-2 text-sm font-medium bg-[#00FF41]/10 text-[#00FF41] rounded hover:bg-[#00FF41]/20 transition-colors" |
| 35 | > |
| 36 | Try again |
| 37 | </button> |
| 38 | </div> |
| 39 | </body> |
| 40 | </html> |
| 41 | ); |
| 42 | } |
warning
Server Components run during rendering, so any uncaught exception propagates to the nearest error boundary. That is the default behavior, but it is rarely the best behavior. Production code should catch errors at the data source, decide whether the failure is recoverable, and return degraded UI when it is.
The simplest pattern is to wrap data fetching in try/catch and return a fallback component. This keeps the page usable even when one data source is down. Always log the error on the server, because the client cannot see the original stack trace or error message once the boundary is rendered.
| 1 | // app/dashboard/page.tsx |
| 2 | import { getDashboardData } from "@/lib/dashboard"; |
| 3 | import { logger } from "@/lib/logger"; |
| 4 | |
| 5 | export default async function DashboardPage() { |
| 6 | let data: DashboardData | null = null; |
| 7 | |
| 8 | try { |
| 9 | data = await getDashboardData(); |
| 10 | } catch (error) { |
| 11 | logger.error({ error }, "Failed to load dashboard data"); |
| 12 | } |
| 13 | |
| 14 | if (!data) { |
| 15 | return ( |
| 16 | <main> |
| 17 | <h1>Dashboard</h1> |
| 18 | <p className="text-[#A0A0A0]"> |
| 19 | We could not load your dashboard right now. Some features may be unavailable. |
| 20 | </p> |
| 21 | </main> |
| 22 | ); |
| 23 | } |
| 24 | |
| 25 | return ( |
| 26 | <main> |
| 27 | <h1>Dashboard</h1> |
| 28 | <DashboardStats data={data} /> |
| 29 | </main> |
| 30 | ); |
| 31 | } |
For critical data, throwing is appropriate. If a page is meaningless without its primary data, let the error reach the boundary and render a clear failure state. For non-critical widgets, catch and degrade. The decision depends on whether the user can still accomplish their goal with partial data.
Use typed errors to distinguish expected failures from bugs. A NotFoundError might render a 404, while a DatabaseError might render a generic message and alert the operations team. Never expose internal error details to the browser; log them server-side and show only safe, actionable text to the user.
| 1 | // lib/errors.ts |
| 2 | export class NotFoundError extends Error { |
| 3 | constructor(resource: string) { |
| 4 | super(`${resource} not found`); |
| 5 | this.name = "NotFoundError"; |
| 6 | } |
| 7 | } |
| 8 | |
| 9 | export class AuthorizationError extends Error { |
| 10 | constructor() { |
| 11 | super("Forbidden"); |
| 12 | this.name = "AuthorizationError"; |
| 13 | } |
| 14 | } |
| 15 | |
| 16 | export class DatabaseError extends Error { |
| 17 | constructor(cause?: unknown) { |
| 18 | super("Database operation failed"); |
| 19 | this.name = "DatabaseError"; |
| 20 | this.cause = cause; |
| 21 | } |
| 22 | } |
| 23 | |
| 24 | // lib/dashboard.ts |
| 25 | import { NotFoundError, DatabaseError } from "@/lib/errors"; |
| 26 | |
| 27 | export async function getDashboardData(userId: string) { |
| 28 | try { |
| 29 | const data = await db.query("SELECT * FROM dashboard WHERE user_id = $1", [ |
| 30 | userId, |
| 31 | ]); |
| 32 | |
| 33 | if (!data) { |
| 34 | throw new NotFoundError("Dashboard"); |
| 35 | } |
| 36 | |
| 37 | return data; |
| 38 | } catch (error) { |
| 39 | if (error instanceof NotFoundError) throw error; |
| 40 | throw new DatabaseError(error); |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | // app/dashboard/page.tsx |
| 45 | import { notFound } from "next/navigation"; |
| 46 | import { getDashboardData } from "@/lib/dashboard"; |
| 47 | import { NotFoundError } from "@/lib/errors"; |
| 48 | |
| 49 | export default async function DashboardPage() { |
| 50 | try { |
| 51 | const data = await getDashboardData("user_123"); |
| 52 | return <DashboardView data={data} />; |
| 53 | } catch (error) { |
| 54 | if (error instanceof NotFoundError) { |
| 55 | notFound(); |
| 56 | } |
| 57 | throw error; // Let the segment error.tsx handle it |
| 58 | } |
| 59 | } |
best practice
Server Actions are functions that run on the server in response to user interaction. Errors thrown inside a Server Action are returned to the client as a serialized error object, but the stack trace is stripped. This is good for security, but it means you must design your error response deliberately.
For forms, the React useActionState hook is the canonical way to handle Server Action results. It gives you the previous state, the pending state, and the action result. The action returns a structured object instead of throwing, and the form renders the result. This pattern separates validation errors from unexpected errors cleanly.
| 1 | // app/actions/contact.ts |
| 2 | "use server"; |
| 3 | |
| 4 | import { z } from "zod"; |
| 5 | |
| 6 | const contactSchema = z.object({ |
| 7 | name: z.string().min(1, "Name is required"), |
| 8 | email: z.string().email("Invalid email"), |
| 9 | message: z.string().min(10, "Message must be at least 10 characters"), |
| 10 | }); |
| 11 | |
| 12 | export type ContactFormState = |
| 13 | | { success: true; message: string } |
| 14 | | { success: false; errors: Record<string, string[]>; message: string }; |
| 15 | |
| 16 | export async function submitContactForm( |
| 17 | _prevState: ContactFormState | null, |
| 18 | formData: FormData |
| 19 | ): Promise<ContactFormState> { |
| 20 | const parsed = contactSchema.safeParse({ |
| 21 | name: formData.get("name"), |
| 22 | email: formData.get("email"), |
| 23 | message: formData.get("message"), |
| 24 | }); |
| 25 | |
| 26 | if (!parsed.success) { |
| 27 | return { |
| 28 | success: false, |
| 29 | errors: parsed.error.flatten().fieldErrors, |
| 30 | message: "Please fix the errors below.", |
| 31 | }; |
| 32 | } |
| 33 | |
| 34 | try { |
| 35 | await sendEmail(parsed.data); |
| 36 | return { success: true, message: "Message sent successfully." }; |
| 37 | } catch (error) { |
| 38 | return { |
| 39 | success: false, |
| 40 | errors: {}, |
| 41 | message: "Failed to send message. Please try again later.", |
| 42 | }; |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | // app/contact/page.tsx |
| 47 | "use client"; |
| 48 | |
| 49 | import { useActionState } from "react"; |
| 50 | import { submitContactForm } from "@/app/actions/contact"; |
| 51 | |
| 52 | export default function ContactPage() { |
| 53 | const [state, action, pending] = useActionState(submitContactForm, null); |
| 54 | |
| 55 | return ( |
| 56 | <form action={action} className="space-y-4"> |
| 57 | {state?.message && ( |
| 58 | <p className={state.success ? "text-green-400" : "text-red-400"}> |
| 59 | {state.message} |
| 60 | </p> |
| 61 | )} |
| 62 | <div> |
| 63 | <label htmlFor="name">Name</label> |
| 64 | <input id="name" name="name" /> |
| 65 | {state?.errors?.name && ( |
| 66 | <p className="text-red-400 text-sm">{state.errors.name[0]}</p> |
| 67 | )} |
| 68 | </div> |
| 69 | <div> |
| 70 | <label htmlFor="email">Email</label> |
| 71 | <input id="email" name="email" type="email" /> |
| 72 | {state?.errors?.email && ( |
| 73 | <p className="text-red-400 text-sm">{state.errors.email[0]}</p> |
| 74 | )} |
| 75 | </div> |
| 76 | <div> |
| 77 | <label htmlFor="message">Message</label> |
| 78 | <textarea id="message" name="message" /> |
| 79 | {state?.errors?.message && ( |
| 80 | <p className="text-red-400 text-sm">{state.errors.message[0]}</p> |
| 81 | )} |
| 82 | </div> |
| 83 | <button type="submit" disabled={pending}> |
| 84 | {pending ? "Sending..." : "Send"} |
| 85 | </button> |
| 86 | </form> |
| 87 | ); |
| 88 | } |
For non-form actions, such as a button that deletes an item, throwing is sometimes acceptable. The client can catch the error in a try/catch and show a toast. The key is to throw safe, user-facing errors and keep sensitive details out of the message. Log the full error on the server before sending a clean response.
| 1 | // app/actions/delete-post.ts |
| 2 | "use server"; |
| 3 | |
| 4 | import { revalidatePath } from "next/cache"; |
| 5 | import { auth } from "@/auth"; |
| 6 | |
| 7 | export async function deletePost(postId: string) { |
| 8 | const session = await auth(); |
| 9 | if (!session?.user) { |
| 10 | throw new Error("You must be signed in to delete a post."); |
| 11 | } |
| 12 | |
| 13 | try { |
| 14 | await db.post.delete({ |
| 15 | where: { id: postId, authorId: session.user.id }, |
| 16 | }); |
| 17 | } catch (error) { |
| 18 | logger.error({ error, postId, userId: session.user.id }, "Delete post failed"); |
| 19 | throw new Error("Could not delete the post. Please try again later."); |
| 20 | } |
| 21 | |
| 22 | revalidatePath("/posts"); |
| 23 | } |
| 24 | |
| 25 | // components/DeleteButton.tsx |
| 26 | "use client"; |
| 27 | |
| 28 | import { useState } from "react"; |
| 29 | import { deletePost } from "@/app/actions/delete-post"; |
| 30 | |
| 31 | export default function DeleteButton({ postId }: { postId: string }) { |
| 32 | const [status, setStatus] = useState<"idle" | "error" | "success">("idle"); |
| 33 | |
| 34 | async function handleClick() { |
| 35 | try { |
| 36 | await deletePost(postId); |
| 37 | setStatus("success"); |
| 38 | } catch (error) { |
| 39 | setStatus("error"); |
| 40 | // toast({ title: error.message, type: "error" }); |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | return ( |
| 45 | <button onClick={handleClick} className="text-red-400"> |
| 46 | {status === "success" ? "Deleted" : "Delete"} |
| 47 | </button> |
| 48 | ); |
| 49 | } |
danger
Route handlers in app/api are the HTTP layer of your application. They are expected to return standard HTTP responses with appropriate status codes. A well-behaved API returns predictable error shapes, consistent field names, and enough detail for the client to handle the failure without exposing internals.
Use NextResponse.json with a status code for every response path. Return 400 for client-side validation failures, 401 for missing authentication, 403 for forbidden actions, 404 for missing resources, 409 for conflicts, and 500 for unexpected server failures. A custom error response shape makes client code easier to write and test.
| 1 | // app/api/posts/route.ts |
| 2 | import { NextResponse } from "next/server"; |
| 3 | import { z } from "zod"; |
| 4 | import { auth } from "@/auth"; |
| 5 | |
| 6 | const createPostSchema = z.object({ |
| 7 | title: z.string().min(1), |
| 8 | content: z.string().min(1), |
| 9 | }); |
| 10 | |
| 11 | export async function POST(request: Request) { |
| 12 | try { |
| 13 | const session = await auth(); |
| 14 | if (!session?.user) { |
| 15 | return NextResponse.json( |
| 16 | { error: "Unauthorized", code: "AUTH_REQUIRED" }, |
| 17 | { status: 401 } |
| 18 | ); |
| 19 | } |
| 20 | |
| 21 | const body = await request.json(); |
| 22 | const parsed = createPostSchema.safeParse(body); |
| 23 | |
| 24 | if (!parsed.success) { |
| 25 | return NextResponse.json( |
| 26 | { |
| 27 | error: "Validation failed", |
| 28 | code: "VALIDATION_ERROR", |
| 29 | issues: parsed.error.issues, |
| 30 | }, |
| 31 | { status: 400 } |
| 32 | ); |
| 33 | } |
| 34 | |
| 35 | const post = await db.post.create({ |
| 36 | data: { ...parsed.data, authorId: session.user.id }, |
| 37 | }); |
| 38 | |
| 39 | return NextResponse.json({ data: post }, { status: 201 }); |
| 40 | } catch (error) { |
| 41 | logger.error({ error }, "POST /api/posts failed"); |
| 42 | return NextResponse.json( |
| 43 | { error: "Internal server error", code: "INTERNAL_ERROR" }, |
| 44 | { status: 500 } |
| 45 | ); |
| 46 | } |
| 47 | } |
Centralized error handling keeps route handlers small and consistent. Create a wrapper function that catches errors, logs them, and maps them to the right HTTP response. This avoids repeating try/catch blocks in every route and ensures that error messages are consistent across the API.
| 1 | // lib/api-errors.ts |
| 2 | import { NextResponse } from "next/server"; |
| 3 | |
| 4 | export class ApiError extends Error { |
| 5 | constructor( |
| 6 | public status: number, |
| 7 | public code: string, |
| 8 | message: string |
| 9 | ) { |
| 10 | super(message); |
| 11 | this.name = "ApiError"; |
| 12 | } |
| 13 | } |
| 14 | |
| 15 | export function handleApiError(error: unknown) { |
| 16 | if (error instanceof ApiError) { |
| 17 | return NextResponse.json( |
| 18 | { error: error.message, code: error.code }, |
| 19 | { status: error.status } |
| 20 | ); |
| 21 | } |
| 22 | |
| 23 | logger.error({ error }, "Unhandled API error"); |
| 24 | return NextResponse.json( |
| 25 | { error: "Internal server error", code: "INTERNAL_ERROR" }, |
| 26 | { status: 500 } |
| 27 | ); |
| 28 | } |
| 29 | |
| 30 | // app/api/posts/[id]/route.ts |
| 31 | import { NextResponse } from "next/server"; |
| 32 | import { ApiError, handleApiError } from "@/lib/api-errors"; |
| 33 | |
| 34 | export async function GET( |
| 35 | _request: Request, |
| 36 | { params }: { params: Promise<{ id: string }> } |
| 37 | ) { |
| 38 | try { |
| 39 | const { id } = await params; |
| 40 | const post = await db.post.findUnique({ where: { id } }); |
| 41 | |
| 42 | if (!post) { |
| 43 | throw new ApiError(404, "NOT_FOUND", "Post not found"); |
| 44 | } |
| 45 | |
| 46 | return NextResponse.json({ data: post }); |
| 47 | } catch (error) { |
| 48 | return handleApiError(error); |
| 49 | } |
| 50 | } |
Zod validation errors should be transformed before sending to the client. The raw Zod issue array contains internal paths and messages that may not match your UI. Map issues to a simpler shape, or return a single human-readable message and let the client re-validate with the same schema if it needs field-level errors.
info
Not all errors happen on the server. Client-side exceptions, failed network requests, and unhandled promise rejections need their own tracking layer. React error boundaries catch rendering errors, but event handlers and async code need explicit try/catch or a global listener.
A client error boundary is a regular React component with componentDidCatch. It is useful for wrapping risky components or third-party widgets that might throw. Keep it small and focused, and report the error to the same service you use for server errors so you have a single view of application health.
| 1 | "use client"; |
| 2 | |
| 3 | import { Component, ReactNode } from "react"; |
| 4 | import * as Sentry from "@sentry/nextjs"; |
| 5 | |
| 6 | interface Props { |
| 7 | children: ReactNode; |
| 8 | fallback: ReactNode; |
| 9 | } |
| 10 | |
| 11 | interface State { |
| 12 | hasError: boolean; |
| 13 | } |
| 14 | |
| 15 | export class ClientErrorBoundary extends Component<Props, State> { |
| 16 | state: State = { hasError: false }; |
| 17 | |
| 18 | static getDerivedStateFromError(): State { |
| 19 | return { hasError: true }; |
| 20 | } |
| 21 | |
| 22 | componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { |
| 23 | Sentry.captureException(error, { extra: errorInfo }); |
| 24 | } |
| 25 | |
| 26 | render() { |
| 27 | if (this.state.hasError) { |
| 28 | return this.props.fallback; |
| 29 | } |
| 30 | return this.props.children; |
| 31 | } |
| 32 | } |
Error boundaries do not catch errors inside useEffect callbacks or other async code. For those, wrap the body of the effect in try/catch and report the error manually. Alternatively, use a global window.addEventListener("error", ...) and window.addEventListener("unhandledrejection", ...) to catch errors that escape your components.
| 1 | "use client"; |
| 2 | |
| 3 | import { useEffect, useState } from "react"; |
| 4 | import * as Sentry from "@sentry/nextjs"; |
| 5 | |
| 6 | export function useFetchData<T>(url: string) { |
| 7 | const [data, setData] = useState<T | null>(null); |
| 8 | const [error, setError] = useState<Error | null>(null); |
| 9 | |
| 10 | useEffect(() => { |
| 11 | let cancelled = false; |
| 12 | |
| 13 | async function fetchData() { |
| 14 | try { |
| 15 | const response = await fetch(url); |
| 16 | if (!response.ok) { |
| 17 | throw new Error(`HTTP ${response.status}`); |
| 18 | } |
| 19 | const json = await response.json(); |
| 20 | if (!cancelled) setData(json); |
| 21 | } catch (err) { |
| 22 | const error = err instanceof Error ? err : new Error(String(err)); |
| 23 | Sentry.captureException(error, { tags: { effect: "useFetchData" } }); |
| 24 | if (!cancelled) setError(error); |
| 25 | } |
| 26 | } |
| 27 | |
| 28 | fetchData(); |
| 29 | return () => { cancelled = true; }; |
| 30 | }, [url]); |
| 31 | |
| 32 | return { data, error }; |
| 33 | } |
Sentry integration for Next.js is done in two files: sentry.server.config.ts for server-side errors and sentry.client.config.ts for client-side errors. The SDK instruments route handlers, Server Components, client components, and error boundaries automatically. It also captures performance data if you enable tracing.
| 1 | // sentry.client.config.ts |
| 2 | import * as Sentry from "@sentry/nextjs"; |
| 3 | |
| 4 | Sentry.init({ |
| 5 | dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, |
| 6 | environment: process.env.NEXT_PUBLIC_VERCEL_ENV || "development", |
| 7 | tracesSampleRate: 0.1, |
| 8 | replaysSessionSampleRate: 0.01, |
| 9 | replaysOnErrorSampleRate: 1.0, |
| 10 | }); |
| 11 | |
| 12 | // sentry.server.config.ts |
| 13 | import * as Sentry from "@sentry/nextjs"; |
| 14 | |
| 15 | Sentry.init({ |
| 16 | dsn: process.env.SENTRY_DSN, |
| 17 | environment: process.env.VERCEL_ENV || "development", |
| 18 | tracesSampleRate: 0.1, |
| 19 | }); |
| 20 | |
| 21 | // next.config.ts |
| 22 | import { withSentryConfig } from "@sentry/nextjs"; |
| 23 | |
| 24 | const nextConfig = withSentryConfig( |
| 25 | { /* your existing config */ }, |
| 26 | { |
| 27 | org: "your-org", |
| 28 | project: "your-project", |
| 29 | silent: true, |
| 30 | } |
| 31 | ); |
| 32 | |
| 33 | export default nextConfig; |
pro tip
Logs are the primary source of truth for debugging production failures. A good log entry is structured, timestamped, levelled, and correlated with a request. Structured logging means outputting JSON instead of plain strings, so log aggregation systems can query by field without regex parsing.
pino is a fast, low-overhead JSON logger for Node.js. It is a good default for Next.js applications because it produces structured logs with minimal configuration and integrates well with OpenTelemetry. Winston is an older, more flexible logger with many transports. Choose pino when you want speed and simplicity; choose winston when you need complex transport pipelines.
| 1 | // lib/logger.ts |
| 2 | import pino from "pino"; |
| 3 | |
| 4 | export const logger = pino({ |
| 5 | level: process.env.LOG_LEVEL || "info", |
| 6 | transport: |
| 7 | process.env.NODE_ENV === "development" |
| 8 | ? { target: "pino-pretty", options: { colorize: true } } |
| 9 | : undefined, |
| 10 | base: { |
| 11 | service: "forgelearn-web", |
| 12 | version: process.env.VERCEL_GIT_COMMIT_SHA || "local", |
| 13 | }, |
| 14 | }); |
| 15 | |
| 16 | // Usage |
| 17 | logger.info({ userId: "user_123", path: "/dashboard" }, "Page rendered"); |
| 18 | logger.error({ error, userId: "user_123" }, "Failed to load dashboard"); |
| 19 | logger.warn({ postId: "post_456" }, "Post has no content"); |
Correlation IDs tie together all logs from a single request. Without them, a busy server's logs are an interleaved mess. In Next.js, you can generate a request ID in middleware and pass it to Server Components and route handlers via headers. The same ID should be included in every log entry and returned to the client in a response header for support triage.
| 1 | // middleware.ts |
| 2 | import { NextResponse } from "next/server"; |
| 3 | import type { NextRequest } from "next/server"; |
| 4 | import { randomUUID } from "crypto"; |
| 5 | |
| 6 | export function middleware(request: NextRequest) { |
| 7 | const requestId = request.headers.get("x-request-id") || randomUUID(); |
| 8 | const response = NextResponse.next(); |
| 9 | response.headers.set("x-request-id", requestId); |
| 10 | return response; |
| 11 | } |
| 12 | |
| 13 | export const config = { |
| 14 | matcher: "/((?!_next/static|_next/image|favicon.ico|.*\.\w+$).*)", |
| 15 | }; |
| 16 | |
| 17 | // lib/request-context.ts |
| 18 | import { headers } from "next/headers"; |
| 19 | |
| 20 | export async function getRequestId() { |
| 21 | const h = await headers(); |
| 22 | return h.get("x-request-id") || "unknown"; |
| 23 | } |
| 24 | |
| 25 | // app/dashboard/page.tsx |
| 26 | import { logger } from "@/lib/logger"; |
| 27 | import { getRequestId } from "@/lib/request-context"; |
| 28 | |
| 29 | export default async function DashboardPage() { |
| 30 | const requestId = await getRequestId(); |
| 31 | logger.info({ requestId, path: "/dashboard" }, "Rendering dashboard"); |
| 32 | // ... |
| 33 | } |
OpenTelemetry is the open standard for distributed tracing. A trace follows a request through every service it touches: edge function, server function, database, external API, and cache. In Next.js, you can add OpenTelemetry instrumentation by creating an instrumentation.ts file at the project root and registering an SDK.
| 1 | // instrumentation.ts |
| 2 | export async function register() { |
| 3 | if (process.env.NEXT_RUNTIME === "nodejs") { |
| 4 | const { NodeSDK } = await import("@opentelemetry/sdk-node"); |
| 5 | const { OTLPTraceExporter } = await import("@opentelemetry/exporter-trace-otlp-http"); |
| 6 | const { getNodeAutoInstrumentations } = await import("@opentelemetry/auto-instrumentations-node"); |
| 7 | |
| 8 | const sdk = new NodeSDK({ |
| 9 | traceExporter: new OTLPTraceExporter({ |
| 10 | url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, |
| 11 | }), |
| 12 | serviceName: "forgelearn-web", |
| 13 | instrumentations: [getNodeAutoInstrumentations()], |
| 14 | }); |
| 15 | |
| 16 | sdk.start(); |
| 17 | } |
| 18 | } |
| 19 | |
| 20 | // lib/tracing.ts |
| 21 | import { trace } from "@opentelemetry/api"; |
| 22 | |
| 23 | const tracer = trace.getTracer("forgelearn-web"); |
| 24 | |
| 25 | export async function fetchWithSpan<T>( |
| 26 | name: string, |
| 27 | fn: () => Promise<T> |
| 28 | ): Promise<T> { |
| 29 | return tracer.startActiveSpan(name, async (span) => { |
| 30 | try { |
| 31 | const result = await fn(); |
| 32 | span.setStatus({ code: SpanStatusCode.OK }); |
| 33 | return result; |
| 34 | } catch (error) { |
| 35 | span.recordException(error as Error); |
| 36 | span.setStatus({ code: SpanStatusCode.ERROR }); |
| 37 | throw error; |
| 38 | } finally { |
| 39 | span.end(); |
| 40 | } |
| 41 | }); |
| 42 | } |
note
Monitoring is what turns logs and traces into action. A dashboard without alerts is just a pretty picture; alerts without thresholds are noise. Start with a small set of high-signal alerts: error rate, p95 latency, and request volume. Add business-specific alerts, such as failed checkout rate or signup conversion drop, once the basics are reliable.
| Metric | Why it matters | Typical threshold |
|---|---|---|
| 5xx rate | Server-side failures that affect users | > 0.1% over 5 min |
| 4xx rate | Client bugs, abuse, or broken links | > 5% over 15 min |
| p95 response time | Latency experienced by most users | > 500 ms over 10 min |
| p99 response time | Worst-case latency tail | > 1000 ms over 10 min |
| Function error rate | Server Action / route handler failures | > 1% over 5 min |
| Core Web Vitals | User experience and SEO | LCP > 2.5 s, CLS > 0.1 |
Distinguish 4xx and 5xx errors in your alerting logic. A spike in 400 responses usually means a client-side bug, a malformed API call, or a bot scraping your endpoints. A spike in 500 responses means something is broken in your infrastructure or code. Paging the on-call engineer for a 4xx spike is usually wrong; paging for a 5xx spike is almost always right.
Real User Monitoring (RUM) captures actual client performance and errors. Tools like Sentry, Datadog RUM, or LogRocket record page loads, navigation, JavaScript errors, and user interactions. RUM is essential for catching issues that synthetic tests miss, such as errors on specific devices, browsers, or network conditions. Start with error tracking and Core Web Vitals, then expand to session replay and navigation timing.
| 1 | // lib/web-vitals.ts |
| 2 | "use client"; |
| 3 | |
| 4 | import { onLCP, onFID, onCLS, onINP, onTTFB } from "web-vitals"; |
| 5 | import * as Sentry from "@sentry/nextjs"; |
| 6 | |
| 7 | export function reportWebVitals() { |
| 8 | onLCP(sendMetric); |
| 9 | onFID(sendMetric); |
| 10 | onCLS(sendMetric); |
| 11 | onINP(sendMetric); |
| 12 | onTTFB(sendMetric); |
| 13 | } |
| 14 | |
| 15 | function sendMetric(metric: { name: string; value: number; id: string }) { |
| 16 | Sentry.captureMessage(`Web Vital: ${metric.name}`, { |
| 17 | level: "info", |
| 18 | tags: { web_vital: metric.name }, |
| 19 | extra: { value: metric.value, id: metric.id }, |
| 20 | }); |
| 21 | } |
best practice
1. Swallowing errors with empty catch blocks. An empty catch hides bugs and makes failures invisible. Always log or rethrow, and only swallow errors when you have a specific degraded state to show.
2. Not using error boundaries. A single buggy widget can crash an entire page if there is no boundary around it. Add boundaries at major layout segments and around risky third-party components.
3. Leaking stack traces to users. Internal paths, database queries, and library internals belong in logs, not in user-facing error messages. Map errors to safe messages before rendering them.
4. Logging secrets or PII. Never log passwords, tokens, credit card numbers, or personal data. Redact sensitive fields before logging, and use field filters in your error reporting SDK.
5. Confusing notFound() with error.tsx. Use notFound() for missing resources and error.tsx for unexpected failures. They produce different status codes and different user experiences.
6. Ignoring client-side errors. Server logs miss JavaScript errors that happen in the browser. You need a client-side error tracker to see the full picture.
7. Returning 500 for validation failures. A 400 with clear validation errors is correct for bad input. Reserve 500 for genuine server failures.
8. Not using request IDs. Without correlation IDs, tracing a single request through logs is nearly impossible under load. Generate them in middleware and attach them to every log and response.
9. Over-alerting on every error. Alerts should be actionable. If an alert fires and the response is always "ignore it," the threshold or signal is wrong.
10. Assuming logs are enough. Logs tell you what happened; traces tell you why and where. Invest in distributed tracing to understand latency and failures across service boundaries.
danger