External Publication
Visit Post

Next.js 15 Error Handling: error.tsx, Server Actions, and Sentry (2026)

DEV Community [Unofficial] June 18, 2026
Source

Proper error handling in Next.js 15 is spread across four different mechanisms that serve different purposes. Most guides cover one of them. This covers all four — and how they work together in a production app.

The Four Layers of Error Handling

  1. error.tsx — Client component that catches rendering errors in a route segment
  2. global-error.tsx — Catches errors in the root layout itself
  3. Server action errors — Errors that happen during mutations and form submissions
  4. not-found.tsx — Handles 404s from notFound() calls

These are separate concerns with separate solutions. Confusing them leads to errors that silently fail or crash the wrong boundary.

error.tsx — Route-Level Boundaries

Every folder in your app/ directory can have its own error.tsx. When a component in that segment throws, Next.js renders the error.tsx instead.

// app/dashboard/error.tsx
'use client'

import { useEffect } from 'react'
import * as Sentry from '@sentry/nextjs'

interface ErrorProps {
  error: Error & { digest?: string }
  reset: () => void
}

export default function DashboardError({ error, reset }: ErrorProps) {
  useEffect(() => {
    Sentry.captureException(error, {
      tags: { section: 'dashboard', digest: error.digest },
    })
  }, [error])

  return (
    <div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
      <h2 className="text-xl font-semibold text-gray-900">
        Something went wrong
      </h2>
      <p className="text-sm text-gray-500 max-w-md text-center">
        {error.message || 'An unexpected error occurred.'}
      </p>
      <button
        onClick={reset}
        className="px-4 py-2 bg-blue-600 text-white rounded-md text-sm hover:bg-blue-700"
      >
        Try again
      </button>
    </div>
  )
}

The reset function re-renders the segment. The error.digest is a server-side hash you can use to correlate client errors with server logs.

Important: error.tsx must include 'use client'. Error boundaries in React require client-side code.

The error.tsx in app/dashboard/ catches errors in app/dashboard/page.tsx and any nested routes. It does not catch errors in app/dashboard/layout.tsx itself.

global-error.tsx — Root Layout Boundary

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

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <html>
      <body>
        <div className="flex flex-col items-center justify-center min-h-screen gap-4 p-8">
          <h1 className="text-2xl font-bold">Something went wrong</h1>
          <p className="text-gray-600 text-center max-w-md">
            A critical error occurred. Please refresh the page.
          </p>
          <button
            onClick={reset}
            className="px-6 py-2 bg-red-600 text-white rounded-md hover:bg-red-700"
          >
            Refresh
          </button>
        </div>
      </body>
    </html>
  )
}

Notice it includes <html> and <body> — it replaces the root layout entirely when it triggers. In development, Next.js shows its own error overlay instead.

not-found.tsx

// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { getPost } from '@/lib/posts'

export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const post = await getPost(slug)

  if (!post) {
    notFound()
  }

  return <article>{/* render post */}</article>
}



// app/not-found.tsx
import Link from 'next/link'

export default function NotFound() {
  return (
    <div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
      <h2 className="text-4xl font-bold text-gray-900">404</h2>
      <p className="text-gray-600">The page you're looking for doesn't exist.</p>
      <Link href="/" className="px-4 py-2 bg-gray-900 text-white rounded-md text-sm">
        Go home
      </Link>
    </div>
  )
}

Unlike error.tsx, not-found.tsx is a Server Component by default. You can have one per route segment.

Server Action Errors

Throwing inside a server action in production shows nothing useful to the client. The pattern that works is returning a typed result object instead:

// lib/actions/auth.ts
'use server'

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

export async function signIn(
  formData: FormData
): Promise<ActionResult<{ userId: string }>> {
  const email = formData.get('email') as string
  const password = formData.get('password') as string

  if (!email || !password) {
    return { success: false, error: 'Email and password are required' }
  }

  try {
    const user = await db.user.findUnique({ where: { email } })

    if (!user || !(await verifyPassword(password, user.passwordHash))) {
      return { success: false, error: 'Invalid credentials' }
    }

    return { success: true, data: { userId: user.id } }
  } catch (err) {
    console.error('SignIn error:', err)
    return { success: false, error: 'Authentication failed. Please try again.' }
  }
}

On the client side, use useActionState:

