Skip to content
Chapter 92Lesson 5

Server-side debugging with the inspector

Attach the Node V8 inspector to your local Next.js server, the third diagnostic surface alongside Sentry and structured logs.

A user clicks a button and the toast shows the generic “Something went wrong.” You open Sentry: a clean stack trace points straight at a validation predicate in a server action. You copy the requestId, filter the log drain by it, and read the per-request narrative: the input looked valid, and the predicate still returned false. Both surfaces agree on where the failure is. Neither can tell you why an input that looks correct fails the check.

That is the wall. Both surfaces show only what was captured, and nobody captured the value that explains this bug, because nobody knew in advance it would matter. So you stop reading recordings and open a live window: attach a debugger to your local server, pause on that line, replay the same input, and read the variables as the code runs. Sentry answers what threw, the logs answer what happened, the debugger answers what’s in scope right now. Three surfaces, three questions, one incident.

By the end you will have a .vscode/launch.json that starts a debuggable Next.js server in one keypress, the three breakpoint moves you reach for in practice (plain, conditional, and logpoint), and a drill that wires this chapter’s tools into a single incident.

When a bug gets slippery, the wrong instinct is to reach for the debugger first. Getting this decision right matters more than any breakpoint mechanic.

Logs and Sentry breadcrumbs are retroactive: you see only the values captured at the time, with no way to ask a follow-up. The debugger is interactive: you can read every local, closure, and property in scope, even mutate state and resume to test a hypothesis. That power is why it’s the last tool, not the first. Sentry plus structured logs resolve roughly nine incidents in ten; the debugger is for the tenth, and reaching for it earlier just means you skipped the cheaper surfaces.

Three signals say it earns its weight:

  • The bug doesn’t reproduce in the logs. The deciding state was on the wire, a value that flowed through the request but never landed in a logged variable. You can’t log a value you didn’t know mattered, but you can pause and read it.
  • The call stack points into a library doing something you don’t expect. The throw is two frames deep into a dependency, and you need the arguments your code handed it.
  • It’s a heisenbug , where the failure vanishes the instant you add a console.log. A breakpoint observes without recompiling or shifting the timing the way an edit-and-restart does.

Three more cases point elsewhere: a known bug you can reproduce wants a durable failing test, not a session that evaporates when you close the tab; anything in production is the line you never cross (the last section covers why); and performance work is a profiler’s job, covered later in this unit.

The walk below follows the order these questions come in; the skill is in the order, not any single answer.

Which surface answers this question?

The debugger is not a feature of your editor. It is a feature of the runtime, and your editor merely attaches to it.

Node ships with a built-in V8 inspector. Start any Node process with the --inspect flag and it opens a WebSocket, by default on 127.0.0.1:9229, that speaks the Chrome DevTools Protocol , or CDP. Any CDP-speaking client can attach over that socket: VS Code, Chrome DevTools, WebStorm, Firefox. One protocol, many clients, which is why Chrome DevTools is a drop-in alternative to VS Code later in this lesson.

node --inspect
your server process
:9229 — CDP over WebSocket
VS Code
Chrome DevTools
WebStorm

Two flag variants are worth knowing. --inspect runs your program normally but leaves it attachable; it is the one you want almost always. --inspect-brk pauses on the very first line and waits for a client before running anything, useful for debugging startup but rarely what you need for app code, and it has a sharp edge we hit in the next section.

The debugger reads the same dev source maps the bundler already produces, the ones Sentry used to rebind your minified production stack traces in this chapter’s first lesson. That is how a breakpoint on a line of your .ts source maps to the transpiled code Node actually runs.

One thing to plant now: a production Node process never runs with --inspect. There, an open inspector port is not a convenience but a remote-code-execution surface, as the last section explains.

To debug server code you need a Next.js dev server listening for a debugger. As of Next.js 16.1 there is a first-class flag for exactly this.

Terminal window
pnpm dev --inspect

The course default. The flag passes --inspect through to only the Node process running your code.

Why a dedicated flag instead of NODE_OPTIONS=--inspect? NODE_OPTIONS attaches the inspector to every Node process the dev command spawns, so they fight over the port. The --inspect flag threads it through to only your server.

However you start it, look for one confirmation line in the terminal:

Terminal window
Debugger listening on ws://127.0.0.1:9229/3f8b1c2a-...
For help, see: https://nodejs.org/en/docs/inspector
Next.js 16.2.0
- Local: http://localhost:3000

No Debugger listening line means the flag didn’t take, and on npm the missing -- is almost always why. That 9229 is the same port any client attaches to; if it’s already in use, override it with pnpm dev --inspect=9230.

