Dev Logs
/Next.js/ Chapter 9: Error Handling
Chapters
  • 01Chapter 1: Introduction & Setup
  • 02Chapter 2: Project Structure & Configuration
  • 03Chapter 3: Layouts & Pages
  • 04Chapter 4: Linking & Navigation
  • 05Chapter 5: Dynamic Routes & Params
  • 06Chapter 6: Route Groups & Organization
  • 07Chapter 7: Parallel & Intercepting Routes
  • 08Chapter 8: Loading, Suspense & Streaming
  • 09Chapter 9: Error Handling
    • Plain English Explanation
    • error.tsx — the segment error boundary
    • retry vs reset
    • What error.tsx catches — and what it doesn't
    • The digest and why production messages are vague
    • global-error.tsx
    • catchError — boundaries anywhere
    • notFound() and not-found.tsx
    • unauthorized() and forbidden()
    • A caveat about status codes
    • The control-flow trap
    • Another gotcha: un-awaited promises
    • Expected errors in Server Actions
    • The decision table
    • Common Pitfalls
    • . Forgetting 'use client' in error.tsx
    • . redirect() inside try/catch
    • . Using reset() instead of retry()
    • . Expecting error.tsx to catch its own layout
    • . Throwing for a 404
    • . Leaking error details in production
    • . Throwing validation errors from Server Actions
    • . No error boundary at all
    • When & Why to Use
    • Mini Practice Problems
    • Problem 1: Why doesn't the redirect fire?
    • Problem 2: Pick the mechanism
    • Problem 3: Where does it get caught?
    • Problem 4: Write it
    • Interview Notes
    • Common Questions
    • Asked at Companies
    • Visual Memory Aid
    • Key Takeaways
  • 10Chapter 10: Server & Client Components
  • 11Chapter 11: Data Fetching
  • 12Chapter 12: Server Actions & Mutations
  • 13Chapter 13: Route Handlers
  • 14Chapter 14: Caching & use cache
  • 15Chapter 15: Revalidation & ISR
  • 16Chapter 16: Cache Components & Partial Prerendering
  • 17Chapter 17: Proxy (formerly Middleware)
  • 18Chapter 18: Authentication & Authorization
  • 19Chapter 19: Metadata, SEO & OG Images
  • 20Chapter 20: Images & Fonts
  • 21Chapter 21: Styling
  • 22Chapter 22: Performance Optimization
  • 23Chapter 23: Testing
  • 24Chapter 24: Deployment & Self-Hosting
  • 25Chapter 25: Upgrading to Next.js 16
  • 26Chapter 26: Capstone Project
  • 27Chapter 27: React Performance Profiling
All chapters

🛡️ Chapter 9: Error Handling

Catching what breaks without taking the whole app down, and the difference between an error and an expected outcome.

📖 Plain English Explanation

Things go wrong. A database connection drops. A third-party API returns 503. A user requests a post that was deleted five minutes ago. Someone without permission opens an admin URL.

Not all of these are the same kind of problem, and treating them identically produces a bad app.

Next.js splits them into two categories:

Expected outcomes — things you knew could happen. A missing post. A form with an invalid email. A logged-out user hitting a private page. These aren't bugs; they're branches in your logic. Handle them by returning a value or calling a purpose-built function like notFound().

Unexpected errors — genuine failures. The database is down. A null reference. A bug you didn't anticipate. These get caught by an error boundary: a component that catches errors from everything below it and renders a fallback instead of a blank screen.

The whole chapter is about mapping each failure to the right mechanism.

🚧 error.tsx — the segment error boundary

Add an error.tsx to a folder, and Next.js wraps that segment in a React error boundary.

app/dashboard/
├── error.tsx      ← catches errors from page.tsx and below
├── layout.tsx
└── page.tsx
tsx
// app/dashboard/error.tsx
'use client'                    // ← error boundaries MUST be Client Components

import { useEffect } from 'react'

export default function Error({
  error,
  retry,
}: {
  error: Error & { digest?: string }
  retry: () => void
}) {
  useEffect(() => {
    // Report to Sentry, Datadog, etc.
    console.error(error)
  }, [error])

  return (
    <div className="rounded-lg border border-red-200 bg-red-50 p-6">
      <h2 className="font-semibold">Something went wrong</h2>
      <p className="text-sm text-red-700">
        {error.digest ? `Reference: ${error.digest}` : null}
      </p>
      <button
        onClick={() => retry()}
        className="mt-4 rounded bg-red-600 px-4 py-2 text-white"
      >
        Try again
      </button>
    </div>
  )
}
jsx
// app/dashboard/error.js
'use client'

