Observability, Logging, Errors, and Analytics
Observability in a Next.js application means being able to answer what happened, where it happened, who or what triggered it, how long it took, and whether users were affected. Logging, error handling, tracing, metrics, and analytics are separate signals, but they become useful when they share stable identifiers such as route, deployment, request id, user id hash, and operation name.
This lesson sits in the operations part of the Next.js course because performance work is incomplete without evidence. A slow dashboard, a failed checkout, or a broken Server Component fetch cannot be fixed reliably from screenshots alone. By the end, you should be able to instrument App Router code paths, structure logs for machines instead of humans, separate expected user errors from defects, and collect analytics without leaking sensitive data.
How Next.js Produces Signals
A Next.js request can pass through several runtimes. Middleware may run before routing. A route handler may execute on the server or edge runtime. A Server Component may fetch data during render. A Client Component may report user interactions after hydration. A server action may mutate data after a form submission. Observability design starts by naming which runtime owns each signal.
Logs are event records emitted by code. They should be structured objects, not long sentences, because production log systems filter and aggregate fields. Errors are exceptional outcomes represented by thrown values, rejected promises, HTTP status codes, or React error boundaries. Traces connect multiple spans into one timed path, such as middleware, database query, render, and response. Metrics aggregate numeric behavior over time, such as request latency or cache hit rate. Analytics describe user behavior, such as page views, signup funnel steps, and feature usage.
Next.js also has special files that affect failure behavior. In the App Router, error.tsx catches rendering errors for a route segment and must be a Client Component. global-error.tsx handles root-level failures. not-found.tsx is for intentional missing resources, not system crashes. Route handlers can return explicit status codes. Instrumentation code can initialize telemetry once when the server process starts. These mechanisms should complement each other instead of all trying to catch the same problem.
API Anatomy
A practical setup usually has four small layers. First, a logger function normalizes fields and redacts sensitive data. Second, request code creates or forwards a correlation id. Third, segment-level error UI reports defects and offers recovery. Fourth, analytics events are accepted by a narrow route handler that validates event names and payload shape.
Choose field names deliberately. Useful log fields include level, event, route, requestId, durationMs, status, and errorName. Avoid logging passwords, tokens, cookies, authorization headers, raw credit card fields, and full request bodies. Use identifiers that let an operator join evidence without exposing private content.
Example 1: Structured Server Logs
This first example is intentionally small. It creates JSON logs that work in local development, container logs, and hosted platforms that ingest stdout. The deterministic behavior is that each call prints one JSON object with an ISO timestamp and named event fields.
function logEvent(level, event, fields = {}) {
const record = {
level,
event,
time: new Date("2026-09-06T00:00:00.000Z").toISOString(),
...fields,
};
console.log(JSON.stringify(record));
}
logEvent("info", "course.loaded", {
route: "/courses/nextjs",
requestId: "req_123",
durationMs: 42,
});
The output is a single parseable line: {"level":"info","event":"course.loaded","time":"2026-09-06T00:00:00.000Z","route":"/courses/nextjs","requestId":"req_123","durationMs":42}. In real code, use the current time rather than a fixed timestamp, but keep the stable field names. Do not put the whole response object into the log; log the route, outcome, and measured duration.
Example 2: Segment Error Boundaries
Rendering errors in an App Router segment are handled by an error.tsx file in that segment. The component receives the error and a reset function. It runs in the browser, so it should show a recovery control and report a sanitized error summary, not secrets from server-side context.
"use client";
import { useEffect } from "react";
export default function CourseError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(JSON.stringify({
level: "error",
event: "segment.render_failed",
errorName: error.name,
digest: error.digest,
route: "/courses/nextjs",
}));
}, [error]);
return (
<main>
<h2>Course section unavailable</h2>
<p>Refresh this section or return to the course outline.</p>
<button onClick={reset}>Try again</button>
</main>
);
}
The expected user behavior is that a failed segment shows this fallback instead of blanking the whole app. The expected operator behavior is that the console or client log collector receives segment.render_failed with a digest that can be compared with server logs. The boundary is not a replacement for fixing the bug; it keeps navigation recoverable while preserving evidence.
Example 3: Request Timing in a Route Handler
Route handlers are good places to measure API latency because they have a clear start, finish, status code, and error path. This example accepts a request, performs work, logs duration, and returns either data or a controlled failure response.
import { NextResponse } from "next/server";
export async function GET(request: Request) {
const started = performance.now();
const requestId = request.headers.get("x-request-id") ?? crypto.randomUUID();
try {
const data = { ok: true, lessons: 27 };
const durationMs = Math.round(performance.now() - started);
console.log(JSON.stringify({
level: "info",
event: "api.course_summary",
route: "/api/course-summary",
requestId,
status: 200,
durationMs,
}));
return NextResponse.json(data, { headers: { "x-request-id": requestId } });
} catch (error) {
const durationMs = Math.round(performance.now() - started);
console.error(JSON.stringify({
level: "error",
event: "api.course_summary_failed",
route: "/api/course-summary",
requestId,
status: 500,
durationMs,
errorName: error instanceof Error ? error.name : "UnknownError",
}));
return NextResponse.json({ error: "internal_error", requestId }, { status: 500 });
}
}
On success, callers receive JSON with ok and lessons, and operators receive an api.course_summary log containing duration and status. On failure, the client receives a stable error code and request id, while details stay in server logs. That split is important: the user gets something supportable, and the system avoids exposing stack traces.
Example 4: Privacy-Aware Analytics
Analytics events should be constrained. A route that accepts any event name and arbitrary payload will eventually collect secrets or unusable noise. Validate event names, keep payloads small, and attach server-known context such as user agent category or request id on the server.
import { NextResponse } from "next/server";
const allowedEvents = new Set(["lesson_started", "lesson_completed", "exercise_checked"]);
export async function POST(request: Request) {
const body = await request.json();
if (!allowedEvents.has(body.event) || typeof body.lessonSlug !== "string") {
return NextResponse.json({ error: "invalid_analytics_event" }, { status: 400 });
}
console.log(JSON.stringify({
level: "info",
event: "analytics.received",
analyticsEvent: body.event,
lessonSlug: body.lessonSlug,
}));
return NextResponse.json({ accepted: true });
}
If the browser sends {"event":"lesson_completed","lessonSlug":"nextjs-observability-logging-errors-and-analytics"}, the deterministic response is {"accepted":true}. If it sends {"event":"password_typed"}, the response is a 400 with invalid_analytics_event. This keeps analytics useful and reduces accidental data collection.
Design Choices and Trade-offs
Logging everything feels safe, but it increases cost, slows diagnosis, and raises privacy risk. Logging too little makes incidents opaque. A good middle ground is high-cardinality identifiers for debugging, low-cardinality event names for aggregation, and sampling for noisy success paths. Keep error logs complete enough to identify the failing operation, but store sensitive detail in the protected system of record rather than logs.
Client-side analytics sees browser behavior that the server cannot, such as button clicks and abandoned flows. Server-side analytics is harder to block and easier to validate, but it cannot see every interaction. Use both when the distinction matters: client events for product behavior, server events for authoritative outcomes such as completed purchase, enrollment, or mutation.
Error boundaries improve resilience, but they can hide recurring defects if teams only watch page availability. Pair every fallback with telemetry. Similarly, tracing is powerful when services call each other, but it adds dependency and setup overhead. Start with structured logs and request ids; add traces when you need to follow work across multiple internal services or queues.
Failure Modes and Troubleshooting
Symptom: production users report a blank course page, but logs show no server error. Cause: the failure occurs during client hydration or inside a Client Component effect. Diagnose: reproduce with browser devtools, check client error reporting, and inspect whether the affected route has an error.tsx. Correct: add a segment boundary, report sanitized client errors, and move data assumptions into server validation where possible.
Symptom: a log search for one failed request returns unrelated entries. Cause: request ids are generated in several places and not forwarded. Diagnose: compare response headers, route handler logs, and downstream fetch headers. Correct: create the id at the edge or first server boundary, return it to the client, and forward it in internal calls.
Symptom: analytics dashboards show impossible counts, such as more completions than starts. Cause: client events are retried, duplicated across tabs, or emitted before authentication state settles. Diagnose: inspect event timestamps, session identifiers, and route transitions. Correct: deduplicate by event id, record authoritative completion on the server, and define whether analytics counts events, sessions, or users.
Security, Performance, and Reliability
Observability data often contains operationally sensitive information. Treat log storage and analytics tools as privileged systems. Redact tokens, avoid raw payloads, hash or pseudonymize user identifiers when possible, and set retention periods. Make sure support staff can use request ids without gaining broad access to private data.
Instrumentation also has runtime cost. Synchronous logging on hot paths can add latency. Large analytics payloads compete with user traffic. Error reporters can create loops if the reporting route itself throws. Keep payloads bounded, use nonblocking delivery where appropriate, sample high-volume success events, and test behavior when the telemetry provider is unavailable.
Hands-on Lab
Prerequisites: a local Next.js App Router project, Node installed, and one route you can safely modify. No external observability vendor is required; stdout and browser devtools are enough.
- Create a small logger module that accepts
level,event, and field objects, then prints one JSON object per line. - Add timing and a request id to one route handler. Return the request id as a response header and include it in both success and failure logs.
- Add an
error.tsxfile to the route segment. Render a concise fallback and callresetfrom a retry button. - Add an analytics route that accepts only three named lesson events and rejects everything else with status 400.
- Trigger one successful request, one rejected analytics event, and one deliberate rendering error.
Verification: confirm that each server request emits parseable JSON, that the response header request id matches the server log, that the route segment shows fallback UI on render failure, and that invalid analytics input returns invalid_analytics_event. Use node -e 'JSON.parse(process.argv[1])' '{...}' with one copied log line to prove it is valid JSON.
Cleanup: remove deliberate throws, remove test-only fixed timestamps, and keep the logger, request id handling, and validation. If you added a temporary noisy log, delete it or lower it behind a development-only condition.
Assessment
- A Server Component throws while loading course data. Which signals would you expect from the server, from
error.tsx, and from the browser, and what should each one omit? - Design log fields for a slow server action that saves an exercise answer. Include enough information to debug latency without logging the answer text.
- An analytics provider is down for ten minutes. What should happen to course navigation, mutation routes, and client-side event delivery?
- Explain when a 404, a validation error, and an exception should be represented differently in a Next.js route.
- Given duplicate
lesson_completedevents from one user, propose a deduplication key and explain its trade-off.
Summary
Next.js observability is built from runtime-aware signals: structured server logs, segment error boundaries, route-handler status and timing, request correlation, and constrained analytics events. The goal is not more data; it is better evidence. Instrument the server-client boundary, keep sensitive values out of telemetry, validate analytics input, and verify both success and failure paths before relying on dashboards in production.
