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

flowchart LR subgraph Dev["Development only (next dev)"] OV["Error overlay and DevTools indicator"] LG["logging option: fetches, requests, server functions, browser to terminal"] MCP["MCP endpoint /_next/mcp"] INS["Node.js inspector (--inspect)"] end subgraph Srv["Server, dev and production"] REG["instrumentation.ts: register()"] ORE["instrumentation.ts: onRequestError()"] OT["OpenTelemetry spans from Next.js and your code"] end subgraph Cli["Browser, dev and production"] IC["instrumentation-client.ts"] WV["useReportWebVitals"] end REG --> OT OT --> COL["OTLP collector or vendor backend"] ORE --> ERR["Error tracker"] IC --> AN["Analytics endpoint"] WV --> AN OV --> DEV["You"] LG --> DEV MCP --> AG["Coding agent"] INS --> DEV
Table 1. Which tool answers which question
Question Tool Where it runs

Why is this variable wrong at this line?

The Node.js debugger (--inspect) for server code, the browser DevTools for client code

Dev (and next start for the server)

What did the server just do?

The logging option in next.config.ts; console output of your own code

Dev only (logging); everywhere (console)

What does my coding agent see of the running app?

The Next.js DevTools MCP server and the version-matched docs in AGENTS.md

Dev only

Which server errors happen in production?

onRequestError in instrumentation.ts

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?

useReportWebVitals and instrumentation-client.ts

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 sets debugWithChrome, which is what the guide says to use with Chrome. A Firefox configuration ("type": "firefox") needs the Firefox Debugger extension and a pathMappings entry.

  • If you changed the port, change 3000 in the URLs. In a monorepo (for example Turborepo) add cwd, such as "cwd": "${workspaceFolder}/apps/web", to the server-side and full-stack configurations.

  • npm run dev can be yarn dev or pnpm 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.

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):

Starting the dev server with the inspector and asking it what it is
$ 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 build
Error 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.

Table 2. Debugging flags and options
Option Effect Since

next build --debug-prerender

Un-minified server code, server source maps, keeps going after the first prerender error. Do not deploy the output.

15.4

next build --debug

More verbose build output, such as the rewrites, redirects and headers.

Not dated in the docs

next build --debug-build-paths="app/page.tsx,!app/admin/**"

Builds only matching routes (comma-separated paths and globs, ! excludes), so debugging a large app is faster.

Not dated in the docs

next build --no-mangling

Disables name mangling for debugging; may affect performance.

Not dated in the docs

--experimental-cpu-prof (dev, build, start)

V8 CPU profiling; profiles are written to .next-profiles/ on exit.

Experimental (not dated)

productionBrowserSourceMaps: true

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:

Table 3. Tools of the Next.js MCP server
Tool What the agent gets

get_errors

Current build, runtime and type errors, with source-mapped stacks (the live version of the overlay).

get_logs

The path of the development log file, with browser console output and server output.

get_page_metadata

Routes, components and rendering information of a page.

get_project_metadata

Project structure, configuration and the dev server URL.

get_routes

Every route that will be an entry point, scanned from the file system and grouped by router (appRouter, pagesRouter), with dynamic segments as [param] or […​slug]. Added in 16.1.

get_server_action_by_id

The source file and function name of a Server Action, looked up by its ID.

get_compilation_issues, compile_route

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):

Talking to /_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 next places the docs at node_modules/next/dist/docs/, mirroring the website (01-app, 02-pages, 03-architecture, plus an index.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.md and CLAUDE.md. create-next-app generates both (pass --no-agents-md to skip). AGENTS.md contains 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.md contains the single line @AGENTS.md, which imports it. Most coding agents read AGENTS.md automatically 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 dev detects 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 printed Generated 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 files
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  agentRules: false,
};

export default nextConfig;
  • Earlier versions. On 16.2 the docs are bundled but AGENTS.md is not generated, so add the file yourself with the instruction to read node_modules/next/dist/docs/. On 16.1 and earlier there are no bundled docs; the guide names the legacy npx @next/codemod@canary agents-md command, which downloads a copy to .next-docs/.

  • Docs over the network. Any page under nextjs.org/docs is also available as Markdown by appending .md (or by sending Accept: text/markdown), and /docs/llms.txt indexes 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.

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:

instrumentation.ts
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,
    }),
  );
};

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):

The server log of 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".

Table 4. The context argument of onRequestError
Field Values

routerKind

'Pages Router' or 'App Router'

routePath

The route, such as /[locale]/boom

routeType

'render' (Server Components or Pages rendering), 'route' (Route Handlers), 'action' (Server Actions) or 'proxy'

renderSource

'react-server-components', 'react-server-components-payload' or 'server-rendering' (optional)

revalidateReason

'on-demand', 'stale' or undefined for a normal request

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/ or pages/; it must be at the project root (or src/).

  • Heavy or failing work in register, which blocks startup.

  • Forwarding request.headers or request bodies to a tracker, which leaks credentials and personal data.

  • Forgetting await inside onRequestError: the process may finish the request before your report leaves.

  • Importing Node-only packages at module top level, which breaks the Edge bundle.

  • Expecting onRequestError to see client-side errors; it only sees server-captured ones. Client errors need instrumentation-client.ts or 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.

instrumentation-client.ts: a window error listener and navigation tracking
// 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",
    }),
  );
}

The receiving Route Handler validates its input, because anything a browser sends is untrusted:

