Módulo 14: Errores y estados especiales
Objetivos del módulo
- Distinguir los errores esperados (un empleado que no existe, un formulario mal rellenado) de los inesperados (la API se cae, un fallo de programación), y tratar cada uno con la herramienta adecuada
- Entender cómo envuelve Next.js cada segmento de ruta con
layout,template,error,loading,not-foundypage, y qué captura cada fichero - Escribir límites de error con
error.tsxy saber qué props recibe de verdad en Next.js 16.4:error,retryyreset - Saber por qué
retry()recupera una página que falló en el servidor yreset()no - Leer el
digestde un error y encontrar con él el error completo en el registro del servidor - Capturar los fallos del layout raíz con
global-error.tsx - Poner límites de error dentro de una página, alrededor de un solo componente, con
catchError - Responder con páginas de “no encontrado”, “no autorizado” y “prohibido” mediante
notFound(),unauthorized()yforbidden(), y conocer el código HTTP real que sale en cada caso - Tratar los errores de Server Actions, Route Handlers y del propio navegador
- Registrar todos los errores del servidor en un único sitio con
instrumentation.tsyonRequestError
💡 Consejo: Hasta ahora casi todos los ejemplos del curso funcionaban a la primera: la API respondía, los empleados existían y los formularios llegaban bien rellenados. En una aplicación real nada de eso está garantizado. Este módulo reúne en un solo sitio todo lo que ofrece Next.js para cuando algo sale mal, y lo aplica a la aplicación EMPRESA. Todo el código se ha compilado y ejecutado con Next.js 16.4 y la API EMPRESA; las alternativas para Next.js 15 se han probado con 15.5.
1. Dos clases de errores
Antes de escribir un solo fichero conviene separar dos situaciones que en el código se parecen mucho (algo “falla”) pero que piden respuestas muy distintas.
| Error esperado | Error inesperado | |
|---|---|---|
| Ejemplos | El empleado 9999 no existe. El nombre del departamento tiene dos letras. El usuario no ha iniciado sesión | La API no responde. Una variable vale undefined donde no debía. La base de datos rechaza la conexión |
| ¿Es culpa de un fallo del programa? | No: forma parte del funcionamiento normal | Sí, o de algo externo que debería funcionar |
| Cómo se trata | Se comprueba y se responde con una interfaz pensada para ese caso | Se lanza (throw) y lo recoge un límite de error |
| Herramientas de Next.js | notFound(), forbidden(), unauthorized(), valores de retorno en Server Actions, redirect() | error.tsx, global-error.tsx, catchError, onRequestError |
| ¿Se registra como error? | No hace falta | Sí, siempre |
📘 Concepto: Un error esperado es un resultado posible de la operación, igual de legítimo que el éxito: buscar un empleado puede dar “encontrado” o “no existe”. Un error inesperado es una excepción en el sentido literal: algo que no debería haber pasado y que alguien tiene que investigar.
La regla práctica que sigue la documentación de Next.js, y que seguirá este módulo, es esta: los errores esperados se devuelven como valores; los inesperados se lanzan. Lanzar un error para algo que sabías que podía ocurrir obliga a capturarlo en otro sitio, lejos de donde tienes la información para mostrar un buen mensaje. Y al revés: intentar envolver cada línea en try/catch para que nunca salte nada esconde los fallos de verdad.
⚠️ Importante:
notFound(),forbidden(),unauthorized()yredirect()sí “lanzan” algo por dentro, pero no son errores: son interrupciones que Next.js reconoce y convierte en una respuesta concreta. Lo verás en el apartado 7, y también qué pasa si las capturas sin querer con untry/catch.
Si vienes del Curso de React, el mecanismo de fondo te sonará: los ficheros error.tsx son error boundaries de React, y loading.tsx es un <Suspense>. Next.js no inventa nada nuevo, sino que los coloca automáticamente en cada segmento de ruta. Puedes repasar los límites de error en el Curso de React.
2. Los ficheros especiales y cómo se anidan
En el módulo 03 viste que cada carpeta de app/ es un segmento y que layout.tsx y page.tsx son ficheros con un significado especial. Hay más ficheros con nombre reservado, y casi todos tienen que ver con este módulo:
| Fichero | Qué muestra | Cuándo |
|---|---|---|
loading.tsx | Un esqueleto o aviso de carga | Mientras la página espera datos |
error.tsx | Una interfaz de error con botón de reintentar | Cuando algo lanza un error al renderizar |
not-found.tsx | Una página de “no existe” | Cuando se llama a notFound() o (en la raíz) cuando la URL no casa con ninguna ruta |
forbidden.tsx | Una página de “no tienes permiso” | Cuando se llama a forbidden() (experimental) |
unauthorized.tsx | Una página de “inicia sesión” | Cuando se llama a unauthorized() (experimental) |
global-error.tsx | Una página completa de error | Cuando falla el propio layout raíz |
Lo importante no es la lista, sino cómo los anida Next.js. Para cada segmento, construye este árbol de componentes:
<Layout>
<Template>
<ErrorBoundary fallback={<Error />}>
<Suspense fallback={<Loading />}>
<NotFoundBoundary fallback={<NotFound />}> {/* y forbidden, unauthorized */}
<Page />
</NotFoundBoundary>
</Suspense>
</ErrorBoundary>
</Template>
</Layout>
Figura 1. Cómo envuelve Next.js los ficheros especiales de un segmento. El límite de error queda por dentro del layout y del template del mismo segmento.
De ese árbol se deducen tres reglas que explican casi todas las dudas del tema:
- Un
error.tsxcaptura lo que hay por debajo de él: la página, elloading.tsx, elnot-found.tsxy todos los segmentos hijos que no tengan su propioerror.tsx. - Un
error.tsxno captura el layout de su misma carpeta, porque el layout está por fuera. Si fallaapp/empleados/layout.tsx, el error sube alerror.tsxdeapp/. - Los errores suben hasta el límite más cercano. Puedes poner un
error.tsxgenérico en la raíz y otros más específicos donde te interese.
Si un segmento hijo tiene su propio layout, ese layout se coloca en el lugar de <Page /> y repite toda la estructura dentro. Así, el error.tsx de app/empleados/[id]/ queda dentro del layout de app/empleados/: cuando falla la ficha de un empleado, la cabecera de la sección sigue en pantalla.
3. error.tsx: el límite de error de un segmento
3.1. El primer límite de error
Partimos de la ficha de empleado que construiste en el módulo 04. Su capa de datos distingue ya los dos tipos de error: el 404 de la API es un caso esperado (devuelve null), y cualquier otro fallo es inesperado (lanza).
// app/_lib/api.ts
import 'server-only'
const API = process.env.API_URL ?? 'http://localhost:3000/api'
export type Empleado = {
empId: number
nombre: string
apellidos: string
email: string
oficio: string
salario: number
deptId: number | null
departamento: string | null
}
// Error propio para fallos de la API: lleva el código HTTP.
export class ErrorApi extends Error {
constructor(
public readonly estado: number,
mensaje: string,
) {
super(mensaje)
this.name = 'ErrorApi'
}
}
// Devuelve null si el empleado no existe (404 es un caso esperado)
// y lanza ErrorApi si la API falla (caso inesperado).
export async function obtenerEmpleado(id: number): Promise<Empleado | null> {
const res = await fetch(`${API}/empleados/${id}`)
if (res.status === 404) return null
if (!res.ok) {
throw new ErrorApi(res.status, `La API respondió ${res.status} al pedir el empleado ${id}`)
}
return res.json()
}
La página valida el identificador, pide el empleado y, para poder probar el límite de error sin tirar la API, admite un parámetro ?fallo=1 que simula una avería:
// app/empleados/[id]/page.tsx
import { notFound, unstable_rethrow } from 'next/navigation'
import { obtenerEmpleado, type Empleado } from '@/app/_lib/api'
export default async function PaginaEmpleado({
params,
searchParams,
}: PageProps<'/empleados/[id]'>) {
const { id } = await params
const { fallo } = await searchParams
const n = Number(id)
if (!Number.isInteger(n) || n <= 0) {
notFound() // un identificador mal formado tampoco existe
}
let empleado: Empleado | null
try {
if (fallo === '1') {
throw new Error(`Fallo simulado al leer el empleado ${n}`)
}
empleado = await obtenerEmpleado(n)
if (!empleado) notFound()
} catch (e) {
// notFound() lanza un error especial: hay que dejarlo pasar
unstable_rethrow(e)
throw new Error(`No se pudo cargar el empleado ${n}`, { cause: e })
}
return (
<article className="rounded border bg-white p-4">
<h2 className="text-lg font-semibold">
{empleado.nombre} {empleado.apellidos}
</h2>
<p>{empleado.oficio}</p>
<p>Departamento: {empleado.departamento ?? 'sin departamento'}</p>
</article>
)
}
El try/catch tiene un propósito: añadir contexto al error (“No se pudo cargar el empleado 100”) y conservar el original en cause, que es una opción estándar del constructor Error de JavaScript. El papel de unstable_rethrow lo verás en el apartado 7.3.
Y este es el límite de error, en la misma carpeta:
// app/empleados/[id]/error.tsx
'use client' // los límites de error son Client Components
import { useEffect } from 'react'
export default function ErrorEmpleado(props: {
error: unknown
retry: () => void
reset: () => void
}) {
const { error, retry } = props
useEffect(() => {
console.error('[error.tsx]', error)
}, [error])
const mensaje = error instanceof Error ? error.message : String(error)
const digest =
error instanceof Error && 'digest' in error ? String(error.digest) : 'sin digest'
return (
<div role="alert" className="rounded border border-red-300 bg-red-50 p-4">
<h2 className="font-semibold text-red-800">No se ha podido mostrar el empleado</h2>
<p className="text-sm" data-mensaje>{mensaje}</p>
<p className="text-xs text-slate-500" data-digest>Referencia: {digest}</p>
<p className="text-xs text-slate-500" data-props>
Props recibidas: {Object.keys(props).join(', ')}
</p>
<button
onClick={() => retry()}
className="mt-3 rounded bg-red-700 px-3 py-1 text-white"
>
Reintentar
</button>
</div>
)
}
Fíjate en cuatro cosas:
'use client'es obligatorio. Un límite de error tiene que poder reaccionar a lo que pasa en el navegador (pulsar “Reintentar”, errores al hidratar), así que es un Client Component. Si lo olvidas,next buildfalla conerror.tsx must be a Client Component.- El componente se exporta por defecto y recibe sus datos por props; no lo usas tú, lo coloca Next.js.
errores de tipounknown. En JavaScript se puede lanzar cualquier cosa (throw 'texto',throw 42), así que antes de leermessagehay que comprobar que es unError.- El
useEffectregistra el error en la consola del navegador. Es el sitio donde, en una aplicación real, lo enviarías a un servicio de monitorización desde el cliente.
He añadido una línea que enseña los nombres de las props recibidas, para comprobar qué entrega Next.js de verdad. Esto es lo que sale al abrir http://localhost:3001/empleados/100?fallo=1 con la aplicación compilada (next build y next start):

