SecureStartKit
SecurityFeaturesPricingDocsBlogChangelog
Sign inBuy Now
Apr 10, 2026·Tutorial·SecureStartKit Team

Next.js Error Handling: error.tsx + Sentry Setup [2026]

Next.js strips Server Component error details in production. Here's how to wire up error.tsx, global-error.tsx, and Sentry to actually see what broke.

Summarize with AI

On this page

  • Table of Contents
  • What Next.js Hides in Production
  • How error.tsx Catches Client-Side Errors
  • Where to Place Error Boundaries
  • When global-error.tsx Fires
  • The Server Action Error Problem
  • Wiring Up Sentry in Next.js 15
  • Correlating Errors: Reading the Digest
  • Putting It Together

On this page

  • Table of Contents
  • What Next.js Hides in Production
  • How error.tsx Catches Client-Side Errors
  • Where to Place Error Boundaries
  • When global-error.tsx Fires
  • The Server Action Error Problem
  • Wiring Up Sentry in Next.js 15
  • Correlating Errors: Reading the Digest
  • Putting It Together

Next.js strips Server Component error messages in production and replaces them with an opaque digest identifier. Without an error boundary and a monitoring hook, your users see a generic fallback page and you see nothing in your logs that tells you what actually broke. This guide covers how to wire up error.tsx, global-error.tsx, and Sentry so production failures become debuggable instead of mysterious.

If you've ever deployed a Next.js app and seen users complain about a broken page that "works fine locally," you've met this problem. The stack trace you need is hidden behind a security filter that Next.js applies by default, and the digest it gives you is useless unless you've wired up a monitoring tool that knows how to correlate it.

This isn't an optional hardening step. In most production Next.js apps, server-side rendering errors account for a significant share of user-visible failures, and they're the exact errors that disappear without instrumentation.

Table of Contents

  • What Next.js Hides in Production
  • How error.tsx Catches Client-Side Errors
  • Where to Place Error Boundaries
  • When global-error.tsx Fires
  • The Server Action Error Problem
  • Wiring Up Sentry in Next.js 15
  • Correlating Errors: Reading the Digest
  • Putting It Together

What Next.js Hides in Production

In development, when a Server Component throws, you see the full error message and stack trace in both the browser and the terminal. In production, that same error reaches the client as a generic string:

An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error.

The reasoning is sound. Next.js can't tell the difference between an error you threw intentionally and one that leaked a database password, a JWT secret, or an internal schema. So it masks them all [1]. The cost is that your production logs contain nothing more than a digest like 1930860604, and the error object your error.tsx receives has no real message attached.

This isn't a bug. It's a deliberate security decision, and the solution isn't to disable it. The solution is to capture errors on the server before Next.js strips them, correlate them with the digest that reaches the client, and make both searchable from one place.

Three failure categories matter in production:

  • Server Component render errors: The component throws during server-side rendering. The user sees error.tsx, the client gets a digest, and the real error only exists in the server log.
  • Server Action errors: A Server Action throws instead of returning a structured error. Same result: the client sees a redacted message, the user sees a broken form, and the real cause is buried.
  • Client-side React errors: A Client Component throws during hydration or on interaction. These are the easy ones. error.tsx catches them with the full error intact because the error happens in the browser.

error.tsx handles the third category by default. The first two require instrumentation to become useful.

How error.tsx Catches Client-Side Errors

error.tsx is a file convention in the App Router. Drop it inside a route segment, and Next.js automatically wraps that segment's page and its children in a React error boundary. When anything below it throws, Next.js unmounts the broken tree and renders your error component instead [1].

A few constraints are non-negotiable. The file must be a Client Component, because it relies on React's error boundary mechanism which doesn't exist on the server. It receives two props: an error object (which includes an optional digest string) and a reset function that re-renders the segment.

Here's the minimal version, exactly as it ships in SecureStartKit:

// app/error.tsx
'use client'

export default function Error({
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <div className="flex min-h-[60vh] flex-col items-center justify-center px-4 text-center">
      <h1 className="text-4xl font-bold">Something went wrong</h1>
      <p className="mt-4 text-muted-foreground">
        An unexpected error occurred. Please try again.
      </p>
      <button
        onClick={reset}
        className="mt-8 rounded-md bg-primary px-6 py-2.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
      >
        Try again
      </button>
    </div>
  )
}

