Next.js error handling: a practical guide
Every Next.js application has to deal with errors, whether it's a failed API call, invalid user input, or a bug that slips into production. The App Router gives you built-in tools to handle each of these cases differently, keeping your UI intact and your users informed.
No matter how carefully you write your code, things will go wrong. An API will go down, a user will submit unexpected input, or a bug will surface in production. Next.js error handling, especially with the App Router, gives you a structured way to deal with both the errors you expect and the ones that catch you off guard. In this article, I'll walk you through how to handle errors at every level of a Next.js application and show you how to integrate Honeybadger so that no error goes unnoticed.
Handling expected errors

Expected errors are the ones you can anticipate: a user submits a form without filling in the required fields, an API request returns a 404, or an authentication check fails. These aren't bugs. They're normal outcomes of how web applications work. The key principle in Next.js is to model expected errors as return values, not thrown exceptions. You return a value that describes what went wrong and let the UI respond accordingly.
Server Actions
Let's start with Server Actions. Imagine you have a form that lets users create a new blog post. The Server Action needs to validate the input and communicate with an API, and either step could fail:
// app/actions.ts
'use server'
export async function createPost(prevState: any, formData: FormData) {
const title = formData.get('title')
if (!title || typeof title !== 'string' || title.trim().length === 0) {
return { message: 'Title is required.' }
}
const res = await fetch('https://api.example.com/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: title.trim() }),
})
if (!res.ok) {
return { message: 'Failed to create post. Please try again.' }
}
// If we get here, everything worked
}
Instead of throwing an error, the function returns an object with a message property. On the client, you consume this with React's useActionState hook:
'use client'
import { useActionState } from 'react'
import { createPost } from './actions'
export function PostForm() {
const [state, formAction, pending] = useActionState(createPost, { message: '' })
return (
<form action={formAction}>
<label htmlFor="title">Post Title</label>
<input id="title" type="text" name="title" required />
<button type="submit" disabled={pending}>
{pending ? 'Creating...' : 'Create Post'}
</button>
{state?.message && (
<p role="alert" className="error">{state.message}</p>
)}
</form>
)
}
The hook gives you the current state (including the error message), the form action, and a pending boolean. When the Server Action returns an error, the component re-renders and the error message appears below the form. The user sees exactly what went wrong and can try again.
Server Components
In Server Components, you can check whether a request succeeded and conditionally render different UI:
export default async function PostPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const res = await fetch(`https://api.example.com/posts/${id}`)
if (!res.ok) {
return (
<div className="error-state">
<h2>Could not load this post</h2>
<p>The server returned an error. Please try again later.</p>
</div>
)
}
const post = await res.json()
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
)
}
You fetch data, check the response, and if it's not ok, you return a fallback UI instead of the normal content. This same pattern works when you fetch data in any Server Component.
Handling 404s with notFound()
Next.js provides a dedicated notFound() function from next/navigation for the 404 case. When you call it, Next.js stops rendering the current page and shows a 404 UI instead:
import { notFound } from 'next/navigation'
export default async function PostPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const post = await getPost(id)
if (!post) {
notFound()
}
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
)
}
By default, Next.js renders a generic 404 error page when notFound() is called. But you can create a custom error page by adding a not-found.tsx file in the same route segment. For example, if you place a not-found.tsx file inside app/blog/, it will render your custom error page whenever notFound() is called from any page within that route segment.
Handling uncaught exceptions
The other type of error is the ones you didn't see coming — a null reference, a third-party library throwing unexpectedly, or a network request failing in a way you didn't account for. These are actual bugs. Next.js handles them with error boundaries, a React concept where a component catches errors during rendering and displays a fallback UI instead of crashing the entire component tree. The App Router builds this directly into the file system routing convention.
The error.tsx convention
Create a file called error.tsx in any route segment. It must be a Client Component and receives two props: the error object and an unstable_retry function that lets the user try again (the unstable_ prefix means this API may change in a future release):
// app/dashboard/error.tsx
'use client'
export default function DashboardError({
error,
unstable_retry,
}: {
error: Error & { digest?: string }
unstable_retry: () => void
}) {
return (
<div className="error-container">
<h2>Something went wrong</h2>
<p>An unexpected error occurred while loading the dashboard.</p>
<button onClick={() => unstable_retry()}>Try again</button>
</div>
)
}
When an uncaught error occurs anywhere within the app/dashboard/ route segment or its children, Next.js will catch the error and render this fallback UI instead of crashing the page. The unstable_retry function re-renders the segment without a full page reload, which is handy for transient errors like network timeouts.
Note the digest property on the error object. When a server-side error occurs, Next.js replaces the original error message with a hash to avoid leaking sensitive details like database queries or file paths. The full error message stays in your server-side logs.
Nested error boundaries
Errors bubble upward through the route hierarchy until they hit the nearest error boundary, so you can be strategic about where you place your error.tsx files.

