Skip to main content
Next.js learning series · Blog 9

Next.js 16 Error Handling Explained

Turn predictable failures into useful states, contain unexpected crashes, render correct 404s, and keep production details private.

A failed route safely contained inside a glowing boundary before continuing through a green recovery path

Fetching, caching, and updating data all introduce failure cases. After learning Next.js caching and revalidation and Server Actions, the next step is making those operations fail safely. A production application should guide users without exposing database messages, stack traces, credentials, or implementation details.

This tutorial follows the current App Router model. It separates expected errors from uncaught exceptions, then applies route-level boundaries, 404 UI, action state, fetch checks, safe logging, and production recovery. If routing terminology is new, revisit layouts and pages and linking and navigation.

Error Handling at a Glance

SituationTypical handling
Invalid form inputReturn validation state
Login requiredAuthentication flow or redirect where appropriate
No permissionReturn a safe denied state, 403 response, or an approved auth flow
Resource missingnotFound() and not-found.tsx
Unexpected API or database failureThrow; render the nearest error boundary; log on the server
Temporary client interaction failureLocal UI state and a deliberate retry
Diagram 1: Choose the right error strategyAn operation branches into expected input, missing-resource, permission, and unexpected-failure paths.
Something failed
Invalid inputReturn useful state
Missing resourcenotFound()
No permissionAuth or denied UI
Unexpected crashThrow → error.tsx

Expected vs Unexpected Errors

An expected error is a normal, foreseeable outcome: an email is invalid, stock disappeared, a session expired, or a rule rejected a mutation. The UI can explain what happened and what the user can do next. Model it as a typed return value rather than throwing it into a generic failure screen.

An unexpected error means normal execution broke: a database connection crashed, a dependency returned an impossible shape, or application code threw. Let it reach an error boundary and record the full diagnostic detail on the server.

Predictable validation follows a stable UI path while an unexpected server failure is captured by a boundary and offered a retry
Different failures need different contracts. Expected outcomes become usable state; unexpected exceptions enter containment and recovery.
Diagram 2: Expected form error flowA submission is validated. Valid data continues to save; invalid data returns accessible field and form messages.
Submit formValidate
ValidAuthorize and save
InvalidReturn useful state

Handling Errors in Server Components

A Server Component can render a local expected state when a response represents a known outcome. For an unexpected upstream failure, check response.ok and throw. The Web fetch() API does not throw just because a server returns 404 or 500.

Illustrative server data function
async function getPost(slug: string) {
  const response = await fetch(`${process.env.API_URL}/posts/${slug}`)

  if (response.status === 404) return null
  if (!response.ok) throw new Error('Failed to load post')

  return response.json()
}

Keep a real internal endpoint in an environment variable and never put tokens in a public error. The calling page can use notFound() for null; a thrown failure activates its closest boundary.

What Is error.tsx?

An error.tsx file creates a React error boundary for a route segment. It is a Client Component because recovery is interactive. The boundary wraps the segment's children, so it can preserve layouts above the failure and replace only the failed subtree.

Route-level error file
app/
└── dashboard/
    ├── error.tsx
    ├── layout.tsx
    └── page.tsx
Version note: retry changed during Next.js 16

The current Next.js 16.2 documentation exposes unstable_retry(). It refreshes route data and resets the boundary inside a transition, so it is expected to be more useful than the older reset() for data and Server Component failures. Next.js 16.0/16.1 projects may still expose reset(). Check the installed version before copying a signature.

Current Next.js 16.2 boundary example
'use client'

import { useEffect } from 'react'
import type { ErrorInfo } from 'next/error'

export default function Error({ error, unstable_retry }: ErrorInfo) {
  useEffect(() => {
    console.error('Dashboard boundary:', error.digest)
  }, [error])

  return (
    <main>
      <h2>We could not load this area.</h2>
      <p>Your other work is still available.</p>
      <button type="button" onClick={() => unstable_retry()}>
        Try again
      </button>
    </main>
  )
}

Do not show error.message directly to users. A client-side exception may contain technical detail, and production Server Component messages are intentionally generic. A digest can be shown as a support reference only if that fits your support process.

Diagram 3: How error.tsx protects a route segmentA parent layout stays active while an error in a child page is replaced by the route segment fallback.
Parent layout stays interactive
Route boundary
page.tsx throwserror.tsx fallback

How Error Boundaries Work

The generated boundary catches uncaught rendering errors in the subtree below it. It does not catch an error thrown by the layout.tsx in the same segment because that layout sits outside its own boundary. Move the fallback to a parent segment when the layout itself needs protection.

It also does not magically catch every event-handler or later asynchronous callback error. Handle predictable client interaction failures with local state. React can surface an error thrown inside a startTransition callback to the nearest boundary, but that is different from a rejected promise that your handler already caught.

Using unstable_retry() and reset()

A retry should rerun work that can plausibly succeed: a temporary network failure, an expired connection, or a transient service interruption. It should not create an infinite loop or repeat a mutation whose result is unknown. Disable or debounce rapid retries, and offer navigation when retry cannot help.

