vM.

Error Handling in the Next.js App Router: error.tsx, not-found, and Server Actions

Author
Vishal Maurya
Published on
Reading time
5 min read

Overview

A user sees a blank page or a generic production error, your error boundary does not catch a failed form submission, or a missing record returns a 200 with an empty page. The App Router has file conventions for each situation, but they only work if you use them for the right kind of failure.

1. Separate Expected From Unexpected Errors

Expected errors are things like validation failures, a duplicate email, or a record that does not exist. Model these as return values or notFound(). Unexpected errors are bugs and infrastructure failures; let them throw so an error boundary handles them.

2. error.tsx for Route Segments

An error.tsx file wraps its segment's children in a React error boundary. It must be a Client Component:

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

import { useEffect } from 'react';

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    // send to your logging service
    console.error(error);
  }, [error]);

  return (
    <div role="alert">
      <h2>Something went wrong</h2>
      <button onClick={() => reset()}>Try again</button>
    </div>
  );
}

In production, errors thrown in Server Components are sanitized before reaching the client. You receive a generic message plus a digest you can match against server logs. Do not expect the original message in the browser.

An error.tsx does not catch errors thrown in the layout of the same segment, because the boundary sits below that layout. Place it in a parent segment to cover that case. It also does not catch errors in event handlers; handle those with try/catch.

3. global-error.tsx for the Root Layout

Errors in the root layout need global-error.tsx. It replaces the root layout when active, so it must define its own html and body:

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

export default function GlobalError({ reset }: { error: Error; reset: () => void }) {
  return (
    <html lang="en">
      <body>
        <h2>The application hit an unexpected error</h2>
        <button onClick={() => reset()}>Reload</button>
      </body>
    </html>
  );
}

It is mainly a last resort and is typically active in production builds.

4. not-found.tsx and notFound()

Call notFound() when a resource does not exist. It renders the nearest not-found.tsx and sets a 404 status in most cases:

// app/posts/[slug]/page.tsx
import { notFound } from 'next/navigation';

export default async function Post({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const post = await getPost(slug); // your data function
  if (!post) notFound();
  return <article>{post.title}</article>;
}

Remember params is a Promise in newer versions and a plain object in Next.js 14. A root app/not-found.tsx also handles unmatched URLs. Behavior around the status code when content has already started streaming can differ, so verify with curl -I in a production build.

5. Server Actions: Return Expected Errors

Throwing from a Server Action sends it to the nearest error boundary, which is wrong for validation. Return a result and render it with useActionState (React 19):

// app/signup/actions.ts
'use server';

import { redirect } from 'next/navigation';

export type State = { error?: string };

export async function signup(prev: State, formData: FormData): Promise<State> {
  const email = String(formData.get('email') ?? '');
  if (!email.includes('@')) return { error: 'Enter a valid email address.' };

  try {
    // await createUser(email);
  } catch {
    return { error: 'Could not create the account. Try again later.' };
  }
  redirect('/welcome');
}
// app/signup/form.tsx
'use client';

import { useActionState } from 'react';
import { signup, type State } from './actions';

export function SignupForm() {
  const [state, action, pending] = useActionState<State, FormData>(signup, {});
  return (
    <form action={action}>
      <input name="email" type="email" required />
      <button disabled={pending}>Sign up</button>
      {state.error && <p role="alert">{state.error}</p>}
    </form>
  );
}

Note that redirect() works by throwing a special error. Call it outside any try block, or it will be caught by your own catch. The same is true of notFound(). In React 18 setups the hook is named useFormState from react-dom; check your versions.

6. Log and Monitor

Error boundaries show a UI, but they do not replace monitoring. Log the digest or use your framework's instrumentation hook to forward server errors to your logging service. Avoid exposing stack traces or internal messages to users.

7. Test the Failure Paths

Force an error in a development route, build for production, and confirm each boundary renders. Verify that a missing slug returns a 404, that a thrown error in a Server Action shows the boundary, and that validation errors do not.

Conclusion

Use notFound() and return values for expected conditions, error.tsx for unexpected route failures, and global-error.tsx for the root. Keep redirect out of try/catch and test in production mode.

If you want a review of error handling and observability in a Next.js application, I can help. Contact me.

Additional Resources

  • Error handling in Next.js
  • not-found.js file convention
  • useActionState reference