Skip to main content

Error handling in Next.js

In the previous documents we built the login with a session (Sessions) and protected routes (Protected routes), but we almost always assumed everything goes well. In a real application things fail all the time: the user gets the password wrong, the data service goes down, someone opens a routine that does not exist. What matters is that every failure reaches the screen in a controlled way, with a message the user understands, without breaking the rest of the page.

This document answers three questions:

  1. What kinds of error are there and which mechanism fits each one?
  2. How does an error travel through the login chain (LoginForm → hook → thunk → Server Action → use case)?
  3. What does Next.js do when an error happens while rendering a page (error.tsx, global-error.tsx, not-found.tsx)?

It follows the official Next.js error handling documentation, applied to the course architecture.


1. Two kinds of error​

The Next.js guide splits errors into two groups, and each group is handled differently:

ExpectedUnexpected
What they areForeseen failures: form validation, wrong credentials, a resource that does not exist.Bugs or external failures: an exception in the code, a service that is down, the network.
How they are handledThey are returned as a value and shown in the interface.They are thrown (throw) and caught by an error boundary or a try/catch.
Examplereturn { ok: false, message: 'Credenciales inválidas' }The backend responds 500 and the datasource throws an exception.

There is also a third group: functions that throw on purpose to control the flow (redirect(), notFound(), unauthorized(), forbidden()). They are not program errors: they are commands that Next.js understands and turns into a redirect or a special page.

Pick what failed in your routines application and see which mechanism applies, where it happens and what the user sees:

Error handling in Next.js: expected, unexpected and helpers such as redirect and notFound

The practical rule that the piece summarizes:

SituationMechanismResult
Validation or wrong credentialsreturn { ok: false, message } in the actionThe thunk turns it into rejectWithValue and the form shows errorMessage.
A bug or a service that is down while rendering a pageAn exception (throw)The segment's error.tsx, with retry().
Failure of an action called from a thunktry/catch in the thunkerrorMessage in the store.
No sessionredirect('/login')Navigates to the login page.
No session, with its own page (experimental)unauthorized()unauthorized.tsx, status 401.
No permission (experimental)forbidden()forbidden.tsx, status 403.
The resource does not existnotFound()not-found.tsx, status 404.
Failure in an onClicktry/catch and useStateA boundary does not catch it (nor asynchronous code).

2. Expected errors: return them as a value​

A mistyped email is not a program failure: it is something you knew could happen. For those situations the Server Action does not throw an exception: it returns an object that describes the result. That is why loginAction returns { ok: true, user } or { ok: false, message }.

src/features/auth/presentation/actions/auth.actions.ts (excerpt)
const tokens = await authContainer.login.execute(parsed.data.email, parsed.data.password)
if (!tokens) {
return { ok: false, message: 'Credenciales inválidas' } // expected: it is returned
}

Why not a throw? There are two reasons:

  • The message would be lost. When a server function throws an exception, Next.js in production hides the error text from the client (for security, so internal details do not leak) and only sends an identifier. What does travel intact is a returned value.
  • It is the normal flow, not an emergency. The user got the password wrong: they should see the message next to the form and be able to try again, not an error screen.

That distinction starts in the datasource. When the backend responds, not all codes are equal:

Backend responseTypeWhat the datasource does
401 (invalid credentials)ExpectedReturns null; the action responds { ok: false, message }.
500, or no responseUnexpectedThrows an error (throw new Error('El backend no respondió')).

In our architecture the returned value follows a fixed path to the interface:

StepWhat happens
loginActionReturns { ok: false, message }.
Thunk loginMutationSees ok: false and calls rejectWithValue(message).
SliceOn rejected, stores the text in state.errorMessage.
useAuth()Exposes errorMessage.
LoginFormShows it with {errorMessage && <p role="alert">…</p>}.
How the official guide would do it

With <form action> and useActionState, the action receives (prevState, formData), returns { message } and the component reads it from state: const [state, action, pending] = useActionState(login, initialState). The principle is the same (the expected error is a value); only who stores it changes: the component instead of the slice.

The message's role="alert" attribute makes screen readers announce it as soon as it appears. It is part of showing an error well: that everyone perceives it.


3. The journey of an error in your login​

An expected error has a clear path. But what happens when something fails that nobody foresaw? Pick a failure with the "Qué falla" (What fails) selector and follow its journey, from right to left, to the message Ana reads.

The journey of an error in the login: invalid credentials, backend down and network down

What the piece shows:

  • The three cases end in the same place: the form message. None reaches error.tsx.
  • The thunk is the place to catch the unexpected. Asynchronous code (a thunk, an onClick) is not covered by any error boundary: boundaries only catch errors during rendering. That is why the thunk has a try/catch.
  • createAsyncThunk already catches exceptions for you: if the thunk throws, Redux Toolkit dispatches the rejected action with the error. The explicit try/catch is for choosing your own message instead of a technical one.
  • What the user sees and what you see are different. An unexpected error should show Ana a generic message ("No se pudo iniciar sesión. Intenta de nuevo.") and leave the real detail in the server logs.
src/features/auth/presentation/store/auth.slice.ts (excerpt)
export const loginMutation = createAsyncThunk(
'auth/login',
async (params: { email: string; password: string }, { rejectWithValue }) => {
try {
const result = await loginAction(params.email, params.password)
if (!result.ok) return rejectWithValue(result.message) // expected: it arrived as a value
return result.user
} catch {
return rejectWithValue('No se pudo iniciar sesión. Intenta de nuevo.') // unexpected
}
}
)

How to read it: line 6 handles the foreseen failure (the action responded that something was wrong). The catch block handles the unforeseen one (the call to the action failed: server down, lost network, uncaught exception). In both cases the result is the same for the user: an errorMessage.

