How I wire Sentry into React and Next.js apps in production, where Error Boundaries actually go, and why source maps and releases matter more than dashboards.
A Friday afternoon, an hour before I was going to log off, and a creator emailed support saying the visual builder for their branded mobile app had gone blank in the middle of a session. No error, just a white screen. The kind of bug that does not show up in your local dev. I pulled up Sentry, filtered by the creator’s user id, and saw a ChunkLoadError clustered into one issue with about 40 sessions attached. We’d shipped a release ninety minutes earlier and Cloudflare had already evicted the old chunk from the edge. Their browser was still trying to fetch it.
That whole investigation took maybe four minutes. Without Sentry it would have been a day. So when I say frontend error monitoring is worth setting up properly, I mean it the way I mean writing tests. Not a nice to have.
This is how I wire it up on React and Next.js codebases I actually run in production.
The default Sentry.init snippet from the docs is fine to start with but I always end up customizing four things. Sample rate, release tag, environment, and the beforeSend hook. The last one is where most of the value lives, honestly.
// src/lib/sentry.ts
import * as Sentry from '@sentry/react'
import { useEffect } from 'react'
import {
createRoutesFromChildren,
matchRoutes,
useLocation,
useNavigationType,
} from 'react-router-dom'
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
environment: process.env.NEXT_PUBLIC_APP_ENV,
release: process.env.NEXT_PUBLIC_RELEASE_SHA,
tracesSampleRate: 0.1,
replaysSessionSampleRate: 0,
replaysOnErrorSampleRate: 1.0,
integrations: [
Sentry.reactRouterV6BrowserTracingIntegration({
useEffect,
useLocation,
useNavigationType,
createRoutesFromChildren,
matchRoutes,
}),
Sentry.replayIntegration({ maskAllText: true, blockAllMedia: true }),
],
beforeSend(event, hint) {
const err = hint.originalException as Error | undefined
if (err?.message?.includes('ResizeObserver loop')) return null
if (err?.message?.includes('Non-Error promise rejection')) return null
return event
},
})
Two things I do not budge on. release always carries the git SHA, not a semver tag. And replaysOnErrorSampleRate is 1.0 so every error event has a replay attached. Session replay at 100% is loud and expensive. Error-replay at 100% is gold.
The beforeSend filter starts small and grows. Every codebase I’ve worked on has a ResizeObserver loop limit exceeded issue that fills up the inbox for no reason. Drop it at the source.
I see teams put a single Error Boundary at the app root and call it done. That is the worst place to put the only one. If the boundary catches and shows a fallback, the entire app is gone. The user came in to do one thing. Now they get a sad face.
The pattern I land on is three layers. Root boundary that logs and shows a hard fallback. Route-level boundary at every major surface so a broken route doesn’t take down navigation. Feature-level boundary around anything that mounts third-party code or anything I genuinely don’t trust.
// src/components/AppErrorBoundary.tsx
import * as Sentry from '@sentry/react'
import { type ReactNode } from 'react'
export function RouteErrorBoundary({
children,
routeName,
}: {
children: ReactNode
routeName: string
}) {
return (
<Sentry.ErrorBoundary
fallback={({ resetError, eventId }) => (
<section role="alert" className="p-6">
<h2>Something broke on this page</h2>
<p>We logged it. Reference: <code>{eventId}</code></p>
<button onClick={resetError}>Try again</button>
</section>
)}
beforeCapture={(scope) => {
scope.setTag('boundary', 'route')
scope.setTag('route', routeName)
}}
>
{children}
</Sentry.ErrorBoundary>
)
}
That beforeCapture tagging is small but pays off in the inbox. When you’ve got hundreds of issues a week, the difference between “knowing this came from the checkout route” and “guessing from the stack” is the difference between fixing it on a Tuesday and putting it in the icebox.
One thing I learned the hard way at a live-video creator platform I led engineering at. Don’t put a boundary around your video player itself. If the player throws and you swap it for a fallback, you’ve just lost the user’s stream connection, their seek position, and any controls state. Wrap the chrome around the player, not the player. Let the player handle its own errors.
Sentry without source maps is a stack of chunk-1a2b.js:1:4823. Useless. Every CI deploy has to upload them. I use the Sentry CLI in GitHub Actions and tie the release tag to the same SHA the runtime SDK uses.
- name: Upload source maps to Sentry
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
run: |
pnpm sentry-cli releases new "$GITHUB_SHA"
pnpm sentry-cli releases files "$GITHUB_SHA" \
upload-sourcemaps ./dist --url-prefix "~/assets" --rewrite
pnpm sentry-cli releases finalize "$GITHUB_SHA"
pnpm sentry-cli releases set-commits "$GITHUB_SHA" --auto
The set-commits --auto is the bit teams skip. That’s what powers the suspect-commits feature in the Sentry UI, the one that lights up “this error first appeared in this PR, here are the three files it touched”. I lean on that more than I’d like to admit when triaging.
If a thing in your frontend can fail in a self-amplifying loop, instrument the failure as a custom event before you ever see the loop. You’ll spot it first there, before infra alerts fire.
import * as Sentry from '@sentry/react'
socket.on('reconnect_error', (err: Error) => {
Sentry.captureMessage('WebSocketReconnectFailed', {
level: 'warning',
tags: { transport: socket.io.engine.transport.name },
extra: { attempt: socket.io.backoff.attempts, err: err.message },
})
})
Sentry’s default grouping is by stack frame. It’s good. It is not perfect. Two issues I always tune. First, errors from third-party scripts (analytics, support widgets) get their own fingerprint so they don’t pollute. Second, errors with dynamic data in the message (uuids, timestamps) get a fingerprint without the dynamic part, or you end up with one issue per occurrence.
Sentry.init({
// ...rest
beforeSend(event, hint) {
const err = hint.originalException as Error | undefined
if (err?.stack?.includes('intercom.io')) {
event.fingerprint = ['third-party:intercom']
}
if (err?.message?.match(/Failed to load resource: chunk-[a-z0-9]+\.js/)) {
event.fingerprint = ['chunk-load-error']
}
return event
},
})
The other piece is user impact. Issues sorted by event count alone are misleading. One user refreshing the page 80 times is not a higher-priority issue than 60 different users hitting the same bug once. Sort by “users affected” in the inbox, not “events”. Tag user ids on every event (with whatever pseudonymization your privacy posture requires) so this works at all.
This is the one most teams miss. Sentry has a separate Performance product, but you can send Web Vitals through the same SDK and get them on the same release timeline as your errors. When a release ships and CLS spikes from 0.05 to 0.21, you want that next to the error chart, not in a different tool.
import { onCLS, onFID, onLCP } from 'web-vitals'
import * as Sentry from '@sentry/react'
function report(metric: { name: string; value: number; id: string }) {
Sentry.metrics.distribution(`web_vitals.${metric.name.toLowerCase()}`, metric.value, {
tags: { release: process.env.NEXT_PUBLIC_RELEASE_SHA ?? 'unknown' },
})
}
onCLS(report)
onFID(report)
onLCP(report)
This is how you spot the “perf regressed in release X” pattern early, before customer support emails arrive. I’ve caught a hydration-mismatch regression this way that wouldn’t have showed up as a JS error at all, just LCP getting worse.
beforeCapture.set-commits --auto in CI is what turns Sentry from a log dump into a debugger.Thanks for reading. If you’ve got thoughts, send them my way.