Frontend Error Monitoring With Sentry

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.

Init the SDK once, in the right place

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.

Error Boundaries: where, not whether

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.

Source maps or you’re guessing

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.

Custom events for self-amplifying failures

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 },
  })
})

Grouping and user impact

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.

Tie it to Web Vitals

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.

Takeaways

  • Put Error Boundaries at three layers (root, route, risky-feature), not just one. Tag the boundary in beforeCapture.
  • Source maps + release SHA + set-commits --auto in CI is what turns Sentry from a log dump into a debugger.
  • 100% replay on errors, low or zero on sessions. Replays are how you stop guessing.
  • Custom events for failures that can self-amplify. Those are your lead indicators.
  • Sort by users affected, not event count. Fingerprint third-party noise so it doesn’t drown signal.
  • Send Web Vitals through the same SDK as errors. Same release timeline, same dashboard, fewer tabs.

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.