app/api/analytics/route.ts
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 });
}

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-level await is 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 conditional import().

  • 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.

  • onRouterTransitionStart is App Router only and receives, in 16.3 when you set experimental.instrumentationClientRouterTransitionEvents: true, a third event argument with id, timestamp, fromRoutes and prefetchIntent. 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 your instrumentation-client file, in array order, and may export their own onRouterTransitionStart. It is meant for next.config plugins 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:

instrumentation.ts with @vercel/otel (here exporting to the console so the output is visible)
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.

instrumentation.ts
export async function register() {
  if (process.env.NEXT_RUNTIME === "nodejs") {
    await import("./instrumentation.node");
  }
}
instrumentation.node.ts
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:

app/api/books-traced/route.ts
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

Table 5. Default spans (names as documented; <route> is the route pattern)
Span next.span_type Meaning

<method> <route> (for example GET /api/books-traced)

BaseServer.handleRequest

The root span of each request: method, route, target and status code.

render route (app) <route>

AppRender.getBodyResult

Rendering a route in the App Router (the Pages Router has render route (pages), plus getServerSideProps and getStaticProps spans).

executing api route (app) <route>

AppRouteRouteHandlers.runHandler

A Route Handler running.

fetch <method> <url>

AppRender.fetch

A fetch made by your code. Turn it off with NEXT_OTEL_FETCH_DISABLED=1 when you use another fetch instrumentation.

generateMetadata <page>

ResolveMetadata.generateMetadata

Metadata generation; one route can have several.

resolve page components, resolve segment modules

NextNodeServer.findPageComponents, NextNodeServer.getLayoutOrPageModule

Finding the page and loading the layout or page modules.

start response

NextNodeServer.startResponse

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:

Table 6. The logging options (all development only)
Option Effect Added

fetches.fullUrl

Logs the full URL of fetch requests made in Server Components.

14.0 (stable for the App Router)

fetches.hmrRefreshes

Also logs fetch calls restored from the Server Components HMR cache, which are hidden by default.

15.0

serverFunctions

Logs every Server Function invocation with its name, arguments and duration; on by default, set false to hide.

16.2

incomingRequests

Logs incoming requests; { ignore: [regexps] } skips matching paths and false disables it.

15.2

browserToTerminal

Forwards browser console output to the terminal: 'warn' (default, warnings and errors), 'error', true (everything) or false; each line carries the source location.

16.2 (earlier experimental.browserDebugInfoInTerminal, 15.4)

false (the whole option)

Disables development logging.

15.0

next.config.ts, exactly as run in the scratch app
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;

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):

The development terminal with the logging options above
 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.

  • useReportWebVitals from next/web-vitals calls 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.

app/_components/web-vitals.tsx, rendered once in the root layout
"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;
}

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:

pages/_app.tsx (Pages Router)
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

Table 7. Debugging and observability changes by version
Version Change

13.2 / 14.0.4

instrumentation introduced as an experimental feature (13.2); Turbopack support (14.0.4).

14.0

logging.fetches stable for the App Router.

15.0

instrumentation stable and onRequestError introduced; logging: false and logging.fetches.hmrRefreshes.

15.2

logging.incomingRequests.

15.3

instrumentation-client introduced.

15.4

next build --debug-prerender; experimental.browserDebugInfoInTerminal (later logging.browserToTerminal).

16.0

The DevTools MCP server (/_next/mcp, next-devtools-mcp); Turbopack becomes the default bundler for next dev and next build.

16.1

next dev --inspect; the MCP get_routes tool.

16.2

next start --inspect; hydration diff in the overlay; logging.browserToTerminal and Server Function logging; bundled docs in node_modules/next/dist/docs/ and AGENTS.md and CLAUDE.md from create-next-app.

16.3

next dev generates and maintains the AGENTS.md block when it detects an AI agent (agentRules opts out); instrumentationClientInject; the experimental router transition event of onRouterTransitionStart; Instant Insights and the Navigation Inspector in the DevTools (see the performance page).

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 reportWebVitals export. The Packt book (2022) collects Web Vitals by exporting reportWebVitals from _app.js and forwarding to Google Analytics or Plausible, and presents FID as a Core Web Vital. Today the documented way is the useReportWebVitals hook from next/web-vitals (called inside pages/_app or 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-client and 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-analyze is covered on the performance page.

  • No server-side observability and no agent tooling. Server error reporting (onRequestError), tracing and the MCP and AGENTS.md mechanisms did not exist when the books were written, and many blog posts from the same period still show the Pages Router _error.js as the only place to catch server errors.

Common Pitfalls

Table 8. 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 next dev is running with --inspect for the latter.

--inspect-brk is rejected by next dev

Use NODE_OPTIONS=--inspect-brk next dev.

Production error with no message

Search the server log for the digest printed in the browser.

Build fails with minified server frames

Rerun with --debug-prerender; never deploy that build.

instrumentation.ts in app/ is ignored

Move it to the project root (or src/), and add any pageExtensions suffix to the name.

Edge build breaks after adding the SDK

Import Node-only packages inside a NEXT_RUNTIME === 'nodejs' branch, in a separate file, or use @vercel/otel.

Spans are missing in the backend

Check register ran (a log line), the exporter endpoint and headers come from the environment, and set NEXT_OTEL_VERBOSE=1 while investigating.

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 useReportWebVitals.

MCP or AGENTS.md expectations on production

They are dev-server features; nothing is exposed by next start.

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.