Wire Sentry
The audit target lists @sentry/nextjs as a dependency but wires up none of it, so a server action that throws in production goes nowhere an operator can see. This lesson closes that gap: wire Sentry across client, server, and edge so a thrown error lands in the dashboard decoded, grouped under one issue, release-tagged, with breadcrumbs and a stack trace you can read.
To prove it, GET /api/test/throw throws Error('Sentry smoke test') on every request. Right now that renders the default Next.js error page and produces no Sentry event. By the end of the lesson, the same throw becomes a Sentry issue titled Sentry smoke test, tagged with a release matching your commit SHA, carrying navigation breadcrumbs, and showing a stack trace that reads as file and line rather than chunk-abc123.js:1:42.
Your mission
Section titled “Your mission”This is finding 1 from last lesson’s audit, and the most operator-critical gap on the board: a monitoring blind spot loses data outright, so it closes before launch rather than going to the backlog. The wiring is missing in four places: the per-runtime initializers, the boot hook, the wrapped build config, and the Sentry keys in the env schema.
The fast path is the Sentry wizard: npx @sentry/wizard@latest -i nextjs scaffolds all of this in one command. Run it if you like, but the work here is to read what it emits and defend every line. Two decisions separate wiring that is merely present from wiring that is useful. First, source maps: the upload that rebinds a minified stack to your original source runs at build time and is gated on an auth token, so without that token every stack trace stays unreadable. Second, the release tag: compute it from the deploy’s commit SHA so a regression maps to the exact deploy that introduced it, instead of tying a week of unrelated errors to a hardcoded "v1.0.0".
Keep the trace sample rate at 1.0 while you wire this locally so every request is visible. Production drops it to 0.1–0.2 because traces cost more to collect than error events, but that tuning is a later lesson’s.
This lesson installs Sentry only. The redactor that strips secrets and the request-ID join that ties a log line to its Sentry event both live in the server config’s beforeSend, 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 deliverable is four short config files at the project root, a four-key edit to next.config.ts, and five env keys. We’ll take them in the order the wiring flows: the per-runtime initializers, the boot hook that loads them, the build wrapper, and the env schema.
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 each loads a different build of the Sentry SDK. So each needs its own Sentry.init call, in its own file, which keeps the heavy Node SDK off the edge and the edge SDK out of Node.
Start with the client:
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;Without the onRouterTransitionStart export you lose the trail of where the user had been before the error.
The server init is the same shape, the bare Sentry.init for this lesson:
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 matches the server’s, minus anything Node-specific:
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 don’t load themselves on the server side. instrumentation.ts, the Next.js 16 boot hook, wires them in through two exports that do 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;onRequestError is the line people most often forget. The lesson test asserts this export specifically, because it is the difference between Sentry being configured and Sentry catching anything.
Wrap the build config
Section titled “Wrap the build config”Sentry’s build step injects instrumentation and uploads source maps. You bolt it on by wrapping your existing config in withSentryConfig rather than replacing it: the target’s next.config.ts already holds security headers and the PostHog reverse proxy, and those pass through untouched.
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 wrapper takes the existing nextConfig and layers Sentry’s build behavior on top; the headers and rewrites pass through unchanged.
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 the output.
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 upload. They read process.env directly because slugs aren’t application config, so they stay out of 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 browser stack traces decode too, not just server ones. Without it, browser errors stay minified.
What to defend here is what’s missing. A wizard or stale tutorial may still emit hideSourceMaps and disableLogger; both now do nothing — hidden maps are the default since @sentry/nextjs v9, and disableLogger is inert under Turbopack — so the lesson test asserts both keys are absent. These four are the canonical set.
Declare the env keys
Section titled “Declare the env keys”Every environment variable validates through one createEnv boundary in src/env.ts, which five Sentry keys now join. The DSN sits on the client partition so the browser SDK can 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, where createEnv 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,The lesson test pins this shape: 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 uses the same rule-location-consequence-fix template as every finding in this audit. Because the audit fixes Sentry rather than only documenting it, the Fix is a paragraph naming the seam you installed, not a diff. The four sections:
- Rule: Sentry initialized across client, server, and edge, with source-map upload, a release tag, and breadcrumbs. Cite chapter 092 lesson 1.
- Location: where each missing piece lives: the three init files,
instrumentation.ts, thewithSentryConfigwrap innext.config.ts, and theSENTRY_*keys insrc/env.ts. - Consequence: what an operator sees: a production throw produces no signal, the stack is minified, and there’s no grouping or release to triage by.
- Fix: the installed seam, the per-runtime
Sentry.initfiles, theinstrumentation.tshook exportingonRequestError, and thewithSentryConfigwrapper, plus what makes it useful: the upload gated onSENTRY_AUTH_TOKENand the SHA-derived release.
The lesson test checks 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, how Sentry.init is shaped, what breadcrumbs are, how the source-map decode works, lean on that lesson.
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 in place, without a live round-trip to Sentry. Run them:
pnpm test:lesson 3A clean pass looks like this:
✓ tests/lessons/Lesson 3.test.ts (20 tests)
Test Files 1 passed (1) Tests 20 passed (20)The real proof is that a thrown error arrives decoded, which the probes never check. That takes 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 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.