Skip to content
Chapter 92Lesson 1

Sentry: capture, releases, and breadcrumbs

Wire Sentry as your error-monitoring surface so production exceptions arrive grouped, source-mapped, release-tagged, and tied to the user who hit them.

A createInvoice Server Action throws in production: a required field came back from Stripe as null, and a property access blew up. The action did exactly what you built it to do in the error-handling chapter. It caught the throw, returned a Result.err, and the user saw a calm generic toast instead of a stack trace. The user side is handled; the operator side is a promise you haven’t kept.

Right now that failure lives in one place: Vercel’s function output, as a single minified line like Function.t [as h] (chunk-abc123.js:1:42). No user attached, no org, no plan, no link to the deploy that introduced it. To answer “what happened?” you’d open the Vercel dashboard, find the right function, scroll to the right invocation, and reconstruct the rest from memory. Next to that catch you left a Sentry.captureException(error) line beside your logger.error(...), with the wiring deferred to a later chapter. This is that chapter.

By the end of this lesson, two deliberate throws, one from a Server Action and one from a client component, will arrive in Sentry grouped, with a readable stack trace, the correct release tag, and the user and org context attached. One rule makes all of it work: a server-side error reaches Sentry by exactly one of two routes, and you should always be able to say which.

What Sentry gives you that a log line can’t

Section titled “What Sentry gives you that a log line can’t”

A log line, which you’ll build next lesson, is a flat record. It’s there only if you wrote it, and it helps only if you already know what to filter for: the right shape for “replay this request,” the wrong shape for “something is broken and I don’t yet know what.”

A Sentry event is that same failure, enriched along five axes a raw log line lacks:

  • Grouped by fingerprint . Forty-seven instances of one bug collapse into a single issue with a count, not forty-seven lines you have to notice are identical.
  • Source-mapped. The minified production frame is rebound to your original .ts source, down to the file and line.
  • Release-tagged. Each event carries the deploy it shipped in, so a regression points back at the commit that introduced it.
  • User- and org-tagged. You can filter to every error a given user hit this week.
  • Breadcrumb-trailed. The event arrives with a trail of what happened in the seconds before the throw.

Each section below wires one of these five. Keep the chapter’s frame in view: errors and logs are two surfaces of one incident. Later they’ll share a single requestId, so you can stand in a Sentry event, copy that ID, and jump to the matching log lines.

Vercel function log
Sentry issue
chunk-abc123.js:1:42
Stack trace
createInvoice.ts:34
one line per occurrence
Grouping
47 occurrences → 1 issue
none
Release
tagged a1b2c3d
none
User / org
user_8fa… · org_pro_12
none
Trail before the throw
navigate → load invoice → submit
The same production failure, as Vercel sees it and as Sentry sees it.

Sentry is the default error monitor for a 2026 Next.js stack, and its edge is the integration: a first-party-quality SDK that folds release tracking and source-map upload into one wizard and hooks straight into the App Router’s instrumentation file. Because that does so much for you, this lesson is short on setup and long on judgment.

Sentry also ships session replay, but leave it off: the next chapter picks PostHog for replay, and running two replay products means paying twice for one capability.

Don’t hand-wire Sentry; run its wizard. One command writes a complete, working baseline, where doing it by hand only adds chances to get a config path wrong. You still own these files, so we’ll walk every one and label what’s load-bearing.

Terminal window
npx @sentry/wizard@latest -i nextjs

The wizard asks you to log in and pick or create a Sentry project, then writes the files below. The highlighted entries are new.

  • Directorysrc/
    • Directoryapp/
      • global-error.tsx error boundary, calls Sentry.captureException
    • instrumentation.ts Next.js hook: register() + onRequestError
    • instrumentation-client.ts browser SDK: Sentry.init
    • sentry.server.config.ts Node SDK: Sentry.init
    • sentry.edge.config.ts edge runtime SDK: Sentry.init
  • next.config.ts wrapped in withSentryConfig
  • .env.sentry-build-plugin auth token, git-ignored

That’s more files than expected because there is no single place where Sentry starts. Sentry.init, the call that boots the SDK with your config, runs once per runtime your app executes in:

  • instrumentation-client.ts boots it in the browser.
  • sentry.server.config.ts boots it in the Node server runtime.
  • sentry.edge.config.ts boots it in the edge runtime.

instrumentation.ts is the glue, a Next.js file with a reserved name that does two jobs. Its register() function runs once at server boot and imports the config matching the live runtime. It also exports onRequestError, the hook that catches server-side throws and drives this whole lesson.

