Wire Sentry
The audit target ships @sentry/nextjs in its dependencies, but nothing is actually wired — so when a server action throws in production, the error goes nowhere an operator can see it. Your goal in this lesson is to close that gap end to end: wire Sentry across client, server, and edge so a deliberately thrown error lands in the dashboard, decoded — grouped under one issue, tagged with the deploy it shipped in, carrying the breadcrumbs that led to it, and showing a stack trace you can actually read.
The target hands you a proof: a route at GET /api/test/throw that throws Error('Sentry smoke test') on every request. Right now, hitting it renders the default Next.js error page and produces no Sentry event at all. By the end of the lesson, that same throw produces a Sentry issue titled Sentry smoke test, with a release matching your current commit SHA, navigation breadcrumbs showing where you’d been, and a stack trace that reads as file and line — not chunk-abc123.js:1:42.
Your mission
Section titled “Your mission”This is finding 1 from the audit you toured in the last lesson, and it is the most operator-critical gap on the board: an error-monitoring blind spot is lost data, not slow data, so it closes before launch rather than going to the backlog. The audit target ships the Sentry package but none of its wiring — the per-runtime initializers are missing, the boot hook is missing, the build config is not wrapped, and the env schema has no Sentry keys. Without that wiring an uncaught throw either vanishes into the framework boundary or, if it does reach the platform’s log tab, arrives as minified noise no one can act on at 3am.
The fast path is the Sentry wizard — npx @sentry/wizard@latest -i nextjs scaffolds all of this in one command. You’re welcome to run it, but the work of this lesson is to read what it emits and be able to defend every line, because a wizard from a stale tutorial still writes flags that no longer belong. Two decisions are what separate wiring that’s merely present from wiring that’s useful. The first is source maps: the upload that rebinds a minified stack to your original source runs at build time and is gated on an auth token — without it, every stack trace stays unreadable and the event is worthless when you need it most. The second is the release tag: it has to be computed from the deploy’s commit SHA, so a regression maps to the exact deploy that introduced it. A hardcoded "v1.0.0" ties a whole week of unrelated errors to one string, and the moment that happens, “which release broke this?” stops having an answer. Keep the trace sample rate at 1.0 while you wire this locally so every request is visible; just know that production drops it to 0.1–0.2 because traces cost more to collect than error events do — that tuning belongs to the Sentry lesson back in the observability chapter, not here.
This lesson installs Sentry only. The redactor that strips secrets from events and the request-ID join that ties a log line to its Sentry event both live inside the server config’s beforeSend, and both are the next lesson’s work — leave that hook out for now.
'dev' fallback — never a hardcoded version./api/test/throw produces no event at all.findings/001-sentry-not-wired.md is filled with all four sections, its Fix naming the seam you installed and the build wiring that now governs every captured error./api/test/throw lands an event in the Sentry dashboard within about a minute, tagged with the release matching your current commit and carrying navigation breadcrumbs.Coding time
Section titled “Coding time”Wire it against the brief and the lesson tests first. Run the wizard or write the files by hand, hit the throw route, and confirm the event lands before you open the walkthrough below.
Reference solution and walkthrough
The whole deliverable is four short config files at the project root, a four-key edit to next.config.ts, and the five env keys. We’ll go through them in the order the wiring flows: the per-runtime initializers, the boot hook that loads them, the build wrapper, and the env schema that documents the shape.
The three per-runtime initializers
Section titled “The three per-runtime initializers”Next.js runs your code across three runtimes — the browser, the Node server, and the edge — and Sentry needs a separate Sentry.init call in each, because each loads a different build of the SDK. The init lives in these dedicated files rather than inline anywhere, so the heavy Node SDK never loads on the edge and vice versa.
Start with the client. Next.js 16 loads instrumentation-client.ts automatically in the browser:
import * as Sentry from '@sentry/nextjs';
// The client-runtime Sentry SDK (browser). Next.js 16 loads this file automatically on// the client. One DSN covers client and server — a separate "client" DSN is the trap// the 092 lesson names (extra config to maintain). NEXT_PUBLIC_SENTRY_DSN is the// client-readable copy of the same DSN; the release tag matches the server config so// events from both sides on one deploy group together (092 L1).const release = process.env.VERCEL_GIT_COMMIT_SHA ?? 'dev';
Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, release, tracesSampleRate: 1.0,});
// Required by Next.js 16 to instrument client-side router navigations as Sentry spans.export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;Two things worth pausing on. One DSN covers client and server — a project has a single DSN, and reaching for a separate “client” DSN is a trap that only buys you more config to keep in sync. And the onRouterTransitionStart export is required by Next.js 16 to turn client-side router navigations into Sentry spans; leave it out and you lose the breadcrumb trail of where the user had been before the error.
The server init is the same shape. For this lesson it’s just the bare Sentry.init:
import * as Sentry from '@sentry/nextjs';
const release = process.env.VERCEL_GIT_COMMIT_SHA ?? 'dev';
Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, release, // 1.0 locally for full visibility while wiring; production drops to 0.1–0.2 because // traces cost more than error events (092 L1). tracesSampleRate: 1.0,});The edge init is identical to the server’s, minus anything Node-specific — same DSN and release so events from both runtimes group under one deploy:
import * as Sentry from '@sentry/nextjs';
// The edge-runtime Sentry client. Loaded by instrumentation.ts's `register` when// NEXT_RUNTIME === 'edge' (the proxy and any edge route handlers). Same DSN and release// as the server config so events from both runtimes group under one deploy (092 L1).const release = process.env.VERCEL_GIT_COMMIT_SHA ?? 'dev';
Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, release, tracesSampleRate: 1.0,});The boot hook
Section titled “The boot hook”The three configs above don’t load themselves on the server side — instrumentation.ts is what wires them in. It’s the Next.js 16 boot hook, and it carries two exports that do very different jobs:
import * as Sentry from '@sentry/nextjs';
// The Next.js 16 instrumentation hook (092 L1). `register` runs once per runtime at// boot and lazy-imports the matching Sentry config by NEXT_RUNTIME so the Node SDK// never loads in the edge runtime and vice versa. The config (Sentry.init) lives in the// sentry.*.config.ts files, NOT inline here — the canonical wiring shape.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'); }}
// The load-bearing export: Next.js calls onRequestError for every uncaught throw in a// server component, route handler, or server action (the framework-boundary errors that// never reach a try/catch). Without it, GET /api/test/throw renders the default error// page and no Sentry event is produced — the finding-1 broken state.export const onRequestError = Sentry.captureRequestError;register runs once per runtime at boot and uses a lazy import() so the Node config loads only when NEXT_RUNTIME is nodejs and the edge config only on the edge — that branch is what keeps the two SDK builds from ever colliding.
The onRequestError export is the load-bearing line, and it’s the single most common thing people forget. Next.js calls it for every uncaught throw in a server component, route handler, or server action — exactly the framework-boundary errors that never reach a try/catch and so never hit Sentry.captureException on their own. Leave it out and your Sentry.init calls are all in place, the SDK is running, and GET /api/test/throw still produces no event. That’s why the lesson test asserts this export specifically: it’s the difference between Sentry being configured and Sentry actually catching anything.
Wrap the build config
Section titled “Wrap the build config”Sentry’s build-time work — injecting the instrumentation and uploading source maps — is bolted on by wrapping your existing config in withSentryConfig. In the audit target, next.config.ts already carries a real config object (security headers, the PostHog reverse proxy); you’re wrapping it, not replacing it. The default export becomes the wrapped call:
import { withSentryConfig } from '@sentry/nextjs';import type { NextConfig } from 'next';
const nextConfig: NextConfig = { cacheComponents: true, typedRoutes: true, reactCompiler: true, turbopack: { root: __dirname }, // …the pre-existing security headers and PostHog rewrites stay here…};
export default withSentryConfig(nextConfig, { silent: true, org: process.env.SENTRY_ORG, project: process.env.SENTRY_PROJECT, widenClientFileUpload: true,});The whole existing nextConfig is passed straight through withSentryConfig as the first argument. Nothing about the headers or rewrites changes — the wrapper only layers Sentry’s build behavior on top of the config you already had.
import { withSentryConfig } from '@sentry/nextjs';import type { NextConfig } from 'next';
const nextConfig: NextConfig = { cacheComponents: true, typedRoutes: true, reactCompiler: true, turbopack: { root: __dirname }, // …the pre-existing security headers and PostHog rewrites stay here…};
export default withSentryConfig(nextConfig, { silent: true, org: process.env.SENTRY_ORG, project: process.env.SENTRY_PROJECT, widenClientFileUpload: true,});Mutes the upload logs during a normal build so they don’t drown your build output. Cosmetic, but it’s what the wizard sets and it’s worth keeping.
import { withSentryConfig } from '@sentry/nextjs';import type { NextConfig } from 'next';
const nextConfig: NextConfig = { cacheComponents: true, typedRoutes: true, reactCompiler: true, turbopack: { root: __dirname }, // …the pre-existing security headers and PostHog rewrites stay here…};
export default withSentryConfig(nextConfig, { silent: true, org: process.env.SENTRY_ORG, project: process.env.SENTRY_PROJECT, widenClientFileUpload: true,});The org and project slugs that address the source-map upload. They read process.env directly — these slugs aren’t application config, so they live outside the env schema.
import { withSentryConfig } from '@sentry/nextjs';import type { NextConfig } from 'next';
const nextConfig: NextConfig = { cacheComponents: true, typedRoutes: true, reactCompiler: true, turbopack: { root: __dirname }, // …the pre-existing security headers and PostHog rewrites stay here…};
export default withSentryConfig(nextConfig, { silent: true, org: process.env.SENTRY_ORG, project: process.env.SENTRY_PROJECT, widenClientFileUpload: true,});Uploads more of the App Router’s client chunks, so a browser stack trace decodes too — not just the server one. Without it the client-side maps are incomplete and browser errors stay minified.
The detail to defend here is the absence of two flags. A wizard or an older tutorial might still emit hideSourceMaps and disableLogger. Drop both: hideSourceMaps was removed in @sentry/nextjs v9+ because hidden source maps are now the default, and disableLogger is deprecated and inert under Turbopack. Passing them does nothing useful and signals you pasted defaults blind — which is exactly what the lesson test checks for by asserting those two keys are not present. The four keys above are the whole canonical set.
Declare the env keys
Section titled “Declare the env keys”The last piece is the env schema. The audit target validates every environment variable through one createEnv boundary in src/env.ts, and five Sentry keys join it. The DSN goes on the client partition, because the browser SDK has to read it; the rest are server-only build keys:
server: { // …existing server keys… // Sentry build-time keys (finding 1). The auth token gates the source-map upload at // build (empty → upload skipped, traces stay minified — the named trap); org/project // address the upload; release is computed from the deploy SHA with a static dev // fallback so a week of errors is never tied to one hardcoded version. SENTRY_AUTH_TOKEN: z.string().optional(), SENTRY_ORG: z.string().optional(), SENTRY_PROJECT: z.string().optional(), SENTRY_RELEASE: z .string() .default(process.env.VERCEL_GIT_COMMIT_SHA ?? 'dev'), }, client: { // …existing client keys… // The client-readable Sentry DSN (finding 1) — one DSN for client and server. // Optional so the dummy local value can stay commented in .env without failing the // build; the SDK no-ops when the DSN is absent. NEXT_PUBLIC_SENTRY_DSN: z.string().optional(), },And the matching runtimeEnv entries, which is where createEnv actually reads process.env:
SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN, SENTRY_ORG: process.env.SENTRY_ORG, SENTRY_PROJECT: process.env.SENTRY_PROJECT, SENTRY_RELEASE: process.env.SENTRY_RELEASE, NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,Every Sentry key is optional(), and that’s deliberate. It lets you boot locally with the dummy values commented out: the SDK simply no-ops when the DSN is absent, and the source-map upload skips when the auth token is empty rather than failing the build. SENTRY_RELEASE is the one with a default — process.env.VERCEL_GIT_COMMIT_SHA ?? 'dev', the same expression the config files use — so the release is the deploy SHA in production and a static dev marker on your machine. The lesson test asserts exactly this: the auth token and DSN are optional(), and the release is defaulted off the commit SHA, never hardcoded.
Fill the finding report
Section titled “Fill the finding report”Finding 1 gets its report filled the same way every finding in this audit does — the rule-location-consequence-fix template you saw modeled on finding 7 last lesson. Because Sentry is a finding the audit fixes rather than just documents, the Fix section is a paragraph naming the seam you installed, not a diff.
The Rule names the standard and cites its source lesson: Sentry initialized across client, server, and edge, with source-map upload, a release tag, and breadcrumbs — the rule from lesson 1 of the Sentry chapter (chapter 092). The Location is a “missing-piece” location, since the wiring was absent: name where each piece must live — the three init files, instrumentation.ts, the withSentryConfig wrap in next.config.ts, and the SENTRY_* keys in src/env.ts. The Consequence is operator-visible, not a code smell: a production throw produces no signal, the stack is minified, there’s no grouping or release, and triage means grepping a log tab by hand at 3am. The Fix names the installed seam — the per-runtime Sentry.init files, the instrumentation.ts hook exporting onRequestError, the withSentryConfig wrapper — and, crucially, what makes that seam useful: the source-map upload gated on SENTRY_AUTH_TOKEN and the SHA-derived release. The lesson test checks that all four sections are present and non-empty, that the Rule names Sentry and cites chapter 092 lesson 1, and that the Fix names withSentryConfig plus the init seam and the release strategy.
For the Sentry concepts underneath all of this — how Sentry.init is shaped, what breadcrumbs are, how the source-map decode works, why the release strategy matters — lean on lesson 1 of chapter 092 rather than re-deriving them here.
One deploy-time follow-up to keep in your back pocket: in production these errors and their structured logs flow to a Vercel Log Drain (lesson 4 of chapter 092) as the read surface. You don’t wire it locally, and it’s not part of this lesson — just know it’s the production-side companion to what you built today.
The canonical reference for every file you wire here — the three init configs, instrumentation.ts, and withSentryConfig. Read it to defend each line the wizard emits.
The auth-token-gated upload that turns a minified stack into file-and-line — the decision that separates useful wiring from worthless.
The framework side of the boot hook: register, the NEXT_RUNTIME branch, and onRequestError — the load-bearing export this lesson hinges on.
Why the release tag is computed from the commit SHA, so a regression maps to the exact deploy that introduced it.
Moment of truth
Section titled “Moment of truth”The lesson tests are source-shape probes — they read the files you wrote and confirm the seam is there, without a live round-trip to Sentry. Run them:
pnpm test:lesson 3A clean pass means the finding-1 describe is green and Vitest reports the file passing:
✓ tests/lessons/Lesson 3.test.ts (20 tests)
Test Files 1 passed (1) Tests 20 passed (20)The tests can’t reach the live dashboard, though, and the whole point of this lesson is that a real throw arrives decoded. To confirm that, you need a free-tier Sentry org and project: paste your DSN, org, and project slugs into .env, set SENTRY_AUTH_TOKEN for the build, then hit the throw route and check the dashboard. Work down this list by hand on the Sentry dashboard:
/api/test/throw lands an event in Sentry within about 60 seconds.release matching your current commit SHA.line 1 column 12345 stack means SENTRY_AUTH_TOKEN was missing at build time and the maps never uploaded.findings/001-sentry-not-wired.md Fix section names the installed seam, not a diff.