The problem with "it works on my machine"
Your Next.js app has four places where code runs: the browser, the Node.js server, the Edge runtime, and the build step. An error in production can originate in any of them, and by default you learn about it in exactly one way — a user tells you.
Even when you do have logs, they are usually useless. A minified stack trace pointing at chunk-4f2a.js:1:88213 tells you nothing. A Vercel function log shows the exception but not the three database queries that preceded it. And once you add an AI agent that makes six tool calls per request, "the request was slow" becomes an unanswerable question without span-level detail.
This tutorial wires Sentry into a Next.js 16 App Router application so that every one of those gaps is closed. By the end you will have:
- Errors captured from all three runtimes, with readable stack traces mapped back to your TypeScript source
- Distributed traces that follow a click in the browser through a Server Action into your database
- Structured logs attached to the trace that produced them
- Session Replay so you can watch the 20 seconds before a crash
- Cron monitors that alert when a background job silently stops running
- Token counts, model names, and cost per AI agent run
Prerequisites
- Node.js 20 or newer
- A Next.js 16 project using the App Router (Next.js 15.3+ also works — the client instrumentation file requires it)
- A free Sentry account with an organization and a project created for platform "Next.js"
- Basic TypeScript familiarity
If you are starting fresh:
npx create-next-app@latest sentry-demo --typescript --app --tailwind
cd sentry-demoWhat you'll build
A small dashboard app with three deliberately fragile surfaces — a Server Action that hits a database, an API route that calls an LLM, and a client component that can throw during render — all fully instrumented. Every step below is additive, so you can stop at any point and still have a working setup.
Step 1: Install and connect the SDK
Install the SDK:
npm install @sentry/nextjsThe fastest path is the wizard, which creates config files and writes your DSN into .env:
npx @sentry/wizard@latest -i nextjsThe wizard is convenient, but it hides what is actually happening — and when something breaks later you will need to know. The rest of this tutorial does it manually. If you ran the wizard, read on anyway and compare against what it generated.
Add your DSN and auth token to .env.local:
# Safe to expose — a DSN only allows writing events, not reading them
NEXT_PUBLIC_SENTRY_DSN="https://examplePublicKey@o0.ingest.sentry.io/0"
# NOT safe to expose. Used at build time only, to upload source maps.
SENTRY_AUTH_TOKEN="sntrys_your_token_here"Create the auth token in Sentry under Settings → Auth Tokens with the project:releases and org:read scopes. Never commit it — add .env.local and .sentryclirc to .gitignore.
Step 2: Initialize all three runtimes
Sentry needs a separate init for each runtime because they have different globals and different integrations available. Create four files at the project root (or inside src/ if you use that layout).
instrumentation-client.ts
This replaces the older sentry.client.config.ts. Next.js loads it before any of your client code runs.
// instrumentation-client.ts
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
// Attach request headers and IP address to events.
// Turn this off if you have strict PII requirements.
sendDefaultPii: true,
// Performance: capture every transaction in dev, a slice in production.
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
// Forward console.* and Sentry.logger.* calls as structured logs
enableLogs: true,
integrations: [
Sentry.replayIntegration({
maskAllText: true,
blockAllMedia: true,
}),
Sentry.feedbackIntegration({ colorScheme: "system" }),
],
// Record 10% of all sessions, and 100% of sessions where an error occurred.
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
environment: process.env.NEXT_PUBLIC_VERCEL_ENV ?? "development",
});
// Required so App Router client-side navigations become their own transactions
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;That last export matters more than it looks. Without it, a soft navigation from /dashboard to /settings gets folded into the previous pageload transaction, and your navigation timing data is meaningless.
sentry.server.config.ts
// sentry.server.config.ts
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
sendDefaultPii: true,
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
enableLogs: true,
environment: process.env.VERCEL_ENV ?? "development",
// Ship a release identifier so stack traces map to the right source maps
release: process.env.VERCEL_GIT_COMMIT_SHA,
});sentry.edge.config.ts
Middleware and any route with export const runtime = "edge" run here. The Edge runtime has no Node.js APIs, so several integrations are unavailable — keep this config minimal.
// sentry.edge.config.ts
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
enableLogs: true,
environment: process.env.VERCEL_ENV ?? "development",
});instrumentation.ts
Next.js calls register() once per server process. This is where you pick the right config based on the runtime, and where you hook the framework's error reporting.
// 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");
}
}
// Next.js calls this for every error thrown in a Server Component,
// Server Action, route handler, or middleware.
export const onRequestError = Sentry.captureRequestError;The onRequestError export is the single most commonly missed step. Without it, errors thrown inside React Server Components are swallowed by Next.js and never reach Sentry — you see a generic 500 in your logs and nothing in your dashboard.
Step 3: Wrap next.config.ts
withSentryConfig does the build-time half of the job: it injects the Sentry webpack/Turbopack plugin, uploads source maps, and optionally sets up a tunnel route.
// next.config.ts
import type { NextConfig } from "next";
import { withSentryConfig } from "@sentry/nextjs";
const nextConfig: NextConfig = {
// your existing config
};
export default withSentryConfig(nextConfig, {
org: "your-org-slug",
project: "your-project-slug",
authToken: process.env.SENTRY_AUTH_TOKEN,
// Only print upload logs in CI
silent: !process.env.CI,
// Proxy Sentry requests through your own domain so ad blockers
// don't drop 30-50% of your browser events.
tunnelRoute: "/monitoring-tunnel",
sourcemaps: {
// Upload maps to Sentry, then delete them from the deployed bundle
// so nobody can read your source from the browser.
deleteSourcemapsAfterUpload: true,
},
// Strip Sentry's own debug logging from the production bundle
disableLogger: true,
// Instrument Vercel Cron jobs defined in vercel.json automatically
automaticVercelMonitors: true,
});Two of these options earn their keep immediately.
tunnelRoute creates an API route on your own domain that forwards events to Sentry. Roughly a third of browser traffic runs an ad blocker that recognizes *.ingest.sentry.io and blocks it outright, so without a tunnel your client-side error counts are systematically wrong — and biased toward exactly the technical users most likely to hit edge cases.
deleteSourcemapsAfterUpload is the difference between readable traces and leaked source code. Sentry needs the maps; your users do not.
One caveat: if you use middleware.ts with a matcher, exclude the tunnel route, or your middleware will run on every event upload:
// middleware.ts
export const config = {
matcher: ["/((?!_next/static|_next/image|monitoring-tunnel|favicon.ico).*)"],
};Step 4: Catch what React swallows
Next.js has two error boundaries you must wire up manually, because React catches those errors before they reach any global handler.
app/global-error.tsx
This is the last line of defense — it catches errors in the root layout itself.
"use client";
import * as Sentry from "@sentry/nextjs";
import { useEffect } from "react";
import NextError from "next/error";
export default function GlobalError({
error,
}: {
error: Error & { digest?: string };
}) {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
return (
<html>
<body>
<NextError statusCode={0} />
</body>
</html>
);
}app/dashboard/error.tsx
Segment-level boundaries deserve the same treatment, plus a way for the user to tell you what they were doing:
"use client";
import * as Sentry from "@sentry/nextjs";
import { useEffect } from "react";
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
Sentry.captureException(error, {
tags: { section: "dashboard" },
});
}, [error]);
return (
<div className="p-8">
<h2>Something went wrong loading your dashboard.</h2>
<button onClick={reset}>Try again</button>
<button onClick={() => Sentry.showReportDialog()}>
Tell us what happened
</button>
</div>
);
}The digest property is worth noting. In production, Next.js replaces server error messages with an opaque digest hash before sending them to the browser. Sentry captures both sides, so searching your Sentry issues for that digest links the user-visible hash to the real server-side exception.
Server Actions
Server Actions that catch their own errors need an explicit capture, otherwise onRequestError never fires:
"use server";
import * as Sentry from "@sentry/nextjs";
import { db } from "@/lib/db";
export async function updateProfile(formData: FormData) {
try {
await db.user.update({
where: { id: formData.get("id") as string },
data: { name: formData.get("name") as string },
});
return { ok: true };
} catch (error) {
Sentry.captureException(error, {
tags: { action: "updateProfile" },
extra: { userId: formData.get("id") },
});
return { ok: false, message: "Could not save your profile." };
}
}A rule of thumb: any catch block that returns a friendly message to the user is a place where an error would otherwise disappear. Capture there.
Step 5: Make traces answer real questions
Sampling at 10% is the standard starting point, but a flat rate wastes quota on healthy traffic and misses rare slow paths. Replace tracesSampleRate with a tracesSampler for finer control:
// sentry.server.config.ts
Sentry.init({
// ...
tracesSampler: (samplingContext) => {
const name = samplingContext.name ?? "";
// Never sample health checks — they are pure noise
if (name.includes("/api/health")) return 0;
// Always sample checkout: low volume, high value
if (name.includes("/api/checkout")) return 1.0;
// Always sample AI routes so you can debug agent behaviour
if (name.includes("/api/agent")) return 1.0;
// Inherit the parent's decision on distributed traces
if (samplingContext.parentSampled !== undefined) {
return samplingContext.parentSampled ? 1.0 : 0;
}
return 0.05;
},
});Custom spans
Automatic instrumentation covers HTTP, database drivers, and React rendering. Anything you wrote yourself is invisible until you wrap it:
import * as Sentry from "@sentry/nextjs";
export async function generateMonthlyReport(orgId: string) {
return Sentry.startSpan(
{
name: "generateMonthlyReport",
op: "task.report",
attributes: { orgId },
},
async (span) => {
const rows = await Sentry.startSpan(
{ name: "fetch invoice rows", op: "db.query" },
() => db.invoice.findMany({ where: { orgId } }),
);
span.setAttribute("row_count", rows.length);
const pdf = await Sentry.startSpan(
{ name: "render pdf", op: "task.render" },
() => renderPdf(rows),
);
return pdf;
},
);
}Now a slow report is not "the endpoint took 9 seconds" — it is "the database query took 400ms and PDF rendering took 8.6s." That is an actionable difference. If you are already running OpenTelemetry, Sentry consumes OTel spans directly; our Next.js OpenTelemetry tracing guide covers that path.
Step 6: Structured logs tied to traces
With enableLogs: true, Sentry gives you a logger whose output is automatically correlated with the active trace:
import * as Sentry from "@sentry/nextjs";
const { logger } = Sentry;
export async function processPayment(orderId: string, amountCents: number) {
logger.info("payment started", { orderId, amountCents });
try {
const result = await stripe.paymentIntents.create({
amount: amountCents,
currency: "usd",
metadata: { orderId },
});
logger.info(logger.fmt`payment ${result.id} succeeded for order ${orderId}`);
return result;
} catch (error) {
logger.error("payment failed", {
orderId,
code: (error as { code?: string }).code,
});
throw error;
}
}To capture existing console.* calls without rewriting them, add the console integration:
integrations: [
Sentry.consoleLoggingIntegration({ levels: ["warn", "error"] }),
],Keep console.log out of that list. Debug-level logs at production volume will burn your quota in a day, and this codebase's own code quality rules already push you toward a real logger instead of stray console.log calls.
Step 7: Session Replay without leaking data
Replay is the feature that turns "user says the button did nothing" into a 30-second video of exactly that. It is also the feature most likely to get you in trouble with a privacy review, so configure it deliberately.
The defaults in Step 2 already mask all text and block all media. That is the right starting point: mask everything, then selectively unmask what is safe.
// Text inside this element will be visible in replays
<h1 data-sentry-unmask>Monthly Revenue</h1>
// Explicitly hide a value even if unmasking is on elsewhere
<span data-sentry-mask>{user.taxId}</span>
// Remove an element from the recording entirely
<div data-sentry-block>
<CreditCardForm />
</div>For inputs, maskAllInputs defaults to true — leave it that way. A replay that captures a password field is a security incident, not a debugging tool.
Step 8: AI agent observability
This is where Sentry has moved well past classic APM. If your app calls an LLM — especially through an agent loop with tool calls — you need to see the model, the token counts, the cost, and which tool call made the run take 40 seconds.
Enable the Vercel AI integration in your server config:
// sentry.server.config.ts
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
tracesSampleRate: 1.0,
enableLogs: true,
integrations: [
Sentry.vercelAIIntegration({
recordInputs: process.env.NODE_ENV !== "production",
recordOutputs: process.env.NODE_ENV !== "production",
}),
],
});Then enable telemetry on each AI SDK call. The functionId is what groups runs together in the dashboard, so keep it stable:
// app/api/agent/route.ts
import { generateText, tool } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";
export async function POST(req: Request) {
const { question } = await req.json();
const result = await generateText({
model: anthropic("claude-sonnet-5"),
prompt: question,
tools: {
lookupOrder: tool({
description: "Look up an order by its ID",
inputSchema: z.object({ orderId: z.string() }),
execute: async ({ orderId }) => db.order.findUnique({ where: { id: orderId } }),
}),
},
stopWhen: ({ steps }) => steps.length >= 8,
experimental_telemetry: {
isEnabled: true,
functionId: "support-agent",
metadata: { tenant: "acme" },
},
});
return Response.json({ answer: result.text });
}What you get in the AI Agents view: one span per model invocation with input and output token counts, one span per tool call with its arguments and duration, and a computed cost per run based on the model's pricing. When an agent run costs 40 cents instead of 3, you can see which step did it.
Two production notes. First, set recordInputs and recordOutputs to false in production unless you have decided that prompts and completions are safe to store — user prompts routinely contain personal data. Second, tool-call spans are the usual culprit for slow agents; a single unindexed database lookup inside a tool executed eight times per run is a very common shape.
For prompt-level evaluation and versioning rather than infrastructure tracing, pair this with Langfuse LLM observability — the two answer different questions and coexist happily.
Step 9: Monitor background jobs
An error that never fires is the hardest kind to notice. If your nightly billing job stops running, nothing throws — the job simply is not there. Cron monitors solve this by alerting on absence.
// app/api/cron/reconcile/route.ts
import * as Sentry from "@sentry/nextjs";
export async function GET() {
return Sentry.withMonitor(
"nightly-reconcile",
async () => {
const count = await reconcileInvoices();
return Response.json({ reconciled: count });
},
{
schedule: { type: "crontab", value: "0 3 * * *" },
checkinMargin: 10, // alert if it has not started 10 minutes late
maxRuntime: 30, // alert if it runs longer than 30 minutes
timezone: "Africa/Tunis",
},
);
}Sentry now knows the job is expected at 03:00 daily and opens an issue if a run is missed, fails, or hangs. With automaticVercelMonitors: true from Step 3, jobs declared in vercel.json get monitors created for you. For heavier orchestration, see our tutorials on Trigger.dev v4 background jobs and Inngest durable functions, both of which report into Sentry through the same server config.
Step 10: Releases and source maps in CI
Stack traces are only readable if the uploaded source maps match the deployed bundle exactly. Tie both to a release identifier — the commit SHA is the obvious choice:
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # needed for commit association
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run build
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: your-org-slug
SENTRY_PROJECT: your-project-slug
NEXT_PUBLIC_SENTRY_DSN: ${{ secrets.NEXT_PUBLIC_SENTRY_DSN }}Because withSentryConfig runs inside next build, source map upload happens automatically as long as SENTRY_AUTH_TOKEN is present. If the variable is missing, the build still succeeds — it just silently ships unreadable traces, which is why silent: !process.env.CI matters: in CI you want the upload log visible. Our GitHub Actions CI/CD tutorial covers the surrounding pipeline in more depth.
Step 11: Control noise and cost
A default Sentry install on a busy app will drown you within a week. Three filters do most of the work.
Sentry.init({
// 1. Drop known-irrelevant errors before they leave the browser
ignoreErrors: [
"ResizeObserver loop limit exceeded",
"Non-Error promise rejection captured",
/^Network request failed$/,
"AbortError",
],
// 2. Ignore errors originating in third-party scripts
denyUrls: [/extensions\//i, /^chrome:\/\//i, /googletagmanager\.com/],
// 3. Scrub and filter programmatically
beforeSend(event, hint) {
const error = hint.originalException;
// Never report expected auth redirects
if (error instanceof Error && error.message.includes("NEXT_REDIRECT")) {
return null;
}
// Strip an auth header that slipped into request data
if (event.request?.headers) {
delete event.request.headers["authorization"];
delete event.request.headers["cookie"];
}
return event;
},
});NEXT_REDIRECT and NEXT_NOT_FOUND deserve special attention: Next.js implements redirect() and notFound() by throwing. Recent SDK versions filter these automatically, but if you re-throw them from your own catch blocks they can reappear as fake errors. Always re-throw rather than capture:
try {
await doWork();
} catch (error) {
// Let framework control-flow errors through untouched
if (error instanceof Error && error.message.startsWith("NEXT_")) throw error;
Sentry.captureException(error);
throw error;
}Testing your implementation
Add a route that fails on purpose:
// app/api/sentry-check/route.ts
export async function GET() {
throw new Error("Sentry server test — safe to ignore");
}And a client button:
"use client";
export function BreakThings() {
return (
<button onClick={() => { throw new Error("Sentry client test"); }}>
Break things
</button>
);
}Run through this checklist:
npm run dev, hit/api/sentry-check— a server issue appears within a few seconds.- Click the button — a client issue appears, with a replay attached.
- Open the issue and confirm the stack trace shows your
.tssource, not minified output. If it does not, source maps did not upload. - Open Traces and confirm a pageload transaction contains child spans for your database calls.
- Open Logs and confirm your
logger.infocalls appear, linked to the trace. - Trigger an AI route and confirm the AI Agents view shows model, tokens, and tool spans.
- Build for production and check the deployed
_next/staticdirectory contains no.mapfiles.
Troubleshooting
Nothing arrives from the browser. Almost always an ad blocker. Confirm by opening the Network tab and looking for a blocked request to ingest.sentry.io, then set tunnelRoute.
Server Component errors never appear. You are missing export const onRequestError = Sentry.captureRequestError in instrumentation.ts.
Stack traces are minified. Either SENTRY_AUTH_TOKEN was absent at build time, or the release value differs between build and runtime. Check Settings → Source Maps in Sentry — it lists uploaded artifacts per release.
Traces have no child spans. Your tracesSampleRate is 0 in that runtime, or automatic instrumentation cannot patch your database client because it was imported before Sentry.init ran. Importing the client lazily inside the function usually fixes it.
Edge runtime throws about missing Node APIs. You put a Node-only integration in sentry.edge.config.ts. Keep that file minimal.
Quota exhausted mid-month. Lower tracesSampleRate, drop replaysSessionSampleRate to 0 and rely on replaysOnErrorSampleRate, and add the noisiest errors to ignoreErrors. Sampling rates apply per event type, so tune them independently.
Next steps
- Add product analytics alongside error data with our PostHog analytics and feature flags tutorial — flag context on a Sentry issue tells you whether a rollout caused it.
- Wire uptime and synthetic checks with Playwright end-to-end tests in CI so failures are caught before users find them.
- Extend agent instrumentation with the Vercel AI SDK 7 harness and sandbox guide.
- Set alert rules: a spike-detection rule on error rate and a threshold rule on p95 transaction duration cover most real incidents.
Conclusion
The setup here is roughly forty lines of configuration spread across five files, and it changes the shape of every production incident you will handle. Instead of reconstructing what happened from user reports and grep, you open an issue and find the stack trace, the trace waterfall, the logs from that exact request, and a replay of the user's session.
Three things are worth doing today even if you skip everything else: export onRequestError so Server Component errors are captured at all, set tunnelRoute so your browser numbers are honest, and confirm source maps actually upload. Those three account for most of the difference between a Sentry install that works and one that quietly does not.