import * as Sentry from '@sentry/nextjs';
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
await import('./sentry.server.config');
}
if (process.env.NEXT_RUNTIME === 'edge') {
await import('./sentry.edge.config');
}
}
export const onRequestError = Sentry.captureRequestError;

The SDK import now resolves to the real @sentry/nextjs package, the same import the captureException stub from the error-handling chapter referenced.

import * as Sentry from '@sentry/nextjs';
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
await import('./sentry.server.config');
}
if (process.env.NEXT_RUNTIME === 'edge') {
await import('./sentry.edge.config');
}
}
export const onRequestError = Sentry.captureRequestError;

register() runs once at server boot and lazily imports the config for the live runtime, keyed off NEXT_RUNTIME. This is why Sentry.init lives in separate config files.

import * as Sentry from '@sentry/nextjs';
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
await import('./sentry.server.config');
}
if (process.env.NEXT_RUNTIME === 'edge') {
await import('./sentry.edge.config');
}
}
export const onRequestError = Sentry.captureRequestError;

The onRequestError export. This one line is the entire uncaught-error path, worked through in the next section.

1 / 1

A few calls in the wizard’s output are load-bearing, and a couple are yours to adjust:

  • Keep the tunnel route. Inside withSentryConfig the wizard sets a tunnelRoute . Ad-blockers recognize and block Sentry’s default ingest endpoint, so client-side events silently vanish for a chunk of your users; a same-origin path stops that. Leave it on.
  • Delete the example page. The wizard adds a route with a deliberate-error button. Smoke-test with it, then delete it before it ships.
  • One DSN , both sides. The client and server SDKs share one DSN. Ignore advice to configure a separate client DSN: it’s extra config to keep in sync for no benefit.

onRequestError is a Next.js framework hook, called whenever an error bubbles up to its boundary: an uncaught throw in a Server Component, a route handler, or a Server Action that doesn’t catch its own error. The wizard binds it to Sentry.captureRequestError, so every such throw is reported with the request’s context attached.

Forgetting this export is the most common wiring mistake there is, so hold it as a rule: without it, server-side throws that reach the framework boundary never reach Sentry. Delete it and a whole class of production errors goes dark silently: nothing breaks, events just stop arriving.

Two throws make the boundary concrete. A Server Component that throws, say a page.tsx that hits an unguarded null, bubbles up to Next.js, which renders the nearest error.tsx for the user and fires onRequestError, so the event lands in Sentry. But your authedAction wrapper from the forms-and-actions chapters catches its own throw and returns a Result.err, so the error never reaches the framework boundary and onRequestError never fires. That is why there has to be a second path.

Uncaught — framework boundary
throw
Next.js boundary
onRequestError
Caught & handled — your wrapper
throw
catch in authedAction
Sentry.captureException
Result.err → user
Sentry issue
An uncaught throw in a Server Component, route handler, or Server Action bubbles up to Next.js's framework boundary.
Uncaught — framework boundary
throw
Next.js boundary
onRequestError
Caught & handled — your wrapper
throw
catch in authedAction
Sentry.captureException
Result.err → user
Sentry issue
Next.js binds onRequestError to Sentry — the uncaught path is wired for you by the wizard, no code of yours required.
Uncaught — framework boundary
throw
Next.js boundary
onRequestError
Caught & handled — your wrapper
throw
catch in authedAction
Sentry.captureException
Result.err → user
Sentry issue
But authedAction catches its own throw to return Result.err, so the error never reaches the boundary — onRequestError never fires, and nothing reaches Sentry.
Uncaught — framework boundary
throw
Next.js boundary
onRequestError
Caught & handled — your wrapper
throw
catch in authedAction
Sentry.captureException
Result.err → user
Sentry issue
The fix: call captureException inside the catch. Both lanes now converge on the same Sentry issue — and Result.err still returns to the user.

captureException inside your wrappers: the caught path

Section titled “captureException inside your wrappers: the caught path”

Here the stub from the error-handling chapter becomes real. Your authedAction and authedRoute wrappers exist to catch the throw: that’s how the user gets a safe message instead of a stack trace, and how operator detail stays out of the response. But a caught error never bubbles to the framework boundary, so onRequestError never sees it. The wrapper’s catch is the only place the caught path can be covered.

