4 min read
Error and Not-Found Pages in the App Router
The App Router gives you two built-in escape hatches for things going wrong: error.tsx catches runtime exceptions, and not-found.tsx handles missing resources. They look simple on the surface, but there are a few details that catch people out once the app grows past a single layout.
error.tsx
Drop an error.tsx file into any route segment and the App Router wraps that segment in a React error boundary. If anything inside that segment throws during rendering, the boundary catches it and renders your error component instead.
There is one hard requirement: error.tsx must be a Client Component.
'use client'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div>
<h2>Something went wrong</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
)
}
The component receives two props. error is the thrown value (typed as Error with an optional digest property Next.js attaches for server-side error correlation). reset is a function that triggers a re-render of the segment, giving the user a way to recover without a full page refresh.
The reason error.tsx must be a Client Component is that React error boundaries are a client-side mechanism. Server Components can throw, but the boundary that catches them lives in the browser.
Render errors vs. async errors
In a Server Component, a throw during the initial render is caught by the nearest error.tsx above it. An async Server Component that throws while awaiting data is also caught. The boundary does not care whether the error came from synchronous rendering or an async function. What matters is that the throw propagates up through React's component tree to the boundary.
Client Component errors are caught at the same boundary. If a Client Component throws after hydration (for example, inside an event handler), that is different. Error boundaries only catch render-time throws. Errors inside event handlers need their own try/catch.
Nested error boundaries
Because error.tsx files follow the folder structure, you can have multiple boundaries at different levels of the tree. A segment's error boundary only catches errors from within that segment and its children. It does not catch errors in the layout that wraps it.
This matters for isolating failures. If you have a dashboard with a sidebar and a main content area, you can put an error.tsx inside the content segment so a broken page does not take down the whole dashboard:
app/
└── dashboard/
├── layout.tsx (sidebar lives here)
├── error.tsx (catches errors in dashboard pages)
└── analytics/
├── page.tsx
└── error.tsx (catches errors only in analytics)
The analytics error.tsx catches failures in analytics/page.tsx. If analytics throws, the dashboard layout can stay mounted, so the sidebar and the rest of the dashboard stay visible. The dashboard error.tsx catches errors in the dashboard segment, including dashboard/layout.tsx. If dashboard/layout.tsx throws, the dashboard error UI replaces that layout, so the sidebar cannot remain visible.
global-error.tsx
The root layout.tsx is not wrapped by app/error.tsx. If the root layout throws, there is nothing above it to catch the error. That is what global-error.tsx is for. It sits at the top of the app and replaces the entire document, including the <html> and <body> tags, so it needs to render those itself:
'use client'
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<html>
<body>
<h2>Something went wrong</h2>
<button onClick={reset}>Try again</button>
</body>
</html>
)
}
In practice, the root layout throwing is rare. Most projects use global-error.tsx as a last resort rather than their primary error UI.
not-found.tsx
not-found.tsx is a separate convention for resources that do not exist. You can trigger it explicitly by calling notFound() from the next/navigation package inside any Server Component, route handler, or server action.
import { notFound } from 'next/navigation'
export default async function NotePage({
params,
}: {
params: Promise<{ section: string; slug: string }>
}) {
const { section, slug } = await params
const note = await getNote(section, slug)
if (!note) {
notFound()
}
return <article>{note.content}</article>
}
Calling notFound() throws a special internal error that Next.js intercepts. It does not bubble up to error.tsx. Instead, the router renders the nearest not-found.tsx file up the tree from where notFound() was called.
The not-found.tsx file is a Server Component by default (unlike error.tsx). You do not need 'use client' unless you want interactivity inside it.
export default function NotFound() {
return (
<div>
<h2>Page not found</h2>
<p>The content you were looking for does not exist.</p>
</div>
)
}
If no not-found.tsx exists in the tree, Next.js falls back to a default 404 page. You can place a not-found.tsx at app/not-found.tsx to override the global default, and add more specific ones inside nested segments if different sections of the app need different 404 designs.
Putting it together
The mental model to hold: error.tsx handles unexpected failures (something threw), not-found.tsx handles expected absences (the data was not there). Use notFound() deliberately when a lookup returns nothing, rather than letting your component fall through into a broken render. Keep your error boundaries scoped close to the parts of the UI that are likely to fail, so a broken widget does not kill the whole page.
The folder structure controls scope for both. Walk the tree, find the nearest file, and that is the component that renders.