Granular error.tsx and global-error Boundaries
Catch render and action failures at segment and root level with recoverable reset flows.
Granular error.tsx and global-error Boundaries is a free Next.js 15 Fullstack (App Router + Server Actions) lesson on CoddyKit — lesson 2 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the Next.js 15 Fullstack (App Router + Server Actions) learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.
Why Error Boundaries Matter in App Router
In Next.js 15's App Router, your application is composed of nested route segments. Each segment can independently fail during rendering or data fetching. Without proper boundaries, a single broken segment crashes the entire page — or worse, the entire application.
Next.js solves this with two special files:
- error.tsx — catches errors within a specific route segment and its children
- global-error.tsx — catches errors in the root layout, acting as a last-resort safety net
These boundaries give you granular control: a failure in /dashboard/analytics can be caught and recovered without taking down /dashboard/settings.
The error.tsx Contract
An error.tsx file must export a default React Client Component (it requires the 'use client' directive). It receives two props from Next.js automatically:
- error — the thrown
Errorobject, including adigestproperty (a server-generated hash for server-side error correlation) - reset — a function you call to re-render the segment, giving users a recovery path
The component is rendered in place of the segment that failed, wrapped in a React Error Boundary by the framework. You do not need to write the class-based boundary yourself.
'use client'
import { useEffect } from 'react'
interface ErrorPageProps {
error: Error & { digest?: string }
reset: () => void
}
export default function ErrorPage({ error, reset }: ErrorPageProps) {
useEffect(() => {
// Log to your observability service (e.g. Sentry, Datadog)
console.error('[Segment Error]', error.message, 'digest:', error.digest)
}, [error])
return (
<div role="alert" className="p-6 rounded-md border border-red-300 bg-red-50">
<h2 className="text-lg font-semibold text-red-700">Something went wrong</h2>
<p className="text-sm text-red-600 mt-1">{error.message}</p>
<button
onClick={reset}
className="mt-4 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Try again
</button>
</div>
)
}Placing error.tsx in the Right Segment
Error boundaries in App Router follow the folder hierarchy. An error.tsx placed in a folder catches errors thrown by the page.tsx and any child layouts within that folder — but not the layout at the same level.
This is a critical distinction: the layout wraps the error boundary, so layout errors bubble up to the parent segment's boundary.
Typical placement strategy:
app/error.tsx— catches errors in the root page and all segment pages (but notapp/layout.tsx)app/dashboard/error.tsx— catches only the dashboard subtreeapp/dashboard/analytics/error.tsx— hyper-targeted to one leaf segment
// File tree showing boundary scopes:
//
// app/
// ├── layout.tsx ← NOT caught by app/error.tsx
// ├── error.tsx ← catches app/page.tsx failures
// ├── page.tsx
// └── dashboard/
// ├── layout.tsx ← NOT caught by dashboard/error.tsx
// ├── error.tsx ← catches dashboard subtree
// ├── page.tsx
// └── analytics/
// ├── error.tsx ← catches only this segment
// └── page.tsx
// A page that deliberately throws to test the boundary:
export default async function AnalyticsPage() {
const data = await fetch('/api/analytics')
if (!data.ok) {
// This error is caught by analytics/error.tsx
throw new Error('Failed to load analytics data')
}
return <div>Analytics content</div>
}The reset() Function and Recoverable Failures
The reset function passed to your error boundary attempts to re-render the failed segment without a full page reload. This is important for user experience: transient failures (network hiccups, momentary server unavailability) can often be resolved just by retrying.
However, reset() only re-renders the client-side React tree. For Server Components that fetch data, Next.js will also re-fetch server data as part of the reset cycle in Next.js 15.
Best practices for reset flows:
- Show a clear, actionable error message — avoid raw error strings in production
- Limit retry attempts to avoid infinite loops on permanent failures
- Use
router.refresh()fromuseRouteralongsidereset()to invalidate the router cache when needed
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
interface ErrorPageProps {
error: Error & { digest?: string }
reset: () => void
}
export default function ErrorPage({ error, reset }: ErrorPageProps) {
const router = useRouter()
const [retryCount, setRetryCount] = useState(0)
const MAX_RETRIES = 3
useEffect(() => {
console.error('[Error boundary triggered]', {
message: error.message,
digest: error.digest,
retryCount,
})
}, [error, retryCount])
function handleReset() {
if (retryCount >= MAX_RETRIES) return
setRetryCount((c) => c + 1)
router.refresh() // invalidate router cache
reset() // re-render the segment
}
return (
<div role="alert">
<p>An error occurred: {error.message}</p>
{retryCount < MAX_RETRIES ? (
<button onClick={handleReset}>Retry ({MAX_RETRIES - retryCount} left)</button>
) : (
<p>Too many retries. Please <a href="/">return home</a>.</p>
)}
</div>
)
}global-error.tsx: The Root-Level Safety Net
global-error.tsx sits in the app/ directory and catches errors thrown by app/layout.tsx — the one place that regular error.tsx cannot reach. It is the absolute last line of defence.
Key differences from error.tsx:
- It replaces the entire document when active, including the root layout. You must render your own
<html>and<body>tags inside it. - It is only active in production builds. In development, Next.js shows its own overlay instead.
- It still receives the same
errorandresetprops.
Because it replaces the layout, keep it minimal but functional — include just enough structure to display a meaningful message and a recovery option.
'use client'
// app/global-error.tsx
// Catches errors thrown inside app/layout.tsx
interface GlobalErrorProps {
error: Error & { digest?: string }
reset: () => void
}
export default function GlobalError({ error, reset }: GlobalErrorProps) {
return (
// Must include <html> and <body> since root layout is bypassed
<html lang="en">
<body>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
fontFamily: 'sans-serif',
padding: '2rem',
}}
>
<h1 style={{ fontSize: '1.5rem', marginBottom: '0.5rem' }}>
Application Error
</h1>
<p style={{ color: '#666', marginBottom: '1rem' }}>
{process.env.NODE_ENV === 'production'
? 'An unexpected error occurred.'
: error.message}
</p>
{error.digest && (
<code style={{ fontSize: '0.75rem', color: '#999' }}>
Error ID: {error.digest}
</code>
)}
<button
onClick={reset}
style={{ marginTop: '1.5rem', padding: '0.5rem 1.5rem', cursor: 'pointer' }}
>
Reload Application
</button>
</div>
</body>
</html>
)
}Typed Errors and the digest Property
When a Server Component throws an error, Next.js does not expose the raw error message to the client in production. Instead, it generates a digest — a short hash string that uniquely identifies the server-side error. This prevents sensitive implementation details from leaking to users.
The digest appears in your server logs alongside the full error. By displaying the digest in your error UI, support teams can correlate user-reported issues with server logs without ever exposing stack traces.
Custom error classes let you pass structured metadata through your boundaries, but remember: only information that reaches the client error props is safe to display.
// lib/errors.ts — custom typed errors for structured handling
export class AppError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly statusCode: number = 500,
public readonly isOperational: boolean = true,
) {
super(message)
this.name = 'AppError'
// Maintains proper prototype chain in TypeScript
Object.setPrototypeOf(this, AppError.prototype)
}
}
export class NotFoundError extends AppError {
constructor(resource: string) {
super(`${resource} not found`, 'NOT_FOUND', 404)
this.name = 'NotFoundError'
}
}
export class UnauthorizedError extends AppError {
constructor() {
super('Unauthorized access', 'UNAUTHORIZED', 401)
this.name = 'UnauthorizedError'
}
}
// Usage in a Server Component:
// app/posts/[id]/page.tsx
export default async function PostPage({ params }: { params: { id: string } }) {
const post = await db.post.findUnique({ where: { id: params.id } })
if (!post) {
throw new NotFoundError('Post') // caught by nearest error.tsx
}
return <article>{post.content}</article>
}Catching Server Action Errors in error.tsx
Server Actions can throw errors, but the behavior differs depending on how the action is invoked:
- Action called during initial render (e.g., inside a Server Component's async body) — the error propagates to the nearest
error.tsxboundary automatically. - Action called from a form or event handler on the client — the error is not automatically caught by
error.tsx. You must handle it in the component using try/catch or by returning structured error objects.
The recommended pattern is to never throw from Server Actions called client-side. Instead, return a discriminated union so the client can react gracefully without relying on error boundaries.
// app/actions/create-post.ts
'use server'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
const CreatePostSchema = z.object({
title: z.string().min(3).max(100),
content: z.string().min(10),
})
type ActionResult =
| { success: true; postId: string }
| { success: false; error: string; fieldErrors?: Record<string, string[]> }
export async function createPost(
_prevState: ActionResult | null,
formData: FormData,
): Promise<ActionResult> {
const parsed = CreatePostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
})
if (!parsed.success) {
return {
success: false,
error: 'Validation failed',
fieldErrors: parsed.error.flatten().fieldErrors,
}
}
try {
const post = await db.post.create({ data: parsed.data })
revalidatePath('/posts')
return { success: true, postId: post.id }
} catch (err) {
// Return error, don't throw — the client form handles this
console.error('[createPost] DB error:', err)
return { success: false, error: 'Failed to save post. Please try again.' }
}
}Using useActionState with Error Handling
Next.js 15 uses React 19's useActionState hook (formerly useFormState) to wire Server Actions to forms with built-in state management. This is the idiomatic way to surface Server Action errors in the UI without triggering error boundaries.
The hook returns a tuple of [state, dispatch, isPending]. The state reflects the last return value from your action — including any error payloads you return.
This pattern keeps the happy path, validation errors, and unexpected errors all in one cohesive component with no boundary involvement.
'use client'
import { useActionState } from 'react'
import { createPost } from '@/app/actions/create-post'
const initialState = null
export function CreatePostForm() {
const [state, formAction, isPending] = useActionState(createPost, initialState)
return (
<form action={formAction} className="space-y-4">
{state && !state.success && (
<div role="alert" className="p-3 bg-red-50 text-red-700 rounded">
<p className="font-medium">{state.error}</p>
{state.fieldErrors?.title && (
<p className="text-sm">{state.fieldErrors.title[0]}</p>
)}
</div>
)}
{state?.success && (
<p className="text-green-600">Post created! ID: {state.postId}</p>
)}
<div>
<label htmlFor="title" className="block text-sm font-medium">
Title
</label>
<input id="title" name="title" type="text" className="mt-1 w-full border rounded px-3 py-2" />
</div>
<div>
<label htmlFor="content" className="block text-sm font-medium">
Content
</label>
<textarea id="content" name="content" rows={4} className="mt-1 w-full border rounded px-3 py-2" />
</div>
<button
type="submit"
disabled={isPending}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
>
{isPending ? 'Saving...' : 'Create Post'}
</button>
</form>
)
}Integrating Sentry with error.tsx
Error boundaries are the natural integration point for observability tools like Sentry. The useEffect in your error boundary fires on every caught error, giving you a clean hook to report to external services.
For server-side errors in Server Components and Route Handlers, Next.js 15 supports Sentry's instrumentation via instrumentation.ts — a file at the project root that runs in the Node.js runtime on startup. Combined with client-side reporting in error boundaries, you get full-stack coverage.
Key considerations:
- Never log raw user data or PII to external services
- Use the
digestas a correlation ID to link client-reported errors to server logs - Filter out expected operational errors (e.g., 404s, auth errors) to keep your alert noise low
'use client'
import { useEffect } from 'react'
import * as Sentry from '@sentry/nextjs'
interface ErrorPageProps {
error: Error & { digest?: string }
reset: () => void
}
export default function ErrorPage({ error, reset }: ErrorPageProps) {
useEffect(() => {
// Capture with structured context for Sentry
Sentry.captureException(error, {
tags: {
digest: error.digest ?? 'unknown',
errorName: error.name,
},
// Avoid logging PII — only structural metadata
extra: {
digest: error.digest,
},
})
}, [error])
const isOperational = error.name === 'AppError'
return (
<div role="alert" className="p-6">
<h2 className="text-xl font-bold">
{isOperational ? error.message : 'An unexpected error occurred'}
</h2>
{error.digest && (
<p className="text-sm text-gray-500 mt-1">
Reference: <code>{error.digest}</code>
</p>
)}
<button onClick={reset} className="mt-4 px-4 py-2 bg-blue-600 text-white rounded">
Try again
</button>
</div>
)
}Nested Boundaries: Granular Segment Isolation
One of the most powerful patterns in App Router is composing multiple error boundaries to isolate independent UI regions. A dashboard with a sidebar, main content, and a widget panel should have separate boundaries — a broken analytics widget should not kill the navigation.
You achieve this by placing error.tsx files at each relevant level of your route hierarchy, but you can also achieve intra-segment isolation using React's own ErrorBoundary class component or libraries like react-error-boundary for non-route UI regions.
// app/dashboard/layout.tsx
// The dashboard layout renders slots independently
// so each slot has its own error.tsx for isolation
import { Suspense } from 'react'
import { SidebarNav } from '@/components/SidebarNav'
export default function DashboardLayout({
children,
analytics, // parallel route slot
activity, // parallel route slot
}: {
children: React.ReactNode
analytics: React.ReactNode
activity: React.ReactNode
}) {
return (
<div className="grid grid-cols-[240px_1fr_320px] min-h-screen">
{/* Sidebar: wrapped by its own Suspense + route-level error.tsx */}
<aside className="border-r">
<Suspense fallback={<div>Loading nav...</div>}>
<SidebarNav />
</Suspense>
</aside>
{/* Main content: errors caught by children segment's error.tsx */}
<main className="p-6">{children}</main>
{/* Right panel: each slot has its own @analytics/error.tsx */}
<aside className="border-l p-4 space-y-4">
<Suspense fallback={<div>Loading analytics...</div>}>
{analytics}
</Suspense>
<Suspense fallback={<div>Loading activity...</div>}>
{activity}
</Suspense>
</aside>
</div>
)
}Production vs Development Error Exposure
Next.js intentionally behaves differently in development and production regarding error details:
- Development — Full error overlays with stack traces are shown.
global-error.tsxis not activated; the dev overlay takes priority. Error messages from Server Components are forwarded to the client in full. - Production — Server Component errors are sanitized. Only the
digestis forwarded to the client. Yourerror.tsxwill receive a generic message like'An error occurred in the Server Components render...'with the digest attached.
This means your error boundary UI must account for two realities: rich messages in dev (great for debugging) and sanitized messages in production (critical for security). Always check process.env.NODE_ENV before rendering raw error messages.
'use client'
interface ErrorPageProps {
error: Error & { digest?: string }
reset: () => void
}
export default function ErrorPage({ error, reset }: ErrorPageProps) {
const isDev = process.env.NODE_ENV === 'development'
return (
<div role="alert" className="p-6 border border-red-200 rounded bg-red-50 max-w-lg mx-auto mt-8">
<h2 className="text-lg font-semibold text-red-800 mb-2">Something went wrong</h2>
{/* In development, show the raw message for fast debugging */}
{isDev && (
<details className="mb-3">
<summary className="text-sm text-red-600 cursor-pointer">Error details (dev only)</summary>
<pre className="mt-2 text-xs text-red-700 overflow-auto p-2 bg-red-100 rounded">
{error.message}
</pre>
</details>
)}
{/* In production, only show the digest for support correlation */}
{!isDev && error.digest && (
<p className="text-sm text-red-600 mb-3">
Error code: <code className="font-mono">{error.digest}</code>
<br />
<span className="text-xs">Share this code with support if the issue persists.</span>
</p>
)}
<button
onClick={reset}
className="px-4 py-2 bg-red-600 text-white rounded text-sm hover:bg-red-700"
>
Try again
</button>
</div>
)
}When Does an Error Bubble Past error.tsx?
Consider the following scenario in an App Router project:
A developer places an error.tsx inside app/dashboard/. An unhandled error is thrown inside app/dashboard/layout.tsx during rendering.
What happens to that error?
Lesson Recap: Error Boundaries in App Router
In this lesson you learned how to build robust, granular error handling in Next.js 15 App Router applications. Here is a summary of the key concepts:
- error.tsx must be a Client Component ('use client') and receives
errorandresetprops. It catches errors frompage.tsxand child segments — but not from the layout at the same level. - global-error.tsx is the root-level safety net that catches errors from
app/layout.tsx. It must render its own<html>and<body>and is only active in production. - The
digestproperty on the error object is a server-generated hash that correlates client-visible errors with server logs without exposing sensitive details. - reset() re-renders the failed segment without a full page reload. Combine it with
router.refresh()when you need to invalidate cached server data. - Server Actions called from client forms should return errors as structured data rather than throwing — use
useActionStateto wire these results to your UI. - Always guard raw error messages behind
process.env.NODE_ENV === 'development'to prevent information leakage in production.
Frequently asked questions
Is the “Granular error.tsx and global-error Boundaries” lesson free?
Yes — the full text of “Granular error.tsx and global-error Boundaries” is free to read here on the web, and the Next.js 15 Fullstack (App Router + Server Actions) course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the Next.js 15 Fullstack (App Router + Server Actions) course, upgrade to CoddyKit PRO.
What will I learn in “Granular error.tsx and global-error Boundaries”?
Catch render and action failures at segment and root level with recoverable reset flows. You practise Next.js 15 Fullstack (App Router + Server Actions) with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.
Do I need any experience to start Next.js 15 Fullstack (App Router + Server Actions)?
No prior experience is required. Next.js 15 Fullstack (App Router + Server Actions) on CoddyKit is structured for beginners through advanced learners; this is — lesson 2 of 4, so you can start here or from the beginning and move at your own pace.
How long does the “Granular error.tsx and global-error Boundaries” lesson take?
Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.
Can I write and run code in this Next.js 15 Fullstack (App Router + Server Actions) lesson?
Yes. Every Next.js 15 Fullstack (App Router + Server Actions) lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.
All lessons in this course
- OpenTelemetry Tracing with instrumentation.ts
- Granular error.tsx and global-error Boundaries
- Structured Logging Across Server and Edge
- Capturing Server Action Failures and Telemetry