Recall the shape of that catch: it normalizes the unknown error, writes an operator-facing log line, then calls Sentry before returning the user-safe result. The two tabs are the stub you wrote and the enriched version that replaces it.

} catch (e) {
const error = ensureError(e);
logger.error(
{ action: fn.name, userId, orgId, role, input: redact(input.data), err: error },
'action failed',
);
Sentry.captureException(error);
return mapError(error);
}

The wire exists, but the event is bare. It arrives grouped and source-mapped, with no record of which action threw or who hit it.

Here is the rule, stated both ways: every catch-and-handle seam calls captureException; every uncaught throw rides onRequestError. Because the split lives in the wrapper, you wire captureException in one place and every action through authedAction inherits it for free. The flip side is the warning: an action that bypasses authedAction skips not just fail-closed authorization and the message split, but Sentry capture too. The wrapper is now load-bearing for observability, not just for safety.

One name to set aside: withServerActionInstrumentation is Sentry’s wrapper that puts a performance span around an action; reach for it when an action needs a trace, but basic error capture works without it.

Both lanes now reach the same Sentry issue: the uncaught lane via onRequestError, the caught lane via the captureException you just added. Prove you can route any throw.

Each throw below reaches Sentry by one path — or by neither server path. Sort them. Drag each item into the bucket it belongs to, then press Check.