import { useEffect } from 'react'

export default function Error({ error, retry }) {
  useEffect(() => {
    console.error(error)
  }, [error])

  return (
    <div className="rounded-lg border border-red-200 bg-red-50 p-6">
      <h2 className="font-semibold">Something went wrong</h2>
      <p className="text-sm text-red-700">
        {error.digest ? `Reference: ${error.digest}` : null}
      </p>
      <button
        onClick={() => retry()}
        className="mt-4 rounded bg-red-600 px-4 py-2 text-white"
      >
        Try again
      </button>
    </div>
  )
}

retry vs reset

⚠️ Changed in Next.js 16

error.tsx now receives a retry prop, stable as of 16.3 (it was unstable_retry in 16.2). Prefer it over reset.

PropWhat it does
retry()Re-fetches and re-renders the boundary's children. Recovers from Server Component errors.
reset()Clears the error state and re-renders without re-fetching. Cannot recover from a Server Component failure.

If your data fetch failed on the server, reset() just re-renders the same broken tree. retry() actually asks the server again. Older tutorials all show reset — switch them.

retry() runs inside a React Transition, so Client Component state outside the boundary is preserved.

What error.tsx catches — and what it doesn't

app/dashboard/
├── layout.tsx     ← ❌ NOT caught by this error.tsx
├── template.tsx   ← ❌ NOT caught
├── error.tsx      ← the boundary
├── loading.tsx    ← ✅ caught
├── not-found.tsx  ← ✅ caught
├── page.tsx       ← ✅ caught
└── settings/
    └── page.tsx   ← ✅ caught (nested)

The boundary sits inside its segment's layout. An error thrown by app/dashboard/layout.tsx bubbles up to the parent segment's error.tsx.

This is deliberate — the error UI renders inside the layout, so the user keeps their navigation and can click elsewhere.

The digest and why production messages are vague

In production, error.message from a Server Component is replaced with a generic string. That's a security feature: a raw error might contain a connection string, a query, or a file path.

Instead you get error.digest — a hash that also appears in your server logs:

[server log]  Error: connect ECONNREFUSED 10.0.0.4:5432
              digest: 3721896449

[browser]     Something went wrong. Reference: 3721896449

Show the digest in your error UI. It turns "the site is broken" into a support ticket you can actually trace.

Errors from Client Components keep their real message, since that code already shipped to the browser.

🌍 global-error.tsx

If the root layout itself throws, there's no parent boundary left. app/global-error.tsx is the last line of defense — it replaces the root layout, so it must render its own <html> and <body>.

tsx
// app/global-error.tsx
'use client'

export default function GlobalError({
  error,
  retry,
}: {
  error: Error & { digest?: string }
  retry: () => void
}) {
  return (
    <html lang="en">
      <body style={{ fontFamily: 'system-ui', padding: '4rem', textAlign: 'center' }}>
        <h1>Something went badly wrong</h1>
        <p>We&apos;ve been notified. Reference: {error.digest}</p>
        <button onClick={() => retry()}>Try again</button>
      </body>
    </html>
  )
}
jsx
// app/global-error.js
'use client'

export default function GlobalError({ error, retry }) {
  return (
    <html lang="en">
      <body style={{ fontFamily: 'system-ui', padding: '4rem', textAlign: 'center' }}>
        <h1>Something went badly wrong</h1>
        <p>We&apos;ve been notified. Reference: {error.digest}</p>
        <button onClick={() => retry()}>Try again</button>
      </body>
    </html>
  )
}

Three things to know:

  1. It replaces the root layout, so your global CSS, fonts, and providers are gone. Use inline styles or import a minimal stylesheet directly.
  2. It's a Client Component, so metadata exports don't work. Use React's <title> component if you need one.
  3. It renders its own document and won't pick up an app-level theme class. If you support dark mode, apply it inside global-error yourself.

Keep it simple. A global error page that itself throws is a very bad day.

🎯 catchError — boundaries anywhere

error.tsx is tied to route segments. Sometimes you want a boundary around one widget in the middle of a page. That's catchError, stable in 16.3:

tsx
// app/widget-boundary.tsx
'use client'

import { catchError, type ErrorInfo } from 'next/error'