The reset callback is the important part. When a user hits the error page and clicks "Try again," Next.js attempts to re-render the segment. If the error was transient (a flaky fetch, a race condition, a cold start timing out), the retry succeeds without a full page reload. If the error is deterministic, it fails again and the user sees the same page, which is acceptable. The user has a path forward that doesn't require reloading the browser.

A common mistake is to render the raw error message in the UI for debugging. Don't. The error.message you receive on the client for a Server Component failure is the redacted string, and even for client-side errors, exposing stack traces to end users is an information disclosure risk. Log the error to your monitoring service and show the user a human-readable fallback. Nothing more.

Where to Place Error Boundaries

error.tsx files work like nested catch blocks. An error boundary catches errors thrown in its children but not in itself or in the layout above it. Understanding this placement rule is the difference between a boundary that does what you want and one that silently breaks the whole app.

A single app/error.tsx at the root catches errors from every page in the app, but it cannot catch errors from the root layout.tsx. That's why global-error.tsx exists, and we'll get to it next. For most apps, you want at least two boundaries and often three:

  • Root boundary (app/error.tsx): Catches page-level errors across the whole app. This is the default fallback when nothing more specific is defined.
  • Route group boundaries (app/(dashboard)/error.tsx, app/(marketing)/error.tsx): Let you render different fallbacks per section. Dashboard errors might need a "return to home" button, marketing errors should look like the rest of the marketing site.
  • Feature boundaries (app/dashboard/billing/error.tsx): Scope failures to a specific feature so the rest of the dashboard keeps working. If the billing widget throws, the user can still navigate to settings.

The general rule: place boundaries where a contained failure is better than a full-app fallback. If a Stripe API call in the billing section throws, you don't want the entire dashboard to display "Something went wrong." You want billing to show an error, and the sidebar and nav to keep working.

One subtle thing about boundary placement: an error thrown during server-side rendering propagates up to the nearest error.tsx boundary, but the boundary itself is rendered on the client. Next.js streams the fallback as part of the initial HTML when possible, then hydrates it. In practice, the user sees the fallback quickly, but you can't rely on error.tsx to access request-specific server state because it runs in the browser.

When global-error.tsx Fires

error.tsx cannot catch errors thrown by the root layout, the root template, or by error.tsx itself. When any of those throw, there's no active error boundary because the layout that would have held one never finished rendering. This is where global-error.tsx steps in.

global-error.tsx lives in the app/ directory next to layout.tsx and error.tsx. Unlike other error files, it must define its own <html> and <body> tags because it completely replaces the root layout when it fires. Global styles, fonts, and providers from layout.tsx are all skipped. You're rendering from scratch.

// app/global-error.tsx
'use client'

export default function GlobalError({
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <html lang="en">
      <body>
        <div style={{ padding: '2rem', textAlign: 'center', fontFamily: 'system-ui' }}>
          <h1>Application error</h1>
          <p>A critical error occurred. Please reload the page or try again.</p>
          <button onClick={reset}>Try again</button>
        </div>
      </body>
    </html>
  )
}

Inline styles aren't a mistake here. Your Tailwind classes, your design system, your custom fonts: none of them are guaranteed to be available because the layout that loads them didn't render. Use plain HTML and inline styles that don't depend on anything.

In practice, global-error.tsx almost never fires. It exists as a last-resort fallback for catastrophic failures (a typo in the root layout, an import that breaks at module load, a provider that throws during initialization). But when it does fire, it's usually because a deploy shipped broken code, and you want to know immediately. This is one of the two files every production Next.js app should ship with instrumentation wired into it.

The Server Action Error Problem

Here's the production issue that catches most teams off guard. Server Actions are how mutations work in the App Router, and when they throw, the error is subject to the same production masking as Server Components. Your try/catch on the client sees a redacted message, and the user sees a broken form with no actionable feedback.

Consider a naive checkout action:

// actions/billing.ts -- don't do this
'use server'

import { createAdminClient } from '@/lib/supabase/server'
import { getStripe } from '@/lib/stripe/client'

export async function createCheckoutSession(priceId: string) {
  const supabase = createAdminClient()
  const { data: user } = await supabase.auth.getUser()

  if (!user) {
    throw new Error('Not authenticated')
  }

  const stripe = getStripe()
  const session = await stripe.checkout.sessions.create({
    // ...
  })

  return session.url
}