Attaching by hand works, but you do it often enough to be worth a one-keypress button: a .vscode/launch.json file, committed so the whole team gets it. The shape below is the current official Next.js config. Read it field by field, since the most important field is also the one that changed recently.

{
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: debug server-side",
"type": "node-terminal",
"request": "launch",
"command": "pnpm dev --inspect",
"skipFiles": ["<node_internals>/**"]
},
{
"name": "Next.js: debug client-side",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3000"
},
{
"name": "Next.js: debug full stack",
"type": "node",
"request": "launch",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["dev", "--inspect"],
"serverReadyAction": {
"pattern": "- Local:.+(https?://.+)",
"uriFormat": "%s",
"action": "debugWithChrome"
}
}
]
}

The server-side config, the load-bearing one for this lesson. It launches your dev command and attaches the debugger to the server process for you.

{
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: debug server-side",
"type": "node-terminal",
"request": "launch",
"command": "pnpm dev --inspect",
"skipFiles": ["<node_internals>/**"]
},
{
"name": "Next.js: debug client-side",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3000"
},
{
"name": "Next.js: debug full stack",
"type": "node",
"request": "launch",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["dev", "--inspect"],
"serverReadyAction": {
"pattern": "- Local:.+(https?://.+)",
"uriFormat": "%s",
"action": "debugWithChrome"
}
}
]
}

The field that changed, and the one to really understand. node-terminal runs your dev command in VS Code’s integrated terminal and auto-attaches the debugger to the Node process it spawns, so you never think about ports. Older posts use a "request": "attach" config pointed at 9229, which makes you start the server in one place and attach in another; this collapses both into one button.

{
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: debug server-side",
"type": "node-terminal",
"request": "launch",
"command": "pnpm dev --inspect",
"skipFiles": ["<node_internals>/**"]
},
{
"name": "Next.js: debug client-side",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3000"
},
{
"name": "Next.js: debug full stack",
"type": "node",
"request": "launch",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["dev", "--inspect"],
"serverReadyAction": {
"pattern": "- Local:.+(https?://.+)",
"uriFormat": "%s",
"action": "debugWithChrome"
}
}
]
}

The command VS Code runs, the same line from the previous section. --inspect makes the spawned process attachable, and node-terminal does the attaching.

{
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: debug server-side",
"type": "node-terminal",
"request": "launch",
"command": "pnpm dev --inspect",
"skipFiles": ["<node_internals>/**"]
},
{
"name": "Next.js: debug client-side",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3000"
},
{
"name": "Next.js: debug full stack",
"type": "node",
"request": "launch",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["dev", "--inspect"],
"serverReadyAction": {
"pattern": "- Local:.+(https?://.+)",
"uriFormat": "%s",
"action": "debugWithChrome"
}
}
]
}