function Fallback({ label }: { label: string }, { error, retry }: ErrorInfo) {
  // ErrorInfo["error"] is typed `unknown` — narrow before reading .message
  const message = error instanceof Error ? error.message : 'Unknown error'

  return (
    <div className="rounded border border-amber-300 bg-amber-50 p-4 text-sm">
      <p>{label} is unavailable: {message}</p>
      <button onClick={() => retry()}>Reload</button>
    </div>
  )
}

export default catchError(Fallback)
jsx
// app/widget-boundary.js
'use client'

import { catchError } from 'next/error'

function Fallback({ label }, { error, retry }) {
  const message = error instanceof Error ? error.message : 'Unknown error'

  return (
    <div className="rounded border border-amber-300 bg-amber-50 p-4 text-sm">
      <p>{label} is unavailable: {message}</p>
      <button onClick={() => retry()}>Reload</button>
    </div>
  )
}

export default catchError(Fallback)

TypeScript note: ErrorInfo is { error: unknown; reset: () => void; retry: () => void }. Reading error.message directly is a compile error — narrow with error instanceof Error first. The official docs example omits this; your build won't.

Use the returned component anywhere:

tsx
// app/dashboard/page.tsx
import WidgetBoundary from '../widget-boundary'
import { Suspense } from 'react'

export default function Page() {
  return (
    <div className="grid gap-6">
      <WidgetBoundary label="Revenue chart">
        <Suspense fallback={<Skeleton />}>
          <RevenueChart />
        </Suspense>
      </WidgetBoundary>

      <WidgetBoundary label="Activity feed">
        <Suspense fallback={<Skeleton />}>
          <ActivityFeed />
        </Suspense>
      </WidgetBoundary>
    </div>
  )
}

Why not a hand-written React error boundary? Because catchError knows about Next.js internals: it won't swallow redirect() or notFound(), its retry() re-fetches server data, and the error state clears automatically on client navigation. A plain class-component boundary gets all three wrong.

🔍 notFound() and not-found.tsx

For content that doesn't exist:

tsx
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { getPost } from '@/lib/posts'

export default async function Page(props: PageProps<'/blog/[slug]'>) {
  const { slug } = await props.params
  const post = await getPost(slug)

  if (!post) notFound()

  return <article>{post.body}</article>
}
jsx
// app/blog/[slug]/page.js
import { notFound } from 'next/navigation'
import { getPost } from '@/lib/posts'

export default async function Page({ params }) {
  const { slug } = await params
  const post = await getPost(slug)

  if (!post) notFound()

  return <article>{post.body}</article>
}
tsx
// app/blog/not-found.tsx
import Link from 'next/link'

export default function NotFound() {
  return (
    <div className="py-16 text-center">
      <h2 className="text-2xl font-semibold">Post not found</h2>
      <p className="mt-2 text-slate-600">It may have been moved or deleted.</p>
      <Link href="/blog" className="mt-4 inline-block underline">
        Browse all posts
      </Link>
    </div>
  )
}

notFound() renders the nearest not-found.tsx and sends a real 404 status. A root app/not-found.tsx also handles URLs that match no route at all.

Use this, not error.tsx, for missing content. A 404 is an expected outcome with a specific HTTP status and specific SEO meaning. Throwing a generic error gives you a 500 and tells Google your site is broken.

🔐 unauthorized() and forbidden()

Two more purpose-built interrupts, for the difference between "who are you?" and "you can't do that":

FunctionStatusMeaningFile
unauthorized()401Not signed inunauthorized.tsx
forbidden()403Signed in, but not allowedforbidden.tsx

They require an experimental flag:

ts
// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  experimental: {
    authInterrupts: true,
  },
}

export default nextConfig
js
// next.config.js
module.exports = {
  experimental: {
    authInterrupts: true,
  },
}
tsx
// app/admin/page.tsx
import { unauthorized, forbidden } from 'next/navigation'
import { verifySession } from '@/lib/dal'

export default async function Page() {
  const session = await verifySession()

  if (!session) unauthorized()              // 401 → unauthorized.tsx
  if (session.role !== 'admin') forbidden() // 403 → forbidden.tsx

  return <AdminPanel />
}
jsx
// app/admin/page.js
import { unauthorized, forbidden } from 'next/navigation'
import { verifySession } from '@/lib/dal'

export default async function Page() {
  const session = await verifySession()

  if (!session) unauthorized()
  if (session.role !== 'admin') forbidden()

  return <AdminPanel />
}
tsx
// app/unauthorized.tsx
import { LoginForm } from '@/components/LoginForm'

export default function Unauthorized() {
  return (
    <main className="mx-auto max-w-sm py-16">
      <h1 className="text-xl font-semibold">Please sign in</h1>
      <LoginForm />
    </main>
  )
}