// app/login/page.tsx
'use client'

import { useActionState } from 'react'
import { signIn } from '@/lib/actions/auth'

type State = { error?: string } | null

export default function LoginPage() {
  const [state, formAction, isPending] = useActionState(
    async (_prev: State, formData: FormData): Promise<State> => {
      const result = await signIn(formData)
      if (!result.success) return { error: result.error }
      return null
    },
    null
  )

  return (
    <form action={formAction} className="flex flex-col gap-4 max-w-sm mx-auto">
      <input name="email" type="email" placeholder="Email" required className="border rounded-md px-3 py-2" />
      <input name="password" type="password" placeholder="Password" required className="border rounded-md px-3 py-2" />
      {state?.error && (
        <p className="text-sm text-red-600">{state.error}</p>
      )}
      <button
        type="submit"
        disabled={isPending}
        className="px-4 py-2 bg-blue-600 text-white rounded-md disabled:opacity-50"
      >
        {isPending ? 'Signing in...' : 'Sign in'}
      </button>
    </form>
  )
}

Typed Errors with Discriminated Unions

For complex apps, typed error codes prevent string messages from getting out of control:

// lib/errors.ts
export type AppError =
  | { code: 'NOT_FOUND'; resource: string }
  | { code: 'UNAUTHORIZED'; reason: string }
  | { code: 'VALIDATION_FAILED'; fields: Record<string, string> }
  | { code: 'RATE_LIMITED'; retryAfter: number }
  | { code: 'INTERNAL'; message: string }

export function getErrorMessage(error: AppError): string {
  switch (error.code) {
    case 'NOT_FOUND':
      return `${error.resource} not found`
    case 'UNAUTHORIZED':
      return `Unauthorized: ${error.reason}`
    case 'VALIDATION_FAILED':
      return Object.values(error.fields).join(', ')
    case 'RATE_LIMITED':
      return `Too many requests. Try again in ${error.retryAfter}s`
    case 'INTERNAL':
      return 'An unexpected error occurred'
  }
}

TypeScript enforces that you handle every error code — no silent fallthrough.

Sentry Integration

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



// sentry.client.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,
  replaysOnErrorSampleRate: 1.0,
  replaysSessionSampleRate: 0.05,
  integrations: [
    Sentry.replayIntegration({
      maskAllText: true,
      blockAllMedia: true,
    }),
  ],
})

For server actions, capture before returning:

import * as Sentry from '@sentry/nextjs'

export async function createPost(formData: FormData) {
  try {
    // ...
  } catch (err) {
    Sentry.captureException(err, { tags: { action: 'createPost' } })
    return { ok: false, error: { code: 'INTERNAL' as const, message: 'Failed' } }
  }
}

Handling Errors in Layouts

If app/dashboard/layout.tsx throws, the app/dashboard/error.tsx does not catch it. Use try/catch inside the layout:

// app/dashboard/layout.tsx
import { redirect } from 'next/navigation'
import { getUser } from '@/lib/auth'

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode
}) {
  let user

  try {
    user = await getUser()
  } catch {
    redirect('/login')
  }

  if (!user) redirect('/login')

  return (
    <div className="min-h-screen bg-gray-50">
      <nav>{/* nav */}</nav>
      <main className="container mx-auto py-8">{children}</main>
    </div>
  )
}

The Complete File Structure

app/
  dashboard/
    layout.tsx       ← shared layout
    page.tsx         ← content (async Server Component)
    loading.tsx      ← skeleton while page.tsx suspends
    error.tsx        ← error boundary (Client Component)
    not-found.tsx    ← 404 for this segment

Next.js automatically wraps page.tsx in a <Suspense> boundary when loading.tsx is present, and in an error boundary when error.tsx is present.

Quick Reference

Scenario Solution
Route segment throws error.tsx in same folder
Root layout throws global-error.tsx
Resource not found notFound() + not-found.tsx
Server action fails Return { ok: false, error }
Track in production Sentry in error.tsx + server actions
Layout error try/catch in layout or parent error.tsx

Production apps fail. The question is whether they fail gracefully with useful Sentry traces and clear user messages, or with a blank white screen and zero context to debug from.

Full guide at stacknotice.com/blog/nextjs-error-handling-2026

Discussion in the ATmosphere

Loading comments...