In development, throwing 'Not authenticated' works fine. In production, the client receives the generic "An error occurred in the Server Components render" string, and the form has no idea the user just needs to log in. Worse, the Stripe SDK can throw errors with details you definitely don't want reaching the browser (API keys in error messages, account IDs, internal error codes). Next.js strips those correctly, but the stripping is indiscriminate.

The fix is to return structured errors instead of throwing them [1]:

// actions/billing.ts -- the right pattern
'use server'

import { createAdminClient } from '@/lib/supabase/server'
import { getStripe } from '@/lib/stripe/client'
import { redirect } from 'next/navigation'

type ActionResult<T> =
  | { ok: true; data: T }
  | { ok: false; error: string }

export async function createCheckoutSession(
  priceId: string
): Promise<ActionResult<{ url: string }>> {
  try {
    const supabase = createAdminClient()
    const { data: { user } } = await supabase.auth.getUser()

    if (!user) {
      return { ok: false, error: 'Please log in to continue.' }
    }

    const stripe = getStripe()
    const session = await stripe.checkout.sessions.create({
      // ...
    })

    if (!session.url) {
      return { ok: false, error: 'Checkout session could not be created.' }
    }

    return { ok: true, data: { url: session.url } }
  } catch (err) {
    console.error('[createCheckoutSession] Unexpected error:', err)
    return { ok: false, error: 'Something went wrong. Please try again.' }
  }
}

Three things are happening here. Expected errors (not authenticated, no session URL) return a structured failure with a user-friendly message that's safe to display. Unexpected errors land in the catch block, get logged server-side with full detail, and return a generic message to the client. The console.error call is what Sentry's onRequestError hook picks up. We'll wire that up in the next section.

The ActionResult<T> discriminated union is the key pattern. On the client, you check result.ok and TypeScript narrows the type. No exceptions crossing the network, no redacted error messages, no guessing. This pairs naturally with Zod validation for form inputs, which also returns structured errors instead of throwing.

One exception: auth errors that should redirect. redirect() throws internally to unwind the call stack, and Next.js understands that throw and handles it as a navigation, not an error. Never catch a redirect() call in a try/catch. If you do, Next.js will treat it as a real error and render the error boundary instead of navigating.

Wiring Up Sentry in Next.js 15

Now the monitoring piece. Sentry is the most widely adopted error tracking service for Next.js, and since version 8.28.0 it ships with a hook into Next.js 15's instrumentation system that captures server-side errors directly, including the digest the client sees [3].

Install the package and run the wizard, which generates the configuration files for you:

npm install @sentry/nextjs
npx @sentry/wizard@latest -i nextjs

The wizard creates five files. Here's what each one does:

  • sentry.server.config.ts: Initializes Sentry for the Node.js runtime. This runs during server rendering, in Server Actions, and in API routes.
  • sentry.edge.config.ts: Initializes Sentry for the Edge runtime. Most apps don't need edge runtime code, but if you use middleware or edge functions, this catches their errors.
  • instrumentation-client.ts: Initializes Sentry in the browser. This captures the client-side errors that error.tsx catches in its render tree.
  • instrumentation.ts: The Next.js instrumentation entry point. It registers the server and edge configs based on the runtime, and exports the onRequestError hook.
  • app/global-error.tsx: A version of global-error that calls Sentry.captureException(error) inside a useEffect. The wizard overwrites your existing global-error if you have one, so back it up first.

The critical piece is the onRequestError hook, which Next.js calls whenever the server captures an error during rendering [2]:

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

This two-line export is what turns the digest from an opaque identifier into a searchable error in your Sentry dashboard. Sentry.captureRequestError receives the raw error (with the full message, before Next.js redacts it), the request context, and the digest. It sends all of that to Sentry, tagged so you can find it by digest later.

The server config is where you set the DSN and sampling rate:

// sentry.server.config.ts
import * as Sentry from '@sentry/nextjs'

Sentry.init({
  dsn: process.env.SENTRY_DSN,
  tracesSampleRate: 1.0,
  environment: process.env.VERCEL_ENV ?? 'development',
  beforeSend(event) {
    // Scrub anything you don't want in error reports
    if (event.request?.cookies) {
      delete event.request.cookies
    }
    return event
  },
})

The beforeSend hook is the escape hatch. Sentry captures request data by default, including headers and cookies, and cookies often contain session tokens. Strip them here. For SaaS apps handling PII or payment data, beforeSend is where you enforce what leaves your server. Setting tracesSampleRate to 1.0 in production gets expensive fast. Drop it to 0.1 or 0.01 once you have traffic.