Both are marked experimental in Next.js 16.3 — the API may still change, so weigh that before adopting them in production. redirect('/login') remains the conservative choice.

A caveat about status codes

If the auth check runs inside a <Suspense> boundary, the response has already started streaming as a 200 and the status can't be changed. The user still sees the right UI, but the HTTP status is wrong.

To get a real 401/403 status, the check must run before streaming begins — which in practice means in proxy.ts (Chapter 17).

💥 The control-flow trap

redirect(), notFound(), forbidden(), and unauthorized() all work by throwing a special error. That means a try/catch will swallow them.

tsx
// ❌ the redirect silently never happens
export default async function Page() {
  try {
    const session = await getSession()
    if (!session) redirect('/login')
    const data = await getData()
    return <View data={data} />
  } catch (error) {
    return <p>Something went wrong</p>   // ← catches the redirect signal
  }
}

Three ways to fix it:

1. Keep the interrupt outside the try (best):

tsx
export default async function Page() {
  const session = await getSession()
  if (!session) redirect('/login')       // outside any try

  let data
  try {
    data = await getData()
  } catch {
    return <p>Couldn&apos;t load your data</p>
  }
  return <View data={data} />
}

2. Re-throw framework errors with unstable_rethrow:

tsx
import { unstable_rethrow } from 'next/navigation'

export default async function Page() {
  try {
    const session = await getSession()
    if (!session) redirect('/login')
    return <View data={await getData()} />
  } catch (error) {
    unstable_rethrow(error)              // lets redirect/notFound pass through
    return <p>Something went wrong</p>   // only real errors reach here
  }
}
jsx
import { unstable_rethrow } from 'next/navigation'

export default async function Page() {
  try {
    const session = await getSession()
    if (!session) redirect('/login')
    return <View data={await getData()} />
  } catch (error) {
    unstable_rethrow(error)
    return <p>Something went wrong</p>
  }
}

unstable_rethrow must be the first statement in the catch block, before any logging.

3. Don't wrap render logic in try/catch at all. Let error.tsx do its job. try/catch in a Server Component is usually a sign you're duplicating the error boundary.

Another gotcha: un-awaited promises

tsx
// ❌ the interrupt throws where nothing catches it
export default async function Page() {
  checkAuth()                    // not awaited!
  return <Dashboard />
}

If checkAuth() calls unauthorized(), the throw happens outside the render path. You get an unhandledRejection in the server log and no error UI. Always await anything that might interrupt.

📝 Expected errors in Server Actions

Server Actions are different. A validation failure is not an exception — it's a normal result the UI needs to display.

Return an error object; don't throw.

tsx
// app/actions.ts
'use server'

import { z } from 'zod'
import { db } from '@/lib/db'

const schema = z.object({
  email: z.string().email('Enter a valid email address'),
  name: z.string().min(2, 'Name is too short'),
})

export type State = {
  errors?: Record<string, string[]>
  message?: string
}

export async function createUser(prev: State, formData: FormData): Promise<State> {
  const parsed = schema.safeParse({
    email: formData.get('email'),
    name: formData.get('name'),
  })

  if (!parsed.success) {
    return { errors: parsed.error.flatten().fieldErrors }
  }

  try {
    await db.user.create({ data: parsed.data })
  } catch (error) {
    console.error(error)
    return { message: 'Could not create the account. Please try again.' }
  }

  return { message: 'Account created.' }
}
js
// app/actions.js
'use server'

import { z } from 'zod'
import { db } from '@/lib/db'

const schema = z.object({
  email: z.string().email('Enter a valid email address'),
  name: z.string().min(2, 'Name is too short'),
})

export async function createUser(prev, formData) {
  const parsed = schema.safeParse({
    email: formData.get('email'),
    name: formData.get('name'),
  })

  if (!parsed.success) {
    return { errors: parsed.error.flatten().fieldErrors }
  }

  try {
    await db.user.create({ data: parsed.data })
  } catch (error) {
    console.error(error)
    return { message: 'Could not create the account. Please try again.' }
  }

  return { message: 'Account created.' }
}

Consumed with useActionState:

tsx
// app/signup/Form.tsx
'use client'
import { useActionState } from 'react'
import { createUser, type State } from '../actions'

const initial: State = {}

