2024-10-19 Web Development

Next.js: Guide to Error Handling in Next.js

By O. Wolfson

A guide to error handling in Next.js, covering expected and unexpected errors, try/catch blocks, error boundaries, and more.

Introduction to Error Handling

Error handling is a critical aspect of software development, ensuring that your application gracefully handles expected issues and unexpected failures. The goal is to prevent the application from crashing and provide a seamless experience for users, even when things go wrong. In web development, handling errors can take different forms, from displaying user-friendly messages for validation errors to using more advanced techniques like error boundaries for unexpected exceptions.

In past, error handling in JavaScript has often been achieved through try/catch blocks, which are used to capture errors that occur during code execution. While effective, using try/catch indiscriminately can lead to cluttered and difficult-to-maintain code. In modern frameworks like Next.js, better approaches such as returning error values, using hooks, and implementing error boundaries are preferred to manage errors efficiently.

What is try/catch?

The try/catch statement allows you to catch exceptions that occur in a block of code and manage them in a controlled manner. This technique is beneficial when dealing with unexpected issues like failed API requests, invalid data types, or runtime bugs. However, in many cases, especially in server-side applications, using try/catch for expected errors (such as form validation failures) can result in unnecessary overhead. Instead, we should return error values to the client directly.

javascript
try {
  // Code that may throw an error
  let result = 10 / 0;
  console.log(result);
} catch (error) {
  // Handle the error
  console.error("An error occurred:", error);
}

What is an Error Boundary?

An error boundary is a component that catches JavaScript errors anywhere in its child component tree, logs those errors, and displays a fallback UI instead of crashing the entire application. Error boundaries are particularly useful in React applications for handling exceptions that arise in rendering lifecycle methods. They allow developers to isolate components and ensure that failures in one part of the component tree do not bring down the whole page.

jsx
import React, { Component } from "react";

class ErrorBoundary extends Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    // Update state so the next render shows the fallback UI
    return { hasError: true };
  }

  componentDidCatch(error, info) {
    // You can also log the error to an error reporting service
    console.error("ErrorBoundary caught an error:", error, info);
  }

  render() {
    if (this.state.hasError) {
      // Render fallback UI
      return <h1>Something went wrong.</h1>;
    }

    return this.props.children;
  }
}

export default ErrorBoundary;

Usage:

jsx
<ErrorBoundary>
  <MyComponent />
</ErrorBoundary>

Handling Errors in Next.js

Next.js offers structured and efficient ways to manage errors, ranging from expected errors (such as validation issues) to unexpected, uncaught exceptions that might crash an application. Let's explore these methods in more detail.

Handling Expected Errors

Expected errors occur during the normal operation of an application and are generally recoverable. These are errors you anticipate and can predict—like form validation errors or failed API requests. For expected errors, it’s better to avoid using try/catch blocks. Instead, model these errors as return values and manage them explicitly within the UI.

Expected Errors in Server Actions

In Next.js, server actions can be used to handle form submissions, fetch data from APIs, and more. For example, consider a user sign-up action that attempts to create a user but encounters an invalid email input. Instead of throwing an exception, you can return an error message as part of the action's return value. This allows the UI to react gracefully to the error without causing a crash.

typescript
"use server";

import { redirect } from "next/navigation";

export async function createUser(prevState: any, formData: FormData) {
  const res = await fetch("https://...");
  const json = await res.json();

  if (!res.ok) {
    return { message: "Please enter a valid email" };
  }

  redirect("/dashboard");
}

In the UI component, you can use React’s useFormState hook to handle the returned state, allowing the component to display error messages to users directly without using a try/catch.

typescript
"use client";

import { useFormState } from "react";
import { createUser } from "@/app/actions";

const initialState = {
  message: "",
};

export function Signup() {
  const [state, formAction] = useFormState(createUser, initialState);

  return (
    <form action={formAction}>
      <label htmlFor="email">Email</label>
      <input type="text" id="email" name="email" required />
      <p aria-live="polite">{state?.message}</p>
      <button>Sign up</button>
    </form>
  );
}

This pattern ensures that the client handles errors gracefully without needing a try/catch block to manage common validation issues.

Handling Uncaught Exceptions with Error Boundaries

Uncaught exceptions refer to errors that occur due to bugs or other unexpected behavior in the application. These errors are not expected during normal application flow and typically need to be logged or reported. To manage these, Next.js leverages React's error boundary system.

Error boundaries catch errors within their child components and prevent the entire UI from crashing. When an error occurs, they display a fallback UI and provide the user with options to recover, such as retrying the failed operation.

typescript
"use client"; // Error boundaries must be Client Components

import { useEffect } from "react";

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

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

You can implement error boundaries at various levels of your application, from specific routes (like /dashboard) to the entire application. By placing an error boundary at a higher level (such as the root layout), you can ensure that uncaught exceptions do not break the entire application, providing a more resilient user experience.

Granular Error Handling with Nested Routes

Next.js allows you to create more granular error boundaries by placing them in nested routes. Errors in deeply nested components can be caught and handled by placing an error.tsx file at the appropriate level in your route hierarchy. This approach provides more fine-grained control over how different sections of your app handle failures.

Handling Global Errors

In some cases, you may want to handle errors at the global level, affecting the entire app rather than a specific route or component. In Next.js, this can be achieved by adding a global-error.tsx file in the root of your app directory. This error component replaces the root layout or template when an error occurs, providing a fallback UI for the entire app.

typescript
"use client"; // Error boundaries must be Client Components

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <html>
      <body>
        <h2>Something went wrong!</h2>
        <button onClick={() => reset()}>Try again</button>
      </body>
    </html>
  );
}

Conclusion

Error handling in Next.js is a sophisticated system that allows developers to handle expected and unexpected issues gracefully. By leveraging useFormState for expected errors and error boundaries for uncaught exceptions, you can create robust applications that provide a smooth user experience even in the face of failure.

By strategically placing error boundaries and modeling errors as return values rather than exceptions, your codebase can become cleaner, more predictable, and easier to maintain. Ultimately, handling errors effectively leads to more resilient and user-friendly applications.