→ onRequestError Bubbles to the framework boundary
→ manual captureException Caught and handled in a wrapper
→ neither (client SDK's job) Never touches the server
An unguarded null access throws in a Server Component’s render
authedAction catches a failed DB write and returns Result.err
A route handler rethrows after logging, letting it bubble
A webhook handler catches a signature mismatch and returns a 400
An unhandled promise rejection in a client component’s onClick

Source maps and releases: a readable, dated stack

Section titled “Source maps and releases: a readable, dated stack”

The capture paths get the event to Sentry; these two enrichments make it worth reading. The wizard wires both, and each asks you to verify one thing.

Source maps solve the minified-frame problem. Production JavaScript is minified, so the raw frame reads chunk-abc123.js:1:42 and tells you nothing. A source map lets Sentry rebind it to createInvoice.ts:34, and the wizard’s build step uploads one on every production build. Verify the build-time authToken is set and that the org and project slugs in withSentryConfig point at the right Sentry project. CI uploads source maps; your local dev build does not, so don’t expect symbolicated traces from next dev.

Releases solve the “when did this start?” problem. A release tags every event with the deploy it shipped in. Derive the release name from the commit SHA so it lines up with the source maps from that same commit; on Vercel the SHA arrives as VERCEL_GIT_COMMIT_SHA. Now Sentry tells you “first seen in release a1b2c3d by Jordan,” pinning the bug to one deploy and author without git bisect.

Rather than copy tokens by hand, use the Sentry–Vercel integration. Connected once from either dashboard, it auto-injects SENTRY_ORG, SENTRY_PROJECT, SENTRY_AUTH_TOKEN, and NEXT_PUBLIC_SENTRY_DSN into your Vercel project and notifies Sentry on every deploy. That’s the recommended path; the manual env route is the fallback when you’re not on Vercel.

Before — what the server emits
Function.t [as h] (chunk-abc123.js:1:42)
at r (chunk-abc123.js:1:1180)
no release
After — what Sentry shows
createInvoice (src/app/_lib/actions/create-invoice.ts:34:11)
at authedAction (src/lib/auth/authed-action.ts:58:9)
release a1b2c3d · first seen 2d ago
Source maps rebind the frame to your source; the release tag dates it to a deploy.

User, org, and tags: making errors filterable

Section titled “User, org, and tags: making errors filterable”

Source maps make one event readable. User and tags make a pile of events queryable, and this is the one place the lesson touches PII.

The core call is Sentry.setUser. After authentication, you attach the actor to the Sentry scope, and from then on every event in that request carries it:

Sentry.setUser({ id: ctx.user.id, email: ctx.user.email });
Sentry.setTag('orgId', ctx.orgId);

With the user set, Sentry groups errors per person and lets an operator filter to “every error user X hit this week.” That call carries an internal ID and an email, and here, email is operator-safe context, not PII to redact. The error-handling chapter already put internal IDs and emails on the operator side so support and incident response can use them; we’re applying that decision, not re-making it.

Tags are the other half: low-cardinality labels you filter on, such as plan: 'pro', feature: 'invoicing', or seam: 'authedAction'. Tags are filter dimensions, so they must be low-cardinality. Two rules follow. Never put a secret in a tag, because tags are indexed and visible. And never put a high-cardinality one-off in a tag, a requestId, a full URL, anything with effectively unbounded distinct values; those belong in extra / context, which Sentry stores on the event but doesn’t index as a filter. Cardinality is the instinct to build here.

Flag the requestId now: next lesson it becomes the join key between a Sentry event and your logs, so it rides as context, not a tag. Set the user and tags once, at the request entry or in the wrapper next to captureException, so every event in the request inherits them.

Sort these six fields the way you’d treat them on a Sentry event.

Each field below could end up on a Sentry event. Where does it belong? Drag each item into the bucket it belongs to, then press Check.

Tag Low-cardinality filter dimension
Context / extra High-cardinality, one-off, not a filter
Never send Secret — keep it off the event
plan (free / pro / enterprise)
orgId
requestId
The Stripe secret API key
feature name (invoicing)
The full request URL with query string
Section titled “Breadcrumbs: per-event context attached to an error”

User and tags tell you who and what kind; breadcrumbs tell you what just happened right before the throw. A breadcrumb is one step in that trail. Sentry auto-captures some — navigation, fetch calls, console output — and they all ship attached to the next error that fires, then they’re dropped. They answer what a stack trace can’t: what was the user doing when this broke?

Breadcrumbs look like logs but aren’t. Hold three stores apart:

  • Breadcrumbs are per-event, ephemeral context that ships with the error and is gone once it fires. No error, no breadcrumbs.
  • Logs (next lesson) are persistent, queryable lines that exist whether or not anything ever throws.
  • The audit log (from the security chapter) is durable domain events kept for compliance.

Three stores, three lifetimes. A breadcrumb is not a log only Sentry can see.

You add custom breadcrumbs where the stack trace alone won’t explain the failure:

Sentry.addBreadcrumb({
category: 'invoice',
message: 'Loaded invoice for billing',
data: { invoiceId },
});

The stack tells you which line, but not which invoice, which webhook event, or which step of a multi-step action was in flight. So the high-value places are webhook handlers (drop the event id and type), background jobs (the job id and its input shape), and multi-step actions (which step ran before it broke). Where the stack already says everything, skip it — one breadcrumb on every function entry buries the signal.

Breadcrumbs (this event)
navigate /invoices/123
fetch GET /api/invoice/123 200
breadcrumb: loaded invoice for billing
click "Pay"
TypeError: cannot read 'amount' of null
Bounded · ships with this one error · then dropped
Logs (always on)
14:31:58 info invoice.list org_12
14:32:01 info invoice.get org_12
14:32:04 error invoice.pay org_12
14:32:09 info invoice.list org_44
Unbounded · runs whether or not anything throws
Breadcrumbs are a bounded trail that ships with one error. Logs run whether or not anything throws.

Each claim is about which of the three stores — breadcrumbs, logs, or the audit log — fits the job. Mark each statement True or False.

Breadcrumbs are queryable across all of your events, like a database you can filter and search after the fact.

Breadcrumbs are per-event and ephemeral — the trail attaches to the one error that fires next, then it’s gone. The persistent, queryable store is the log stream (next lesson), not breadcrumbs.

A breadcrumb is dropped after its error fires.

The trail ships with the error and is cleared once it does. No error, no breadcrumbs — they only ever exist as context attached to a captured event.

To record a successful payment for a compliance audit, you add a breadcrumb.

That’s the durable audit log’s job (from the security chapter) — domain events kept for compliance. Breadcrumbs vanish after an error fires and don’t exist at all if nothing throws, so a successful payment would never be recorded by one.

A log line exists even when no error is thrown.

Logs are persistent records that exist whether or not anything ever fails — that’s exactly the shape that lets you “replay the request” after the fact. Breadcrumbs, by contrast, only materialise alongside an error.

Sentry on the client, and from your error boundaries

Section titled “Sentry on the client, and from your error boundaries”

Every throw so far was server-side. Two surfaces remain: errors thrown in the browser, and errors caught by a Next.js error boundary.

The client SDK is its own engine. instrumentation-client.ts runs Sentry in the browser; sentry.server.config.ts and sentry.edge.config.ts run it on the server. All three share one DSN and the same release tag, so a client error and a server error from one deploy fall under one release, and a bad deploy reads as a single event. The client SDK auto-captures unhandled promise rejections and console errors (via captureConsoleIntegration); the server SDK captures via onRequestError plus the manual captureException you wired. An unhandled rejection in a client component is already the client SDK’s job.

Error boundaries are the second surface. error.tsx and global-error.tsx are the components Next.js renders when a render throws, each receiving the thrown error as a prop. The wizard wires Sentry.captureException(error) into the useEffect of global-error.tsx, so the top-level boundary reports. It does not touch your per-segment error.tsx files; those are yours to add.

One detail makes the segment capture correct: the digest. Next.js attaches a digest to server-originated errors. Thread it through when you capture from error.tsx, and the client boundary’s report links to the server-side event instead of grouping as an unrelated client error. A digest is high-cardinality, so it rides as context on the event, not as a tag.

'use client';
import * as Sentry from '@sentry/nextjs';
import { useEffect } from 'react';
export default function GlobalError({ error }: { error: Error & { digest?: string } }) {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
return (
<html lang="en">
<body>{/* fallback UI */}</body>
</html>
);
}

Given. The wizard covers the top-level boundary, which catches errors the root layout cannot.

Two closing concerns about the tool you just installed: don’t leak, and don’t overspend.

Redaction, via beforeSend. beforeSend runs on every event just before it’s sent, your last chance to strip anything sensitive the SDK swept up incidentally, like a request body or a query string carrying a token. Strip password, token, apiKey, and Authorization from event request data, but let user emails and IDs through, since the error-handling chapter made those operator-side. This is the same posture as your logger: one denylist, two enforcement points. Sentry enforces it in beforeSend, pino in its redact config (a later lesson in this chapter owns the full denylist).

Sampling. Errors are cheap; performance traces and session replays cost meaningfully more. This chapter’s floor is 100% of errors, 0% of traces, 0% of replays. You’ll raise trace sampling when traces earn their weight (the performance chapter), and replay stays off because PostHog owns it next chapter. The wizard seeds tracesSampleRate: 0.1; override it to 0, since this chapter is errors only.

sentry.server.config.ts
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
release: process.env.VERCEL_GIT_COMMIT_SHA,
tracesSampleRate: 0, // raise when traces earn weight (perf chapter)
replaysSessionSampleRate: 0, // replay owned by PostHog next chapter
beforeSend(event) {
const headers = event.request?.headers;
if (headers) {
delete headers.Authorization;
delete headers.Cookie;
}
return event;
},
});

