Production Error Tracking
Wire Sentry into Next.js 16 the way it should be wired: instrumentation files, source maps that survive the Vercel build, ad-blocker tunneling, user and workspace tags on every event, and a Claude Code triage loop that opens the fix PR.
Hören Sie auf zu konfigurieren. Fangen Sie an zu bauen.
SaaS-Builder-Vorlagen mit KI-Orchestrierung.
Problem: Your Next.js 16 app throws in production and you find out from a customer. When you finally open Sentry, the stack trace points at page-4f2a91.js:1:88213, there is no user attached, and you cannot tell whether it hit one workspace or all of them.
Quick Win: Four files and one wrapped config give you readable stack traces, per-request user context, and events that survive ad blockers.
npm install @sentry/nextjsThe rest of this guide wires the pieces properly, then hands the triage loop to Claude Code.
The Four Files Next.js 16 Needs
The Sentry Next.js SDK initializes once per runtime. Next.js 16 has three: Node, Edge, and the browser. Each gets its own config file, and instrumentation.ts decides which server-side one to load.
Start with the entry point. Next.js calls register() once when a server runtime boots, and onRequestError on every unhandled error inside a Server Component, Route Handler, or Server Action.
// instrumentation.ts
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;That one exported line is the difference between catching server render errors and losing them. Without onRequestError, a throw inside an async Server Component surfaces as a generic digest in your logs and nothing in Sentry.
The Node config is where most of your errors land. Pin the environment and release to the Vercel build variables so every event knows which deploy produced it.
// sentry.server.config.ts
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.VERCEL_ENV ?? "development",
release: process.env.VERCEL_GIT_COMMIT_SHA,
tracesSampleRate: process.env.NODE_ENV === "development" ? 1.0 : 0.1,
ignoreErrors: ["AbortError", "ECONNRESET"],
});The Edge config is nearly identical, but it runs in a restricted runtime with no Node APIs. Keep it thin. Anything you add here has to work without fs, crypto internals, or native modules.
// sentry.edge.config.ts
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.VERCEL_ENV ?? "development",
release: process.env.VERCEL_GIT_COMMIT_SHA,
tracesSampleRate: process.env.NODE_ENV === "development" ? 1.0 : 0.1,
});The browser gets its own file at the project root. The name matters: Next.js loads instrumentation-client.ts automatically on the client, and the exported onRouterTransitionStart hook lets Sentry trace App Router navigations instead of treating every soft navigation as one endless page view.
// instrumentation-client.ts
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
environment: process.env.NEXT_PUBLIC_VERCEL_ENV ?? "development",
release: process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA,
tracesSampleRate: process.env.NODE_ENV === "development" ? 1.0 : 0.1,
replaysSessionSampleRate: 0,
replaysOnErrorSampleRate: 1.0,
integrations: [
Sentry.replayIntegration({ maskAllText: true, blockAllMedia: true }),
],
ignoreErrors: [
"ResizeObserver loop completed with undelivered notifications",
"Non-Error promise rejection captured",
],
denyUrls: [/extensions\//i, /^chrome:\/\//i, /^moz-extension:\/\//i],
});
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;Session replay at replaysSessionSampleRate: 0 and replaysOnErrorSampleRate: 1.0 records nothing during normal browsing and captures the last few seconds only when something throws. That combination keeps your replay quota for the sessions you actually want to watch.
Source Maps That Survive the Vercel Build
Minified stack traces are the fastest way to make error tracking useless. The fix is a build-time upload, and it is a config wrap plus three environment variables.
Wrap your Next.js config. withSentryConfig handles source map generation, upload, and deletion in one pass.
// next.config.ts
import type { NextConfig } from "next";
import { withSentryConfig } from "@sentry/nextjs";
const nextConfig: NextConfig = {
images: {
remotePatterns: [{ protocol: "https", hostname: "**.supabase.co" }],
},
};
export default withSentryConfig(nextConfig, {
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
authToken: process.env.SENTRY_AUTH_TOKEN,
silent: !process.env.CI,
widenClientFileUpload: true,
sourcemaps: {
deleteSourcemapsAfterUpload: true,
},
tunnelRoute: "/sentry-tunnel",
telemetry: false,
});Three options here do real work. widenClientFileUpload: true includes the Next.js internal chunks, which is where framework errors bury themselves. deleteSourcemapsAfterUpload: true strips the client-side .map files from the deployed output so nobody can pull your unminified source off the CDN, and it is already the default, so setting it explicitly is documentation for the next person reading the config. Server source maps are kept either way, because the Node runtime needs them to symbolicate. telemetry: false stops the build plugin from reporting its own usage back to Sentry.
The auth token is the part people miss. It has to exist in the Vercel build environment, not just your laptop. A token that only lives in .env.local produces a green build and minified traces forever.
npx vercel env add SENTRY_AUTH_TOKEN production
npx vercel env add SENTRY_ORG production
npx vercel env add SENTRY_PROJECT production
npx vercel env add SENTRY_DSN production
npx vercel env add NEXT_PUBLIC_SENTRY_DSN productionTwo things worth knowing about Turbopack, which is the default bundler in Next.js 16. Source maps are only uploaded during next build, never during next dev, so a local dev error will always show unminified code and tell you nothing about whether upload works. And with Turbopack the upload runs after the build completes rather than during it, which requires @sentry/nextjs 10.13.0 or newer with next 15.4.1 or newer. If you are on an older SDK, the build passes and the upload silently does nothing.
Verify with the build log rather than with hope. Run a production build locally with the token loaded and look for the upload step.
SENTRY_AUTH_TOKEN=$(grep SENTRY_AUTH_TOKEN .env.local | cut -d= -f2) npm run buildIf you see uploaded artifact counts, you are done. If the Sentry section is missing entirely, the token never reached the build.
Tunneling Past Ad Blockers
Browser extensions block requests to *.ingest.sentry.io by domain. Every user running one is invisible in your client-side error data, and those users skew toward the technical audience most likely to hit edge cases.
tunnelRoute: "/sentry-tunnel" in the config above tells the SDK to POST events to your own domain instead. Next.js generates the route handler at build time and forwards the payload server side. Nothing else changes.
The gotcha is your proxy. Next.js 16 renamed middleware.ts to proxy.ts, and a broad matcher will happily intercept the tunnel route, run an auth check against an unauthenticated POST, and redirect your error events into a login page.
// proxy.ts
import { NextResponse, type NextRequest } from "next/server";
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
if (pathname.startsWith("/dashboard") && !request.cookies.get("session")) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!_next/static|_next/image|sentry-tunnel|favicon.ico).*)"],
};The sentry-tunnel exclusion in the matcher is the whole fix. Ship without it and your tunnel returns 307 redirects to every event, which the SDK swallows quietly.
Tagging Every User and Workspace
An error with no owner is a ticket nobody picks up. An error tagged with the workspace, the plan, and the user id is a five-minute fix, because you can open the account, reproduce the state, and tell whether it is one customer or your whole enterprise tier.
The Sentry Next.js SDK creates an isolation scope per request on the server, so calling Sentry.setUser inside a Route Handler affects that request only. It does not bleed into the next invocation on a warm serverless function.
Here is a route handler that awaits params the Next.js 16 way and stamps context before doing any work.
// app/api/workspaces/[workspaceId]/invoices/route.ts
import * as Sentry from "@sentry/nextjs";
import { NextResponse } from "next/server";
import { getSession } from "@/lib/auth";
import { listInvoices } from "@/lib/db";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ workspaceId: string }> },
) {
const { workspaceId } = await params;
const session = await getSession();
Sentry.setTag("workspace_id", workspaceId);
if (session) {
Sentry.setUser({ id: session.user.id, email: session.user.email });
Sentry.setTag("plan", session.plan);
}
const invoices = await listInvoices(workspaceId);
return NextResponse.json({ invoices });
}The browser needs the same context, and it has to arrive before the first error. Render a tiny client component from the root layout that pushes the identity into the client SDK on mount.
// components/sentry-identity.tsx
"use client";
import * as Sentry from "@sentry/nextjs";
import { useEffect } from "react";
type Identity = {
userId: string;
email: string;
workspaceId: string;
plan: string;
};
export function SentryIdentity({ identity }: { identity: Identity | null }) {
useEffect(() => {
if (!identity) {
Sentry.setUser(null);
return;
}
Sentry.setUser({ id: identity.userId, email: identity.email });
Sentry.setTag("workspace_id", identity.workspaceId);
Sentry.setTag("plan", identity.plan);
}, [identity]);
return null;
}Mount it in the root layout, above children, so it runs before any page code has a chance to throw.
// app/layout.tsx
import type { ReactNode } from "react";
import { SentryIdentity } from "@/components/sentry-identity";
import { getSession } from "@/lib/auth";
export default async function RootLayout({
children,
}: {
children: ReactNode;
}) {
const session = await getSession();
return (
<html lang="en">
<body>
<SentryIdentity
identity={
session
? {
userId: session.user.id,
email: session.user.email,
workspaceId: session.workspaceId,
plan: session.plan,
}
: null
}
/>
{children}
</body>
</html>
);
}Server Actions are the other place errors escape unlabelled. Wrap them once and every action gets a span, a tag, and a captured exception on the way out.
// lib/with-observability.ts
import * as Sentry from "@sentry/nextjs";
export function withObservability<Args extends unknown[], Result>(
name: string,
action: (...args: Args) => Promise<Result>,
) {
return async (...args: Args): Promise<Result> => {
return Sentry.startSpan({ name, op: "server.action" }, async () => {
try {
return await action(...args);
} catch (error) {
Sentry.captureException(error, { tags: { action: name } });
throw error;
}
});
};
}Applying it is one line per action, and the rethrow keeps Next.js error handling intact.
// app/settings/actions.ts
"use server";
import { withObservability } from "@/lib/with-observability";
import { updateWorkspaceName } from "@/lib/db";
export const renameWorkspace = withObservability(
"renameWorkspace",
async (workspaceId: string, name: string) => {
await updateWorkspaceName(workspaceId, name);
},
);Finally, the last-resort boundary. app/global-error.tsx catches the render failures that take out the root layout itself, which is exactly the class of bug that produces a white screen and zero telemetry.
// app/global-error.tsx
"use client";
import * as Sentry from "@sentry/nextjs";
import { useEffect } from "react";
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
return (
<html lang="en">
<body className="flex min-h-screen flex-col items-center justify-center gap-4">
<h1 className="text-xl font-semibold">Something broke on our end.</h1>
<p className="text-sm text-neutral-500">
Reference: {error.digest ?? "unknown"}
</p>
<button
onClick={reset}
className="rounded-md bg-neutral-900 px-4 py-2 text-sm text-white"
>
Try again
</button>
</body>
</html>
);
}Alert Routing You Will Not Mute
An alert that fires on every new issue gets muted within a week, and then error tracking is decoration. Route by blast radius instead of by novelty.
Two mechanisms do most of the work. Fingerprints control how events group into issues, and tags control who gets paged.
Grouping is worth overriding wherever the default is wrong. Stripe webhook failures, for example, all share a stack trace but mean completely different things depending on the event type.
// lib/stripe/handle-webhook.ts
import * as Sentry from "@sentry/nextjs";
import type Stripe from "stripe";
export async function handleWebhook(event: Stripe.Event, workspaceId: string) {
try {
await route(event);
} catch (error) {
Sentry.captureException(error, {
level: "error",
fingerprint: ["stripe-webhook", event.type],
tags: { workspace_id: workspaceId, webhook_event: event.type },
});
throw error;
}
}Now invoice.payment_failed and customer.subscription.deleted are separate issues with separate counts, and you can alert on one without the other.
Ownership rules in the Sentry project settings map issues to teams using the tags you are already sending. Path and tag matchers cover most of it.
path:app/api/stripe/* #billing
path:app/(dashboard)/* #product
tags.plan:enterprise #foundersThen keep the alert rules blunt. One rule for a new issue in production affecting more than a handful of users in an hour. One rule for any issue tagged plan:enterprise. One rule for regressions, meaning an issue you already marked resolved that came back. Everything else lives in a dashboard you check on purpose, not in a channel that buzzes.
The Claude Code Triage Loop
Once errors are readable and tagged, the triage work is mechanical: read the trace, open the file, understand the state, write a fix, prove it. That is the shape of work Claude Code handles well.
Connect the Sentry MCP server so Claude reads issues directly instead of you pasting stack traces. Sentry hosts it, and Claude Code supports remote MCP servers over HTTP with OAuth.
{
"mcpServers": {
"sentry": {
"type": "http",
"url": "https://mcp.sentry.dev/mcp"
}
}
}Save that as .mcp.json in your project root, start claude, and run /mcp to authorize. That opens a browser OAuth flow against your Sentry org. The server is not read-only, it exposes triage tools that can change issue state, so grant the narrowest scopes you can live with and keep resolving issues a human action.
Then make the loop repeatable with a custom slash command. Claude Code reads command files from .claude/commands/, and the frontmatter constrains which tools the command may use.
---
description: Triage the top unresolved Sentry issue and open a fix PR
allowed-tools: Bash(git:*), Bash(gh:*), Bash(npm run:*), Bash(npx tsc:*), Read, Edit, Grep
---
Fetch the unresolved Sentry issue with the highest event count in the
production environment for project $ARGUMENTS.
Then, in order:
1. Read the full stack trace, the release SHA, and the tags. Note the
workspace_id and plan values so you know the blast radius.
2. Open the exact file and line from the topmost application stack frame.
Ignore frames inside node_modules unless the trace bottoms out there.
3. Explain the failure in two sentences before changing anything. State
what input reached that line and why the code did not handle it.
4. Write a failing test that reproduces the error, then fix the code until
it passes. Do not widen a type or add a non-null assertion to silence it.
5. Run: npx tsc --noEmit, then npm run lint, then npm run build.
All three must pass with zero errors.
6. Create a branch named fix/sentry-<issue-short-id>, commit, and open a
pull request with `gh pr create`. Put the Sentry issue link and the
affected workspace count in the PR body.
Do not merge. Do not mark the Sentry issue resolved.Run it against a project slug and Claude works the whole chain without you copying anything.
claude "/triage my-app-production"Two constraints in that command matter more than the rest. Step four forbids the silencing fixes, because the default reflex on a type error in a stack trace is to add ?? "" and move on, which converts a loud crash into a quiet wrong answer. And the closing lines forbid merging, because the trace tells you where execution died, not what the correct behavior was.
You can run the same command headless in CI on a schedule, which turns overnight errors into open pull requests waiting for review in the morning.
claude -p "/triage my-app-production" \
--allowedTools "Bash,Read,Edit,Grep" \
--output-format jsonOne billing note: Claude Code needs a paid Anthropic plan. Anthropic announced a change that would have moved programmatic runs (the Agent SDK, claude -p, and GitHub Actions) onto a separate credit pool billed at API rates from June 15, 2026, but that change was paused before it took effect. For now, programmatic and interactive runs both draw from your normal subscription limits. Check the current policy before you budget a nightly triage job, because this is the part most likely to have moved since publication.
Verify Before You Trust It
Error tracking that has never been tested is an assumption. Throw on purpose and confirm the event arrives with everything attached.
// app/api/debug/boom/route.ts
import { NextResponse } from "next/server";
export async function GET() {
if (process.env.VERCEL_ENV === "production") {
return NextResponse.json({ error: "not available" }, { status: 404 });
}
throw new Error("Sentry wiring check: server route");
}Deploy to a preview, hit the route, and check four things in the Sentry issue. The stack frame shows your TypeScript source and not a minified chunk. The release matches the commit SHA of that deploy. The workspace_id and plan tags are present. And the request went through /sentry-tunnel rather than the ingest domain. If any one of those is wrong, fix it now, because you will not fix it during an incident.
Then run the gates you would run on any change.
npx tsc --noEmit && npm run lint && npm run buildWhere the Harness Fits
Everything above is one feature done properly, and doing it properly took instrumentation files, build config, a proxy matcher, context helpers, an action wrapper, an error boundary, alert rules, and a triage command. That is the actual cost of production quality, and it is the same story for auth, billing, background jobs, and file uploads. The $29 Code Kit is a harness that sits on top of Claude Code and runs that shape of work as a pipeline: plan the feature before writing code, build it, evaluate the output against the plan, test it, then hold it at the quality gates until there are zero type errors, zero lint errors, and a clean build. It is $29 one-time with no subscription, and it is a harness rather than a product on its own, so Claude Code and a paid Anthropic plan are still what does the work.
Posted by @speedy_devv
Hören Sie auf zu konfigurieren. Fangen Sie an zu bauen.
SaaS-Builder-Vorlagen mit KI-Orchestrierung.
Database Migrations
How to change a live PostgreSQL schema in Supabase without downtime: expand/backfill/contract migrations, a generated-types check that breaks the build, an Inngest backfill job, and a rollback path you actually test in CI.
Feature Flags
How to ship server side feature flags in Next.js 16 with PostHog: percentage rollouts, a kill switch on oRPC procedures, per workspace overrides in Supabase, and Claude Code sweeping dead flags out of the codebase.