|$ curl https://forge-ai.dev/api/markdown?path=docs/nextjs/error-handling
$cat docs/error-handling-&-observability-in-next.js.md
updated Last weekยท30 min readยทpublished

Error Handling & Observability in Next.js

โ—†Next.jsโ—†Reliabilityโ—†Monitoringโ—†Intermediate to Advanced๐ŸŽฏFree Tools
Introduction

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.

Error Boundaries

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.

app/dashboard/error.tsx
TSX
1"use client";
2
3import { useEffect } from "react";
4
5export 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

Error boundaries only catch errors during rendering. They do not catch errors inside event handlers, asynchronous code that runs after mount, or errors thrown in Server Actions. Those need explicit handling with try/catch or a global error handler.

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.

error_with_sentry.tsx
TSX
1"use client";
2
3import { useEffect } from "react";
4import * as Sentry from "@sentry/nextjs";
5
6export 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}
Not Found & Loading States

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.

not-found.tsx
TSX
1// app/blog/[slug]/page.tsx
2import { notFound } from "next/navigation";
3import { getPost } from "@/lib/posts";
4
5export 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
26import Link from "next/link";
27
28export 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.

loading.tsx
TSX
1// app/dashboard/loading.tsx
2export 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
17import { getDashboardData } from "@/lib/dashboard";
18
19export 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.

app/global-error.tsx
TSX
1"use client";
2
3import { useEffect } from "react";
4import * as Sentry from "@sentry/nextjs";
5
6export 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

Global errors should be rare. If you find yourself hitting global-error.tsx often, add more granular error.tsx boundaries closer to the source of the failures. The global boundary is for catastrophic failures, not routine errors.
Handling Errors in Server Components

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.

server-component-graceful.tsx
TSX
1// app/dashboard/page.tsx
2import { getDashboardData } from "@/lib/dashboard";
3import { logger } from "@/lib/logger";
4
5export 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.

typed-errors.tsx
TSX
1// lib/errors.ts
2export class NotFoundError extends Error {
3 constructor(resource: string) {
4 super(`${resource} not found`);
5 this.name = "NotFoundError";
6 }
7}
8
9export class AuthorizationError extends Error {
10 constructor() {
11 super("Forbidden");
12 this.name = "AuthorizationError";
13 }
14}
15
16export 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
25import { NotFoundError, DatabaseError } from "@/lib/errors";
26
27export 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
45import { notFound } from "next/navigation";
46import { getDashboardData } from "@/lib/dashboard";
47import { NotFoundError } from "@/lib/errors";
48
49export 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

Log errors as close to the failure as possible. If you rethrow an error, attach the original cause so the final log entry contains the full chain. A bare rethrow without context makes root-cause analysis much harder.
Handling Errors in Server Actions

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.

server-action-state.tsx
TSX
1// app/actions/contact.ts
2"use server";
3
4import { z } from "zod";
5
6const 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
12export type ContactFormState =
13 | { success: true; message: string }
14 | { success: false; errors: Record<string, string[]>; message: string };
15
16export 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
49import { useActionState } from "react";
50import { submitContactForm } from "@/app/actions/contact";
51
52export 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.

server-action-throw.tsx
TSX
1// app/actions/delete-post.ts
2"use server";
3
4import { revalidatePath } from "next/cache";
5import { auth } from "@/auth";
6
7export 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
28import { useState } from "react";
29import { deletePost } from "@/app/actions/delete-post";
30
31export 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

Never return raw database errors or stack traces from a Server Action. They leak schema details and internal paths. Always map internal failures to safe, user-facing messages before the client sees them.
Route Handler Errors

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.

route-handler-errors.ts
TSX
1// app/api/posts/route.ts
2import { NextResponse } from "next/server";
3import { z } from "zod";
4import { auth } from "@/auth";
5
6const createPostSchema = z.object({
7 title: z.string().min(1),
8 content: z.string().min(1),
9});
10
11export 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.

api-error-wrapper.ts
TSX
1// lib/api-errors.ts
2import { NextResponse } from "next/server";
3
4export 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
15export 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
31import { NextResponse } from "next/server";
32import { ApiError, handleApiError } from "@/lib/api-errors";
33
34export 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

Distinguish 4xx from 5xx errors in your logs and dashboards. 4xx errors are usually client bugs or abuse; 5xx errors are operational issues that need immediate attention. Blending them together hides real problems.
Client-Side Error Tracking

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.