Diagram 4: Recovery and retry lifecycleA failure shows fallback UI. A user retry refreshes data and rerenders; success restores content while another failure returns to the fallback.
  1. Unexpected failure
  2. Fallback UI
  3. User retries
  4. Refresh + rerender
  5. Success or fallback

Nested Error Boundaries

Boundaries follow the route tree. A dashboard can keep its navigation and account controls alive while an analytics child segment fails. An error bubbles to the nearest matching parent boundary; a more local boundary produces a smaller, more useful fallback.

Nested application compartments isolate a failed inner module while surrounding layouts remain healthy and a local retry loops around the failure
Contain the smallest meaningful failure. Local boundaries preserve more working interface and make recovery easier to understand.
Diagram 5: Nested boundaries and error bubblingA chart error first reaches the analytics boundary. Without that local boundary, it bubbles to the dashboard boundary while the root layout remains available.
Root layout
Dashboard boundary
Analytics boundaryChart throws here
Nearest boundary handles the failure

Global Errors with global-error.tsx

Use app/global-error.tsx as a last-resort fallback for the root layout. It must be a Client Component and must include its own <html> and <body> because it replaces the root layout when active. Keep it independent from providers that may have failed.

Minimal global fallback
'use client'

import type { ErrorInfo } from 'next/error'

export default function GlobalError({ unstable_retry }: ErrorInfo) {
  return (
    <html lang="en">
      <body>
        <h1>The application could not continue.</h1>
        <button onClick={() => unstable_retry()}>Try again</button>
      </body>
    </html>
  )
}

Handling 404s with not-found.tsx

A missing post is not a server crash. Call notFound() in the segment that knows the resource is absent. Next.js terminates rendering for that segment, renders the closest not-found.tsx, and adds a noindex robots meta tag. A root app/not-found.tsx also handles unmatched URLs for the application.

Dynamic route with current async params
import { notFound } from 'next/navigation'

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const post = await getPost(slug)

  if (!post) notFound()

  return <article>{post.title}</article>
}
Segment not-found UI
import Link from 'next/link'

export default function NotFound() {
  return (
    <main>
      <h1>Post not found</h1>
      <p>It may have moved or no longer be available.</p>
      <Link href="/posts">Browse all posts</Link>
    </main>
  )
}
Diagram 6: Missing resource flowA dynamic page awaits params and queries a resource. Found data renders the page; missing data invokes notFound and renders the closest not-found UI.
Await paramsFind resource
FoundRender page
MissingnotFound() → not-found.tsx

Form and Server Action Errors

For validation and business-rule failures, follow the pattern introduced in Blog 7's Server Action error section: return a serializable state and display it with React's useActionState. Authenticate and authorize again inside every mutation.

Expected action result
'use server'

type State = {
  message: string
  errors?: { title?: string[] }
}

export async function createPost(
  previousState: State,
  formData: FormData
): Promise<State> {
  const title = String(formData.get('title') ?? '').trim()

  if (title.length < 3) {
    return {
      message: 'Check the highlighted field.',
      errors: { title: ['Use at least 3 characters.'] },
    }
  }

  await savePost({ title })
  return { message: 'Post created.' }
}

If savePost unexpectedly loses its database connection, do not return the raw database error. Let the exception reach the boundary and log its diagnostic context on the server.

Data Fetching Errors

Map known HTTP statuses deliberately. A 404 may become notFound(); an expected 401 can begin a login flow; a 403 can render a safe denied state or return a 403 from a Route Handler. A surprising 5xx should normally throw. See the data-fetching guide for server fetch placement and request patterns.

Diagram 7: Redirect, not found, denied, or errorA response status is mapped to success, missing-resource UI, authentication or permission handling, or an unexpected exception.
Response
2xxRender data
401 / 403Auth or denied flow
404notFound()
Unexpected 5xxThrow

Authorization and Permission Errors

Authentication asks who the user is; authorization asks what that user may do. Hiding a button is not security. Check permission close to the protected data and repeat it inside Server Actions and Route Handlers. Return only the minimum safe information.

Experimental APIs

The current unauthorized()/unauthorized.tsx and forbidden()/forbidden.tsx features require experimental.authInterrupts and are not recommended as a production default. This tutorial keeps the stable baseline: verified server-side access checks, redirects when appropriate, safe denied UI, and correct Route Handler status responses.

Redirects vs Errors

redirect() and notFound() use thrown framework control-flow signals. A broad try/catch can accidentally swallow them. Keep the redirect or not-found call outside the catch when possible. Catch only the operation that needs translation.

Keep framework control flow outside catch
export async function createPost(formData: FormData) {
  'use server'

  let post
  try {
    post = await insertPost(formData)
  } catch (error) {
    console.error('createPost failed', { error })
    throw error
  }

  redirect(`/posts/${post.slug}`)
}

Production Error Messages and Safe Logging

During development, detailed overlays help diagnose failures. In production, Next.js intentionally hides sensitive Server Component error detail from the client. The fallback receives a generic error plus a digest identifier. Use that identifier to correlate the user's failure with protected server logs.

Log useful context—operation, route, stable internal record identifiers, runtime, and digest—but redact cookies, authorization headers, access tokens, passwords, full payment data, and unnecessary personal information. Avoid logging raw FormData or complete request headers.