export function Form() {
  const [state, formAction, isPending] = useActionState(createUser, initial)

  return (
    <form action={formAction}>
      <input name="name" />
      {state.errors?.name && <p className="text-red-600">{state.errors.name[0]}</p>}

      <input name="email" type="email" />
      {state.errors?.email && <p className="text-red-600">{state.errors.email[0]}</p>}

      <button disabled={isPending}>{isPending ? 'Saving…' : 'Sign up'}</button>
      {state.message && <p>{state.message}</p>}
    </form>
  )
}

An uncaught throw inside a Server Action bubbles to the nearest error.tsx — which blows away the entire form, including everything the user typed. That's almost never what you want for a validation failure. Chapter 12 covers actions in full.

🗺️ The decision table

SituationUseResult
Content doesn't existnotFound()404 + not-found.tsx
User isn't signed inredirect('/login') or unauthorized()307 or 401
Signed in, wrong roleforbidden()403 + forbidden.tsx
Form validation failedreturn an error objectUI shows field errors
Third-party API is downtry/catch + fallback UIdegraded but working page
Database is downlet it throwerror.tsx
Bug in a componentlet it throwerror.tsx
Root layout brokelet it throwglobal-error.tsx
One widget on a page brokecatchError boundaryrest of the page survives

⚠️ Common Pitfalls

1. Forgetting 'use client' in error.tsx

Error: Components using `useState` or event handlers must be Client Components

Error boundaries must be Client Components. Every single time.

2. redirect() inside try/catch

Covered above. It's the most common App Router bug, full stop.

3. Using reset() instead of retry()

tsx
// ❌ won't recover from a server-side failure
<button onClick={() => reset()}>Try again</button>

// ✅
<button onClick={() => retry()}>Try again</button>

reset() re-renders without re-fetching. If the server threw, you'll just render the same error.

4. Expecting error.tsx to catch its own layout

app/dashboard/
├── layout.tsx     ← throws
└── error.tsx      ← does NOT catch it

The boundary lives inside the layout. Fix: put an error.tsx in the parent segment, or move the risky work out of the layout.

5. Throwing for a 404

tsx
// ❌ 500 status, generic error UI, bad for SEO
if (!post) throw new Error('Post not found')

// ✅ 404 status, dedicated UI
if (!post) notFound()

6. Leaking error details in production

tsx
// ❌ could expose a connection string or query
<pre>{error.stack}</pre>

// ✅
<p>Reference: {error.digest}</p>

Next.js already scrubs Server Component messages in production — don't undo it by rendering the stack.

7. Throwing validation errors from Server Actions

Wipes the form and the user's input. Return an error object instead.

8. No error boundary at all

An unhandled error in production shows the built-in error page — functional, but jarring and off-brand. Put an error.tsx at the root of each major section at minimum.

🎯 When & Why to Use

error.tsx          →  every major section (dashboard, admin, checkout)
global-error.tsx   →  once, at the app root, kept minimal
not-found.tsx      →  root + any section with its own missing-content UI
catchError         →  a single widget that shouldn't take the page down
notFound()         →  content genuinely doesn't exist
redirect()         →  send the user somewhere else (auth gates, moved URLs)
unauthorized()     →  401 with a login prompt   (experimental)
forbidden()        →  403 for wrong-role access  (experimental)
try/catch          →  a specific recoverable failure with a real fallback
return { error }   →  Server Action validation and expected failures

A pragmatic minimum for any real app:

app/
├── error.tsx           ← catches everything
├── global-error.tsx    ← catches the root layout
├── not-found.tsx       ← unmatched URLs
└── (app)/
    └── error.tsx       ← keeps the sidebar usable when the app section breaks

🏋️ Mini Practice Problems

Problem 1: Why doesn't the redirect fire?

tsx
export default async function Page() {
  try {
    const user = await getUser()
    if (!user) redirect('/login')
    return <Profile user={user} />
  } catch (e) {
    console.error(e)
    return <p>Error</p>
  }
}

Explain the bug and give two fixes.

Problem 2: Pick the mechanism

For each, choose from: notFound(), redirect(), forbidden(), try/catch, return-an-error-object, or let-it-throw.

  • A. /users/99999 — no such user
  • B. A logged-out visitor opens /settings
  • C. A signed-in editor opens /admin
  • D. The weather widget's API times out; the rest of the page is fine
  • E. A signup form gets an email that's already taken
  • F. DATABASE_URL is unset in production

Problem 3: Where does it get caught?

app/
├── error.tsx
└── shop/
    ├── layout.tsx
    ├── error.tsx
    └── cart/
        └── page.tsx

