Error Handling

Errors are an inevitable part of any web application. Sometime, they may be caused by users requesting non-existent resources, submitting invalid input, attempting to upload malicious content, or they may be caused by internal server errors.

It is very important that these errors are handled accordingly, or they can lead to poor user experience, security vulnerability, or even application crush.

In this lesson, how handles errors when they arise in different parts of a Next.js application, and how you can manage them effectively to ensure a smooth user experience.

Error handling in API routes

Let's start with the API routes.

First of all, make sure your logic, let it be pulling or posting data to a database, requesting data from a remove API, or performing eny other tasks, is always wrapped inside a try/catch block. This helps you catch the runtime errors, and preventing them from crushing your application.

javascript
1export default async function GET(req, res) {
2  try {
3    // Simulate a task
4    const result = await someTask();
5
6    // Returns a 200 response if task is successful
7    res
8      .status(200)
9      .json({ success: true, message: "Task successful.", result: result });
10  } catch (error) {
11    // Returns a 500 response if task is unsuccessful
12    res
13      .status(500)
14      .json({ success: false, message: "Task failed.", error: error });
15  }
16}

When the error occurs, you need to make sure a proper response is returned, with the right response code and structured response body.

In the above example, the response code is 500, which indicates there are something wrong on the server. It is a generic error code and doesn't add much information.

Optionally, you can use the more specific response codes such as:

500 errors:

  • 500 Internal Server Error: A generic error indicating something went wrong on the server.
  • 501 Not Implemented: The server does not support the requested functionality.
  • 502 Bad Gateway: The server received an invalid response from an upstream server.
  • 503 Service Unavailable: The server is currently unavailable (e.g., overloaded or under maintenance).
  • 504 Gateway Timeout: The server did not receive a timely response from an upstream server.
  • 505 HTTP Version Not Supported: The HTTP version used in the request is not supported by the server.

400 errors:

  • 400 Bad Request: The server could not understand the request due to invalid syntax.
  • 401 Unauthorized: Authentication is required and has either failed or not been provided.
  • 403 Forbidden: The server understands the request but refuses to authorize it.
  • 404 Not Found: The server cannot find the requested resource.
  • 405 Method Not Allowed: The requested method is not allowed for the resource.
  • 409 Conflict: The request conflicts with the current state of the resource.
  • 410 Gone: The resource is no longer available and has been permanently removed.
  • 415 Unsupported Media Type: The server cannot process the request because of an unsupported format.
  • 429 Too Many Requests: The client has sent too many requests in a given amount of time (rate limiting).

However, in most cases, it is unlikely that the error can be described by a simple response code, and this is why you should also include a response body.

javascript
1res.status(500).json({ success: false, message: "Task failed.", error: error });

The request body should follow a structured format, and should be consistent across the entire application.

It should also include at least three things, a status indicator that indicates something went wrong (success: false), a short message describing what happened (message: "Task failed."), and the error itself (error: error).

Of course, in our example, the message is rather generic, but in practice, it should describe the details of the error that occurred. For example:

text
1Failed to connect to the database. Please try again later.
2Required fields are missing. Please complete the form and try again.
3The requested resource is temporarily unavailable.
4Unable to process the uploaded file. Please try again.

Error handling in server components

When it comes to errors that occur while Next is trying to display a page, things work a bit differently.

Recall that server components is usually where data fetching happens, and in this case, we need to think about what happens when the data fetching fails.

In order to deliver a seamless user experience, you must ensure that even if an error occurs during data fetching, a proper webpage will still be returned. For example:

jsx
1export default async function Page({ params }) {
2  try {
3    const id = (await params).id;
4    const res = await fetch(`https://dummyjson.com/users/${id}`);
5    if (!res.ok) {
6      throw new Error("User not found");
7    }
8
9    const user = await res.json();
10
11    return (
12      <div>
13        {user.firstName} {user.lastName}
14      </div>
15    );
16  } catch (error) {
17    console.error("Server Component Error:", error.message);
18    return (
19      <div>
20        <p>Cannot find the user you requested.</p>
21        <a href="/">Go back</a>
22      </div>
23    );
24  }
25}

There are a few things you should pay attention to here.

First, as usual, the entire logic should be wrapped inside a try/catch block, so that any errors can be caught.

And also, during the data fetching process, you must consider the scenario where data fetching is unsuccessful, before accessing the fetched data.

javascript
1if (!res.ok) {
2  throw new Error("User not found");
3}

res.ok returns the status of the response. If the response is successful (200 responses), res.ok is true, if the response is not successful (400 or 500 responses), res.ok returns false.

If the response is not successful, an error will be thrown, which will then be caught by the try/catch block. There are two things you should do when the error is caught. First, the error should be properly logged and recorded:

javascript
1console.error("Server Component Error:", error.message);

And an alternative user interface should be displayed to the user. This UI should describe what is happening, and provide a way for the user to navigate away.

