Build This Now
Build This Now
What Is Claude CodeInstallationNative InstallerFirst Project
speedy_devvkoen_salo
Blog/Handbook/Workflow/Production Error Tracking

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.

Stop configuring. Start building.

SaaS builder templates with AI orchestration.

See what we build for companies →
speedy_devvWritten by speedy_devvPublished Jul 22, 202610 min readHandbook hubWorkflow index

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/nextjs

The 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 production

Two 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 build

If 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 #founders

Then 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 json

One 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 build

Where 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

Continue in Workflow

  • Agentic Commerce: How to Build an App AI Agents Can Pay For
    A plain-English guide to agentic commerce in 2026: what x402, ACP, and the Machine Payments Protocol do, plus a weekend walkthrough for shipping a paid API that AI agents can buy from.
  • Claude Code Best Practices
    Five habits separate engineers who ship with Claude Code: PRDs, modular CLAUDE.md rules, custom slash commands, /clear resets, and a system-evolution mindset.
  • Claude Code Auto Mode
    A second Sonnet model reviews every Claude Code tool call before it fires. What auto mode blocks, what it allows, and the allow rules it drops in your settings.
  • Channels, Routines, Teleport, Dispatch
    The four Claude Code features Anthropic shipped in March and April 2026 that turn the CLI into an event-driven coordination layer across phone, web, and desktop.
  • Claude Code 1M Context in Practice: When Bigger Isn't Better
    The 1M-token context window is GA at flat pricing, but bigger isn't always better. A decision framework, token-cost math, and when to use /compact, subagents, and dynamic workflows instead.
  • How to Build an Admin Dashboard With Claude Code
    Ship an internal admin panel with Claude Code: role-gated routes, a searchable users and orders table with pagination, impersonation-safe RLS, and metrics tiles pulled through a type-safe API.

More from Handbook

  • Agent Fundamentals
    Five ways to build specialist agents in Claude Code: Task sub-agents, .claude/agents YAML, custom slash commands, CLAUDE.md personas, and perspective prompts.
  • Agent Harness Engineering
    The harness is every layer around your AI agent except the model itself. Learn the five control levers, the constraint paradox, and why harness design determines agent performance more than the model does.
  • Agent Patterns
    Orchestrator, fan-out, validation chain, specialist routing, progressive refinement, and watchdog. Six orchestration shapes to wire Claude Code sub-agents with.
  • Agent Teams Best Practices
    Battle-tested patterns for Claude Code Agent Teams. Context-rich spawn prompts, right-sized tasks, file ownership, delegate mode, and v2.1.33-v2.1.45 fixes.

Stop configuring. Start building.

SaaS builder templates with AI orchestration.

See what we build for companies →

On this page

The Four Files Next.js 16 Needs
Source Maps That Survive the Vercel Build
Tunneling Past Ad Blockers
Tagging Every User and Workspace
Alert Routing You Will Not Mute
The Claude Code Triage Loop
Verify Before You Trust It
Where the Harness Fits

Stop configuring. Start building.

SaaS builder templates with AI orchestration.

See what we build for companies →