Debugging, Instrumentation & Observability
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Next.js 16.3.8 (the latest release on 2026-10-03, when the facts and examples below were
checked; the official documentation reports @doc-version 16.3.8), React 19.3.0, Node.js 20.9.0 or later and
TypeScript 5.1 or later. Debugging, instrumentation.ts, instrumentation-client.ts, OpenTelemetry and
useReportWebVitals work in both routers, and the page says so where the Pages Router differs. The development
tools that talk to a running page (the on-screen Next.js DevTools, the MCP server, the logging option) are
dev-server features and exist in neither router’s production build. Caching is not involved: nothing here depends on
Cache Components, and the examples were built with the default (previous) model. Every example was built and run with
Next.js 16.3.8, Node.js 24.16 and npm 11.13 on macOS, in a scratch copy of the Bookshelf catalogue that this
section grows (the Pages Router examples in a second scratch project), and each one is followed by the official page
it derives from.
The page answers three questions in order of when you ask them: why does this code behave like this? (debugging, in development), what is the framework telling me or my coding agent? (the dev-only DevTools, logging and MCP tooling) and what is happening to real users and real requests? (instrumentation, OpenTelemetry and analytics in production). It does not teach OpenTelemetry itself, a metrics backend or a log pipeline; the Spring Boot, Quarkus and Prometheus sections of this site cover those from the back-end side.
A Map of the Tools
| Question | Tool | Where it runs |
|---|---|---|
Why is this variable wrong at this line? |
The Node.js debugger ( |
Dev (and |
What did the server just do? |
The |
Dev only ( |
What does my coding agent see of the running app? |
The Next.js DevTools MCP server and the version-matched docs in |
Dev only |
Which server errors happen in production? |
|
Server (Node.js and Edge runtimes) |
Where does a request spend its time? |
OpenTelemetry traces: the spans Next.js emits plus your own |
Server |
What do real visitors experience? |
|
Browser |
Debugging
Next.js code runs in two places, so a bug needs two debuggers: the Node.js process that renders Server Components, runs Route Handlers, Server Actions and the Proxy, and the browser that hydrates and runs Client Components. The official guide’s promise is full source-map support in both, using VS Code, Chrome DevTools or Firefox DevTools; any debugger that attaches to Node.js works. The one thing to remember about a Client Component is that it also runs on the server during the first render, so a breakpoint in it can be hit in either process.
VS Code
Create .vscode/launch.json at the project root. The official guide gives one configuration for the server, one for
the browser and one that starts both:
.vscode/launch.json, from the official guide (parsed as valid JSON before use){
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: debug server-side",
"type": "node-terminal",
"request": "launch",
"command": "npm run dev -- --inspect"
},
{
"name": "Next.js: debug client-side",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3000"
},
{
"name": "Next.js: debug full stack",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/node_modules/next/dist/bin/next",
"runtimeArgs": ["--inspect"],
"skipFiles": ["<node_internals>/**"],
"serverReadyAction": {
"action": "debugWithChrome",
"killOnServerStop": true,
"pattern": "- Local:.+(https?://.+)",
"uriFormat": "%s",
"webRoot": "${workspaceFolder}"
}
}
]
}
Pick a configuration in the Run and Debug panel (Ctrl+Shift+D, or Shift+Cmd+D on macOS) and press F5. Details
from the guide:
-
The guide’s full-stack configuration uses
debugWithEdge; the example above setsdebugWithChrome, which is what the guide says to use with Chrome. A Firefox configuration ("type": "firefox") needs the Firefox Debugger extension and apathMappingsentry. -
If you changed the port, change
3000in the URLs. In a monorepo (for example Turborepo) addcwd, such as"cwd": "${workspaceFolder}/apps/web", to the server-side and full-stack configurations. -
npm run devcan beyarn devorpnpm dev. For WebStorm, create a JavaScript Debug configuration with the dev URL; you end up with two applications in debug mode, the Node.js process and the browser.
Source: Next.js: Debugging
The Node.js inspector: --inspect
next dev --inspect and next start --inspect pass the flag to the underlying Node.js process. With
npm run dev — --inspect the terminal reports the debugger address, and with a custom address the guide’s
--inspect=host:port form works as in Node.js. This was the output of the dev server started on a scratch port, and
the inspector answered on its HTTP endpoint (--inspect=127.0.0.1:9805 was used here to keep it off the default
port 9229; the <id> is a per-run UUID):
$ npx next dev -p 3805 --inspect=127.0.0.1:9805
Debugger listening on ws://127.0.0.1:9805/<id>
For help, see: https://nodejs.org/learn/getting-started/debugging
▲ Next.js 16.3.8 (Turbopack)
- Local: http://localhost:3805
- Debugger port: 9805
✓ Ready in 139ms
$ curl -s localhost:9805/json/version
{
"Browser": "node.js/v24.16.0",
"Protocol-Version": "1.1"
}
next start --inspect=127.0.0.1:9805 printed the same Debugger listening and - Debugger port: 9805 lines, so
the inspector is also available for a production build running locally. When I pinned a custom host:port to a dev
server, a second Starting inspector on 127.0.0.1:9805 failed: address already in use line appeared (a worker process
trying the same address) while the main process kept listening; the plain --inspect, which lets Node.js pick its
default, avoids the noise.
Two flags need a different spelling. --inspect-brk (pause on the first line) and --inspect-wait cannot be passed
to next dev directly; the guide says to set NODE_OPTIONS instead, as in NODE_OPTIONS=--inspect-brk next dev.
The CLI reference shows the general form (NODE_OPTIONS='--inspect' next).
npm run dev -- --inspect # attach later
NODE_OPTIONS=--inspect-brk npm run dev # wait for the debugger before running anything
npm run dev -- --inspect=0.0.0.0 # reachable from outside localhost, e.g. a Docker container
The inspector is remote code execution by design: anyone who can reach the port can run arbitrary JavaScript in your
server process. --inspect=0.0.0.0, which the guide mentions for containers, is for a development container behind a
port mapping bound to localhost, never for a shared network or a production host.
Browser DevTools
For client code, run next dev, open the page and open the browser DevTools: the Sources tab in Chrome, the
Debugger tab in Firefox. A debugger statement pauses there, and Ctrl+P (or Cmd+P) searches files so you can
set breakpoints by hand; client sources are under webpack://_N_E/./.
For server code, open chrome://inspect in Chrome (or about:debugging and This Firefox in Firefox) while the
server runs with --inspect, find the Next.js process under Remote Target and click inspect. Server sources are
listed under webpack://<application-name>/./, where the name comes from your package.json. When the error overlay
appears for a server error, a Node.js icon under the version label copies the DevTools URL of the server process to
your clipboard; open it in a new browser tab to inspect the process that failed. The overlay, the route indicator and
the other development UI are described on Getting Started.
Install the React Developer Tools extension for the component tree, props and state, and profiling. On Windows, the guide recommends disabling Windows Defender scanning of the project: it checks every file read and slows Fast Refresh.
Debugging the build
Errors that appear only in next build are harder, because production builds minify server code. Three flags exist for
that, and a prerender failure shows what they change. This page throws while prerendering a static route (the file was
removed afterwards):
// app/failbuild/page.tsx
function loadPrice(): number {
throw new Error("price service is down");
}
export default function Page() {
return <p>{loadPrice()}</p>;
}
next build stops with the frame in the right file and line, but the stack names are anonymous:
next buildError occurred prerendering page "/failbuild". Read more: https://nextjs.org/docs/messages/prerender-error
Error: price service is down
at <unknown> (app/failbuild/page.tsx:2:9)
at <unknown> (app/failbuild/page.tsx:3:1)
1 | function loadPrice(): number {
> 2 | throw new Error("price service is down");
| ^
3 | }
...
Export encountered an error on /failbuild/page: /failbuild, exiting the build.
With next build --debug-prerender the server code is not minified, server source maps are generated, and the build
continues after the first error so you see every failing page at once:
next build --debug-prerender⚠ Prerendering is running in debug mode with NODE_ENV='development'. This will affect performance and should not be used for production.
- Experiments (use with caution):
✓ allowDevelopmentBuild (enabled by `--debug-prerender`)
⨯ prerenderEarlyExit (disabled by `--debug-prerender`)
✓ serverSourceMaps (enabled by `--debug-prerender`)
⨯ turbopackMinify (disabled by `--debug-prerender`)
...
Error occurred prerendering page "/failbuild". Read more: https://nextjs.org/docs/messages/prerender-error
Error: price service is down
at loadPrice (app/failbuild/page.tsx:2:9)
at Page (app/failbuild/page.tsx:6:14)
> 2 | throw new Error("price service is down");
...
> Export encountered errors on 1 path:
/failbuild/page: /failbuild
The function names (loadPrice, Page) are back. The docs warn not to deploy a build made with this flag.
| Option | Effect | Since |
|---|---|---|
|
Un-minified server code, server source maps, keeps going after the first prerender error. Do not deploy the output. |
15.4 |
|
More verbose build output, such as the rewrites, redirects and headers. |
Not dated in the docs |
|
Builds only matching routes (comma-separated paths and globs, |
Not dated in the docs |
|
Disables name mangling for debugging; may affect performance. |
Not dated in the docs |
|
V8 CPU profiling; profiles are written to |
Experimental (not dated) |
|
Emits browser source maps next to the JavaScript and serves them on request. Increases build time and build memory, and exposes your source to anyone who can request the maps. |
Not dated in the docs |
Reading errors, and the digest
An error thrown while rendering a Server Component reaches the browser stripped of its message in production, so that
server details do not leak; what travels is a digest, a hash that also appears next to the full error in the server
log. To debug a production error, copy the digest from the browser (or from your error tracker) and search the server
logs for it. Hydration mismatches, the most common client-only error, show in development a diff in the overlay that
labels each differing line as coming from the client or the server (added in 16.2); the concept is covered on
Hydration. In production the same mismatch is reported as a minified React error
number: in the Server Action experiment below the browser’s window error listener received Minified React error
#441, and the number resolves at react.dev/errors/441.
The Next.js DevTools MCP Server and AGENTS.md
A coding agent that only reads your files cannot see what the running app does: the runtime error in the browser, the
route table, the compile error that Turbopack just hit. Next.js 16 addresses this with a
Model Context Protocol (MCP) server inside the dev server, and 16.2 and 16.3 with
version-matched documentation for agents. This is tooling for development; none of it exists in next start.
The MCP endpoint and next-devtools-mcp
next dev (16 or later) serves an MCP endpoint at /_next/mcp. The next-devtools-mcp package is the bridge: it
discovers running dev servers (several, on different ports), forwards tool calls to the right one and presents a single
interface to the agent. Register it in the .mcp.json file at the project root, the format the official guide gives:
.mcp.json{
"mcpServers": {
"next-devtools": {
"command": "npx",
"args": ["-y", "next-devtools-mcp@latest"]
}
}
}
Start npm run dev; MCP-compatible agents then connect on their own. If they do not, check that the project is on
Next.js 16 or later, that the config is loaded and restart the dev server. The guide lists these tools:
| Tool | What the agent gets |
|---|---|
|
Current build, runtime and type errors, with source-mapped stacks (the live version of the overlay). |
|
The path of the development log file, with browser console output and server output. |
|
Routes, components and rendering information of a page. |
|
Project structure, configuration and the dev server URL. |
|
Every route that will be an entry point, scanned from the file system and grouped by router ( |
|
The source file and function name of a Server Action, looked up by its ID. |
|
Compilation warnings for the whole project, and on-demand compilation of one route without an HTTP request. Turbopack only. |
The endpoint is plain JSON-RPC over HTTP, which makes it checkable without an agent. This is a dev server on the
scratch port answering the MCP handshake and the route tool (the get_routes result, whose entries are the Bookshelf
routes of the scratch app, is shortened):
/_next/mcp with curl$ curl -s -X POST localhost:3805/_next/mcp \
-H 'content-type: application/json' -H 'accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'
event: message
data: {"result":{"protocolVersion":"2025-03-26","capabilities":{"tools":{"listChanged":true}},"serverInfo":{"name":"Next.js MCP Server","version":"0.2.0"}},"jsonrpc":"2.0","id":1}
$ curl -s -X POST localhost:3805/_next/mcp ... -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_routes","arguments":{}}}'
data: {"result":{"content":[{"type":"text","text":"{ \"appRouter\": [ \"/[locale]\", \"/[locale]/books/[id]\", \"/api/books\", ... ] }"}]},...}
In 16.3.8 tools/list returned nine tools: the eight above (with get_compilation_issues and compile_route counted
separately) plus get_request_insights, whose description says it needs the experimental.requestInsights option and
which is not documented in the guide; treat it as experimental. A request that carried an Origin header of an
unrelated site (http://evil.example) was answered with HTTP 403, so a web page cannot simply talk to your local MCP
endpoint; plain curl without an Origin works, because it is a local process on your machine.
The practical rules follow from that: the MCP server shows your agent source-mapped stack traces and logs, so install
next-devtools-mcp in projects where you want agents to look at the running app, do not expose a dev server on a
shared network, and review what an agent changes as you would any other contribution. The same goes for the browser
tooling the guide mentions (Playwright MCP integration).
AGENTS.md and the bundled documentation
Models answer from training data, and Next.js changed a lot in 15 and 16 (async request APIs, proxy.ts,
cacheComponents). The framework therefore ships its own documentation inside the next package and tells agents
to read it:
-
Bundled docs (16.2+). Installing
nextplaces the docs atnode_modules/next/dist/docs/, mirroring the website (01-app,02-pages,03-architecture, plus anindex.md). They match the installed version, need no network and are upgraded by upgrading Next.js. In the scratch app the folder is about 4 MB. -
AGENTS.mdandCLAUDE.md.create-next-appgenerates both (pass--no-agents-mdto skip).AGENTS.mdcontains a managed block between<!-- BEGIN:nextjs-agent-rules -→and<!-- END:nextjs-agent-rules -→markers that tells the agent to read the bundled docs before writing code;CLAUDE.mdcontains the single line@AGENTS.md, which imports it. Most coding agents readAGENTS.mdautomatically when a session starts. Put your own instructions outside the markers; they are preserved when Next.js refreshes the block. -
Existing projects (16.3+).
next devdetects an AI coding agent in the environment and, if the managed block is missing, creates or updates both files. In the scratch app, with the two files deleted and an agent environment variable set, the dev server printedGenerated AGENTS.md and CLAUDE.md for AI agents. Set agentRules: false in next.config to disable.and recreated them. -
Opting out. Set
agentRules: false. Verified: with that option the same run left the folder without the two files.
next.config.ts: opting out of the generated agent filesimport type { NextConfig } from "next";
const nextConfig: NextConfig = {
agentRules: false,
};
export default nextConfig;
-
Earlier versions. On 16.2 the docs are bundled but
AGENTS.mdis not generated, so add the file yourself with the instruction to readnode_modules/next/dist/docs/. On 16.1 and earlier there are no bundled docs; the guide names the legacynpx @next/codemod@canary agents-mdcommand, which downloads a copy to.next-docs/. -
Docs over the network. Any page under
nextjs.org/docsis also available as Markdown by appending.md(or by sendingAccept: text/markdown), and/docs/llms.txtindexes them; that is how the facts on this page were re-checked.
The agent guide also lists Skills for multi-step work (next-dev-loop, next-cache-components-adoption,
next-cache-components-optimizer), which agents install with npx skills add; they package workflows, not framework
knowledge. Treat third-party skills like third-party code and read them before installing.
Source: Next.js: AI Coding Agents, Next.js 16.2
instrumentation.ts: register and onRequestError
instrumentation.ts (or .js) is the server-side hook for observability tools. Place it in the root of the project,
or inside src if you use one, next to app and pages and not inside them. If pageExtensions adds a suffix, the
file name must carry it too. It works with both routers and in the Node.js and Edge runtimes, and it can export two
functions.
register()
register is called once when a new Next.js server instance starts and must finish before the server handles
requests; it can be async. Use it to start an SDK (OpenTelemetry, an error tracker) before any application code runs.
Anything slow here delays readiness, and an error thrown here stops the server from starting, so keep it small and
make optional integrations fail soft.
onRequestError()
onRequestError is called whenever the server captures an error, so you can forward it to an error tracker.
error is typed unknown (narrow it before reading message or digest); when React processed the error during
Server Component rendering it may not be the instance you threw, and the digest identifies it. Await any async work
inside the handler. The example registers both functions, records the fields that are useful and deliberately does
not forward request.headers, which would copy cookies and authorization headers to a third party:
import type { Instrumentation } from "next";
export function register() {
// Runs once per server instance, before it handles requests.
console.log(`[instrumentation] register() runtime=${process.env.NEXT_RUNTIME}`);
}
export const onRequestError: Instrumentation.onRequestError = async (
err,
request,
context,
) => {
const message = err instanceof Error ? err.message : String(err);
const digest =
typeof err === "object" && err !== null && "digest" in err
? String((err as { digest: unknown }).digest)
: undefined;
// Send only what you need; never forward request.headers wholesale
// (cookies and authorization headers would leak into your error tracker).
console.error(
"[onRequestError]",
JSON.stringify({
message,
digest,
path: request.path,
method: request.method,
routerKind: context.routerKind,
routePath: context.routePath,
routeType: context.routeType,
renderSource: context.renderSource,
revalidateReason: context.revalidateReason,
}),
);
};
Source: instrumentation.js, Instrumentation
Three failing endpoints exercise it: a Route Handler that throws, a Server Component that calls await connection() and
throws (so it renders per request), and a Server Action invoked from a form. After next build and next start the
server log showed the single register line at startup and one onRequestError line per failure (digests redacted):
next start, one block per request[instrumentation] register() runtime=nodejs
GET /api/boom
[onRequestError] {"message":"boom from a Route Handler","path":"/api/boom","method":"GET","routerKind":"App Router","routePath":"/api/boom","routeType":"route"}
GET /en/boom
[onRequestError] {"message":"boom from a Server Component","digest":"<redacted>","path":"/en/boom","method":"GET","routerKind":"App Router","routePath":"/[locale]/boom","routeType":"render","renderSource":"react-server-components"}
POST /en/boom/form (the Server Action)
[onRequestError] {"message":"boom from a Server Action","digest":"<redacted>","path":"/en/boom/form","method":"POST","routerKind":"App Router","routePath":"/[locale]/boom/form","routeType":"action","renderSource":"react-server-components-payload"}
A Pages Router project works the same way. In the second scratch project a getServerSideProps that throws produced
"routerKind":"Pages Router","routePath":"/fail","routeType":"render".
| Field | Values |
|---|---|
|
|
|
The route, such as |
|
|
|
|
|
|
The documentation additionally lists a renderType field ('dynamic' or 'dynamic-resume', the latter for Partial
Prerendering). In 16.3.8 the shipped type RequestErrorContext does not declare it: reading context.renderType
failed next build with Property 'renderType' does not exist on type 'Readonly<RequestErrorContext>'. Do not rely
on it until the type and the docs agree.
Targeting a runtime
instrumentation.ts is bundled for both runtimes, so libraries that need Node.js APIs must not be imported at the top
of the file. Branch on process.env.NEXT_RUNTIME and import dynamically; the example in the official reference
imports ./register.edge or ./register.node this way, and the OpenTelemetry section below uses the same shape.
proxy.ts runs on Node.js in version 16, so most apps only meet the Edge runtime for the routes that opt into it.
Pitfalls
-
Putting the file in
app/orpages/; it must be at the project root (orsrc/). -
Heavy or failing work in
register, which blocks startup. -
Forwarding
request.headersor request bodies to a tracker, which leaks credentials and personal data. -
Forgetting
awaitinsideonRequestError: the process may finish the request before your report leaves. -
Importing Node-only packages at module top level, which breaks the Edge bundle.
-
Expecting
onRequestErrorto see client-side errors; it only sees server-captured ones. Client errors needinstrumentation-client.tsor an error boundary.
instrumentation-client
instrumentation-client.ts (or .js), also placed at the project root or in src, runs in the browser after the
HTML document has loaded and before React hydrates, so it is the place for an error tracker, an analytics
initialisation or a polyfill that must exist before components run. It needs no exports; the code is simply executed.
It can optionally export onRouterTransitionStart(url, navigationType), which observes the start of App Router
navigations, where navigationType is 'push', 'replace' or 'traverse' (back and forward). It is the client
counterpart of register and exists in both routers.
// Runs in the browser after the HTML is loaded and before React hydrates.
// Keep it synchronous and light: Next.js warns in development above 16 ms.
performance.mark("app-init");
declare global {
interface Window {
__navLog?: { url: string; type: string }[];
}
}
window.__navLog = [];
window.addEventListener("error", (event) => {
// Replace with your error tracker; here the message is only logged.
console.log("[client-instrumentation] window error:", event.message);
});
export function onRouterTransitionStart(
url: string,
navigationType: "push" | "replace" | "traverse",
) {
window.__navLog?.push({ url, type: navigationType });
// First-party page-view event: path only, no query string, no user data.
const path = new URL(url, location.href).pathname;
navigator.sendBeacon(
"/api/analytics",
new Blob([JSON.stringify({ event: "navigation", path, type: navigationType })], {
type: "application/json",
}),
);
}
Source: instrumentation-client.js
The receiving Route Handler validates its input, because anything a browser sends is untrusted:
import { z } from "zod";
const Event = z.object({
event: z.enum(["navigation"]),
path: z.string().max(200),
type: z.enum(["push", "replace", "traverse"]),
});
export async function POST(request: Request) {
const parsed = Event.safeParse(await request.json().catch(() => null));
if (!parsed.success) return new Response(null, { status: 400 });
// Replace with your analytics client.
console.log("[analytics]", JSON.stringify(parsed.data));
return new Response(null, { status: 204 });
}
Source: route.js and instrumentation-client: analytics tracking
After next build and next start, headless Chrome loaded /en and clicked the first book link. The page’s
performance.getEntriesByName('app-init') had one entry, window.__navLog was
[{"url":"/en/books/1","type":"push"}], and after history.back() it gained {"url":"http://localhost:3805/en","type":"traverse"}
(the traverse URL is absolute), while the server log printed [analytics] {"event":"navigation","path":"/en/books/1","type":"push"}.
Posting a malformed body ({"event":"x"}) returned 400. A Server Action that threw in the browser also reached the
window error listener (Uncaught Error: boom from a Server Action in development, and the minified React error
number in production).
Rules and options
-
Only synchronous top-level code is guaranteed to finish before hydration. A promise, a dynamic
import()or a top-levelawaitis fire-and-forget and may resolve after hydration began. That is why the documentation says to apply a polyfill with a static import and a synchronous feature check, not with a conditionalimport(). -
Keep it light. In development Next.js logs a warning when the file takes longer than 16 ms.
-
Wrap it in try/catch. One failing tracker should not stop the other instrumentation.
-
onRouterTransitionStartis App Router only and receives, in 16.3 when you setexperimental.instrumentationClientRouterTransitionEvents: true, a thirdeventargument withid,timestamp,fromRoutesandprefetchIntent. That event is experimental. -
instrumentationClientInject(16.3). A list of modules, npm package names or project-relative paths that are imported for their side effects before yourinstrumentation-clientfile, in array order, and may export their ownonRouterTransitionStart. It is meant fornext.configplugins such as error-tracker wrappers; application code should use the file convention.
OpenTelemetry
OpenTelemetry (OTel) is the vendor-neutral standard for traces, metrics and logs; the Next.js guide recommends it because you can change the observability provider without changing your code. Next.js is already instrumented: every request produces spans, and you add an SDK that exports them. The terms (span, trace, exporter) are explained in the OpenTelemetry observability primer. A trace is the tree of spans that one request produces; a span records a name, a duration, attributes and a parent.
The quick way: @vercel/otel
The @vercel/otel package configures the SDK for you and works in the Node.js and Edge runtimes, on Vercel and
self-hosted. Install it with the peer packages the guide lists (@opentelemetry/sdk-logs, @opentelemetry/api-logs,
@opentelemetry/instrumentation), then register it in instrumentation.ts:
import { registerOTel } from "@vercel/otel";
import { ConsoleSpanExporter } from "@opentelemetry/sdk-trace-node";
export function register() {
registerOTel({
serviceName: "bookshelf-web",
// In production leave traceExporter out and configure the OTLP exporter
// with OTEL_EXPORTER_OTLP_ENDPOINT / OTEL_EXPORTER_OTLP_HEADERS instead.
traceExporter: new ConsoleSpanExporter(),
});
}
The console exporter is for learning; in production you omit traceExporter and point the OTLP exporter at your
collector with the standard environment variables, with the values coming from your secret store and not from the
repository:
OTEL_SERVICE_NAME=bookshelf-web
OTEL_EXPORTER_OTLP_ENDPOINT=<your collector URL>
OTEL_EXPORTER_OTLP_HEADERS=<vendor authentication header, if the backend needs one>
A request to the /api/books-traced route handler of the next example, with this registration, printed the spans of
resolve page components, bookshelf.search (ours), executing api route (app) /api/books-traced, start response
and the root GET /api/books-traced, all with the same trace ID and with attributes such as next.route,
next.span_type and a resource carrying service.name: 'bookshelf-web'.
Manual configuration with NodeSDK
When @vercel/otel does not cover a need (custom processors, a particular SDK feature), configure NodeSDK
yourself. NodeSDK is not compatible with the Edge runtime, so import it only when NEXT_RUNTIME is nodejs, from
a separate file. If you need the Edge runtime, you must stay with @vercel/otel.
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
await import("./instrumentation.node");
}
}
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { resourceFromAttributes } from "@opentelemetry/resources";
import { NodeSDK } from "@opentelemetry/sdk-node";
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-node";
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
// The exporter reads OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_HEADERS
// from the environment, so no endpoint or credential appears in the code.
const sdk = new NodeSDK({
resource: resourceFromAttributes({
[ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME ?? "bookshelf-web",
}),
spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter())],
});
sdk.start();
// Flush pending spans when the server stops.
process.once("SIGTERM", () => {
void sdk.shutdown();
});
Install @opentelemetry/sdk-node, @opentelemetry/resources, @opentelemetry/semantic-conventions,
@opentelemetry/sdk-trace-node and @opentelemetry/exporter-trace-otlp-http. Two differences from the official
example, both deliberate: it passes spanProcessor with a SimpleSpanProcessor, while @opentelemetry/sdk-node
0.222 marks spanProcessor as deprecated in favour of the spanProcessors array (the example uses it), and a
BatchSpanProcessor exports in batches instead of one request per span, which is what production wants (the simple
processor is useful for demos). The official example also imports ./instrumentation.node.ts with the extension;
the extension-less import above built without any TypeScript setting.
Verification used a throw-away OTLP/HTTP receiver on 127.0.0.1:4318 (a 20-line Node.js script that printed the span
names of each POST to /v1/traces) and OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318 for next start. After two
requests it printed one POST of about 7.7 KB with the spans below, parent=no marking the root:
POST /v1/traces application/json 7699 bytes
span: resolve page components parent=yes
span: executing api route (app) /api/books-traced parent=yes
span: start response parent=yes
span: GET /api/books-traced parent=no
span: resolve page components parent=yes
span: executing api route (app) /api/books parent=yes
span: start response parent=yes
span: GET /api/books parent=no
span: bookshelf.search parent=yes
Custom spans
Next.js creates the request, route and fetch spans; add your own for business operations with the
@opentelemetry/api package. Unlike the official sketch, the example below records failures on the span and ends it in
finally, and it stores the length of the query rather than the query, because attributes travel to a third party:
import { SpanStatusCode, trace } from "@opentelemetry/api";
import { listBooks } from "@/lib/books";
const tracer = trace.getTracer("bookshelf-web");
export async function GET(request: Request) {
const q = new URL(request.url).searchParams.get("q") ?? "";
return tracer.startActiveSpan("bookshelf.search", async (span) => {
try {
span.setAttribute("bookshelf.query_length", q.length); // not the query itself
const books = await listBooks(q);
span.setAttribute("bookshelf.result_count", books.length);
return Response.json(books);
} catch (error) {
span.recordException(error as Error);
span.setStatus({ code: SpanStatusCode.ERROR });
throw error;
} finally {
span.end();
}
});
}
Calling curl "localhost:3805/api/books-traced?q=dune" returned [{"id":"1","title":"Dune",…}], and the span
bookshelf.search appeared in the trace with parent executing api route (app) /api/books-traced and attributes
bookshelf.query_length: 4 and bookshelf.result_count: 1. register runs before your code in a new environment, so
spans created in route code are attached to the exported trace.
The spans Next.js emits
| Span | next.span_type |
Meaning |
|---|---|---|
|
|
The root span of each request: method, route, target and status code. |
|
|
Rendering a route in the App Router (the Pages Router has |
|
|
A Route Handler running. |
|
|
A |
|
|
Metadata generation; one route can have several. |
|
|
Finding the page and loading the layout or page modules. |
|
|
A zero-length span marking the first byte sent. |
Attributes follow the OpenTelemetry semantic conventions, and Next.js adds its own under the next namespace:
next.span_name, next.span_type, next.route, next.rsc (an RSC request such as a prefetch) and next.page
(an internal identifier of a special file; only unique together with next.route). By default only some of the spans are
exported; set NEXT_OTEL_VERBOSE=1 to emit more. With it, one visit to a book page and one to the catalogue added
spans such as LoadComponents.loadComponents, BaseServer.render, AppRender.renderToReadableStream,
AppRender.waitShellReady, NextServer.getRequestHandler and build component tree; that is useful in a
performance investigation and noisy and costly as a permanent setting. A request that goes through the Proxy also
emits a middleware GET span.
To test locally you need a collector with a backend; the guide recommends its
OpenTelemetry dev environment, where the root span is
labelled like GET /requested/pathname with all other spans nested under it. On Vercel, OpenTelemetry works out of
the box and the platform documentation explains how to connect a provider; self-hosted deployments run their own
OpenTelemetry Collector and point the exporter at it (the deployment itself is a topic of the deployment page of this
section).
Logging
The logging option in next.config
The logging option in next.config controls what next dev prints to the terminal. It never affects production
builds. It has five parts, and logging: false turns the dev logging off entirely:
| Option | Effect | Added |
|---|---|---|
|
Logs the full URL of |
14.0 (stable for the App Router) |
|
Also logs |
15.0 |
|
Logs every Server Function invocation with its name, arguments and duration; on by default, set |
16.2 |
|
Logs incoming requests; |
15.2 |
|
Forwards browser |
16.2 (earlier |
|
Disables development logging. |
15.0 |
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
logging: {
fetches: { fullUrl: true, hmrRefreshes: true },
serverFunctions: true,
incomingRequests: { ignore: [/^\/api\/health/, /^\/_next\//] },
browserToTerminal: true,
},
};
export default nextConfig;
Source: next.config.js: logging
A server page that fetches the catalogue API and a client button that calls console.log and console.warn, loaded and
clicked in headless Chrome against next dev, gave this terminal output (the health endpoint, requested earlier, is
absent because ignore matched it; the long React hydration-warning text that also appeared in this run is omitted):
GET /api/books?q=dune 200 in 36ms (next.js: 34ms, application-code: 1.8ms)
GET /en/logdemo 200 in 1057ms (next.js: 770ms, proxy.ts: 40ms, generate-params: 142ms, application-code: 105ms)
│ GET http://localhost:3805/api/books?q=dune 200 in 81ms (cache skip)
│ │ Cache skipped reason: (auto no cache)
[browser] Hello World (app/[locale]/logdemo/log-button.tsx:8:17)
[browser] careful now (app/[locale]/logdemo/log-button.tsx:9:17)
POST /en/boom/form 500 in 202ms (next.js: 1.6ms, proxy.ts: 1.6ms, generate-params: 0.9ms, application-code: 198ms)
└─ ƒ failingAction() in 0ms app/[locale]/boom/actions.ts
[browser] Uncaught Error: boom from a Server Action
The timings split each request into Next.js work, the Proxy, parameter generation and your code, which is the
first place to look when a page is slow in development. fullUrl shows the complete fetch URL beneath the page
line, together with the cache decision. Mind what you forward: a full URL or a browser console line can contain a
token or personal data, and these are developer terminals, not log files to ship.
Production logs
logging is a development convenience. In production the server writes whatever your code and Next.js write to
standard output and standard error, which is where containers and platforms collect it. Practical rules that follow:
log structured lines (one JSON object per line) with a level, the route and a request or trace identifier; log the
digest of an error so the log line matches what the browser saw; never log request bodies, cookies, authorization
headers or tokens; and prefer to send errors through onRequestError and traces through OpenTelemetry instead of
parsing console output. Any logging library works in the Node.js runtime; avoid Node-only libraries in code that
proxy.ts or Edge routes import. How a log pipeline stores and queries the lines is outside Next.js; see the
Django logging and error reporting page and the Spring Boot and
Quarkus pages named above for the conventions that carry over.
Analytics
Next.js gives you two building blocks for measuring real users, and Vercel offers a managed service on top of them. Metrics that describe loading and interaction quality are covered in depth on Performance and Web Vitals; this section shows only how the pieces fit into observability.
-
useReportWebVitalsfromnext/web-vitalscalls your function with each metric (TTFB, FCP, LCP, FID, CLS, INP). It needs'use client', so keep it in a small component that the root layout imports. The function must keep a stable reference, or metrics collected so far are reported again. -
instrumentation-client(above) runs earlier and fits page-view and navigation events or tracker initialisation. The official analytics guide shows the file for global analytics and error tracking. -
Vercel Speed Insights is the managed service the guide names; you can also send the same data to your own endpoint or a vendor.
"use client";
import { useReportWebVitals } from "next/web-vitals";
type Metric = Parameters<Parameters<typeof useReportWebVitals>[0]>[0];
// Module-level function: a stable reference, so metrics are not reported twice.
function report(metric: Metric) {
console.log(`[vitals] ${metric.name} ${metric.rating}`);
}
export function WebVitals() {
useReportWebVitals(report);
return null;
}
Source: useReportWebVitals, Analytics
Rendering <WebVitals /> as the first child of <body> in app/layout.tsx, building, and loading /en, navigating
to a book and then to about:blank in headless Chrome produced [vitals] TTFB good, FCP good, FID good and
LCP good in the browser console. Replace console.log by a sendBeacon to your own endpoint (see the Route Handler
earlier in this page and the validated example on the performance page).
In the Pages Router the hook goes into pages/_app, as the Pages guide shows:
import type { AppProps } from "next/app";
import { useReportWebVitals } from "next/web-vitals";
type Metric = Parameters<Parameters<typeof useReportWebVitals>[0]>[0];
function logMetric(metric: Metric) {
console.log("[vitals:hook]", metric.name);
}
export default function MyApp({ Component, pageProps }: AppProps) {
useReportWebVitals(logMetric);
return <Component {...pageProps} />;
}
Privacy is part of the design: send the path without the query string, never user identifiers you do not need, respect
consent requirements in your jurisdiction before you load a third-party tracker, and remember that FID is deprecated
in favour of INP in Core Web Vitals even though the hook still lists it. Next.js also collects completely anonymous
usage telemetry about the framework itself, which next telemetry --disable (or the NEXT_TELEMETRY_DISABLED
environment variable) turns off; that is unrelated to your app’s analytics.
What Changed Recently
| Version | Change |
|---|---|
13.2 / 14.0.4 |
|
14.0 |
|
15.0 |
|
15.2 |
|
15.3 |
|
15.4 |
|
16.0 |
The DevTools MCP server ( |
16.1 |
|
16.2 |
|
16.3 |
|
Behaviour worth re-checking after an upgrade: the RequestErrorContext type (documentation and types disagreed on
renderType in 16.3.8), the deprecated spanProcessor option of NodeSDK versus spanProcessors, and the list of
MCP tools, which the guide says is growing.
Where the Books Are Outdated
The two reference books predate most of this page; neither covers debugging, instrumentation or OpenTelemetry. What they say about measuring performance is where today’s practice differs:
-
Pages Router
reportWebVitalsexport. The Packt book (2022) collects Web Vitals by exportingreportWebVitalsfrom_app.jsand forwarding to Google Analytics or Plausible, and presents FID as a Core Web Vital. Today the documented way is theuseReportWebVitalshook fromnext/web-vitals(called insidepages/_appor an App Router client component), and INP replaced FID in Core Web Vitals in 2024. The old export is still read by the 16.3.8 Pages Router client, but it is no longer documented, and in my test run it received only a Next.js custom metric (Next.js-hydration) while the hook received FCP and TTFB; do not build new reporting on the export. -
Vercel Analytics as the answer. The same book leans on the Vercel dashboard for field data. That remains an option (Speed Insights), but the self-hosted alternative is now first-class:
useReportWebVitals,instrumentation-clientand OpenTelemetry, with any backend. -
Debugging by Lighthouse walk-through. The Apress book (2023) debugs Lighthouse findings and defers bundle analysis to a webpack plug-in. Both Lighthouse and the plug-in remain valid for a webpack build, but Turbopack is the default bundler in 16 and the built-in
next experimental-analyzeis covered on the performance page. -
No server-side observability and no agent tooling. Server error reporting (
onRequestError), tracing and the MCP andAGENTS.mdmechanisms did not exist when the books were written, and many blog posts from the same period still show the Pages Router_error.jsas the only place to catch server errors.
Common Pitfalls
| Pitfall | What to do |
|---|---|
A breakpoint in a Client Component never hits |
Check which process runs it: attach the browser debugger for hydration and events, the Node.js inspector for the first render. Make sure |
|
Use |
Production error with no message |
Search the server log for the |
Build fails with minified server frames |
Rerun with |
|
Move it to the project root (or |
Edge build breaks after adding the SDK |
Import Node-only packages inside a |
Spans are missing in the backend |
Check |
Sensitive data in telemetry |
Do not forward request headers, query strings or user identifiers in span attributes, errors or analytics events. |
Web vitals reported twice |
Pass a function with a stable reference to |
MCP or |
They are dev-server features; nothing is exposed by |
Related Material on This Site
-
Getting Started: the dev overlay and DevTools indicator,
next devandnext build. -
Performance and Web Vitals: Core Web Vitals, Instant Insights and memory profiling with the inspector.
-
Hydration: reading hydration mismatch errors.
-
Route Handlers and Backend for Frontend: the handlers used for the beacons above.
-
Server Actions and Mutations: the
actionroute type seen byonRequestError. -
Redirects, Rewrites and Proxy: the
proxyroute type and themiddleware GETspan. -
React: Testing and End-to-end testing in real browsers: automated checks that complement debugging.
The testing, Turbopack and compiler, data security and CSP, and authentication pages of this section, and the deployment page that follows (collectors, containers, platform logging), are the natural next stops.