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
- error.tsx — Client component that catches rendering errors in a route segment
- global-error.tsx — Catches errors in the root layout itself
- Server action errors — Errors that happen during mutations and form submissions
-
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.tsxmust 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
Top comments (0)