May 19, 2026

Next.js Error Handling in App Router for Reliable Production Apps

A practical guide to Next.js error handling in App Router apps covering error boundaries, not-found states, route handler failures, logging, and user-safe fallbacks.

Next.js
App Router
Error Handling
Reliability
Security

8 min read

Next.js Error Handling in App Router for Reliable Production Apps

If you are searching for Next.js error handling App Router, you probably already know that errors are not limited to one file. A production route can fail while fetching server data, rendering a Server Component, submitting a Server Action, parsing a route handler request, or loading a Client Component. The App Router gives you strong primitives, but reliability still depends on where you place the boundary and what information you expose.

This guide explains a practical Next.js error boundary App Router strategy: use route-level error.tsx files for recoverable UI failures, not-found.tsx for expected missing resources, safe route handler responses for API failures, and structured logging for everything the user should not see. It pairs naturally with React Suspense and Error Boundaries: A Complete Practical Guide, Next.js Server Components Patterns for Faster App Router Apps, and Secure API Route Patterns in Next.js for Safer App Router Backends. If bad input is the source of the failure, Next.js Zod Validation in App Router for Safer Server Actions covers the validation layer before errors reach the UI. If your error pages need crawlable metadata and internal links, keep Next.js SEO Checklist for App Router Projects close as well.

Start with Expected vs Unexpected Failures

Good error handling starts with classification. Not every failure should become a scary error screen. Some are normal product states.

Expected failures include:

  • a blog post slug does not exist
  • a user lacks access to a project
  • a form submission fails validation
  • a third-party response times out

Unexpected failures include:

  • a database query throws
  • a Server Component receives malformed data
  • a route handler leaks an unhandled exception
  • a Client Component crashes while rendering

Expected failures deserve intentional UI and clear HTTP semantics. Unexpected failures deserve containment, logging, and a recovery path. Mixing those two categories creates bad UX and worse security. A missing article should not look like a server outage, and a server exception should not expose stack traces or internal IDs to the browser.

Use not-found.tsx for Missing Resources

The App Router gives every route segment a clean way to handle known missing states. Use notFound() when the requested resource does not exist or should not be revealed.

// app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
import { getPostBySlug } from "@/lib/posts";

type BlogPostPageProps = {
  params: {
    slug: string;
  };
};

export default async function BlogPostPage({ params }: BlogPostPageProps) {
  const post = await getPostBySlug(params.slug);

  if (!post) {
    notFound();
  }

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.html }} />
    </article>
  );
}

Then add a segment-specific fallback:

// app/blog/[slug]/not-found.tsx
import Link from "next/link";

export default function BlogPostNotFound() {
  return (
    <main>
      <h1>Post not found</h1>
      <p>The article may have moved or the URL may be incorrect.</p>
      <Link href="/blog">Browse all posts</Link>
    </main>
  );
}

This is the simplest form of Next.js not found error handling. It keeps expected absence out of your exception flow and gives users a useful next step. For private resources, the same pattern can avoid confirming whether a record exists.

Add Route-Level error.tsx Boundaries

Use error.tsx for unexpected rendering failures inside a route segment. It must be a Client Component because it receives a reset function that can retry rendering the segment.

// app/dashboard/error.tsx
"use client";

import { useEffect } from "react";

type DashboardErrorProps = {
  error: Error & { digest?: string };
  reset: () => void;
};

export default function DashboardError({ error, reset }: DashboardErrorProps) {
  useEffect(() => {
    console.error("Dashboard route failed", {
      message: error.message,
      digest: error.digest,
    });
  }, [error]);

  return (
    <section>
      <h1>Dashboard could not load</h1>
      <p>Try again. If the issue continues, contact support with the request time.</p>
      <button type="button" onClick={reset}>
        Retry
      </button>
    </section>
  );
}

Keep the message user-safe. Do not render raw database messages, provider responses, stack traces, or serialized errors. Your own fallback copy should follow the same rule.

Place boundaries close to the experience that can fail. A dashboard boundary should not necessarily replace the whole app shell, and a billing boundary should not erase the navigation if only one panel failed.

Keep Server Component Errors Contained

Server Components are a major reason App Router apps can stay fast and secure, but they also change where failures happen. Data fetching often lives directly in the component tree, so error handling needs to be part of route design.

// app/dashboard/page.tsx
import { getDashboardSummary } from "@/lib/dashboard";

export default async function DashboardPage() {
  const summary = await getDashboardSummary();

  return (
    <main>
      <h1>Dashboard</h1>
      <SummaryCards summary={summary} />
    </main>
  );
}

If getDashboardSummary() throws, the nearest error.tsx handles the route segment. That is useful for truly unexpected failures. For expected states, return a typed result instead of throwing.

type DashboardSummaryResult =
  | { ok: true; summary: DashboardSummary }
  | { ok: false; reason: "unauthorized" | "unavailable" };

