Next.js Error Handling
A dashboard that demonstrates error boundaries at two levels: a reusable ErrorBoundary class component that isolates failures per-widget, and Next.js error.tsx that catches unhandled page-level errors. One widget always fails — the rest of the page keeps working. A separate /broken route shows the page-level boundary in action.
How it works
The dashboard renders two widgets: weather (always throws) and news (always succeeds). Each widget is wrapped in a custom <ErrorBoundary> that catches the error and renders an inline message — the news widget is unaffected. Navigating to /broken triggers a page that throws directly. Since there’s no component-level boundary around it, the error bubbles up to error.tsx, which renders a full-page error with a retry button.
nextjs-error-handling/
├── lib/api.ts ← data fetchers (one always throws)
├── app/layout.tsx ← root layout with navigation
├── app/error.tsx ← page-level error boundary (Next.js convention)
├── app/page.tsx ← dashboard: widgets in component-level boundaries
├── app/error-boundary.tsx ← reusable ErrorBoundary class component ('use client')
├── app/weather-widget.tsx ← async server component (always throws)
├── app/news-widget.tsx ← async server component (always succeeds)
└── app/broken/page.tsx ← page that throws (caught by error.tsx)lib/api.ts
Two data fetchers. getWeather always throws after a short delay — simulating an API that’s down. getNews always succeeds. This lets us show an error boundary catching one widget while the other renders normally.
export type Article = {
id: number
title: string
source: string
}
export async function getWeather(): Promise<never> {
await new Promise((r) => setTimeout(r, 500))
throw new Error('Weather API rate limit exceeded')
}
export async function getNews(): Promise<Article[]> {
await new Promise((r) => setTimeout(r, 800))
return [
{ id: 1, title: 'React 19 Released', source: 'React Blog' },
{ id: 2, title: 'Next.js 16 Announcement', source: 'Vercel Blog' },
{ id: 3, title: 'TypeScript 6.0 Preview', source: 'Microsoft' },
]
}app/layout.tsx
Root layout with a nav bar linking to both routes. <Link> from next/link handles client-side navigation so error.tsx can catch errors without a full page reload.
import type { ReactNode } from 'react'
import Link from 'next/link'
export const metadata = { title: 'Next.js Error Handling' }
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body style={{ fontFamily: 'system-ui, sans-serif', maxWidth: 520, margin: '2rem auto' }}>
<nav style={{ marginBottom: '1.5rem' }}>
<Link href="/">Dashboard</Link>
{' · '}
<Link href="/broken">Broken Page</Link>
</nav>
{children}
</body>
</html>
)
}app/error.tsx
Next.js page-level error boundary. When an error isn’t caught by a closer boundary, Next.js renders this component instead of the page. It must be a client component ('use client'). The framework passes the error object and a reset function — calling reset() tells Next.js to re-render the route segment, which retries the server component that threw.
'use client'
export default function ErrorPage({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div style={{ padding: '2rem', border: '1px solid #dc2626', borderRadius: 8 }}>
<h2 style={{ color: '#dc2626', marginTop: 0 }}>Something went wrong</h2>
<p>{error.message}</p>
<button onClick={reset} style={{ cursor: 'pointer' }}>
Try again
</button>
</div>
)
}app/page.tsx
The dashboard composes both widgets with the same pattern: <ErrorBoundary> on the outside catches errors, <Suspense> on the inside handles the loading state. This is the recommended ordering — each wrapper handles one concern. Suspense doesn’t catch errors (only pending states), so the error always propagates up to the nearest error boundary regardless of ordering. The weather widget throws, so its ErrorBoundary renders an inline error message. The news widget succeeds and renders normally. The page itself never throws, so error.tsx isn’t triggered here.
import { Suspense } from 'react'
import { ErrorBoundary } from './error-boundary'
import { WeatherWidget } from './weather-widget'
import { NewsWidget } from './news-widget'
export default function Page() {
return (
<main>
<h1>Dashboard</h1>
<section style={{ marginBottom: '1.5rem' }}>
<h2>Weather</h2>
<ErrorBoundary>
<Suspense fallback={<p style={{ color: '#999' }}>Loading weather...</p>}>
<WeatherWidget />
</Suspense>
</ErrorBoundary>
</section>
<section>
<h2>News</h2>
<ErrorBoundary>
<Suspense fallback={<p style={{ color: '#999' }}>Loading news...</p>}>
<NewsWidget />
</Suspense>
</ErrorBoundary>
</section>
</main>
)
}app/error-boundary.tsx
React error boundaries must be class components — there’s no hook equivalent. getDerivedStateFromError catches any error thrown by a child during rendering and stores it in state. When state.error is set, the boundary renders an inline error message instead of its children. This keeps the error contained — the rest of the page is unaffected. Note 'use client' — error boundaries are inherently interactive.
'use client'
import { Component, type ReactNode } from 'react'
interface Props {
children: ReactNode
}
interface State {
error: Error | null
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null }
static getDerivedStateFromError(error: Error): State {
return { error }
}
render() {
if (this.state.error) {
return (
<div style={{ padding: '1rem', border: '1px solid #f59e0b', borderRadius: 8, background: '#fffbeb' }}>
<p style={{ margin: 0, color: '#92400e' }}>
<strong>Failed to load:</strong> {this.state.error.message}
</p>
</div>
)
}
return this.props.children
}
}app/weather-widget.tsx
An async server component that always throws. When it’s wrapped in an <ErrorBoundary>, the boundary catches the error and renders an inline message — the rest of the page is unaffected. Without the boundary, this error would bubble up to error.tsx and take down the whole page.
import { getWeather } from '../lib/api'
export async function WeatherWidget() {
await getWeather()
return null
}app/news-widget.tsx
An async server component that always succeeds. Even though the weather widget next to it throws, this component renders normally because each widget has its own <ErrorBoundary>. This is the key benefit of component-level error boundaries — failures are isolated.
import { getNews } from '../lib/api'
export async function NewsWidget() {
const articles = await getNews()
return (
<ul style={{ listStyle: 'none', padding: 0 }}>
{articles.map((a) => (
<li key={a.id} style={{ padding: '0.4rem 0', borderBottom: '1px solid #eee' }}>
<strong>{a.title}</strong>
<span style={{ color: '#666', marginLeft: '0.5rem' }}>{a.source}</span>
</li>
))}
</ul>
)
}app/broken/page.tsx
A page that throws unconditionally. There’s no component-level <ErrorBoundary> around it, so the error bubbles up to error.tsx which renders the full-page error UI with a retry button. This demonstrates the page-level boundary — error.tsx is the safety net for any uncaught error in the route.
export default function BrokenPage(): never {
throw new Error('This page crashed to demonstrate error.tsx')
}Run it
npx create-next-app@latest nextjs-error-handling --ts --app --no-tailwind --no-eslint --no-src-dir
cd nextjs-error-handling
mkdir -p lib app/broken
# create the files above
npm run devOpen http://localhost:3000 . The weather widget shows an orange error box while the news widget loads normally — that’s the component-level boundary at work. Click “Broken Page” in the nav to see the page-level error.tsx boundary with its red error box and retry button.