An onClick is not protected either

The same goes for any event handler: if it fails, no boundary sees it. Use try/catch and store the error in the state (setError(e.message)). The exception is startTransition: its uncaught errors do bubble up to the nearest error boundary.


4. Unexpected errors while rendering: error.tsx​

Everything above happened in code that calls something. But what if a page fails while it renders? For example, RoutinesPage requests the routines, the datasource throws an exception and the page cannot be drawn. That is where Next.js's error boundary comes in: the error.tsx file.

An error.tsx is a client component that Next.js shows instead of the part of the screen that failed. The layouts above it stay visible: the user does not lose the menu or the rest of the page.

app/routines/error.tsx
'use client'

import { useEffect } from 'react'

export default function Error({
error,
retry,
}: {
error: Error & { digest?: string }
retry: () => void
}) {
useEffect(() => {
console.error(error) // the error logging would go here
}, [error])

return (
<div role="alert" className="flex flex-col gap-3">
<h2>No pudimos cargar tus rutinas</h2>
<button className="btn" onClick={() => retry()}>Reintentar</button>
</div>
)
}

How to read it: it must be 'use client' (error boundaries are client components). It receives the error and a retry() function that tries to render the segment again. The useEffect is the place to log the error.

Which error.tsx is shown?​

Each app/ folder can have its own error.tsx. What matters is which one catches what: an error.tsx wraps the page and the routes below it, but not the layout.tsx of its own folder (the layout is outside it). Pick where the error is thrown and see which one catches it:

Which error.tsx catches an error depending on where it is thrown
Thrown in…Caught byWhat the user sees
A segment's pageThat segment's error.tsxThe layouts stay on screen; only the page is replaced.
A segment's layoutThe error.tsx of the folder aboveThat segment's layout and everything it contains is replaced.
The root layout (app/layout.tsx)app/global-error.tsxThe whole page is replaced.
The error.tsx itselfThe error.tsx of the upper levelAn error boundary does not catch itself.
A segment without error.tsxThe nearest one upwardsThe error "bubbles up".

There is a special file for the root layout. Since it replaces the whole page, it must include its own <html> and <body> tags:

app/global-error.tsx
'use client'

export default function GlobalError({ retry }: { error: Error; retry: () => void }) {
return (
<html>
<body>
<h2>Algo salió mal</h2>
<button onClick={() => retry()}>Reintentar</button>
</body>
</html>
)
}
retry or reset

In earlier versions of Next.js the error.tsx prop was called reset; the current documentation uses retry. If your project uses another version, check which one it receives.

A fallback inside a component

If you only want to protect a part of a page (a widget), without creating a segment, Next.js offers catchError (from next/error): it wraps a component and defines its own fallback.


5. Errors thrown on purpose​

Some situations are not "errors" but flow decisions: there is no session, you have no permission, the resource does not exist. Next.js offers functions that throw a special exception and turn it into the right response. Each one has a convention file to customize what the user sees:

FunctionWhenResultFile
redirect('/login')There is no session (or any redirect).Navigates to another route.—
notFound()The resource does not exist.Status 404.not-found.tsx
unauthorized()There is no session and you want your own page.Status 401.unauthorized.tsx
forbidden()There is a session but no permission.Status 403.forbidden.tsx

unauthorized() and forbidden() are experimental: they must be enabled in next.config.ts.

next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
experimental: {
authInterrupts: true,
},
}

export default nextConfig

Example with the routine that does not exist and the one that belongs to another user:

app/routines/[id]/page.tsx
import { notFound, forbidden } from 'next/navigation'
import { requireSession } from '@/lib/dal'

export default async function RoutinePage({ params }: { params: Promise<{ id: string }> }) {
const { userId } = await requireSession() // no session: redirect('/login')
const { id } = await params

const routine = await getRoutine(id)
if (!routine) notFound() // does not exist: 404
if (routine.ownerId !== userId) forbidden() // belongs to someone else: 403

return <RoutineView routine={routine} />
}
app/routines/[id]/not-found.tsx
export default function NotFound() {
return <p>No encontramos esa rutina.</p>
}
Do not put them in a try/catch

redirect(), notFound(), unauthorized() and forbidden() work by throwing an exception. If you call them inside a try/catch, the catch swallows them and neither the redirect happens nor the page appears. Call them outside the try, or rethrow the error with unstable_rethrow(error) (from next/navigation). Also do not write return notFound(): the function never returns anything, it stops execution.


6. What the user sees and what you see​

Good error handling separates two audiences:

Message to the userLog for you
Expected errorSpecific and actionable: "Credenciales inválidas", "La contraseña debe tener al menos 6 caracteres".Usually not needed.
Unexpected errorGeneric: "No se pudo iniciar sesión. Intenta de nuevo." Never the text of an exception.The full detail: what failed and where.
SecurityIt must not reveal information: do not say "that email does not exist" (it tells an attacker which emails are registered).You can log the attempt.

In production, Next.js already helps you: the message of an error thrown on the server is replaced with a generic one and a digest (an identifier) is attached. That same digest appears in the server logs, so you can relate what the user saw to what happened. In error.tsx you receive it in error.digest.


Summary​

I want to…I use
Show "invalid credentials" in the formreturn { ok: false, message } + rejectWithValue + errorMessage.
Keep an unexpected failure from breaking the formtry/catch in the thunk.
Make a failure while rendering show an error screenerror.tsx in the segment (and global-error.tsx for the root layout).
Redirect someone without a sessionredirect('/login') in the DAL.
Show my own 404 / 401 / 403 pagenotFound() / unauthorized() / forbidden() + its file.
Keep an onClick error from passing silentlytry/catch and setError(...).