Cost and quota. The free tier is roughly 5k errors a month, and a few shapes can burn that in an afternoon: a tight loop calling captureException, a webhook signature mismatch that fires on every retry, or a missing tag that fragments one error into a high-cardinality pile of near-duplicate groups. Two relief valves: level: 'warning' for events worth tracking but not page-worthy, and Sentry.withScope(scope => scope.setFingerprint([...])) to force known-noisy errors to group together.

A deliberate throw from a Server Action and from a client component should arrive in Sentry grouped, readable, release-tagged, and context-attached. Verify each property by hand instead of trusting the wizard.

  1. Throw from a Server Action. Temporarily make an authedAction throw (or use the wizard’s example route once). Trigger it from the UI and confirm an event appears in Sentry. (Wired by: the caught path → captureException.)

  2. Throw from a client component. Throw in a client onClick handler. Confirm a second event appears. (Wired by: the client SDK.)

  3. Confirm grouping. Fire the same throw a few times and confirm the occurrences collapse into one issue with a count, not separate issues.

  4. Read the stack. Open the issue (from a production/preview deploy) and confirm the stack points at your .ts source, not a minified chunk. (Wired by: source maps.)

  5. Check the release. Confirm the issue is tagged with the deploy’s release (the commit SHA). (Wired by: releases + VERCEL_GIT_COMMIT_SHA.)

  6. Check the context. Confirm the user and org are attached and filterable. (Wired by: setUser + tags.)

  7. Clean up. Remove the deliberate throw (and delete the wizard’s example route).

When a real event lands, you reconstruct the incident in a fixed order: widen first, then narrow.

Order the on-call reconstruction workflow you run once a Sentry event exists — widen first, then narrow. Drag the items into the correct order, then press Check.

Open the grouped issue and read the occurrence count — how widespread is it?
Read the symbolicated stack trace — which line in which file threw?
Check the release and author — which deploy introduced it?
Read the breadcrumb trail — what happened in the seconds before?
Filter by the user / org tag — is it one customer or everyone?

Next, a shared requestId will let you jump from any of these events straight to the matching log lines.

When a config detail here ages, the canonical reference is Sentry’s manual-setup guide. For wiring releases and tokens, follow the Sentry–Vercel integration doc.