Central request error reporting
import type { Instrumentation } from 'next'

export const onRequestError: Instrumentation.onRequestError = async (
  error,
  request,
  context
) => {
  console.error('request failed', {
    digest: error.digest,
    path: request.path,
    method: request.method,
    route: context.routePath,
    routeType: context.routeType,
  })
}

The stable instrumentation.ts onRequestError hook is appropriate for centralized reporting when a Next.js server captures a request error. Await asynchronous reporting and keep the reporting path resilient; this article does not install or assume a monitoring vendor.

Loading UI and Failure UI

loading.tsx and Suspense fallbacks communicate that work is still pending. They are not error UI. A successful request replaces loading with content; a rejected render replaces it with the nearest error boundary. Design both states so the layout does not jump dramatically and assistive technology receives meaningful status.

Diagram 8: Loading, success, and failureNavigation begins with pending UI. Completed work renders content; an uncaught failure renders the segment boundary.
Navigationloading.tsx
ResolvedPage content
Rejectederror.tsx

Practical Mini Project: Resilient Post Route

Build a post route with four explicit contracts: async route params, a data function that distinguishes 404 from 5xx, a local not-found page, and an error boundary. Add a form action that returns validation state, logs unexpected write failures, and redirects only after the write succeeds.

  1. Create app/posts/[slug]/page.tsx and await params.
  2. Return null only when the resource is truly absent.
  3. Call notFound() for that absence.
  4. Throw on unexpected upstream or database failures.
  5. Add app/posts/[slug]/error.tsx with a safe retry and escape route.
  6. Add app/posts/[slug]/not-found.tsx with helpful navigation.
  7. Return typed validation state from the post-edit Server Action.
  8. Correlate production failures with a digest in server logs.
Diagram 9: Error testing matrixTest expected and unexpected outcomes across rendering, navigation, and mutation so every path produces the intended user experience and server record.
Valid postContent renders
Unknown slug404 UI + noindex
API 500Boundary + server log
Invalid formField state
Denied actionSafe permission result
Retry succeedsContent restored

Common Error Handling Mistakes

  • Throwing every validation failure. Predictable input problems deserve useful state.
  • Treating a missing record as a crash. Use notFound() for an absent route resource.
  • Trusting fetch to throw on HTTP errors. Check response.ok and status explicitly.
  • Displaying raw error messages. They can expose private implementation details.
  • Putting only one global boundary in the app. Local boundaries preserve more working UI.
  • Expecting a segment boundary to catch its own layout. The boundary wraps the layout's children.
  • Swallowing redirect or notFound. Keep framework control flow outside broad catches.
  • Retrying a mutation blindly. The first attempt may have committed.
  • Logging secrets. Redact credentials, cookies, tokens, and sensitive inputs.
  • Copying a retry signature from another 16.x release. Confirm whether the project has reset() or the 16.2 unstable_retry().

Best Practices

  • Write a typed contract for every expected failure.
  • Place boundaries around meaningful independent route areas.
  • Give every fallback a next action: retry, return, or navigate.
  • Keep not-found UI specific and useful.
  • Check permission next to protected data and inside every mutation.
  • Preserve diagnostic detail on the server, not in public UI.
  • Use stable operation names and digests for correlation.
  • Test production builds because production messages intentionally differ.
  • Make loading, empty, denied, not-found, and error states visually distinct.
  • Verify version-sensitive APIs against the installed Next.js version.

FAQ

How should expected errors be handled in Next.js 16?

Model predictable failures such as invalid input as return values and display useful UI. Reserve thrown exceptions for unexpected failures.

Does error.tsx have to be a Client Component?

Yes. Add use client because the boundary fallback can receive the error and expose an interactive retry function.

Should I use reset or unstable_retry?

Next.js 16.2 added unstable_retry, which refreshes route data and resets the boundary. Earlier Next.js 16 releases expose reset, so match the exact installed version.

What is the difference between notFound and throwing an Error?

notFound ends the current route segment and renders its not-found UI for a missing resource. A thrown unexpected exception activates the nearest error boundary.

Can error.tsx catch an error in its own layout?

No. The generated boundary wraps the segment children, not that segment layout. Put a boundary in a parent segment to catch a layout failure.

What does an error digest do?

A digest is an identifier that can correlate a production fallback with server-side error records without exposing sensitive internal details to the browser.

Can I call redirect or notFound inside try and catch?

Avoid catching them. They throw framework control-flow errors, so call them after the catch or ensure they are rethrown.

How do I log App Router errors centrally?

Use server-side logging and the stable instrumentation onRequestError hook where centralized request error reporting is required. Redact secrets and await asynchronous reporting.

Are unauthorized and forbidden stable in Next.js 16?

They remain experimental and require authInterrupts in the current documentation, so this guide does not make them the production default.

Official Resources

Next Steps

You can now separate expected states from unexpected exceptions, contain route failures, render real 404 experiences, recover from transient errors, and keep production diagnostics private. Blog #10 will cover Route Handlers and is still upcoming, so no destination is published yet.

WhatsApp