export async function getDashboardSummarySafe(): Promise<DashboardSummaryResult> {
  const session = await requireSession();

  if (!session) {
    return { ok: false, reason: "unauthorized" };
  }

  try {
    const summary = await loadSummary(session.userId);
    return { ok: true, summary };
  } catch {
    return { ok: false, reason: "unavailable" };
  }
}

That approach lets the page decide whether to show an empty state, a retry panel, a sign-in prompt, or a route-level failure. It also keeps authorization logic explicit, matching the trust-boundary discipline from Next.js Authentication Patterns for Secure App Router Apps and Next.js Middleware Security Best Practices for App Router Apps.

Return Safe Errors from Route Handlers

Route handlers need a different pattern. They should return predictable status codes and compact response bodies. The client needs enough information to react, not enough information to inspect your internals.

// app/api/contact/route.ts
import { NextResponse } from "next/server";
import { z } from "zod";

const contactSchema = z.object({
  name: z.string().min(2).max(80),
  email: z.string().email(),
  message: z.string().min(10).max(2000),
});

export async function POST(request: Request) {
  const body = await request.json().catch(() => null);
  const parsed = contactSchema.safeParse(body);

  if (!parsed.success) {
    return NextResponse.json(
      { error: "Invalid contact form submission" },
      { status: 400 }
    );
  }

  try {
    await sendContactEmail(parsed.data);
    return NextResponse.json({ ok: true });
  } catch (error) {
    console.error("Contact email failed", { error });

    return NextResponse.json(
      { error: "Message could not be sent right now" },
      { status: 503 }
    );
  }
}

This pattern covers validation, parsing, and service failure without leaking implementation details. It also makes client handling easier because the response shape is stable. For deeper API protection, pair this with Secure API Route Patterns in Next.js for Safer App Router Backends, and add Next.js Rate Limiting in App Router for Safer Route Handlers when repeated valid requests can become the failure mode.

Handle Server Action Failures as Product States

Server Actions often sit behind forms, so users need field-level or form-level feedback. Throwing can be appropriate for unexpected failures, but validation and business-rule failures should usually return state.

"use server";

type UpdateProfileState = {
  status: "idle" | "success" | "error";
  message?: string;
};

export async function updateProfile(
  _previousState: UpdateProfileState,
  formData: FormData
): Promise<UpdateProfileState> {
  const displayName = String(formData.get("displayName") ?? "").trim();

  if (displayName.length < 2) {
    return {
      status: "error",
      message: "Display name must be at least 2 characters.",
    };
  }

  try {
    await saveProfile({ displayName });
    return { status: "success", message: "Profile updated." };
  } catch (error) {
    console.error("Profile update failed", { error });

    return {
      status: "error",
      message: "Profile could not be updated right now.",
    };
  }
}

This keeps common failures inside the form flow instead of bouncing the user to a route error boundary. It also reinforces a security habit: the server decides what happened and returns a safe message. Next.js Server Actions Security Best Practices for App Router Apps covers the mutation side in more detail.

Log for Operators, Write for Users

The user-facing message and the internal log entry have different jobs. The user message should be short, calm, and actionable. The log entry should include enough context for debugging without storing secrets.

Useful log context:

  • route segment or handler name
  • request ID or trace ID
  • authenticated user ID when appropriate
  • external provider name
  • error digest or normalized error code
  • safe business identifier, such as a project ID

Avoid logging raw passwords, tokens, session cookies, payment data, full request bodies, or AI prompts that may contain sensitive user input. If an AI workflow helps generate error handling code, review the output against the guardrails in AI Coding Workflow Guardrails for Safer React and Next.js Teams. Generated error handling often looks complete while quietly leaking too much detail.

Make Recovery Paths Specific

The weakest error screen says "Something went wrong" and stops there. A better fallback gives the user a route-specific next action.

Good recovery paths include:

  • retry the current segment
  • return to the dashboard overview
  • browse all blog posts
  • reopen a form with preserved input
  • contact support with a request time
  • switch to a cached or read-only view

The recovery path should match the failure. A failed analytics chart may need a retry button. A missing article should link back to the blog index. A failed checkout mutation needs careful state handling so the user does not submit twice.

Final Takeaway

Practical Next.js error handling App Router work is a system, not a single component. Use notFound() and not-found.tsx for expected missing resources. Use error.tsx for unexpected route rendering failures. Return safe, stable responses from route handlers. Treat Server Action validation as product state. Log enough for operators to debug while keeping sensitive details out of the UI and out of unsafe logs.

When those pieces are in place, Next.js error boundary App Router behavior becomes predictable. Users get useful recovery paths, search engines see cleaner route semantics, and developers get enough signal to fix production issues without exposing internals. That is the difference between an app that merely catches errors and an app that handles failure deliberately.