Keeps “step into” shallow. Without it, stepping into a call dives straight into Node’s internals; <node_internals>/** tells the debugger to step over runtime code so you stay in your own. Add "**/node_modules/**" to skip dependencies too.

{
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: debug server-side",
"type": "node-terminal",
"request": "launch",
"command": "pnpm dev --inspect",
"skipFiles": ["<node_internals>/**"]
},
{
"name": "Next.js: debug client-side",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3000"
},
{
"name": "Next.js: debug full stack",
"type": "node",
"request": "launch",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["dev", "--inspect"],
"serverReadyAction": {
"pattern": "- Local:.+(https?://.+)",
"uriFormat": "%s",
"action": "debugWithChrome"
}
}
]
}

The other two official entries. “Client-side” launches Chrome against your dev URL to debug browser code; “full-stack” runs both at once via serverReadyAction. Copy the whole file; this lesson stays on server-side.

1 / 1

The old attach config still has a place: reach for it when you would rather start pnpm dev --inspect in your own terminal and then attach to the running process.

.vscode/launch.json (the manual fallback)
{
"name": "Next.js: attach to running server",
"type": "node",
"request": "attach",
"port": 9229,
"skipFiles": ["<node_internals>/**"]
}

With the file saved, the run procedure is four steps:

  1. Open the Run and Debug panel: ⇧⌘D on macOS, Ctrl+Shift+D on Windows and Linux.

  2. Pick Next.js: debug server-side from the configuration dropdown at the top.

  3. Press F5 to launch it.

  4. Confirm the Debugger listening on ws://127.0.0.1:9229/... line appears in the integrated terminal. That’s your signal the inspector is attached.

Breakpoints, conditional breakpoints, and logpoints

Section titled “Breakpoints, conditional breakpoints, and logpoints”

Three core moves build on each other: a plain pause, a pause with a condition, and a print that never pauses. A fourth, the debugger; statement, is a fallback for cases the first three can’t reach.

Click in the gutter, the narrow strip just left of the line numbers, next to a line inside a server action or a lib/ helper. A red dot appears, and its color tells you whether it will fire. A solid red dot is bound : the source map resolved your line to running code. A hollow grey dot is unbound and won’t fire, a case we’ll handle shortly.

Now trigger the request that runs that line. Execution pauses on the dot, and VS Code fills four panels:

  • Variables holds every local in the current frame, plus the closure scope and this, what’s actually in memory.
  • Watch holds expressions you pin so they re-evaluate on every step.
  • Call Stack shows how execution got here, frame by frame. Because of skipFiles, the node_modules frames stay collapsed, so you see your path, not the framework’s.
  • Debug Console is the most powerful of the four.

The Debug Console is a REPL bound to the paused frame’s scope, so you can evaluate against whatever is in scope. Walk an object with user.role, drill into input.lineItems[0], or run a real query against the live database connection:

await db.query.invoices.findFirst({
where: eq(invoices.id, 'inv_123'),
});

That answers “what’s in scope right now” faster than adding a log line and restarting the server. The Debug Console supports top-level await, so the query resolves to the row rather than a pending Promise.

Two limits are worth knowing. A Drizzle query object isn’t SQL yet, since the SQL is generated lazily when the query runs, so set your breakpoint around the call and inspect the result, not the query. And if a breakpoint pauses on the wrong line, Fast Refresh has probably staled the file the debugger maps against; restart the dev server to realign it.

A plain breakpoint pauses every time the line runs, a problem in a loop or a hot path: you’ll pause thousands of times to reach the one iteration that matters. Add a condition instead. Right-click the gutter, choose Edit Breakpoint, and type an expression:

user.id === 'usr_problem'

Now it fires only when that’s true. This is the move for an intermittent or per-user bug: set the condition to the user or attempt that fails (attempt > 3), let every other request through, and pause exactly when the bad one arrives.

Sometimes you don’t want to pause at all, because pausing on a hot path breaks the timing you’re trying to observe. A logpoint is a breakpoint that prints and keeps running. Right-click the gutter, choose Add Logpoint, and write a message with expressions in braces:

input was {input.amount}, user is {user.id}

Each time the line runs, that message prints to the Debug Console with the values filled in, and execution never stops, a log line you add without editing the file or restarting the server. Reach for it on hot paths, or any time you just want a value to fly by.

One discipline: a logpoint is not a substitute for your logger. Logpoints live in your editor, never in the code, and vanish when you close the session. Real diagnostics an operator needs at 3am go through the structured pino logger from earlier in this chapter, never console.log (which the no-console lint rule blocks in server code for this reason).

A debugger; line in your code pauses execution whenever a debugger is attached and does nothing otherwise. Reach for it when a gutter breakpoint won’t bind (a Turbopack source-map miss leaves a hollow grey dot) or when the location is dynamically generated and you can’t click a stable line. After adding it, restart the dev server so the source maps regenerate; only as a last resort fall back to next dev --webpack, an escape hatch rather than a habit.

Treat debugger; as strictly temporary. CI greps for it and fails the build if you commit it, the same guardrail that catches a stray console.log.

Here is the moment the toolkit pays off, scrubbed step by step: a breakpoint hits the validation predicate from the intro, and the Variables panel reveals the value no log line carried.

lib/authz.ts
1 export function belongsToOrg(
2 input: { orgId: string },
3 ) {
4 if (input.orgId === scopedOrgId) {
5 return true;
6 }
7 return false;
8 }
A breakpoint set on the predicate's `return false` line — the line the Sentry stack trace pointed at.
lib/authz.ts ⏸ Paused on breakpoint
1 export function belongsToOrg(
2 input: { orgId: string },
3 ) {
4 if (input.orgId === scopedOrgId) {
5 return true;
6 }
7 return false;
8 }
Replay the same input from the log line. Execution pauses exactly here.
VARIABLES
Local
input: {orgId: …}
orgId: "org_customerA"
Closure
scopedOrgId: "org_customerB"
The Variables panel shows the input's org compared against a closure-captured scope, and the captured `scopedOrgId` is the wrong tenant.
DEBUG CONSOLE
> input.orgId
"org_customerA"
> scopedOrgId
"org_customerB"
> input.orgId === scopedOrgId
false
Confirm it in the Debug Console: the values don't match, a fact no log line carried because nobody logged the closure's scope.
lib/authz.ts ▶ Continue (F5)
1 export function belongsToOrg(
2 input: { orgId: string },
3 ) {
4 if (input.orgId === scopedOrgId) {
5 return true;
6 }
7 return false;
8 }
Resume. You found the why in seconds; the fix is one line, and the durable artifact is the regression test that locks it in.

And the real thing in VS Code, so you recognize it when you get there.

The real VS Code debug layout: gutter breakpoint, paused line, Variables and Call Stack on the left, Debug Console below. This is the live window the diagram above models.

Now lock in which move fits which situation.

Select every pairing below where the breakpoint move correctly fits the situation. Pick all that apply.

One customer — and only that customer — trips the error, while everyone else’s requests succeed → arm a conditional breakpoint whose expression matches that customer’s id.
You want to watch a value stream past on a request path that fires constantly, without disturbing the timing you’re trying to study → drop a logpoint.
You suspect one line and want to halt there once and walk its locals, closure, and call stack → set a plain breakpoint.
The gutter dot refuses to bind — it sits hollow grey because the source map never resolved the line under Turbopack → drop in a debugger; line instead.
You can already trigger the bug on demand and you understand exactly why it happens → reach for a conditional breakpoint to confirm it once more.
An endpoint feels sluggish and you need to see where its time goes → scatter a logpoint across every line and read the timestamps.

The server-action-failed drill, end to end

Section titled “The server-action-failed drill, end to end”

The skill is descending the diagnostic ladder in order, stopping at each rung only when it runs dry. Walk it with the failing scenario from the intro.

  1. Sentry, what threw. Open the event. The stack trace is rebound to source by the maps you uploaded at build time; it names the failing server action and the exact line, a validation predicate. You know where.

  2. Logs, what happened. Copy the requestId from the Sentry event’s request context (it lives in context, not as a tag, because the AsyncLocalStorage value you wired earlier is shared between Sentry and the logger). Filter the drain by it and read the per-request narrative: the input looked valid, yet the predicate returned false. The two surfaces agree on where but contradict your intuition about why. That disagreement is your signal to descend.

  3. Reproduce locally. Start pnpm dev --inspect, launch the Next.js: debug server-side config, set a breakpoint on the predicate’s return false, and replay the same input. The log line gave you its exact shape, so you reproduce the real failure.

  4. Read live state, what’s in scope right now. The breakpoint hits. The Variables panel shows the predicate comparing the input’s org against a closure-captured tenant scope holding the wrong orgId, a value no log line carried because nobody logs a closure’s captured scope. That gap is exactly what the debugger closes.

  5. Fix and close the loop. The fix is one line in lib/. Then add a regression test: the bug is now “a known bug with a clean reproduction,” and the test, not the debugging session, is the durable artifact that keeps it fixed.

The three surfaces interleave rather than compete, and the order is fixed: Sentry’s stack, then the log narrative, then the debugger, descending only when the rung above runs dry.

Sentry
what threw
the rebound stack trace, the failing line
Logs (the drain)
what happened
the per-request narrative, by requestId
Debugger
what's in scope right now
the live locals, closure, and call stack
Three surfaces, three questions, one incident — and you only move right when the surface to the left runs out of answers.

One protocol, many clients: everything you just did in VS Code works in Chrome DevTools too, with the same inspector, source maps, and caveats, handy when you’re debugging without an editor open.

  1. With pnpm dev --inspect running, open chrome://inspect in Chrome.

  2. Your Node process appears under Remote Target. Click inspect next to it; a DevTools window opens, attached to the server.

  3. Go to the Sources tab and set gutter breakpoints exactly as you would in VS Code.

One shortcut: when a server error hits in development, the Next.js error overlay carries a small Node.js icon. Clicking it copies the inspector’s DevTools URL; paste that into a new tab and you land inside the running server process at the point of the error, the fastest path from a thrown error to a live inspector.

One file-finding gotcha: your server source files appear under paths like webpack://_N_E/./app/actions.ts. That webpack:// prefix shows up even under Turbopack, a naming artifact, not a sign you’re on the wrong bundler. Use ⌘P (or Ctrl+P) to fuzzy-find a file by name instead of hunting through the tree.

Why you never attach the inspector to production

Section titled “Why you never attach the inspector to production”

The --inspect flag is dev-only, full stop. That same await db.query(...) you ran in the Debug Console is the whole problem: the inspector port speaks CDP, and CDP can evaluate arbitrary code in the running process. Locally that is a superpower. On a deployed server it is remote code execution against your live database: anyone who can reach an open 9229 can run that query, or any other code, inside your production process.

Vercel enforces the rule by construction: it doesn’t expose the inspector port, and its serverless functions are short-lived and never attachable, so there is no long-running process to point a debugger at. Production debugging stays the Sentry-plus-drain workflow; the inspector is the local complement.

The official guides go deeper on the launch configs, the alternative clients, and the inspector primitive.