ClientErrorBoundary.tsx
TSX
1"use client";
2
3import { Component, ReactNode } from "react";
4import * as Sentry from "@sentry/nextjs";
5
6interface Props {
7 children: ReactNode;
8 fallback: ReactNode;
9}
10
11interface State {
12 hasError: boolean;
13}
14
15export 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.

useFetchData.ts
TSX
1"use client";
2
3import { useEffect, useState } from "react";
4import * as Sentry from "@sentry/nextjs";
5
6export 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.

sentry-config.ts
TSX
1// sentry.client.config.ts
2import * as Sentry from "@sentry/nextjs";
3
4Sentry.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
13import * as Sentry from "@sentry/nextjs";
14
15Sentry.init({
16 dsn: process.env.SENTRY_DSN,
17 environment: process.env.VERCEL_ENV || "development",
18 tracesSampleRate: 0.1,
19});
20
21// next.config.ts
22import { withSentryConfig } from "@sentry/nextjs";
23
24const nextConfig = withSentryConfig(
25 { /* your existing config */ },
26 {
27 org: "your-org",
28 project: "your-project",
29 silent: true,
30 }
31);
32
33export default nextConfig;
๐Ÿ”ฅ

pro tip

Use Sentry replays sparingly; they record user sessions and can capture sensitive input. Set a low sample rate for normal sessions and a high rate for sessions that contain an error. Always scrub PII from replay and event payloads.
Logging & Observability

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.

pino-logger.ts
TSX
1// lib/logger.ts
2import pino from "pino";
3
4export 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
17logger.info({ userId: "user_123", path: "/dashboard" }, "Page rendered");
18logger.error({ error, userId: "user_123" }, "Failed to load dashboard");
19logger.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.

request-id-middleware.ts
TSX
1// middleware.ts
2import { NextResponse } from "next/server";
3import type { NextRequest } from "next/server";
4import { randomUUID } from "crypto";
5
6export 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
13export const config = {
14 matcher: "/((?!_next/static|_next/image|favicon.ico|.*\.\w+$).*)",
15};
16
17// lib/request-context.ts
18import { headers } from "next/headers";
19
20export async function getRequestId() {
21 const h = await headers();
22 return h.get("x-request-id") || "unknown";
23}
24
25// app/dashboard/page.tsx
26import { logger } from "@/lib/logger";
27import { getRequestId } from "@/lib/request-context";
28
29export 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.

instrumentation.ts
TSX
1// instrumentation.ts
2export 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
21import { trace } from "@opentelemetry/api";
22
23const tracer = trace.getTracer("forgelearn-web");
24
25export 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

OpenTelemetry in Next.js is still evolving. Vercel provides experimental runtime support, and some features require the Node.js runtime rather than the edge runtime. Test your instrumentation locally and in a staging environment before relying on it in production.
Monitoring Best Practices

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.

MetricWhy it mattersTypical threshold
5xx rateServer-side failures that affect users> 0.1% over 5 min
4xx rateClient bugs, abuse, or broken links> 5% over 15 min
p95 response timeLatency experienced by most users> 500 ms over 10 min
p99 response timeWorst-case latency tail> 1000 ms over 10 min
Function error rateServer Action / route handler failures> 1% over 5 min
Core Web VitalsUser experience and SEOLCP > 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.

web-vitals.ts
TSX
1// lib/web-vitals.ts
2"use client";
3
4import { onLCP, onFID, onCLS, onINP, onTTFB } from "web-vitals";
5import * as Sentry from "@sentry/nextjs";
6
7export function reportWebVitals() {
8 onLCP(sendMetric);
9 onFID(sendMetric);
10 onCLS(sendMetric);
11 onINP(sendMetric);
12 onTTFB(sendMetric);
13}
14
15function 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

Set alerts on symptoms, not causes. "High error rate" and "Slow checkout" are symptoms that users feel. "CPU usage high" is a cause that may or may not affect users. Symptom-based alerts reduce pager fatigue and focus response on real impact.
Common Pitfalls

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

Client-side error logs can contain user input. Always review what your tracking SDK captures, and configure scrubbing or sampling rules before enabling broad logging or session replay.
$Blueprint โ€” Engineering DocumentationยทSection ID: NEXT-ERRORSยทRevision: 1.0