Error Handling with error.tsx and not-found.tsx
Step 23 of 31 — Next.js Tutorial Series | Source code for this step
What You Will Build
Right now, if something goes wrong in our app — a database error, a missing post, an unexpected exception — the user sees either a blank page or Next.js's default error screen. That's not a good experience.
In this step, you will:
- Create a route-specific
not-found.tsxfor the posts section — so visiting/posts/999shows a helpful "Post not found" page instead of a generic 404 - Create an
error.tsxerror boundary — so unexpected errors (database failures, runtime exceptions) show a friendly error page with a "Try again" button - Understand how Next.js's error file conventions work and where to place them
Goal: Learn the error.tsx and not-found.tsx file conventions — two of the most important Next.js patterns for building resilient applications.
Table of Contents
- The Problem
- How Next.js Error File Conventions Work
- Adding a Route-Specific not-found.tsx
- Adding an error.tsx Error Boundary
- How error.tsx Works Under the Hood
- Where to Place Error Files
- Testing the Error Handling
- Summary & Key Takeaways
The Problem
Try visiting /posts/99999 in your app — a post ID that doesn't exist. What happens?
In our app/posts/[id]/page.tsx, we already call notFound() when the post is null:
if (!post) {
notFound();
}
This works — it triggers Next.js's notFound() function. But where does the user end up? They see the global app/not-found.tsx, which shows a generic "Page Not Found" message. It doesn't mention posts, doesn't offer a link back to the posts list, and doesn't match the context.
Now imagine a different problem: the database goes down. The prisma.post.findUnique() call throws an exception. Currently, there's no error boundary — the user sees Next.js's default error overlay (in development) or a blank error page (in production) and not the generic "Page Not Found" message served by app/not-found.tsx.
We need two things:
- A route-specific not-found page for the posts section
- An error boundary that catches unexpected errors and lets the user recover
How Next.js Error File Conventions Work
Next.js uses special file names to handle errors at the route level:
| File | Purpose | When it triggers |
|---|---|---|
not-found.tsx | Custom 404 page | When notFound() is called or a route doesn't exist |
error.tsx | Error boundary | When a runtime error occurs during rendering |
global-error.tsx | Root error boundary | When an error occurs in the root layout (rarely needed) |
The hierarchy
Next.js looks for these files from the most specific route segment up to the root:
app/
├── not-found.tsx ← global fallback (we already have this)
├── error.tsx ← catches errors in any route (if no closer one exists)
├── posts/
│ ├── not-found.tsx ← catches notFound() in /posts routes
│ ├── error.tsx ← catches errors in /posts routes
│ └── [id]/
│ ├── not-found.tsx ← catches notFound() specifically in /posts/[id]
│ └── error.tsx ← catches errors specifically in /posts/[id]
When notFound() is called in /posts/[id]/page.tsx, Next.js walks up the tree looking for the nearest not-found.tsx. If it finds one in posts/[id]/, it uses that. If not, it checks posts/. If not there either, it falls back to the global app/not-found.tsx.
The same applies to error.tsx — the nearest one to the error catches it.
Adding a Route-Specific not-found.tsx
Create app/posts/[id]/not-found.tsx:
import Link from "next/link";
export default function PostNotFound() {
return (
<div className="py-16 text-center">
<h2 className="mb-2 text-4xl font-bold text-gray-300">404</h2>
<h3 className="mb-4 text-xl font-semibold text-gray-800">
Post Not Found
</h3>
<p className="mb-6 text-gray-500">
The post you are looking for does not exist or has been deleted.
</p>
<Link
href="/posts"
className="inline-block px-6 py-2 text-white bg-teal-600 rounded hover:bg-teal-700"
>
Browse All Posts
</Link>
</div>
);
}
What changed
- This is a Server Component — no
"use client"directive needed - When
notFound()is called inapp/posts/[id]/page.tsx, Next.js now renders this file instead of the global 404 - The page is contextual: it mentions "Post Not Found" and links back to
/posts
Compare with the global not-found.tsx
Global app/not-found.tsx | Route-specific app/posts/[id]/not-found.tsx | |
|---|---|---|
| Message | "Page Not Found" | "Post Not Found" |
| Link | Goes to / (home) | Goes to /posts (posts list) |
| When used | Any route without a closer not-found.tsx | Only for /posts/[id] routes |
Adding an error.tsx Error Boundary
Create app/posts/error.tsx:
"use client";
export default function PostsError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="py-16 text-center">
<h2 className="mb-2 text-2xl font-bold text-red-600">
Something went wrong
</h2>
<h2 className="text-lg text-gray-700">
{error.message && `${error.message}`}
</h2>
<p className="mb-6 text-gray-500">
We couldn't load the posts. This might be a temporary issue.
</p>
<button
onClick={() => reset()}
className="px-6 py-2 text-white bg-teal-600 rounded hover:bg-teal-700"
>
Try Again
</button>
</div>
);
}
Key details
-
"use client"is required. Error boundaries must be Client Components because they use React'sErrorBoundarymechanism, which requires client-side state to catch and recover from errors. -
Displaying
error.message— we show the actual error message below the heading so developers (and users in development) can quickly understand what went wrong. Theerror.message && ...check ensures nothing renders if the message is empty. -
The
errorprop contains the error that was thrown. It has the standardmessageproperty and an optionaldigest— a hash generated by Next.js for server-side errors (useful for logging without exposing error details to users). -
The
resetprop is a function that re-renders the route segment. When the user clicks "Try Again", React clears the error state and attempts to render the component tree again. If the underlying problem is fixed (e.g., the database is back up), the page will render normally.
Development vs Production: In development,
error.messageshows the full error text (e.g., "Cannot read properties of null"). In production, Next.js replaces server-side error messages with a generic message for security — so sensitive details like database errors are never exposed to end users. Theerror.digesthash is available in both environments for correlating with server logs.
Why "use client" is required
React's error boundary mechanism works by catching errors during rendering and updating component state to show a fallback UI. State management is a client-side concept — Server Components don't have state. So error boundaries must always be Client Components.
This is similar to how useState and useEffect require Client Components — they manage state that lives in the browser.
How error.tsx Works Under the Hood
When you create an error.tsx file, Next.js automatically wraps the corresponding route segment in a React Error Boundary:
┌─ layout.tsx ──────────────────────────┐
│ │
│ ┌─ error.tsx (Error Boundary) ──────┐ │
│ │ │ │
│ │ ┌─ page.tsx ───────────────────┐ │ │
│ │ │ │ │ │
│ │ │ Your page component │ │ │
│ │ │ │ │ │
│ │ └──────────────────────────────┘ │ │
│ │ │ │
│ └────────────────────────────────────┘ │
│ │
└────────────────────────────────────────┘
Notice that error.tsx sits inside the layout but around the page. This means:
- The layout is always visible — even when an error occurs, your header, navigation, and footer remain on screen
- Only the page content is replaced with the error UI
- The user can still navigate away using the layout's navigation
This is intentional. If the entire page disappeared on error, the user would be stuck with no way to navigate.
What error.tsx does NOT catch
error.tsx does not catch errors in the layout at the same level. If app/posts/layout.tsx throws, the app/posts/error.tsx won't catch it — you'd need an error.tsx in the parent (app/error.tsx) to handle it.
For errors in the root layout (app/layout.tsx), you'd need app/global-error.tsx — but this is rarely necessary and we won't need it for our app.
Where to Place Error Files
For our blog app, here's the recommended placement:
app/
├── not-found.tsx ← Global 404 (already exists)
├── posts/
│ ├── error.tsx ← NEW — catches errors in /posts and /posts/[id]
│ └── [id]/
│ └── not-found.tsx ← NEW — contextual 404 for missing posts
Why error.tsx in posts/ and not posts/[id]/?
By placing error.tsx in app/posts/, it catches errors in:
/posts(the listing page — e.g., database query fails)/posts/[id](the detail page — e.g., unexpected error fetching a post)/posts/new(the create page)/posts/[id]/edit(the edit page)
One error boundary covers the entire posts section. If you needed different error UIs for different pages, you could add more specific error.tsx files in subdirectories.
Why not-found.tsx in posts/[id]/ and not posts/?
The notFound() call happens in the detail page (posts/[id]/page.tsx) and the edit page (posts/[id]/edit/page.tsx). By placing not-found.tsx in posts/[id]/, it catches notFound() from both the detail and edit routes.
Testing the Error Handling
Test the not-found page
- Start the dev server:
pnpm dev - Visit
http://localhost:3000/posts/99999(a non-existent post) - You should see your new "Post Not Found" page with a "Browse All Posts" link — not the generic global 404
Test the error boundary
To test the error boundary, you can temporarily add an error to a page component. For example, add this to the top of app/posts/PostList.tsx (inside the function, before the Prisma query):
throw new Error("Test error boundary");
- Visit
http://localhost:3000/posts - You should see "Something went wrong" with a "Try Again" button
- Remove the
throwline after testing!
When you click "Try Again", the reset() function re-renders the page. Since you've removed the throw, the page will render normally.
In production
In production, error details are not sent to the client for security reasons. The error.message will be a generic message, and the error.digest will be a hash you can use to find the full error in your server logs.
Summary & Key Takeaways
| Concept | Details |
|---|---|
not-found.tsx | Renders when notFound() is called — shows a custom 404 page |
error.tsx | Renders when a runtime error occurs — acts as a React Error Boundary |
"use client" | Required for error.tsx because error boundaries need client-side state |
reset() function | Re-renders the route segment — lets users retry after an error |
error.digest | A server-generated hash for the error — safe to log without exposing details |
| Hierarchy | Next.js walks up the route tree to find the nearest error/not-found file |
| Layout persists | error.tsx wraps the page, not the layout — navigation stays visible during errors |
| Placement | Put error.tsx at the level that covers the routes you want; put not-found.tsx where notFound() is called |
What is Next
In Step 24, we will add loading UI using the loading.tsx file convention — showing skeleton placeholders while pages load, as a simpler alternative to manually wrapping components in <Suspense>.