Which boundary catches each?

  • A. shop/cart/page.tsx throws
  • B. shop/layout.tsx throws
  • C. app/layout.tsx throws
  • D. shop/error.tsx itself throws

Problem 4: Write it

Build a /reports/[id] page that:

  • 404s when the report doesn't exist
  • 403s when the user's org doesn't own it
  • Renders the report with a stale-data warning if the live metrics API fails
  • Falls back to a segment error boundary for anything else

💼 Interview Notes

Common Questions

Q: Why must error.tsx be a Client Component? It's a React error boundary, which needs componentDidCatch and interactive recovery — both client-only. It also has to catch errors that happen during hydration in the browser.

Q: What's the difference between error.tsx and global-error.tsx? error.tsx catches errors in its segment and below, and renders inside the parent layout so navigation survives. global-error.tsx catches errors in the root layout, replaces the entire document, and must render its own <html> and <body>.

Q: What's the difference between retry() and reset()? retry() re-fetches server data and re-renders — it can recover from a Server Component failure. reset() only clears the error state and re-renders with the same data, so a server-side failure recurs immediately. retry became stable in 16.3 and is the one to use.

Q: Why does error.message say something generic in production? Server Component error messages are scrubbed to avoid leaking connection strings, queries, or file paths to the browser. You get error.digest instead — a hash that matches the full error in your server logs.

Q: Why shouldn't you wrap redirect() in a try/catch? It signals control flow by throwing a special error. A catch intercepts it and the redirect silently doesn't happen. Either keep the call outside the try, or call unstable_rethrow(error) as the first line of the catch.

Q: How should Server Actions report validation errors? Return a serializable error object and render it with useActionState. Throwing bubbles to error.tsx, which unmounts the form and discards everything the user typed.

Q: notFound() or throw new Error() for a missing record? notFound(). It produces a real 404 status, renders the dedicated not-found.tsx, and tells search engines the resource doesn't exist. A thrown error produces a 500, which is both wrong and bad for SEO.

🏢 Asked at Companies

  • Vercel: "Design the error handling for an e-commerce checkout. Where does each boundary go and why?"
  • Stripe: "A user reports 'Something went wrong' with no detail. How do you find what actually failed?"
  • Airbnb: "Explain why redirect() throws, and what that implies for how you write code around it."
  • Datadog: "How do you make sure every production error reaches your monitoring while keeping messages out of the browser?"

📊 Visual Memory Aid

              ERROR BOUNDARY HIERARCHY

  global-error.tsx
    └── catches: root layout failures
        replaces the whole document

    app/layout.tsx
      └── app/error.tsx
            └── catches: everything below
                renders INSIDE app/layout

          app/shop/layout.tsx
            └── app/shop/error.tsx
                  └── catches: shop pages + nested
                      does NOT catch shop/layout.tsx


              EXPECTED vs UNEXPECTED

  EXPECTED                    UNEXPECTED
  ────────                    ──────────
  notFound()      → 404       throw          → error.tsx
  redirect()      → 307       DB down        → error.tsx
  forbidden()     → 403       null reference → error.tsx
  return {error}  → form UI   root broke     → global-error.tsx


              THE THROW TRAP

  redirect() ──┐
  notFound() ──┤
  forbidden()──┼──► these THROW to signal control flow
  unauthorized()┘

  try {  redirect('/x')  } catch {}   ← ❌ swallowed, nothing happens

  catch (e) {
    unstable_rethrow(e)   ← ✅ first line, lets them through
    // real error handling below
  }

🎯 Key Takeaways

  1. Separate expected outcomes from unexpected failures. notFound(), redirect(), and returned error objects handle the former; error boundaries handle the latter.
  2. error.tsx must be a Client Component, catches its segment and below, and does not catch the layout it lives beside.
  3. Use retry(), not reset(). Only retry() re-fetches, so only it can recover from a Server Component failure. This changed in Next.js 16.
  4. redirect() and friends work by throwing — never wrap them in try/catch, or use unstable_rethrow(error) as the first line of your catch.
  5. Return errors from Server Actions, don't throw them. Throwing unmounts the form and loses the user's input.

Next Chapter: Server & Client Components →

Practice: Add error.tsx, not-found.tsx, and global-error.tsx to an app. Then deliberately break things — throw in a page, throw in a layout, throw in the root layout, request a missing record — and confirm each lands in the boundary you expect.


PreviousChapter 8: Loading, Suspense & StreamingNextChapter 10: Server & Client Components

Open source, free forever. Built by iammhador.

Contribute on GitHub