We found out our checkout was broken because a user emailed us at 11pm saying they couldn't pay. Not from logs. Not from alerts. From an email. That was the moment we stopped treating error tracking as optional infrastructure.
Sentry is the tool we use. It's not perfect — nothing is — but it catches real errors, groups them intelligently, and tells you which ones actually matter. This post is about setting it up for Next.js the right way, including the App Router stuff that most guides skip because they were written in 2022 and never updated.
Install It Right the First Time
Sentry has a wizard that handles most of the setup, and honestly you should use it. It's one of those rare cases where the automated setup is actually correct.
npx @sentry/wizard@latest -i nextjsThis creates four files: sentry.client.config.ts, sentry.server.config.ts, sentry.edge.config.ts, and patches your next.config.js to wrap it with withSentryConfig. Don't fight the wizard. Let it do its thing, then we'll talk about what to customize.
After the wizard runs, you'll have something like this in your sentry.client.config.ts:
import * as Sentry from '@sentry/nextjs';
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
tracesSampleRate: 1.0,
debug: false,
replaysOnErrorSampleRate: 1.0,
replaysSessionSampleRate: 0.1,
integrations: [
Sentry.replayIntegration(),
],
});The defaults are fine to start, but that tracesSampleRate of 1.0 means you're sending 100% of traces to Sentry. Fine for low-traffic apps. At scale, you'll pay for it — both in Sentry bill and in a tiny bit of overhead. We'll come back to this.
App Router Error Boundaries — The Part Everyone Misses
The Pages Router had getInitialProps and a pretty clear error boundary story. App Router is more nuanced. You need error.tsx files at different levels of your route tree, and you need to actually call Sentry inside them — otherwise errors in your RSCs die quietly.
// app/error.tsx
'use client';
import { useEffect } from 'react';
import * as Sentry from '@sentry/nextjs';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
return (
<div>
<h2>Something went wrong</h2>
<button onClick={() => reset()}>Try again</button>
</div>
);
}You probably also want a global-error.tsx at the root level — this catches errors in your root layout, which error.tsx won't handle:
// app/global-error.tsx
'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>
);
}One error.tsx at app root is not enough. Add error.tsx at every route group level that has distinct functionality — /dashboard/error.tsx, /checkout/error.tsx. You want granular recovery, not one big catch-all.
Capturing Server Action Errors
Server Actions are the new way to handle mutations in App Router, and they fail silently if you're not careful. Sentry doesn't automatically instrument them the way it does API routes. You need a wrapper.
// lib/safe-action.ts
import * as Sentry from '@sentry/nextjs';
type ActionFn<T, R> = (input: T) => Promise<R>;
export function withSentry<T, R>(action: ActionFn<T, R>): ActionFn<T, R> {
return async (input: T) => {
try {
return await action(input);
} catch (error) {
Sentry.captureException(error, {
extra: {
// Don't log sensitive input — be careful here
action: action.name,
},
});
throw error; // Re-throw so the UI still gets the error
}
};
}
// Usage in your server action:
// export const createOrder = withSentry(async (data: OrderInput) => {
// // your logic here
// });The re-throw is important. Swallowing errors in server actions means your client-side error boundaries never trigger, and your useFormState stays stuck in a success state that isn't. We learned this debugging a payment flow where orders were silently failing to create but the UI showed a success message. Fun times.
Performance — What Sentry Actually Costs You
Let's be honest about the overhead. Sentry's SDK adds about 80-100KB to your client bundle. That's real. The replay integration adds another 40-50KB on top. For most apps, this is an acceptable trade-off. For apps where every KB matters, you can make some choices.
First, disable replays unless you actually need them. Session replay is genuinely useful for figuring out what a user did before an error, but if you're not watching those replays, you're paying for nothing:
// sentry.client.config.ts
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
// Lower sample rate in production
tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 1.0,
// Only capture replays on errors, skip session replays
replaysOnErrorSampleRate: 1.0,
replaysSessionSampleRate: 0, // Set to 0 to disable session replays
// Only load replay integration when needed
integrations: [
Sentry.replayIntegration({
// Mask all text and inputs by default
maskAllText: true,
blockAllMedia: true,
}),
],
});The tracesSampleRate at 0.1 means you're sending 10% of traces. For error tracking (which is what you actually care about), sample rate doesn't matter — errors are always captured. The sample rate only affects performance tracing.
- Error events: always captured, not affected by tracesSampleRate
- Performance traces: affected by tracesSampleRate — start at 0.1 in production
- Session replays: controlled by replaysSessionSampleRate — set to 0 if you don't use them
- Error replays: controlled by replaysOnErrorSampleRate — 1.0 is usually fine
Source Maps Without Leaking Your Code
Source maps are the difference between seeing 'TypeError at a.b.c line 1 col 84732' and seeing the actual function name and line of code that failed. You need them. But you don't want to ship them publicly.
The withSentryConfig wrapper in next.config.js handles this automatically — it uploads source maps to Sentry during build and then deletes them from the output. Here's what a sensible config looks like:
// next.config.ts
import { withSentryConfig } from '@sentry/nextjs';
const nextConfig = {
// your normal next config
};
export default withSentryConfig(nextConfig, {
org: 'your-org',
project: 'your-project',
// Upload source maps during build
silent: true, // Set to false to see upload logs
// Automatically tree-shake Sentry logger statements in production
disableLogger: true,
// Hides source maps from generated client bundles
hideSourceMaps: true,
// Automatically instrument Next.js Data Fetching methods
autoInstrumentServerFunctions: true,
autoInstrumentAppDirectory: true,
});Make sure SENTRY_AUTH_TOKEN is in your CI environment. Without it, source map uploads silently fail and you're back to minified stack traces. Check your Sentry project settings under Source Maps to confirm they're arriving.
Setting Up Alerts That Don't Cry Wolf
Default Sentry alerts are noisy. If you leave them as-is, you'll get alert fatigue within a week and start ignoring Sentry emails, which defeats the entire purpose. Here's how we have ours set up:
- New issue: alert immediately — something new broke and you want to know fast
- Issue regression: alert immediately — this was fixed and it's back, which means something slipped through
- High volume: alert when an issue hits 100 events in an hour — something is systemically wrong
- Unresolved issues: weekly digest — not urgent, but a reminder to clean up old stuff
Turn off 'First seen by user' alerts unless you're B2C and care deeply about specific user impact. For most SaaS apps, volume matters more than unique users.
Also: add Sentry to your Slack (or Discord, if you're running that kind of ship). Email alerts are fine but they get buried. A Slack message in #incidents when a new error appears is the difference between responding in 5 minutes versus finding out the next morning.
Adding Context That Makes Debugging Actually Useful
A bare exception with a stack trace tells you what broke. User context and extra data tells you why it broke for this specific person. Add this in your auth setup or root layout:
// In your auth callback or session provider
import * as Sentry from '@sentry/nextjs';
// Call this after you authenticate a user
export function setSentryUser(user: { id: string; email: string; plan?: string }) {
Sentry.setUser({
id: user.id,
email: user.email,
// Add custom attributes that help with debugging
plan: user.plan,
});
}
// Clear on logout
export function clearSentryUser() {
Sentry.setUser(null);
}
// Add custom context for specific operations
export async function processPayment(orderId: string, amount: number) {
return Sentry.withScope((scope) => {
scope.setTag('feature', 'payments');
scope.setExtra('orderId', orderId);
scope.setExtra('amount', amount);
return performActualPaymentLogic(orderId, amount);
});
}Don't log sensitive data to Sentry. No passwords, no full credit card numbers, no raw API keys. Sentry's data scrubbing helps, but defense in depth means not sending it in the first place.
The withScope pattern is great for operations where you want context attached to any errors that might happen, without polluting the global scope. We use it for payments, file uploads, and anything touching external APIs.
One Thing That'll Save You Grief: beforeSend
Not every error deserves to be in Sentry. Browser extensions throw errors. Users with ad blockers cause network errors. Bots probe your 404 pages constantly. All of this creates noise. The beforeSend hook lets you filter it:
// sentry.client.config.ts
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
beforeSend(event, hint) {
const error = hint.originalException;
// Ignore browser extension errors
if (
event.exception?.values?.[0]?.stacktrace?.frames?.some(
(frame) => frame.filename?.includes('chrome-extension://')
)
) {
return null;
}
// Ignore network errors from users with flaky connections
if (error instanceof TypeError && error.message === 'Failed to fetch') {
return null;
}
// Ignore cancelled requests (user navigated away)
if ((error as any)?.name === 'AbortError') {
return null;
}
return event;
},
});Returning null drops the event. Be conservative with what you filter — it's easy to accidentally swallow real errors. When in doubt, let it through. You can always add filters later when you see the patterns.
At peal.dev, our Next.js templates ship with Sentry already wired up — server configs, error boundaries, the withSentryConfig wrapper in next.config.ts, and the beforeSend filters that took us a few weeks of noise to figure out. The goal is that you deploy and monitoring just works, rather than being the thing you set up "once things get serious".
The Actual Workflow Once It's Running
Here's the thing about error tracking: setting it up is 20% of the value. The other 80% is actually looking at it. We review our Sentry issues every Monday morning. Issues with high event counts get triaged first. Regressions get fixed immediately. New issues get assigned.
Use Sentry's resolve/ignore workflow. If something is a known third-party issue you can't fix, ignore it so it stops cluttering your view. If you fixed a bug, mark it resolved so you get alerted if it comes back. Treat your Sentry issue list like an inbox — zero is the goal, or at least near-zero.
The difference between Sentry being useful and Sentry being wallpaper is whether you actually act on what it shows you. The tool is just a mirror. What you do with what you see is the whole game.
Set up Sentry before you launch, not after your first production incident. The 2 hours you spend now saves you the 2am panic later. We have both data points to compare.