Figura 2. El error.tsx de la ficha de empleado en producción. La cabecera “Empleados” pertenece al layout de app/empleados/ y sigue visible.
Hay tres detalles en esa captura que merecen apartado propio: las props (error, reset, retry), el mensaje (que no es el que lanzaste) y la referencia.
3.2. Qué props recibe error.tsx 🔄 16.3
🔄 Cambia en Next.js 16.3. En Next.js 15,
error.tsxrecibe soloerror(tipado comoError & { digest?: string }) yreset, que no vuelve a pedir los datos al servidor. Next.js 16.2 añadióunstable_retry, y 16.3 lo estabilizó comoretry. Si usas Next.js 15: para reintentar de verdad, llama dentro destartTransitionarouter.refresh()y después areset()(apartado 4.3).
En Next.js 16.4 los tipos instalados (next/dist/client/components/error-boundary.d.ts) lo dejan claro:
export type ErrorInfo = {
error: unknown;
reset: () => void;
retry: () => void;
};
Y en ejecución, como se ve en la figura 2, llegan exactamente esas tres: error, reset, retry. En Next.js 15.5 los tipos son otros:
// Next.js 15.5
export type ErrorComponent = React.ComponentType<{
error: Error;
reset?: () => void;
}>;
| Next.js 15 | Next.js 16.4 | |
|---|---|---|
error | Error & { digest?: string } | unknown |
reset | Sí | Sí, pero ya no es la opción recomendada |
retry | No existe | Sí (estable desde 16.3) |
⚠️ Importante: Mucho material de internet, y muchas plantillas, usan todavía la firma de Next.js 15:
({ error, reset }: { error: Error & { digest?: string }; reset: () => void }). En Next.js 16.4 sigue compilando y funcionando (la propresetexiste), pero el botón “Reintentar” no recuperará una página que falló en el servidor. Si copias un ejemplo, cambiaresetporretry.
¿Por qué cambió el tipo de error a unknown? Porque era una mentira cómoda. Un error lanzado en un Server Component llega siempre como Error, pero un Client Component puede lanzar cualquier valor, y ese valor llega tal cual. La documentación de 16.4 lo explica así y pide que compruebes el tipo con instanceof Error antes de leer message o digest. Es lo que hace el ejemplo.
3.3. Por qué el mensaje no es el que lanzaste
La página lanzó "No se pudo cargar el empleado 100", pero el navegador recibió otra cosa:
Minified React error #441; visit https://react.dev/errors/441 for the full message
or use the non-minified dev environment for full errors and additional helpful warnings.
No es un fallo: es una medida de seguridad. El mensaje de un error de servidor puede contener cualquier cosa (una consulta SQL, una ruta interna, un dato personal), así que en producción Next.js no envía al navegador el mensaje de los errores de Server Components. Envía un sustituto genérico. Ese sustituto, en la versión del servidor del cliente RSC de React, dice literalmente:
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.
En el navegador el texto está además “minimizado”: React sustituye sus mensajes por un número (aquí el 441) para ahorrar bytes, y por eso ves Minified React error #441. Lo he comprobado en el código de React incluido en Next.js 16.4: la función que crea el error de servidor en producción (resolveErrorProd) construye exactamente ese error 441.
En desarrollo (next dev) el comportamiento cambia: el error llega con su mensaje original para que puedas depurar.
| Origen del error | En desarrollo | En producción |
|---|---|---|
| Server Component, Server Action | Mensaje original | Mensaje genérico (React #441) y digest |
| Client Component | Mensaje original | Mensaje original |
La consecuencia práctica es importante: nunca muestres error.message al usuario esperando que sea un texto útil. Escribe en error.tsx un mensaje propio (“No se ha podido mostrar el empleado”) y, como mucho, la referencia.
3.4. El digest: la referencia que une cliente y servidor
Lo que sí viaja al navegador es el digest: un número que Next.js calcula a partir del error. El mismo número aparece en el registro del servidor. Esto es lo que escribió next start en la terminal durante la petición de la figura 2:
⨯ Error: No se pudo cargar el empleado 100
at g (C:\...\empresa\.next\server\chunks\ssr\[root-of-the-server]__0cxh0my1spg3m._.js:1:765) {
digest: '4199489154',
[cause]: Error: Fallo simulado al leer el empleado 100
at g (C:\...\empresa\.next\server\chunks\ssr\[root-of-the-server]__0cxh0my1spg3m._.js:1:643)
}
El usuario ve “Referencia: 4199489154”, te lo dice por teléfono o lo pega en una incidencia, y tú buscas ese número en el registro: ahí está el mensaje completo, la pila y la causa original ([cause]). Es el mismo sistema que usan los bancos o las administraciones cuando una web te muestra “código de error XXXX”.
Figura 3. Viaje de un error de Server Component en producción. El mensaje se queda en el servidor; al navegador solo llega el digest.
💡 Consejo: El
digestes más útil todavía si los errores se guardan en un sitio consultable y no solo en la terminal. Eso lo resuelveonRequestError, en el apartado 11.
3.5. Dónde poner los error.tsx
Como los errores suben al límite más cercano, la estrategia habitual es por capas:
app/
├── error.tsx ← límite genérico: "Algo ha ido mal"
├── global-error.tsx ← último recurso: falla el layout raíz
├── empleados/
│ ├── layout.tsx ← queda fuera del error.tsx de [id]
│ └── [id]/
│ ├── error.tsx ← límite específico: "No se ha podido mostrar el empleado"
│ ├── loading.tsx
│ ├── not-found.tsx
│ └── page.tsx
└── acciones/
└── page.tsx ← sin error.tsx: sus fallos van al de app/
Este es el límite genérico de la raíz que usa el resto del módulo:
// app/error.tsx
'use client'
import Link from 'next/link'
// Límite de error de la raíz: cubre todas las páginas,
// pero NO el layout raíz (para eso está global-error.tsx).
export default function ErrorRaiz({ error, retry }: { error: unknown; retry: () => void }) {
const digest = error instanceof Error && 'digest' in error ? String(error.digest) : null
return (
<div role="alert" className="rounded border border-red-300 bg-red-50 p-4">
<h2 className="font-semibold text-red-800">Algo ha ido mal</h2>
<p className="text-sm" data-mensaje>
{error instanceof Error ? error.message : String(error)}
</p>
{digest && <p className="text-xs text-slate-500">Referencia: {digest}</p>}
<div className="mt-3 flex gap-3">
<button onClick={() => retry()} className="rounded bg-red-700 px-3 py-1 text-white">
Reintentar
</button>
<Link href="/" className="py-1 text-indigo-700 underline">Ir al inicio</Link>
</div>
</div>
)
}
Un límite que muestra un enlace al inicio, además del botón de reintentar, evita que el usuario quede atrapado si el error se repite. Y como el error.tsx de la raíz está dentro del layout raíz, la cabecera y el menú siguen funcionando: el usuario puede irse a otra sección.
💡 Consejo: Si un
error.tsxconcreto no sabe qué hacer con un error, puede volver a lanzarlo (throw error) durante su render, y el error subirá al límite padre. La documentación lo menciona como forma de “dejar pasar” errores.
4. retry() frente a reset()
4.1. El experimento
Para ver la diferencia hace falta una página que falle unas veces sí y otras no, como un servicio que se cae a ratos. Esta página usa un contador en memoria del servidor: falla en los intentos impares y funciona en los pares.
// app/inestable/page.tsx
import { connection } from 'next/server'
// Contador en memoria del servidor: simula un servicio que falla
// en los intentos impares y responde bien en los pares.
let intentos = 0
export default async function PaginaInestable() {
await connection() // se renderiza en cada petición
intentos++
if (intentos % 2 === 1) {
throw new Error(`El servicio de nóminas no responde (intento ${intentos})`)
}
return (
<p className="rounded border border-green-300 bg-green-50 p-4" data-ok>
Nóminas cargadas en el intento {intentos}
</p>
)
}
connection() (lo viste en el módulo 09) indica que la página depende de la petición y no se puede prerenderizar: sin él, next build intentaría generarla una vez y el contador no tendría sentido. El loading.tsx de la carpeta muestra “Cargando nóminas…” mientras tanto.
El límite de error ofrece los dos botones:
// app/inestable/error.tsx
'use client'
import { useState } from 'react'
export default function ErrorInestable({
error,
retry,
reset,
}: {
error: unknown
retry: () => void
reset: () => void
}) {
const [pulsaciones, setPulsaciones] = useState(0)
const mensaje = error instanceof Error ? error.message : String(error)
return (
<div role="alert" className="space-y-2 rounded border border-red-300 bg-red-50 p-4">
<p data-mensaje>{mensaje}</p>
<p className="text-xs text-slate-500">Botones pulsados: {pulsaciones}</p>
<div className="flex gap-2">
<button
data-retry
onClick={() => {
setPulsaciones((p) => p + 1)
retry()
}}
className="rounded bg-red-700 px-3 py-1 text-white"
>
retry()
</button>
<button
data-reset
onClick={() => {
setPulsaciones((p) => p + 1)
reset()
}}
className="rounded border border-red-700 px-3 py-1 text-red-700"
>
reset()
</button>
</div>
</div>
)
}
He automatizado la prueba con Playwright sobre la aplicación compilada: cargar la página cuando toca un intento impar, pulsar reset() y después retry(). Esto es lo que había en pantalla en cada paso:

Figura 4. La página inestable en un intento impar: el límite de error ofrece los dos botones.
inicial: Minified React error #441; ... Botones pulsados: 0 retry() reset()
tras reset: Minified React error #441; ... Botones pulsados: 0 retry() reset()
tras retry: Nóminas cargadas en el intento 4

Figura 5. Tras pulsar retry(), Next.js vuelve a pedir la página al servidor (intento 4, par) y el límite de error se sustituye por el resultado.
4.2. Qué ha pasado por dentro
reset()borra el estado de error del límite y vuelve a renderizar sus hijos con lo que ya tenía en el navegador. Pero lo que tenía era el resultado del servidor, y ese resultado era el error. Así que el hijo vuelve a fallar al instante y el límite reaparece. El contador de pulsaciones vuelve a 0 porque el componente de error se desmonta y se monta de nuevo. El servidor ni se entera: el contadorintentosno avanza.retry()hace lo que esperarías de un botón “Reintentar”: vuelve a pedir al servidor el contenido del segmento y lo renderiza dentro de una transición de React. Si esta vez va bien, el resultado sustituye al error. Por eso el texto final dice “intento 4”: el 3 falló,reset()no hizo ninguna petición yretry()hizo la cuarta.
reset() | retry() | |
|---|---|---|
| ¿Pide datos al servidor? | No | Sí |
| ¿Recupera un error de Server Component? | No | Sí, si la causa ha desaparecido |
| ¿Recupera un error de Client Component? | Sí, si el estado que lo provocó ha cambiado | Sí |
| Cuándo usarlo | Casi nunca: errores puramente de cliente | Por defecto |
📘 Concepto: Un error de Server Component no está “en el navegador”: está en la respuesta que mandó el servidor. Para salir de él hay que pedir una respuesta nueva. Ese es todo el motivo de que exista
retry.
4.3. Reintentar de verdad en Next.js 15 🔄 16.3
🔄 Cambia en Next.js 16.3. En Next.js 15 no existe
retryyreset()no vuelve a pedir datos. Si usas Next.js 15: combinarouter.refresh()(que pide de nuevo al servidor los Server Components de la ruta) conreset()dentro de un mismostartTransition.
// app/fallo/error.tsx (Next.js 15)
'use client'
import { startTransition } from 'react'
import { useRouter } from 'next/navigation'
// Next.js 15: error es Error & { digest?: string } y solo llega reset.
export default function ErrorFallo(props: {
error: Error & { digest?: string }
reset: () => void
}) {
const { error, reset } = props
const router = useRouter()
// Equivalente a retry() en Next.js 15: pedir de nuevo los datos del
// servidor y, en la misma transición, limpiar el estado de error.
function reintentar() {
startTransition(() => {
router.refresh()
reset()
})
}
return (
<div role="alert">
<p data-mensaje>{error.message}</p>
<p data-props>Props recibidas: {Object.keys(props).join(', ')}</p>
<button data-reset onClick={() => reset()}>reset()</button>
<button data-reintentar onClick={reintentar}>refresh() + reset()</button>
</div>
)
}
Probado con Next.js 15.5 compilado, con una página que falla en los intentos impares, igual que la de 16.4. El navegador mostró esto:
inicial: An error occurred in the Server Components render. The specific message is
omitted in production builds ... Props recibidas: error, reset
tras reset: (el mismo error)
tras refresh+reset: Nóminas cargadas en el intento 2
Tres cosas confirmadas: en 15 solo llegan error y reset; reset() a secas tampoco recupera el error; y router.refresh() más reset() en la misma transición sí lo hace. Fíjate también en el mensaje: Next.js 15.5 muestra en el navegador el texto genérico completo en inglés, mientras que 16.4 muestra su versión minimizada (React #441). Significan lo mismo.
5. Errores que rompen el build
Un límite de error protege al usuario cuando el fallo ocurre al servir la página. Pero con Cache Components, next build prerenderiza todo lo que puede (módulo 02), y si algo lanza un error durante ese prerender, no hay usuario al que mostrarle nada: el build falla.
Lo he comprobado con el panel del apartado 6. La primera versión tenía un Client Component que fallaba al no recibir datos, envuelto en su límite de error, sin nada dinámico alrededor:
Error occurred prerendering page "/panel". Read more: https://nextjs.org/docs/messages/prerender-error
Error: El gráfico necesita al menos un dato
at <unknown> (app\panel\grafico-ventas.tsx:6:11)
4 | export default function GraficoVentas({ datos }: { datos: number[] }) {
5 | if (datos.length === 0) {
> 6 | throw new Error('El gráfico necesita al menos un dato')
| ^
...
Export encountered an error on /panel/page: /panel, exiting the build.
⨯ Next.js build worker exited with code: 1 and signal: null
Y lo mismo con un Route Handler GET que no lee nada de la petición, porque también se prerenderiza:
Error occurred prerendering page "/api/roto". Read more: https://nextjs.org/docs/messages/prerender-error
Error: Fallo en el Route Handler de informes
Export encountered an error on /api/roto/route: /api/roto, exiting the build.
Es un comportamiento deliberado y sensato: si el fallo ocurre al compilar, ocurriría igual para todos los usuarios, así que es mejor enterarte en el build que en producción. Las soluciones son dos:
- Si el error es real, arréglalo. Es lo normal: un componente que falla siempre con los mismos datos es un fallo de programación.
- Si el contenido debe calcularse en cada petición, márcalo como dinámico (
await connection(), leercookies()oparams, etc.) dentro de un<Suspense>. Entonces no se prerenderiza y el error, si ocurre, llega al límite de error en tiempo de ejecución.
⚠️ Importante: Lo mismo pasa con la API: si una página prerenderiza datos de la API EMPRESA y la API está apagada al hacer
next build, el build falla. No es un problema de los límites de error, sino de cuándo se ejecuta el código.
6. Límites de error dentro de una página: catchError 🆕 16.3
🆕 Desde Next.js 16.3. No existe en Next.js 15 (en 16.2 era
unstable_catchError). Si usas Next.js 15: escribe un límite de error de React como componente de clase congetDerivedStateFromError(apartado 6.3); funciona, pero no tieneretry()ni deja pasar por sí solonotFound()oredirect().
6.1. El problema
error.tsx funciona por segmentos: si falla cualquier trozo de la página, se sustituye la página entera. Para un panel de control con varias tarjetas independientes es demasiado: que falle la tarjeta de pedidos no debería esconder la de empleados.
React resuelve esto con límites de error alrededor de cada componente, pero escribirlos a mano tiene pegas en Next.js: hay que dejar pasar las interrupciones de notFound() y redirect(), limpiar el error al navegar y saber reintentar contra el servidor. catchError, de next/error, crea un límite de error que ya hace todo eso.
6.2. Un límite reutilizable para el panel
catchError recibe una función con dos argumentos: las props que tú le pases al componente resultante y un objeto ErrorInfo con error, retry y reset (los mismos de error.tsx). Devuelve un componente que envuelve a sus children.
// app/_componentes/limite-error.tsx
'use client'
import { catchError, type ErrorInfo } from 'next/error'
// Límite de error reutilizable para un trozo de la página (un widget).
function AvisoWidget(props: { titulo: string }, { error, retry }: ErrorInfo) {
const mensaje = error instanceof Error ? error.message : String(error)
return (
<div role="alert" className="rounded border border-red-300 bg-red-50 p-3 text-sm">
<p className="font-semibold">{props.titulo}: no disponible</p>
<p className="text-xs text-slate-600">{mensaje}</p>
<button onClick={() => retry()} className="mt-2 text-red-700 underline">
Reintentar
</button>
</div>
)
}
export default catchError(AvisoWidget)
El panel tiene tres tarjetas. La de empleados funciona; la de pedidos tiene a propósito una ruta mal escrita (/pedidoss) y lanza en el servidor; la de ventas usa un Client Component que lanza en el navegador porque este mes no hay datos.
// app/panel/page.tsx
import { Suspense } from 'react'
import { connection } from 'next/server'
import LimiteError from '@/app/_componentes/limite-error'
import GraficoVentas from './grafico-ventas'
const API = process.env.API_URL ?? 'http://localhost:3000/api'
async function TotalEmpleados() {
await connection()
const res = await fetch(`${API}/empleados?tamano=1`)
const { total } = await res.json()
return <p className="text-3xl font-bold">{total}</p>
}
async function Pedidos() {
await connection()
// Ruta mal escrita a propósito: la API responde 404
const res = await fetch(`${API}/pedidoss`)
if (!res.ok) throw new Error(`Pedidos: la API respondió ${res.status}`)
return <p>Pedidos al día</p>
}
async function Ventas() {
await connection()
const datos: number[] = [] // aún no hay ventas este mes
return <GraficoVentas datos={datos} />
}
function Tarjeta({ titulo, children }: { titulo: string; children: React.ReactNode }) {
return (
<div className="rounded border bg-white p-4">
<h2 className="mb-2 text-sm font-semibold text-slate-500">{titulo}</h2>
{children}
</div>
)
}
export default function Panel() {
return (
<section>
<h1 className="mb-4 text-xl font-bold">Panel de gestión</h1>
<div className="grid grid-cols-3 gap-4">
<Tarjeta titulo="Empleados">
<LimiteError titulo="Empleados">
<Suspense fallback={<p>...</p>}>
<TotalEmpleados />
</Suspense>
</LimiteError>
</Tarjeta>
<Tarjeta titulo="Pedidos">
<LimiteError titulo="Pedidos">
<Suspense fallback={<p>...</p>}>
<Pedidos />
</Suspense>
</LimiteError>
</Tarjeta>
<Tarjeta titulo="Ventas">
<LimiteError titulo="Ventas">
<Suspense fallback={<p>...</p>}>
<Ventas />
</Suspense>
</LimiteError>
</Tarjeta>
</div>
</section>
)
}
// app/panel/grafico-ventas.tsx
'use client'
// Client Component que falla al renderizar si no recibe datos.
export default function GraficoVentas({ datos }: { datos: number[] }) {
if (datos.length === 0) {
throw new Error('El gráfico necesita al menos un dato')
}
const maximo = Math.max(...datos)
return (
<div className="flex h-16 items-end gap-1">
{datos.map((d, i) => (
<div key={i} className="w-4 bg-indigo-500" style={{ height: `${(d / maximo) * 100}%` }} />
))}
</div>
)
}
El componente Ventas existe solo para hacer dinámica esa tarjeta con connection(). Sin él, el gráfico se prerenderizaría y, como viste en el apartado 5, el build fallaría.

Figura 6. Cada tarjeta tiene su propio límite. Dos fallan y la tercera sigue funcionando. El error de servidor (Pedidos) llega con el mensaje genérico; el de cliente (Ventas), con su mensaje real.
La captura confirma en un mismo pantallazo la tabla del apartado 3.3: el error lanzado en un Server Component llega oculto, y el lanzado en un Client Component llega tal cual.
💡 Consejo: No hace falta envolver con
catchErrorel componente de unerror.tsx: ese fichero ya se renderiza dentro de un límite que pone Next.js.catchErrores para trozos de página.
6.3. La alternativa en Next.js 15: un límite de clase
En Next.js 15 next/error solo exporta el componente de error del antiguo Pages Router; catchError no existe. La alternativa es el límite de error clásico de React. Los límites de error tienen que ser componentes de clase, porque se basan en el método estático getDerivedStateFromError, que no tiene equivalente en forma de hook:
// app/limite-error.tsx (Next.js 15)
'use client'
import { Component, type ReactNode } from 'react'
// Alternativa a catchError para Next.js 15: un límite de error clásico de React.
type Props = { titulo: string; children: ReactNode }
type Estado = { error: unknown }
export default class LimiteError extends Component<Props, Estado> {
state: Estado = { error: null }
static getDerivedStateFromError(error: unknown): Estado {
return { error }
}
render() {
if (this.state.error !== null) {
const mensaje =
this.state.error instanceof Error ? this.state.error.message : String(this.state.error)
return (
<div role="alert">
<p>{this.props.titulo}: no disponible</p>
<p data-mensaje>{mensaje}</p>
<button onClick={() => this.setState({ error: null })}>Reintentar</button>
</div>
)
}
return this.props.children
}
}
Probado en Next.js 15.5 con el mismo gráfico sin datos dentro de <LimiteError titulo="Ventas"> y un párrafo después; la página mostró:
Ventas: no disponible
El gráfico necesita al menos un dato
Reintentar
El resto del panel sigue funcionando
Sus límites frente a catchError:
- Su “Reintentar” equivale a
reset(): no vuelve a pedir datos al servidor. - Captura todo, incluidas las interrupciones de
notFound()yredirect()lanzadas dentro de él. Si las usas en los hijos, tendrías que reconocerlas y volver a lanzarlas. - No se limpia solo al navegar a otra ruta.
7. Páginas de “no encontrado”: notFound() y not-found.tsx
7.1. Repaso y novedades
En el módulo 04 usaste notFound() para responder a un empleado inexistente. Aquí lo miramos desde el punto de vista de los estados especiales. Hay dos formas de llegar a una página de “no encontrado”:
| Situación | Qué se muestra | Código HTTP comprobado |
|---|---|---|
La URL no casa con ninguna ruta (/no-existe) | app/not-found.tsx | 404 |
Una página llama a notFound() | El not-found.tsx más cercano hacia arriba | 404, o 200 con noindex si ya había empezado el streaming (apartado 9) |
El not-found.tsx de la ficha de empleado da un mensaje específico, con un enlace útil:
// app/empleados/[id]/not-found.tsx
import Link from 'next/link'
export default function EmpleadoNoEncontrado() {
return (
<div className="rounded border border-amber-300 bg-amber-50 p-4">
<h2 className="font-semibold">Ese empleado no existe</h2>
<p className="text-sm">Revisa el número o vuelve al listado.</p>
<Link href="/empleados/100" className="text-indigo-700 underline">
Ir al empleado 100
</Link>
</div>
)
}

Figura 7. /empleados/9999: la API responde 404, la capa de datos devuelve null y la página llama a notFound().
Y el de la raíz cubre cualquier URL desconocida. Puede exportar metadata como cualquier página:
// app/not-found.tsx
import Link from 'next/link'
export const metadata = { title: 'Página no encontrada | EMPRESA' }
export default function NoEncontrada() {
return (
<div className="space-y-2">
<h1 className="text-2xl font-bold">404: esta página no existe</h1>
<p>La dirección no corresponde a ninguna sección de la aplicación.</p>
<Link href="/" className="text-indigo-700 underline">Volver al inicio</Link>
</div>
)
}
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3001/no-existe
# 404
curl -s http://localhost:3001/no-existe | grep -o '<meta name="robots"[^>]*>\|<title>[^<]*'
# <meta name="robots" content="noindex"/>
# <title>Página no encontrada | EMPRESA
Next.js añade siempre <meta name="robots" content="noindex"> a estas páginas, para que los buscadores no las indexen.
📘 Concepto:
not-found.tsxno recibe props: no sabe qué URL se pidió ni por qué. Si necesitas personalizar el mensaje según el recurso, pon unnot-found.tsxen la carpeta de ese recurso, como el deempleados/[id].
7.2. global-not-found.tsx (experimental)
Existe un tercer fichero, app/global-not-found.tsx, para aplicaciones donde no se puede construir un 404 combinando layout y not-found (por ejemplo, con varios layouts raíz en grupos de rutas, o cuando el layout raíz depende de un segmento dinámico como el idioma). Se activa con experimental: { globalNotFound: true } en next.config.ts, apareció en Next.js 15.4 y sigue siendo experimental en 16.4. Como se salta el layout, tiene que incluir su propio <html>, <body> y estilos. Para la aplicación EMPRESA, que tiene un solo layout raíz, no hace falta. Lo retomarás en el módulo 19 si lo necesitas.
7.3. El try/catch que se traga notFound()
notFound() funciona lanzando un error especial que Next.js reconoce. Si lo llamas dentro de un try, tu catch lo captura como si fuera un error normal. Mira otra vez la ficha de empleado:
try {
empleado = await obtenerEmpleado(n)
if (!empleado) notFound() // lanza dentro del try...
} catch (e) {
unstable_rethrow(e) // ...y aquí se deja pasar
throw new Error(`No se pudo cargar el empleado ${n}`, { cause: e })
}
Sin la línea unstable_rethrow(e), el empleado 9999 acabaría en el error.tsx con “No se pudo cargar el empleado 9999” en lugar de en el not-found.tsx. unstable_rethrow comprueba si el error es una de las interrupciones internas de Next.js (notFound, forbidden, unauthorized, redirect, las de renderizado dinámico…) y, si lo es, la vuelve a lanzar. Si no lo es, no hace nada y tu catch sigue.
Hay dos formas de evitar el problema:
- Llamar a
notFound()fuera deltry, que es lo más limpio: deja dentro solo la operación que puede fallar y comprueba el resultado después. - Poner
unstable_rethrow(e)como primera línea delcatch, cuando no tienes más remedio que tener la llamada dentro.
⚠️ Importante: A pesar del prefijo
unstable_, la función existe tanto en Next.js 15 como en 16.4 con el mismo uso. En el módulo 03 viste el mismo problema conredirect(): dentro de untrysinunstable_rethrow, la redirección no ocurre.
8. forbidden() y unauthorized(): 403 y 401 (experimental)
8.1. Para qué sirven
notFound() cubre el “no existe”. Para el control de acceso hay dos situaciones más, que HTTP distingue con dos códigos:
| Función | Fichero | Código | Significado |
|---|---|---|---|
unauthorized() | unauthorized.tsx | 401 | No sé quién eres: inicia sesión |
forbidden() | forbidden.tsx | 403 | Sé quién eres, pero no tienes permiso |
Aparecieron en Next.js 15.1 y, en Next.js 16.4, siguen siendo experimentales: la documentación instalada los marca con version: experimental y hay que activarlos en next.config.ts. Como existen igual en 15 y en 16, este apartado no lleva marca de versión.
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
experimental: {
authInterrupts: true,
},
// ...
}
export default nextConfig
next build lo recuerda en cada compilación:
- Experiments (use with caution):
✓ authInterrupts
⚠️ Importante: “Experimental” significa que la API puede cambiar en una versión menor sin aviso. Úsala sabiendo que quizá tengas que tocar el código al actualizar, y apóyate en ella solo para la interfaz: la protección de verdad debe estar en la capa de datos, como verás en el módulo 17.
8.2. La zona de gestión
Hasta el módulo 17 no hay inicio de sesión real, así que simulamos la sesión con una cookie rol que vale ADMIN o USER:
// app/gestion/page.tsx
import { Suspense } from 'react'
import { cookies } from 'next/headers'
import { forbidden, unauthorized } from 'next/navigation'
// Simulación de sesión: la cookie "rol" vale ADMIN o USER.
// La autenticación de verdad llega en el módulo 17.
async function ZonaAdmin() {
const rol = (await cookies()).get('rol')?.value
if (!rol) unauthorized() // no hay sesión: 401
if (rol !== 'ADMIN') forbidden() // hay sesión, pero sin permiso: 403
return <p data-ok>Bienvenido a la zona de administración ({rol})</p>
}
export default function PaginaGestion() {
return (
<section>
<h1 className="mb-4 text-xl font-bold">Gestión</h1>
<Suspense fallback={<p>Comprobando permisos...</p>}>
<ZonaAdmin />
</Suspense>
</section>
)
}
// app/unauthorized.tsx
import Link from 'next/link'
export default function NoAutenticado() {
return (
<div className="rounded border border-sky-300 bg-sky-50 p-4">
<h2 className="font-semibold">Necesitas iniciar sesión</h2>
<p className="text-sm">Esta zona es solo para personal de la empresa.</p>
<Link href="/login" className="text-indigo-700 underline">Iniciar sesión</Link>
</div>
)
}
// app/forbidden.tsx
import Link from 'next/link'
export default function Prohibido() {
return (
<div className="rounded border border-orange-300 bg-orange-50 p-4">
<h2 className="font-semibold">No tienes permiso</h2>
<p className="text-sm">Tu usuario no tiene el rol necesario para esta página.</p>
<Link href="/" className="text-indigo-700 underline">Volver al inicio</Link>
</div>
)
}

Figura 8. /gestion sin la cookie rol: unauthorized() muestra app/unauthorized.tsx.

Figura 9. /gestion con rol=USER: forbidden() muestra app/forbidden.tsx.
Igual que con not-found.tsx, Next.js busca el forbidden.tsx o unauthorized.tsx más cercano hacia arriba, así que puedes tener uno por sección. Tampoco reciben props.
⚠️ Importante:
forbidden()yunauthorized()no se pueden llamar en el layout raíz, igual quenotFound(): el fichero de interfaz que deben mostrar está dentro de ese layout.
8.3. En Route Handlers y Server Actions
La documentación indica que estas funciones también se pueden llamar en Route Handlers y Server Functions. En un Route Handler el resultado es una respuesta vacía con el código correcto:
// app/api/gestion/route.ts
import { cookies } from 'next/headers'
import { forbidden, unauthorized } from 'next/navigation'
export async function GET() {
const rol = (await cookies()).get('rol')?.value
if (!rol) unauthorized()
if (rol !== 'ADMIN') forbidden()
return Response.json({ ok: true, rol })
}
GET /api/gestion → 401 (sin cuerpo)
GET /api/gestion (rol=USER) → 403 (sin cuerpo)
GET /api/gestion (rol=ADMIN) → 200 {"ok":true,"rol":"ADMIN"}
Para una API que consumen otros programas, a menudo prefieres devolver un JSON explicativo; entonces es más claro escribir return Response.json({ error: 'Sin permiso' }, { status: 403 }), como en el módulo 12.
9. Estados de carga, streaming y códigos HTTP
9.1. loading.tsx y los errores
loading.tsx ya lo conoces del módulo 08: Next.js lo usa como fallback de un <Suspense> que envuelve la página. La figura 1 muestra dónde queda: dentro del límite de error. Por eso:
- Si la página falla mientras se muestra el
loading.tsx, el esqueleto se sustituye por elerror.tsxdel mismo segmento. - Si falla el propio
loading.tsx(raro, pero posible), también lo captura eseerror.tsx.
// app/empleados/[id]/loading.tsx
export default function CargandoEmpleado() {
return (
<div className="animate-pulse rounded border bg-white p-4" aria-busy="true">
<div className="mb-2 h-5 w-48 rounded bg-slate-200" />
<div className="h-4 w-32 rounded bg-slate-200" />
</div>
)
}
9.2. El código HTTP ya se ha enviado
Aquí está la sorpresa que más dudas genera. Estas son las respuestas reales de la aplicación del módulo, compilada:
/empleados/100 → 200
/empleados/100?fallo=1 → 200 (muestra error.tsx)
/empleados/9999 → 200 (muestra not-found.tsx, con noindex)
/empleados/abc → 200 (muestra not-found.tsx, con noindex)
/no-existe → 404
/gestion → 200 (muestra unauthorized.tsx, con noindex)
/gestion (rol=USER) → 200 (muestra forbidden.tsx, con noindex)
/api/gestion → 401
/api/gestion (rol=USER) → 403
/api/roto → 500
¿Por qué una página que no existe responde 200? Por el streaming. Las páginas de empleados y de gestión tienen un <Suspense> (el de loading.tsx o uno explícito). Next.js envía primero la parte estática (cabecera, menú, esqueleto), y la primera línea de una respuesta HTTP es el código de estado. Cuando, más tarde, la parte dinámica llama a notFound() o lanza un error, el 200 ya ha salido del servidor y no se puede cambiar. Next.js hace lo único que puede: renderiza la interfaz correcta y añade <meta name="robots" content="noindex"> para que los buscadores no guarden esa página.
En cambio, /no-existe no casa con ninguna ruta: Next.js lo sabe antes de enviar nada, así que responde 404. Y los Route Handlers no hacen streaming de HTML, así que dan el código exacto.
📘 Concepto: Con streaming, el código HTTP lo decide la parte de la página que se envía primero. Lo que pase dentro de un
<Suspense>ya no puede cambiarlo. Para un usuario no hay diferencia (ve la página correcta); para un buscador, elnoindexcumple la misma función que el 404.
¿Y si necesitas el código exacto, por ejemplo porque otro sistema comprueba el estado HTTP? Entonces la decisión tiene que tomarse antes de empezar a enviar: en el proxy.ts (módulo 13), que puede responder 401 o redirigir al login antes de que se renderice nada, o en un Route Handler.
💡 Consejo: En Next.js 15 sin
cacheComponents, una página que no tiene<Suspense>niloading.tsxse renderiza entera antes de enviar nada, y entoncesnotFound()oforbidden()sí producen el 404 o el 403. El 200 connoindexes consecuencia del streaming, no de la versión. Comprobado en Next.js 15.5 con la misma comprobación de cookie, sin<Suspense>:/gestionrespondió 401 sin cookie, 403 conrol=USERy 200 conrol=ADMIN.
10. global-error.tsx: cuando falla el layout raíz
El app/error.tsx está dentro del layout raíz, así que no puede capturar los errores del propio layout raíz. Para eso existe app/global-error.tsx. Cuando se activa, sustituye al layout raíz completo, y por eso tiene que llevar su propio <html> y <body>.
// app/global-error.tsx
'use client'
// Sustituye al layout raíz cuando este falla: lleva su propio <html> y <body>.
// No se aplican globals.css ni Tailwind, así que los estilos van en línea.
export default function ErrorGlobal({ error, retry }: { error: unknown; retry: () => void }) {
return (
<html lang="es">
<body style={{ fontFamily: 'system-ui, sans-serif', padding: 40, background: '#fff5f5' }}>
<title>Error | EMPRESA</title>
<h1 style={{ color: '#b71c1c' }}>La aplicación ha dejado de funcionar</h1>
<p data-mensaje>{error instanceof Error ? error.message : String(error)}</p>
<button onClick={() => retry()} style={{ padding: '6px 14px' }}>
Reintentar
</button>
</body>
</html>
)
}
Para probarlo, el layout raíz del laboratorio incluye un botón que, al pulsarlo, hace fallar el render del propio layout:
// app/_componentes/boton-romper-layout.tsx
'use client'
import { useState } from 'react'
// Botón de laboratorio: al pulsarlo, el propio layout raíz falla al renderizar.
// Ese error no lo captura app/error.tsx, sino app/global-error.tsx.
export default function BotonRomperLayout() {
const [roto, setRoto] = useState(false)
if (roto) {
throw new Error('El layout raíz ha fallado')
}
return (
<button
onClick={() => setRoto(true)}
className="ml-auto rounded bg-white/10 px-3 py-1 text-xs"
>
Romper el layout
</button>
)
}

Figura 10. global-error.tsx en acción: han desaparecido la cabecera y el menú, porque pertenecían al layout raíz que ha fallado.
Detalles que conviene saber:
- No hereda los estilos ni las fuentes del layout raíz, ni el tema claro u oscuro que aplicases con una clase. La documentación recomienda incluir dentro todo lo que necesite. En la figura 10 se nota otro efecto: el error se produjo en el navegador, con la hoja de Tailwind ya cargada, y su reset (preflight) sigue aplicándose; por eso el
<h1>sale con el tamaño de un párrafo y el botón sin borde. Los estilos en línea deglobal-error.tsxdeben ser completos: no cuentes ni con los de la aplicación ni con los del navegador. - No admite
metadatanigenerateMetadata, porque es un Client Component. Para el título se usa el elemento<title>de React 19 directamente, como en el ejemplo. - Desde Next.js 15.2 se muestra también en desarrollo; antes, en
next devsolo veías el panel de errores de Next.js. - Es el último recurso, así que mantenlo lo más simple posible: sin llamadas a la API ni componentes que puedan fallar a su vez.
11. Errores en Server Actions
Una Server Action (módulo 10) puede encontrarse con los dos tipos de error, y aquí la regla del apartado 1 se aplica al pie de la letra:
// app/acciones/acciones.ts
'use server'
export type EstadoAlta = { error?: string; ok?: string }
// Error ESPERADO: se devuelve como valor y lo pinta el formulario.
export async function altaDepartamento(
_previo: EstadoAlta,
datos: FormData,
): Promise<EstadoAlta> {
const nombre = String(datos.get('nombre') ?? '').trim()
if (nombre.length < 3) {
return { error: 'El nombre debe tener al menos 3 letras' }
}
return { ok: `Departamento ${nombre.toUpperCase()} creado (simulado)` }
}
// Error INESPERADO: se lanza y lo recoge el error.tsx más cercano.
export async function borrarTodo(): Promise<void> {
throw new Error('La base de datos no acepta borrados masivos')
}
// app/acciones/formulario.tsx
'use client'
import { useActionState } from 'react'
import { altaDepartamento, borrarTodo } from './acciones'
export default function Formularios() {
const [estado, accion, pendiente] = useActionState(altaDepartamento, {})
return (
<div className="space-y-6">
<form action={accion} className="space-y-2 rounded border bg-white p-4">
<label className="block text-sm">
Nombre del departamento
<input name="nombre" className="ml-2 rounded border px-2 py-1" />
</label>
<button disabled={pendiente} className="rounded bg-indigo-700 px-3 py-1 text-white">
Crear
</button>
{estado.error && <p role="alert" className="text-sm text-red-700" data-error>{estado.error}</p>}
{estado.ok && <p className="text-sm text-green-700" data-ok>{estado.ok}</p>}
</form>
<form action={borrarTodo} className="rounded border bg-white p-4">
<button data-borrar className="rounded bg-red-700 px-3 py-1 text-white">
Borrar todos los pedidos
</button>
</form>
</div>
)
}
La página app/acciones/page.tsx solo pinta <Formularios />. Con el nombre “ab”, el error esperado aparece junto al formulario y el resto de la página sigue igual:

Figura 11. Error esperado: la acción devuelve { error } y el formulario lo muestra. No interviene ningún límite de error.
Al pulsar “Borrar todos los pedidos”, la petición POST de la acción responde 500 y el error sube al límite más cercano. app/acciones/ no tiene error.tsx, así que lo recoge el de la raíz:

Figura 12. Error inesperado en una Server Action: lo captura app/error.tsx, con el mensaje oculto y su digest.
⚠️ Importante: Un error lanzado en una acción sustituye la página entera del segmento por el
error.tsx; el usuario pierde lo que hubiera escrito en el formulario. Por eso la validación, los conflictos (“el departamento tiene empleados”) y los permisos se devuelven como valores, y solo se lanza lo que de verdad no se puede tratar. El módulo 11 desarrolla los errores por campo con Zod.
12. Errores en Route Handlers y en el navegador
12.1. Route Handlers
Un Route Handler que lanza un error no tiene interfaz que mostrar: Next.js responde 500 con el cuerpo vacío y escribe el error en el registro.
// app/api/roto/route.ts
import { connection } from 'next/server'
export async function GET() {
await connection() // sin esto, next build lo prerenderiza y el build falla
throw new Error('Fallo en el Route Handler de informes')
}
HTTP/1.1 500 Internal Server Error
Para los errores esperados de una API, devuelve tú la respuesta con su código y un cuerpo que el cliente pueda interpretar:
return Response.json({ error: 'El pedido 5999 no existe' }, { status: 404 })
12.2. Errores que ningún límite captura
Los límites de error de React (y por tanto error.tsx y catchError) solo capturan errores producidos al renderizar. No capturan:
- Errores en manejadores de eventos (
onClick,onSubmit): ocurren después del render. - Errores en código asíncrono suelto (un
setTimeout, una promesa sinawaitdentro de un efecto).
Para esos casos, captura tú el error y guárdalo en el estado:
'use client'
import { useState } from 'react'
export function BotonExportar() {
const [error, setError] = useState<string | null>(null)
async function exportar() {
try {
const res = await fetch('/api/informes/exportar')
if (!res.ok) throw new Error(`Respuesta ${res.status}`)
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}
return (
<>
<button onClick={exportar}>Exportar</button>
{error && <p role="alert">No se pudo exportar: {error}</p>}
</>
)
}
Hay una excepción útil: un error lanzado dentro de startTransition (de useTransition) sí sube al límite de error más cercano, porque React lo trata como parte del render de la transición.
12.3. instrumentation-client.ts: lo que se escapa en el navegador
Para enterarte de los errores que no captura nadie en el navegador, Next.js ejecuta el fichero instrumentation-client.ts de la raíz del proyecto antes de que la aplicación sea interactiva. Ahí puedes registrar escuchadores globales:
// instrumentation-client.ts
// Se ejecuta en el navegador antes de que la aplicación sea interactiva.
window.addEventListener('error', (evento) => {
console.log('[cliente] error no capturado:', evento.message)
})
window.addEventListener('unhandledrejection', (evento) => {
console.log('[cliente] promesa rechazada sin catch:', String(evento.reason))
})
export function onRouterTransitionStart(url: string, tipo: 'push' | 'replace' | 'traverse') {
console.log(`[cliente] navegación ${tipo} a ${url}`)
}
onRouterTransitionStart es opcional y se llama al empezar cada navegación del App Router; sirve para medir tiempos o para dejar un rastro de por dónde iba el usuario antes de un fallo. Estas líneas salieron en la consola del navegador durante la prueba:
[cliente] navegación push a /panel
[cliente] error no capturado: Uncaught Error: Minified React error #419; ...
El error #419 de React dice, en su versión completa: “The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.” Es decir, un <Suspense> no pudo terminar en el servidor por un error y React reintenta renderizarlo en el navegador. Es el rastro que deja el panel del apartado 6 antes de que catchError muestre su aviso.
13. Registro de errores del servidor: instrumentation.ts
13.1. Un único punto de entrada
Hasta ahora los errores del servidor solo salían en la terminal. En producción necesitas guardarlos en algún sitio: un fichero, una base de datos o un servicio de monitorización como Sentry. Next.js ofrece un único gancho para todos ellos: el fichero instrumentation.ts en la raíz del proyecto (o en src/), con dos exportaciones opcionales.
// instrumentation.ts
import type { Instrumentation } from 'next'
export function register() {
console.log(`[instrumentación] servidor listo (runtime ${process.env.NEXT_RUNTIME})`)
}
export const onRequestError: Instrumentation.onRequestError = async (
error,
peticion,
contexto,
) => {
const e = error as Error & { digest?: string }
console.error(
'[onRequestError]',
JSON.stringify({
mensaje: e.message,
digest: e.digest,
metodo: peticion.method,
ruta: peticion.path,
tipo: contexto.routeType,
plantilla: contexto.routePath,
origen: contexto.renderSource,
}),
)
}
register()se ejecuta una vez, al arrancar cada instancia del servidor, y debe terminar antes de que se atienda la primera petición. Es el sitio para inicializar el cliente de monitorización o OpenTelemetry.onRequestError(error, peticion, contexto)se llama cada vez que el servidor captura un error: al renderizar, en una Server Action, en un Route Handler o en el proxy. Si haces algo asíncrono dentro (enviar el error por la red), espera conawait.
13.2. Lo que registra de verdad
Este es el registro de next start durante las pruebas de este módulo, filtrado a las líneas de register y onRequestError:
[instrumentación] servidor listo (runtime nodejs)
[onRequestError] {"mensaje":"No se pudo cargar el empleado 100","digest":"4199489154","metodo":"GET","ruta":"/empleados/100?fallo=1","tipo":"render","plantilla":"/empleados/[id]","origen":"react-server-components"}
[onRequestError] {"mensaje":"El servicio de nóminas no responde (intento 1)","digest":"2669551071","metodo":"GET","ruta":"/inestable","tipo":"render","plantilla":"/inestable","origen":"react-server-components"}
[onRequestError] {"mensaje":"El gráfico necesita al menos un dato","digest":"435907814","metodo":"GET","ruta":"/panel","tipo":"render","plantilla":"/panel","origen":"server-rendering"}
[onRequestError] {"mensaje":"Pedidos: la API respondió 404","digest":"1321978500","metodo":"GET","ruta":"/panel","tipo":"render","plantilla":"/panel","origen":"react-server-components"}
[onRequestError] {"mensaje":"Fallo en el Route Handler de informes","metodo":"GET","ruta":"/api/roto","tipo":"route","plantilla":"/api/roto"}
Fíjate en lo que aporta cada campo:
| Campo | De dónde sale | Para qué sirve |
|---|---|---|
mensaje | error.message | El mensaje completo, el que no llega al navegador |
digest | error.digest | Cruzarlo con la “Referencia” que ve el usuario (4199489154 en la figura 2) |
ruta | peticion.path | La URL concreta, con su query |
tipo | contexto.routeType | render, route, action o proxy |
plantilla | contexto.routePath | La ruta del sistema de ficheros (/empleados/[id]): agrupa errores de todos los empleados |
origen | contexto.renderSource | react-server-components (Server Component), server-rendering (el SSR de un Client Component) |
El error del gráfico tiene origen: "server-rendering": el Client Component falló primero al renderizarse en el servidor (SSR), y luego otra vez en el navegador, donde lo capturó catchError. El del Route Handler no tiene digest, porque no hay interfaz que lo muestre.
⚠️ Importante:
onRequestErrorregistra los errores del servidor. Los que solo ocurren en el navegador (un clic que falla) no pasan por aquí: para esos estáinstrumentation-client.tso eluseEffectde tuerror.tsx.
13.3. routeType: 'proxy' 🔄 16.0
🔄 Cambia en Next.js 16.0. En Next.js 15 los errores del middleware llegan a
onRequestErrorconrouteType: 'middleware'; en 16 el valor es'proxy', igual que el fichero. Si usas Next.js 15: compara con'middleware'en lugar de'proxy'; el resto de la API deinstrumentation.tses idéntica (es estable desde 15.0).
Los tipos instalados lo confirman. En Next.js 15.5:
routeType: 'render' | 'route' | 'action' | 'middleware';
Y en 16.4:
routeType: 'render' | 'route' | 'action' | 'proxy';
13.4. Enviar los errores a otro sitio
Un servicio de monitorización real se integra con su propio paquete (casi todos dan instrucciones para instrumentation.ts). La forma general es siempre la misma: transformar los tres argumentos en un objeto y enviarlo. Por ejemplo, a un servicio propio de registro:
export const onRequestError: Instrumentation.onRequestError = async (error, peticion, contexto) => {
const e = error as Error & { digest?: string }
await fetch(process.env.URL_REGISTRO_ERRORES!, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fecha: new Date().toISOString(),
mensaje: e.message,
pila: e.stack,
digest: e.digest,
ruta: peticion.path,
tipo: contexto.routeType,
}),
})
}
💡 Consejo: No envíes las cabeceras de la petición tal cual (
peticion.headers): incluyen la cookie de sesión. Si las necesitas, quédate con las inofensivas (user-agent,referer). La seguridad de los registros vuelve a salir en el módulo 22.
14. Caso práctico: la aplicación EMPRESA a prueba de fallos
Juntando todo, esta es la estructura de ficheros del laboratorio del módulo, que puedes usar como plantilla:
instrumentation.ts ← register + onRequestError (servidor)
instrumentation-client.ts ← errores no capturados y navegaciones (navegador)
next.config.ts ← experimental.authInterrupts
app/
├── layout.tsx ← cabecera y menú
├── error.tsx ← "Algo ha ido mal" + retry + enlace al inicio
├── global-error.tsx ← falla el layout raíz: su propio <html>
├── not-found.tsx ← URL desconocida: 404
├── forbidden.tsx ← forbidden(): sin permiso
├── unauthorized.tsx ← unauthorized(): sin sesión
├── _lib/api.ts ← null para 404, ErrorApi para el resto
├── _componentes/
│ └── limite-error.tsx ← catchError reutilizable
├── empleados/
│ ├── layout.tsx
│ └── [id]/
│ ├── page.tsx ← notFound(), unstable_rethrow, error con cause
│ ├── loading.tsx
│ ├── error.tsx ← mensaje específico + referencia
│ └── not-found.tsx ← "Ese empleado no existe"
├── panel/page.tsx ← una tarjeta, un límite
├── gestion/page.tsx ← unauthorized() / forbidden()
├── acciones/ ← errores esperados como valor, inesperados lanzados
└── api/
├── gestion/route.ts ← 401 / 403
└── roto/route.ts ← 500 + onRequestError
Y esta es la decisión que tomas cada vez que algo puede fallar:
| Situación | Qué haces | Qué ve el usuario |
|---|---|---|
| El recurso no existe | notFound() | not-found.tsx más cercano |
| No hay sesión | unauthorized(), o redirigir al login desde proxy.ts | unauthorized.tsx |
| Sin permiso | forbidden() | forbidden.tsx |
| Datos de formulario no válidos | Devolver { error } desde la acción | El mensaje junto al formulario |
| Un trozo opcional de la página falla | Envolverlo con un límite catchError | El aviso en su tarjeta; el resto funciona |
| La página entera no se puede mostrar | Lanzar un Error con contexto | error.tsx con “Reintentar” y referencia |
| Falla el layout raíz | Nada: es el último recurso | global-error.tsx |
| Cualquier error del servidor | Registrarlo en onRequestError | Nada: es para ti |
15. Errores frecuentes
| Síntoma | Causa | Solución |
|---|---|---|
next build falla: error.tsx must be a Client Component. Add the "use client" directive... | Falta 'use client' en error.tsx o global-error.tsx | Añádelo en la primera línea |
| El botón “Reintentar” no hace nada en una página que falló en el servidor | Usas reset() (código copiado de Next.js 15) | Usa retry(); en Next.js 15, router.refresh() + reset() en startTransition |
error.message muestra “Minified React error #441” | Es el mensaje genérico de producción para errores de servidor | Muestra un texto propio y el digest; el mensaje real está en el registro |
Property 'message' does not exist on type 'unknown' | En 16.4 error es unknown | Comprueba antes con error instanceof Error |
El empleado inexistente muestra el error.tsx en lugar del not-found.tsx | notFound() dentro de un try cuyo catch lo relanza como otro error | unstable_rethrow(e) al principio del catch, o saca notFound() del try |
El error.tsx de una carpeta no captura un fallo del layout.tsx de la misma carpeta | El layout queda por fuera del límite | Pon el error.tsx en la carpeta padre |
global-error.tsx se ve sin estilos | No hereda los del layout raíz | Incluye sus propios estilos (en línea o importando una hoja) |
notFound() o forbidden() responden 200 | La llamada está dentro de un <Suspense>: el estado ya se envió | Es normal (lleva noindex); si necesitas el código, decide en proxy.ts o en un Route Handler |
`forbidden()` is experimental and only allowed to be enabled when `experimental.authInterrupts` is enabled | Falta la opción experimental | experimental: { authInterrupts: true } en next.config.ts |
next build falla con “Error occurred prerendering page” | Un componente o un GET lanza durante el prerender | Arregla el fallo o haz dinámico ese trozo (connection() dentro de <Suspense>) |
Un error en un onClick no muestra ningún límite | Los límites solo capturan errores de render | try/catch en el manejador y estado con el error |
El mensaje de la fila de forbidden() es el texto exacto que lanza la función en Next.js 16.4 cuando falta la opción (unauthorized() da uno equivalente). El de 'use client' es la salida real de next build al quitar la directiva de un error.tsx:
Error: Turbopack build failed with 1 error:
./app/departamentos/[id]/error.tsx:1:1
Error: app/departamentos/[id]/error.tsx must be a Client Component. Add the "use client"
directive the top of the file to resolve this issue.
16. Ejercicios
Ejercicio 1: Límite de error para la ficha de departamento
La página app/departamentos/[id]/page.tsx muestra un departamento con GET /api/departamentos/:id. Añade un error.tsx en su carpeta que muestre “No se ha podido cargar el departamento”, la referencia del error si la hay, un botón que vuelva a pedir los datos al servidor y un enlace al inicio. Debe compilar en Next.js 16.4 sin avisos de tipos.
Ver solución
// app/departamentos/[id]/error.tsx
'use client'
import Link from 'next/link'
export default function ErrorDepartamento({
error,
retry,
}: {
error: unknown
retry: () => void
}) {
const digest =
error instanceof Error && 'digest' in error ? String(error.digest) : null
return (
<div role="alert" className="rounded border border-red-300 bg-red-50 p-4">
<h2 className="font-semibold text-red-800">No se ha podido cargar el departamento</h2>
{digest && <p className="text-xs text-slate-500">Referencia: {digest}</p>}
<div className="mt-3 flex gap-3">
<button onClick={() => retry()} className="rounded bg-red-700 px-3 py-1 text-white">
Reintentar
</button>
<Link href="/" className="py-1 text-indigo-700 underline">Ir al inicio</Link>
</div>
</div>
)
}
Para probarlo, la página del laboratorio lanza new Error('La API respondió 404 al pedir el departamento 99') cuando la API no responde bien. Con la aplicación compilada, /departamentos/20 muestra “VENTAS (Madrid)” y /departamentos/99 muestra “No se ha podido cargar el departamento”, “Referencia: 1775966444”, el botón y el enlace. (Un departamento inexistente debería ir a notFound(); aquí se usa solo para provocar el error.)
Las claves son tres: 'use client', error tipado como unknown y comprobado con instanceof antes de leer digest, y retry() en lugar de reset(), porque los datos vienen de un Server Component. No se muestra error.message: en producción sería el texto genérico de React.
Ejercicio 2: Un error esperado que no necesita error.tsx
En la ficha de un cliente (/clientes/[id]) se muestran sus pedidos con GET /api/clientes/:id/pedidos. Si esa consulta falla por cualquier motivo (red, 404, 500, más de 3 segundos), la página no debe ir al límite de error, sino mostrar “Los pedidos no están disponibles ahora mismo” y el motivo. Hazlo sin try/catch en el componente.
Ver solución
Se encapsula la petición en una función que nunca lanza: devuelve un resultado con dos formas posibles, y TypeScript obliga a comprobar cuál es.
// app/clientes/[id]/page.tsx
import { Suspense } from 'react'
const API = process.env.API_URL ?? 'http://localhost:3000/api'
type Resultado<T> = { ok: true; datos: T } | { ok: false; motivo: string }
// Convierte cualquier fallo (red, 404, 500) en un valor, sin lanzar.
async function pedir<T>(ruta: string): Promise<Resultado<T>> {
try {
const res = await fetch(`${API}${ruta}`, { signal: AbortSignal.timeout(3000) })
if (!res.ok) return { ok: false, motivo: `la API respondió ${res.status}` }
return { ok: true, datos: (await res.json()) as T }
} catch (e) {
return { ok: false, motivo: e instanceof Error ? e.name : 'error de red' }
}
}
async function PedidosCliente({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
// la API devuelve una lista paginada: { contenido, pagina, tamano, total, paginas }
const r = await pedir<{ total: number }>(`/clientes/${id}/pedidos`)
if (!r.ok) {
return <p data-aviso>Los pedidos no están disponibles ahora mismo ({r.motivo}).</p>
}
return <p data-ok>El cliente {id} tiene {r.datos.total} pedidos.</p>
}
export default function PaginaCliente({ params }: PageProps<'/clientes/[id]'>) {
return (
<Suspense fallback={<p>Cargando pedidos...</p>}>
<PedidosCliente params={params} />
</Suspense>
)
}
Resultado real con la API EMPRESA:
/clientes/1001 → El cliente 1001 tiene 3 pedidos.
/clientes/9999 → Los pedidos no están disponibles ahora mismo (la API respondió 404).
AbortSignal.timeout(3000) cancela la petición si tarda más de 3 segundos; el error que produce tiene name igual a TimeoutError, que es lo que se mostraría como motivo. El <Suspense> es necesario porque la página lee params, un dato de la petición, y con Cache Components eso no puede quedar en el armazón estático.
Ejercicio 3: Comprobar retry y reset
Sin mirar el apartado 4, predice qué texto mostrará /inestable en cada paso: cargas la página y sale el error del intento 5; pulsas reset(); pulsas reset() otra vez; pulsas retry(). Después explica con tus palabras por qué.
Ver solución
| Paso | Texto | Intentos en el servidor |
|---|---|---|
| Carga | Error (intento 5) | 5 |
reset() | El mismo error | 5 |
reset() | El mismo error | 5 |
retry() | “Nóminas cargadas en el intento 6” | 6 |
reset() vuelve a renderizar en el navegador el resultado que ya tenía, que es el error enviado por el servidor; no hace ninguna petición, así que el contador no avanza. retry() pide al servidor una respuesta nueva; el intento 6 es par y funciona. Es exactamente lo que se midió con Playwright en el apartado 4.1 (allí con los intentos 3 y 4).
Ejercicio 4: Un not-found.tsx para productos
Crea app/productos/[id]/not-found.tsx que diga “Ese producto no está en el catálogo” con un enlace a /productos, y modifica la página para que un identificador no numérico (/productos/abc) y un producto que no existe (/productos/999) lo muestren. ¿Qué código HTTP recibirá /productos/999 si la página tiene loading.tsx?
Ver solución
// app/productos/[id]/not-found.tsx
import Link from 'next/link'
export default function ProductoNoEncontrado() {
return (
<div className="rounded border border-amber-300 bg-amber-50 p-4">
<h2 className="font-semibold">Ese producto no está en el catálogo</h2>
<Link href="/productos" className="text-indigo-700 underline">Ver todos los productos</Link>
</div>
)
}
En la página, la comprobación va antes de cualquier try y después de obtener el dato:
const n = Number(id)
if (!Number.isInteger(n) || n <= 0) notFound()
const producto = await obtenerProducto(n) // null si la API responde 404
if (!producto) notFound()
obtenerProducto sigue el mismo patrón que obtenerEmpleado: null para el 404, excepción para lo demás. Con loading.tsx, la página hace streaming: el 200 sale antes de que se sepa que el producto no existe, así que la respuesta es 200 con <meta name="robots" content="noindex">, igual que /empleados/9999 en el apartado 9.2. La interfaz es la correcta en ambos casos.
Ejercicio 5: Tarjetas independientes con catchError
Añade al panel una cuarta tarjeta, “Proyectos”, que pida GET /api/proyectos y muestre cuántos hay. Debe tener su propio límite y, si falla, mostrar el aviso solo en esa tarjeta. Reutiliza el componente LimiteError del apartado 6.2.
Ver solución
async function Proyectos() {
await connection()
const res = await fetch(`${API}/proyectos`)
if (!res.ok) throw new Error(`Proyectos: la API respondió ${res.status}`)
const { total } = await res.json()
return <p className="text-3xl font-bold">{total}</p>
}
// dentro del grid de Panel:
<Tarjeta titulo="Proyectos">
<LimiteError titulo="Proyectos">
<Suspense fallback={<p>...</p>}>
<Proyectos />
</Suspense>
</LimiteError>
</Tarjeta>
Es el mismo patrón que la tarjeta de pedidos: un Server Component asíncrono, con connection() para que se resuelva en cada petición, dentro de un <Suspense> (para no bloquear el resto del panel) y de un LimiteError (para que su fallo no afecte al resto). El orden importa: el límite de error va por fuera del <Suspense>, de modo que capture tanto el fallo del componente como el de su fallback. Con la API EMPRESA la tarjeta muestra 5, y la de pedidos sigue mostrando su aviso sin afectar a las demás; si cambias la ruta por una errónea, el aviso aparece solo en esa tarjeta.
Ejercicio 6: Distinguir 401 y 403 en una sección
Protege app/gestion/empleados/page.tsx para que sin cookie rol muestre “Necesitas iniciar sesión” y con un rol distinto de ADMIN muestre un mensaje propio de esa sección: “Solo administración puede ver las nóminas”. El resto de secciones debe seguir usando los ficheros de la raíz.
Ver solución
Basta un forbidden.tsx en la carpeta de la sección: Next.js usa el más cercano hacia arriba, igual que con not-found.tsx. El unauthorized.tsx de la raíz sigue sirviendo.
// app/gestion/empleados/forbidden.tsx
export default function SinPermisoNominas() {
return (
<div className="rounded border border-orange-300 bg-orange-50 p-4">
<h2 className="font-semibold">Solo administración puede ver las nóminas</h2>
</div>
)
}
// app/gestion/empleados/page.tsx
import { Suspense } from 'react'
import { cookies } from 'next/headers'
import { forbidden, unauthorized } from 'next/navigation'
async function Nominas() {
const rol = (await cookies()).get('rol')?.value
if (!rol) unauthorized()
if (rol !== 'ADMIN') forbidden()
return <p>Listado de nóminas</p>
}
export default function PaginaNominas() {
return (
<Suspense fallback={<p>Comprobando permisos...</p>}>
<Nominas />
</Suspense>
)
}
Resultado comprobado: sin cookie, /gestion/empleados muestra “Necesitas iniciar sesión” (el unauthorized.tsx de la raíz); con rol=USER, “Solo administración puede ver las nóminas”; y /gestion con rol=USER sigue mostrando el “No tienes permiso” general. Recuerda que necesitas experimental.authInterrupts y que la comprobación de la cookie es solo para el ejercicio: en el módulo 17 se sustituye por una sesión firmada.
Ejercicio 7 (propuesto): Registro de errores en fichero
Modifica onRequestError para que, además de escribir en la consola, añada cada error como una línea JSON a logs/errores.jsonl con fs/promises.appendFile. Comprueba que el digest del fichero coincide con la “Referencia” que muestra error.tsx. Pista: instrumentation.ts también se carga en el runtime edge si lo usas; comprueba process.env.NEXT_RUNTIME === 'nodejs' antes de importar fs con await import('node:fs/promises').
Ejercicio 8 (propuesto): Un límite que deja pasar lo que no entiende
Escribe un error.tsx para app/empleados/ que muestre una interfaz propia solo si el error no tiene digest (es decir, viene de un Client Component) y, en caso contrario, lo vuelva a lanzar con throw error para que lo capture app/error.tsx. Pruébalo con /empleados/100?fallo=1 y explica qué límite aparece.
Ejercicio 9 (propuesto): Cabecera que nunca rompe la aplicación
La cabecera del layout raíz muestra el nombre del usuario leyendo una API que a veces falla, y cuando falla aparece global-error.tsx. Usa catchError para que, si la cabecera falla, solo se sustituya el nombre por “Invitado” y el resto de la aplicación siga funcionando.
Ejercicio 10 (propuesto): Proteger la zona de gestión desde proxy.ts
Haz que /gestion responda un 401 de verdad cuando no hay cookie rol, en lugar de 200 con noindex. Pista: decide en proxy.ts (módulo 13) y devuelve un NextResponse con status: 401 antes de que se renderice la página. Comprueba el código con curl -o /dev/null -w "%{http_code}".
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Errores esperados | notFound(), valores de retorno | Forman parte del funcionamiento normal: se comprueban y se devuelven |
| Errores inesperados | throw new Error(..., { cause }) | Se lanzan y los recoge el límite de error más cercano |
| Límite de segmento | error.tsx | Client Component; captura página, loading, not-found y segmentos hijos, no el layout de su carpeta |
Props de error.tsx 🔄 16.3 | error: unknown, retry, reset | En 15: error: Error & { digest? } y solo reset |
| Reintentar | retry() | Vuelve a pedir datos al servidor; reset() no, y no recupera errores de servidor |
| Mensaje en producción | error.message, error.digest | Los errores de servidor llegan con un mensaje genérico (React #441); el digest enlaza con el registro |
| Errores en el build | Prerender | Un error durante next build hace fallar la compilación; lo dinámico va en <Suspense> |
| Layout raíz | global-error.tsx | Sustituye todo el documento; lleva <html>, <body> y sus propios estilos |
| Límites por componente 🆕 16.3 | catchError de next/error | Con retry y sin capturar notFound/redirect; en 15, clase con getDerivedStateFromError |
| No encontrado | notFound(), not-found.tsx | El más cercano; la raíz cubre las URL desconocidas (404) |
Interrupciones dentro de try | unstable_rethrow(e) | Primera línea del catch para no tragarse notFound ni redirect |
| Acceso | unauthorized(), forbidden() | 401 y 403; experimentales, requieren experimental.authInterrupts |
| Códigos HTTP y streaming | loading.tsx, <Suspense> | Si la respuesta ya empezó, sale 200 con noindex; para el código exacto, proxy.ts o Route Handlers |
| Server Actions | useActionState | Validación y conflictos como valor; lo inesperado lanza y sustituye el segmento |
| Route Handlers | Response.json(..., { status }) | Un error lanzado da 500 sin cuerpo |
| Navegador | instrumentation-client.ts | Escuchadores globales y onRouterTransitionStart; los límites no capturan errores de eventos |
| Registro del servidor | instrumentation.ts: register, onRequestError | Un único punto para todos los errores de servidor; routeType 'proxy' 🔄 16.0 |