jsx
1<div>
2  <p>Cannot find the user you requested.</p>
3  <a href="/">Go back</a>
4</div>

Error handling in client components

When it comes to error handling in client components, things operate with the same philosophy, but in a different way. Here is an example:

jsx
1"use client";
2
3import { useState, useEffect } from "react";
4
5export default function Page() {
6  const [data, setData] = useState(null);
7  const [error, setError] = useState(null);
8
9  useEffect(() => {
10    async function fetchUserData() {
11      try {
12        const res = await fetch("https://dummyjson.com/users");
13        if (!res.ok) throw new Error("Failed to fetch user data");
14        const data = await res.json();
15
16        setData(data);
17      } catch (err) {
18        console.error("Client Component Error:", err.message);
19        setError("Unable to load user data");
20      }
21    }
22    fetchUserData();
23  }, []);
24
25  if (!data) return <div>Loading...</div>;
26
27  return (
28    <div>
29      {error && <p style={{ color: "red" }}>{error}</p>}
30      {data && (
31        <ul>
32          {data.users.map((user) => (
33            <li key={user.id}>{user.firstName}</li>
34          ))}
35        </ul>
36      )}
37    </div>
38  );
39}

Since we have access to React APIs in client components, it is common practice to utilize the React states when it comes to error handling.

In the above example, when the error is caught, the state error will be updated, which triggers a rerender, displaying the corresponding error message.

Error handling in server actions

Recall that server actions are most commonly used for form handling, and when it comes to error handling in server actions, the most important part is making sure the user inputs are all validated before sending them to the database. For instance:

javascript
1"use server";
2
3import prisma from "@/libs/db";
4
5export async function createUserAction(data) {
6  try {
7    const name = data.get("name");
8    const email = data.get("email");
9
10    if (!name || !email) {
11      throw new Error("Name and email are required.");
12    }
13
14    const newUser = await prisma.user.create({
15      data: { name, email },
16    });
17
18    return { success: true, user: newUser };
19  } catch (error) {
20    console.error("Server Action Error:", error.message);
21    return { success: false, error: error.message };
22  }
23}

In this example, we implemented a basic data validation system to ensure that the name and email fields are not empty. However, in practice, in order to maintain data integrity, form validation often involves more complex logic.

In such cases, it's common practice to use regular expressions or third-party validation libraries to enforce stricter validation, such as verifying email formats, ensuring passwords meet complexity requirements, or validating phone numbers and other structured input data.

Error boundary for uncaught exceptions

So far, we've only been discussing errors that are expected and can be addressed programmatically, but what about the unexpected ones?

Unexpected errors are issues that we did not account for when developing the application. They indicate bugs or issues that shouldn’t occur during the normal operation of your application.

It is best to have a through testing process in place to prevent these bug. However, not even the most experienced developer can say, with 100 percent certainty, that their app is bug free.

So what should we do to these uncaught errors if they are almost unpreventable? The answer is error boundary.

Error boundaries automatically catches and handles uncaught exceptions by displaying a fallback UI, instead of the page that crushed.

Take a look at this example:

page.jsx

jsx
1export default async function Home() {
2  const error = true;
3
4  if (error) {
5    // Simulating a bug
6    throw Error("There is an error.");
7  } else {
8    return <p>This is the home page.</p>;
9  }
10}

Here we are simulating a bug by throwing an error, and by default, when you visit this page, it will fail to compile and display an error message instead.

Without the error page

And you don't want this to happen in practice, so instead, create a error.jsx file under app directory.

text
1src
2├── app
3│   ├── dashboard
4│   │   └── page.jsx
5│   ├── error.jsx        <===== The custom error page
6│   ├── favicon.ico
7│   ├── fonts
8│   ├── globals.css
9│   ├── layout.jsx
10│   └── page.jsx
11└── components
12    ├── footer.jsx
13    └── navbar.jsx

Inside this error.jsx, you can create a fallback UI that will be shown to the user whenever an unexpected error occurs.

Note that this error UI must be a client component.

error.jsx

jsx
1"use client";
2
3import { useEffect } from "react";
4
5export default function Error({ error, reset }) {
6  useEffect(() => {
7    console.error(error);
8  }, [error]);
9
10  return (
11    <div>
12      <h2>Something went wrong!</h2>
13      <button onClick={() => reset()}>Try again</button>
14    </div>
15  );
16}

With the error page

Handling 404 error

Last but not least, there is one type of error that occurs very often but is difficult to manage programmatically, and that is when the user visits a webpage that haven't been defined yet.

By default, the following page will be shown:

default 404 page

But if you want to change this UI, you can define a custom interface by creating a not-found.jsx file under the app directory.

not-found.jsx

jsx
1import Link from "next/link";
2
3export default function NotFound() {
4  return (
5    <div>
6      <h2>Not Found</h2>
7      <p>Could not find requested resource</p>
8      <Link href="/">Return Home</Link>
9    </div>
10  );
11}

custom 404 page