Leveraging error and not-found boundaries in Next.js
I’ve seen applications break on errors and completely render it useless. Luckily Next.js gives us some neat tooling to handle these gracefully and even better now with error boundaries however, there is one thing that needs improvement. We lack proper feedback as to why something went wrong in a production environment.
With the architectural move towards the app directory and react server components in Next 13 we are able to perform some nifty tricks. With that change came some new reserved file names to catch our errors such as error.tsx
and not-found.tsx
. Since i've had to migrate some projects to the new app directory I wanted to share my learnings.
New directory structure
With the app directory, your structure would look something like this.
/app
├── layout.tsx
├── page.tsx
├── global-error.tsx // uncommon error fallback
├── not-found.tsx // root not-found handles any unmatched URLs for your whole application
├── /dashboard
├── page.tsx
├── error.tsx // granular error, if a fetch in page.tsx throws
├── not-found.tsx
├── /post/[slug]
├── page.tsx
├── not-found.tsx // invoke notFound() if in page.tsx !post
The UI could look like this to help a little with the visualization of it all.
Differences between handling errors or 404 in the pages vs app directory
In Next.js, error handling differs between the pages and app directories due to differences in routing and rendering paradigms. Here are some key differences.
Feature | pages directory | app directory |
---|---|---|
Handling 404s | return { notFound: true } in getServerSideProps or next/error | notFound() function |
Custom 404 Page | pages/404.js | app/not-found.tsx |
Custom Error Handling | next/error or throwing JavaScript errors | error.tsx with automatic error boundaries |
Client vs Server Components | Mostly client-rendered | Mix of server and client components |
When to show a not-found.tsx?
The notFound()
function throws a NEXT_NOT_FOUND
error under the hood. In general we invoke the notFound()
function for the client or server when…
- The requested resource does not exist
- The user navigates to a valid route that points to a missing entity (like a product, user, or blog post).
Example on a post page.
import { notFound } from 'next/navigation';
export default async function PostPage({ slug }) {
const post = await fetchPost(slug)
if (!post) {
notFound()
}
// ...
}
When to show an error.tsx?
We use the error.tsx
to catch for unexpected errors and unhandled exceptions. These might include…
- Network errors
- API errors due to server issues like 500’s etc
- Any other unexpected errors
The way we propagate the error from the fetch on a page.tsx
to the error.tsx
is by throwing an error.
const fetchData = async () => {
try {
const res = await fetch(SOME_API);
return res;
} catch (error) {
throw error; // Re-throw here
}
};
export default async function Page() {
const data = await fetchData();
// ...
};
Notice how there is no try/catch block on the page. This is because Next.js handles the throw in fetchData()
and propagates it to the error boundary by having included the error.tsx
file.
The absence of meaningful error messages in production
If you’ve messed around and thrown some errors around in a development environment, you are able to log the actual server side errors. However, in a production environment, the error is serialized by react and any custom error message you pass in the error is replaced with the following.
Error: An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details.
A digest property is included on this error instance which may provide additional details about the nature of the error. But it does not overwrite the digest
which allows us to pass anything we want.
I ran into a case where I needed to give the user feedback and developer meaningful feedback and maybe I wanted to do something based on an error code that was returned by the backend. There’s an open discussion about this on the Next.js repo.
Passing a custom error code
The solution to this is to create a new custom Error
object and use the digest
property to pass any data you want. In my case i’d pass an error code that I can use to show a localised message.
const fetchData = async () => {
try {
const res = await fetch(SOME_API);
return res;
} catch (error) {
const customError = new Error(error.message || 'custom message');
(customError as any).digest = "CUSTOM_ERROR_CODE";
throw customError; // re-throwing here
}
};
Then in our error.tsx
file we can read the code and perform an action.
'use client' // Error components must be Client Components
import { useEffect } from 'react'
enum ErrorId {
CUSTOM_ERROR_CODE = 'CUSTOM_ERROR_CODE',
INVALID_ID = 'INVALID_ID',
}
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string | ErrorId }
reset: () => void
}) {
useEffect(() => {
// Log the error code to an error reporting service
console.error(error.digest)
}, [error])
return (
<div>
<h2>{getErrorMessage(error.digest as ErrorId)}</h2>
<button
onClick={
// Attempt to recover by trying to re-render the segment
() => reset()
}
>
Try again
</button>
</div>
)
}
// We can localize these messages now.
function getErrorMessage(code?: ErrorId) {
switch (code) {
case ErrorId.CUSTOM_ERROR_CODE:
return 'This is a custom error code.';
case ErrorId.INVALID_ID:
return 'The ID used is invalid.';
default:
return 'Oops, something went wrong.';
}
};
These examples are ofcourse contrived but should provide the foundation to be able to debug and handle errors gracefully in your Next.js application. By leveraging error boundaries and the digest
property, you can create a robust error handling system that provides meaningful feedback to both users and developers, while maintaining security in production environments. The digest property has imo. never had any value for me in the past.