Error Handling with error.tsx and not-found.tsx

Step 23 of 31Next.js Tutorial Series | Source code for this step

Live Demo →


What You Will Build

↑ Index

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:

  1. Create a route-specific not-found.tsx for the posts section — so visiting /posts/999 shows a helpful "Post not found" page instead of a generic 404
  2. Create an error.tsx error boundary — so unexpected errors (database failures, runtime exceptions) show a friendly error page with a "Try again" button
  3. 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

  1. The Problem
  2. How Next.js Error File Conventions Work
  3. Adding a Route-Specific not-found.tsx
  4. Adding an error.tsx Error Boundary
  5. How error.tsx Works Under the Hood
  6. Where to Place Error Files
  7. Testing the Error Handling
  8. Summary & Key Takeaways

The Problem

↑ Index

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

↑ Index

Next.js uses special file names to handle errors at the route level:

FilePurposeWhen it triggers
not-found.tsxCustom 404 pageWhen notFound() is called or a route doesn't exist
error.tsxError boundaryWhen a runtime error occurs during rendering
global-error.tsxRoot error boundaryWhen 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

↑ Index

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 in app/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.tsxRoute-specific app/posts/[id]/not-found.tsx
Message"Page Not Found""Post Not Found"
LinkGoes to / (home)Goes to /posts (posts list)
When usedAny route without a closer not-found.tsxOnly for /posts/[id] routes

Adding an error.tsx Error Boundary

↑ Index

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&apos;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

  1. "use client" is required. Error boundaries must be Client Components because they use React's ErrorBoundary mechanism, which requires client-side state to catch and recover from errors.

  2. Displaying error.message — we show the actual error message below the heading so developers (and users in development) can quickly understand what went wrong. The error.message && ... check ensures nothing renders if the message is empty.

  3. The error prop contains the error that was thrown. It has the standard message property and an optional digest — a hash generated by Next.js for server-side errors (useful for logging without exposing error details to users).

  4. The reset prop 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.message shows 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. The error.digest hash 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

↑ Index

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

↑ Index

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

↑ Index

Test the not-found page

  1. Start the dev server: pnpm dev
  2. Visit http://localhost:3000/posts/99999 (a non-existent post)
  3. 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");
  1. Visit http://localhost:3000/posts
  2. You should see "Something went wrong" with a "Try Again" button
  3. Remove the throw line 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

↑ Index

ConceptDetails
not-found.tsxRenders when notFound() is called — shows a custom 404 page
error.tsxRenders 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() functionRe-renders the route segment — lets users retry after an error
error.digestA server-generated hash for the error — safe to log without exposing details
HierarchyNext.js walks up the route tree to find the nearest error/not-found file
Layout persistserror.tsx wraps the page, not the layout — navigation stays visible during errors
PlacementPut 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>.