We once shipped a SaaS app where the entire dashboard would white-screen if a single API call failed. Not a friendly error page. Not a retry button. Just pure, blinding white. A user emailed us: 'is your site down?' It wasn't down. It just had no idea what to do when things went wrong. That's the day we sat down and actually understood Next.js error handling.
The App Router gives you a pretty solid layered system for catching errors: error.tsx for component-level crashes, not-found.tsx for missing resources, and a global error.tsx for when everything else fails. They're not interchangeable. Each one has a specific job, and using the wrong one in the wrong place is how you end up with broken user experiences or, worse, swallowed errors that never show up in your logs.
How the Error Boundary System Actually Works
Next.js error.tsx files are React error boundaries, but automatic ones. When you create an error.tsx in a route segment, Next.js wraps that segment's page.tsx (and everything it renders) in a React error boundary. If anything in that subtree throws — during render, in a Server Component, in a useEffect — the error boundary catches it and renders your error.tsx instead.
The key thing to understand: error.tsx is a Client Component. It has to be, because React error boundaries are class-based under the hood and need to run on the client. This means you can use state, add retry logic, fire off analytics events — all the interactive stuff you'd want in an error state.
'use client'
import { useEffect } from 'react'
interface ErrorProps {
error: Error & { digest?: string }
reset: () => void
}
export default function Error({ error, reset }: ErrorProps) {
useEffect(() => {
// Log to your error tracking service here
console.error('Segment error:', error)
}, [error])
return (
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
<h2 className="text-xl font-semibold">Something went wrong</h2>
<p className="text-muted-foreground text-sm">
{error.digest ? `Error ID: ${error.digest}` : 'An unexpected error occurred'}
</p>
<button
onClick={reset}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md"
>
Try again
</button>
</div>
)
}That reset function is what makes error.tsx genuinely useful. It re-renders the segment, giving your user a way out without a full page reload. If the error was transient — a flaky API, a race condition — hitting reset might just work. Don't skip the reset button.
One thing that trips people up: error.tsx does NOT catch errors in layout.tsx for the same segment. If your layout.tsx crashes, the error.tsx at the same level won't catch it. You need the parent segment's error.tsx for that. This is intentional — layouts are meant to stay stable while the page content changes, so the error boundary wraps the page, not the layout itself.
not-found.tsx Is Not Just a Pretty 404
not-found.tsx handles a specific case: when you call the notFound() function from next/navigation. This is conceptually different from a crash. A 404 isn't an error in the traditional sense — it's your code explicitly saying 'this resource doesn't exist, tell the user.' The UX should be different too. A crash deserves an apology. A missing page deserves a search bar or helpful navigation.
// app/dashboard/projects/[id]/page.tsx
import { notFound } from 'next/navigation'
import { getProject } from '@/lib/db'
export default async function ProjectPage({ params }: { params: { id: string } }) {
const project = await getProject(params.id)
if (!project) {
notFound() // Renders the nearest not-found.tsx
}
return <ProjectView project={project} />
}// app/dashboard/projects/not-found.tsx
export default function ProjectNotFound() {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
<h2 className="text-2xl font-semibold">Project not found</h2>
<p className="text-muted-foreground">
This project might have been deleted or you may not have access.
</p>
<a
href="/dashboard/projects"
className="px-4 py-2 bg-primary text-primary-foreground rounded-md"
>
Back to projects
</a>
</div>
)
}Place not-found.tsx at the route segment where it makes sense contextually. A not-found.tsx at app/dashboard/projects/ will render inside your dashboard layout, with all your navigation still visible. A not-found.tsx at app/ renders at the root. These are different experiences. Most of the time, you want the one nested inside your authenticated layout so users can actually navigate somewhere useful.
Rule of thumb: use notFound() for 'this resource doesn't exist,' throw an error for 'I tried to get this resource and something broke.' The user experience and HTTP semantics are both different.
global-error.tsx: The Last Resort
There's one more file: app/global-error.tsx. This catches errors in the root layout and root template. It's the absolute last line of defense. When global-error.tsx renders, it replaces the entire document — including your root layout — so you need to include your html and body tags yourself.
'use client'
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<html>
<body>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
fontFamily: 'sans-serif',
gap: '16px',
}}
>
<h1>Something went seriously wrong</h1>
<p style={{ color: '#666' }}>
We've been notified and are looking into it.
</p>
<button
onClick={reset}
style={{
padding: '8px 16px',
background: '#000',
color: '#fff',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
}}
>
Try again
</button>
</div>
</body>
</html>
)
}Notice the inline styles. Your Tailwind classes won't work here because global-error.tsx renders outside your normal layout, which is where your CSS typically gets loaded. Keep it simple — this is a last resort, not a showcase. If your global error page is crashing, you have bigger problems.
In practice, you shouldn't hit global-error.tsx often. If your root layout is throwing, something is deeply wrong with your setup. But having it is important. Without it, Next.js will show its own default error UI, which has no branding and definitely doesn't do anything useful for your users.
Layering Your Error Boundaries Strategically
The real power comes from having multiple error.tsx files at different levels of your route tree. A crash in /dashboard/settings shouldn't blow up the entire dashboard. It should show an error just in the settings area, while the sidebar and navigation remain usable.
- app/error.tsx — catches errors anywhere in the app that don't have a closer boundary
- app/dashboard/error.tsx — catches errors in the authenticated dashboard area
- app/dashboard/projects/error.tsx — catches errors specifically in the projects section
- app/global-error.tsx — catches errors in the root layout itself
Think of it like try/catch blocks that bubble up. Next.js will use the closest error.tsx in the tree. If /dashboard/projects/[id] crashes and there's no error.tsx in /dashboard/projects/, it walks up to /dashboard/error.tsx, and so on. This means a single app/error.tsx gives you a baseline safety net, while more specific ones let you tailor the experience.
For a SaaS app, a reasonable setup looks like this: one error.tsx at the app level for unauthenticated routes, another inside your authenticated layout for dashboard stuff, and not-found.tsx files wherever you're fetching specific resources by ID. That covers 95% of what you'll encounter.
Server Action Errors: The Sneaky Edge Case
Here's where it gets interesting. If a Server Action throws an unhandled error, it does NOT trigger error.tsx. The error gets caught by Next.js and returned to the client as a generic error response. Your error boundary won't fire. This trips up a lot of people.
The pattern you want is to handle errors explicitly in Server Actions and return them as data, not exceptions — or use the useFormState / useActionState pattern to thread error state through your forms.
// lib/actions/projects.ts
'use server'
type ActionResult =
| { success: true; projectId: string }
| { success: false; error: string }
export async function createProject(formData: FormData): Promise<ActionResult> {
try {
const name = formData.get('name') as string
if (!name || name.trim().length === 0) {
return { success: false, error: 'Project name is required' }
}
const project = await db.project.create({
data: { name: name.trim() }
})
return { success: true, projectId: project.id }
} catch (err) {
// Log the real error, return a safe message
console.error('Failed to create project:', err)
return { success: false, error: 'Failed to create project. Please try again.' }
}
}For truly unexpected throws from Server Actions — the ones you didn't catch — Next.js will show a generic error in development but masks the details in production (for security reasons). This is actually fine behavior. The problem is you won't see it in your error boundary. Set up proper error logging — Sentry, Axiom, whatever — and make sure your Server Actions are logging before they return.
The Error Digest: Your Production Debugging Friend
In production, Next.js hashes error messages to prevent leaking sensitive information to users. That's what the digest property is on the error object. Instead of showing 'Cannot read properties of undefined (reading user_id)' to your users, Next.js shows a hash. You match the hash in your logs to find the actual error.
Always display the digest in your error.tsx. It looks like 'Error ID: 1234567890' and your users will copy-paste it when they email you. Beats 'it just broke' by a mile. If you're using Sentry or similar, you can also use the digest to correlate the user-facing error with the logged exception.
Always show error.digest in your error UI. It's the bridge between your user's screenshot and your production logs.
One gotcha: in development, the digest isn't always populated because Next.js shows the full error message instead. Don't be surprised when you see undefined during local testing — it'll be there in production.
Putting It All Together
A production-ready Next.js app needs at minimum: a root error.tsx that's presentable and has a reset button, a root not-found.tsx that isn't just 'page not found' with nothing else, and a global-error.tsx as a last resort. From there, add more specific error boundaries where your app has distinct sections with distinct failure modes.
The templates at peal.dev ship with all three of these pre-configured — not as empty stubs, but actually styled to match the rest of the UI, with error digest display and proper reset handling. It's one of those things that's easy to deprioritize when you're building fast, so having it done from the start saves you the 'our users see a white screen' email.
The mental model that helps us: error.tsx is for 'something crashed, let me recover,' not-found.tsx is for 'this doesn't exist, let me redirect you,' and global-error.tsx is for 'we are in firefighting mode.' Get the semantics right and your users get a coherent experience even when things go sideways. Which they will. At 2am. On a Friday.
