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.
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.
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:
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:
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.
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:
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.
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:
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:
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
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.
And you don't want this to happen in practice, so instead, create a error.jsx
file under app
directory.
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
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}
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:
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
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}