For example, imagine a dashboard with a sidebar and a main content area. By placing error.tsx at the right level, you can contain a crash to just the affected area while keeping the sidebar functional:
app/
dashboard/
error.tsx # Catches errors from all dashboard child routes
layout.tsx # Dashboard layout with sidebar (stays intact)
page.tsx # Main dashboard page
analytics/
error.tsx # Catches errors only in the analytics section
page.tsx
settings/
page.tsx # Errors here bubble up to dashboard/error.tsx
If the analytics page crashes, only that section shows the error UI — the sidebar stays intact. If settings crashes, the error bubbles up to dashboard/error.tsx since it doesn't have its own error boundary.
global-error.tsx
error.tsx can't catch errors in the root layout since it wraps everything. For that, Next.js provides app/global-error.tsx, which replaces the entire page when triggered and must define its own <html> and <body> tags:
// app/global-error.tsx
'use client'
export default function GlobalError({
error,
unstable_retry,
}: {
error: Error & { digest?: string }
unstable_retry: () => void
}) {
return (
<html>
<body>
<div className="global-error">
<h2>Something went wrong</h2>
<p>We encountered an unexpected error. Please try refreshing the page.</p>
<button onClick={() => unstable_retry()}>Try again</button>
</div>
</body>
</html>
)
}
In practice, errors in the root layout are rare, but having a global error page in place ensures your users never see a completely broken page.
Event handler errors
There's an important limitation to be aware of: error boundaries only catch errors that occur during rendering. If an error happens inside an event handler, like an onClick or onSubmit callback, the nearest error boundary won't catch it because event handlers run outside of React's rendering cycle.
For these cases, you need to catch errors manually with a try/catch block:
'use client'
import { useState } from 'react'
export function DeleteButton({ id }: { id: string }) {
const [error, setError] = useState<string | null>(null)
const handleDelete = async () => {
try {
const res = await fetch(`/api/posts/${id}`, { method: 'DELETE' })
if (!res.ok) {
setError('Failed to delete this post. Please try again.')
}
} catch (e) {
setError('Something went wrong. Please check your connection.')
}
}
return (
<div>
<button onClick={handleDelete}>Delete</button>
{error && <p role="alert" className="error">{error}</p>}
</div>
)
}
This is a common pattern in client-side error handling. One exception: if you use useTransition and throw an error inside startTransition, that error will bubble up to the nearest error boundary, even from an event handler.
Capturing and reporting Next.js errors with Honeybadger
Proper error handling in the UI is one thing, but in production, you also need to know when errors are happening, how often, and what's causing them. Users almost never report bugs, and digging through server-side logs is tedious at best.
Honeybadger's Next.js integration plugs into your application to automatically catch errors on both the server side and client side, group duplicates, and notify you with enough context to actually fix things.
Installation and setup
First, install the required packages:
npm install @honeybadger-io/react @honeybadger-io/nextjs
Then run the setup command to generate the configuration files:
npx honeybadger-copy-config-files
This creates configuration files for the server, client, and edge runtimes, as well as error.tsx and global-error.tsx files for the App Router. Next, add your API key to your environment variables:
NEXT_PUBLIC_HONEYBADGER_API_KEY=your_api_key
NEXT_PUBLIC_HONEYBADGER_REVISION=your_deployment_revision
Finally, wrap your Next.js config with Honeybadger's setup function to enable source map uploads and error reporting:
// next.config.js
const { setupHoneybadger } = require('@honeybadger-io/nextjs')
const nextConfig = {}
module.exports = setupHoneybadger(nextConfig)
Source maps are uploaded automatically, so stack traces in Honeybadger point to your original source code instead of minified bundles.
Wrapping your app with the error boundary
Wrap your application with Honeybadger's error boundary in your root layout so any unhandled React component error is automatically reported:
// app/layout.tsx
import { Honeybadger, HoneybadgerErrorBoundary } from '@honeybadger-io/react'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<HoneybadgerErrorBoundary honeybadger={Honeybadger}>
{children}
</HoneybadgerErrorBoundary>
</body>
</html>
)
}
Honeybadger will now capture uncaught client-side exceptions and React component errors. You can also add the showUserFeedbackFormOnError prop to show a feedback form when an error occurs, letting users describe what they were doing.
Manual error reporting
For errors you handle gracefully but still want to track, use Honeybadger.notify():
import { Honeybadger } from '@honeybadger-io/react'
try {
await submitOrder(orderData)
} catch (error) {
Honeybadger.notify(error)
return { message: 'Order failed. Please try again.' }
}
You can also attach context to help with debugging:
Honeybadger.setContext({
user_id: currentUser.id,
user_email: currentUser.email,
})
When an error shows up in Honeybadger's dashboard, you'll know exactly which user was affected and have a full stack trace pointing to the exact line of code.
Next.js error handling best practices
To recap, here's how I think about Next.js error handling.
For expected errors, return values instead of throwing. Use useActionState in Server Actions and conditional rendering in Server Components to handle errors gracefully. Use notFound() for missing resources instead of rolling your own 404 logic.
For uncaught exceptions, place error.tsx files at the right levels of your route hierarchy so a crash in one section doesn't take down the whole page. Add global-error.tsx as a safety net for root layout errors. And remember that error boundaries don't catch errors in event handlers, so you'll need try/catch there.
Finally, don't rely on users to tell you when things break. Set up error monitoring so you find out about production issues before your users do.
That covers the full picture of error handling in Next.js, from data fetching and form validation to production error management. If you want to try Honeybadger with your Next.js project, you can sign up for a free trial and have error reporting running in a few minutes.
Written by
Farhan Hasin ChowdhurySoftware developer with a knack for learning new things and writing about them.