Correlating Errors: Reading the Digest

With onRequestError wired up, the workflow for debugging a production failure becomes straightforward. A user reports a broken page. You open Sentry, search for the digest the user's browser showed them (or the one in your client-side error log), and the matching server-side error is right there with the full stack trace, the request URL, the environment, and the user context if you've attached it.

The digest itself comes from Next.js. It's derived from the error's message and stack, so the same underlying bug produces the same digest across requests. If you see the same digest appearing 500 times in Sentry in an hour, that's one bug affecting 500 users, not 500 separate problems. This is the single most useful property of the digest and the reason it exists at all.

A few practices make this correlation easier:

  • Log the digest in error.tsx: Forward it to your client-side Sentry SDK with Sentry.captureException(error, { tags: { digest: error.digest } }). Now client-side events carry the same tag as the server event.
  • Include the digest in user-facing error pages: Not prominently, but somewhere. A small "Reference: abc123" line lets support agents search Sentry directly when a user opens a ticket. This trades a tiny amount of UI noise for dramatically faster debugging.
  • Set user context early: In your root layout or a provider, call Sentry.setUser({ id: user.id }) after auth resolves. Every error from that session is now attributable to a specific user, which is the difference between "someone's checkout is broken" and "user 8af2's checkout throws a Stripe validation error on line 47."

Don't capture personally identifiable information beyond the user ID unless you've cleared it with your privacy policy. Email addresses, names, and IP addresses all count as PII under GDPR. Sentry has settings to scrub these automatically, and you should enable them.

Putting It Together

A production-ready Next.js error handling setup looks like this:

  1. app/error.tsx: Client component, catches page-level errors, renders a friendly fallback with a reset button.
  2. app/global-error.tsx: Last-resort fallback for root layout failures. Defines its own HTML and body. Calls Sentry.captureException inside useEffect.
  3. Nested error.tsx files: Placed in route groups and feature segments to contain failures to the smallest affected surface.
  4. Server Actions return structured errors: ActionResult<T> discriminated unions instead of thrown exceptions. Unexpected errors logged server-side, generic message returned to client.
  5. Sentry instrumentation: onRequestError hook captures server-side errors with digest correlation, beforeSend scrubs sensitive request data, user context set after auth resolves.
  6. Redirects are not errors: redirect() throws internally, never wrap it in try/catch.

Each of these layers handles a different failure mode. error.tsx handles the common case. global-error.tsx handles catastrophic failures. Structured Server Action responses handle the mutation failure case. Sentry's onRequestError handles the specific Next.js gotcha where Server Component errors lose their details in production.

Error handling belongs alongside the other production hardening steps. The Next.js security hardening checklist covers eleven other items worth auditing before launch, and rate limiting Server Actions is the layer that prevents the kind of abusive traffic that tends to surface edge-case errors in the first place. Both pair naturally with this setup because they all assume the same thing: anything a user can trigger can fail, and anything that can fail should be visible to you before it's visible to them.

SecureStartKit ships with error.tsx, global-error.tsx, and the Server Action response pattern above already in place, so you can focus on wiring Sentry (or your monitoring tool of choice) into the hooks that are already there. The defaults aren't the whole story, but they're the foundation that makes the rest of the setup straightforward.

Built for developers who care about security

SecureStartKit ships with these patterns out of the box.

Backend-only data access, Zod validation on every input, RLS enabled, Stripe webhooks verified. One purchase, lifetime updates.

View PricingSee the template in action

References

  1. Getting Started: Error Handling | Next.js— nextjs.org
  2. Next.js | Sentry for Next.js— docs.sentry.io
  3. Manual Setup | Sentry for Next.js— docs.sentry.io
  4. Server Component error digest discussion— github.com

Related Posts

Apr 4, 2026·Tutorial

Next.js 'use cache' Directive: Complete Guide [2026]

Next.js 16 replaced implicit caching with opt-in 'use cache'. Learn the three directives, cacheLife profiles, and real SaaS patterns.

Mar 23, 2026·Tutorial

Rate Limit Next.js Server Actions Before Abuse

Server Actions are public HTTP endpoints anyone can call. Here's how to add rate limiting to login, checkout, and contact forms.

Mar 20, 2026·Tutorial

Next.js proxy.ts Auth: Protect Routes with Supabase

Next.js 16 renamed middleware.ts to proxy.ts. Here's how to migrate your Supabase route protection and understand what actually changed.