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-found y page, y qué captura cada fichero
  • Escribir límites de error con error.tsx y saber qué props recibe de verdad en Next.js 16.4: error, retry y reset
  • Saber por qué retry() recupera una página que falló en el servidor y reset() no
  • Leer el digest de 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() y forbidden(), 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.ts y onRequestError

💡 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() y redirect() 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 un try/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>

Cajas anidadas: layout, template, límite de error, Suspense, límites de not-found y la página, con notas sobre lo que captura error.tsx y lo que no

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:

  1. Un error.tsx captura lo que hay por debajo de él: la página, el loading.tsx, el not-found.tsx y todos los segmentos hijos que no tengan su propio error.tsx.
  2. Un error.tsx no captura el layout de su misma carpeta, porque el layout está por fuera. Si falla app/empleados/layout.tsx, el error sube al error.tsx de app/.
  3. Los errores suben hasta el límite más cercano. Puedes poner un error.tsx gené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:

  1. '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 build falla con error.tsx must be a Client Component.
  2. El componente se exporta por defecto y recibe sus datos por props; no lo usas tú, lo coloca Next.js.
  3. error es de tipo unknown. En JavaScript se puede lanzar cualquier cosa (throw 'texto', throw 42), así que antes de leer message hay que comprobar que es un Error.
  4. El useEffect registra 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):

Página de empleados con la cabecera intacta y una caja roja de error con el mensaje minimizado de React 441, la referencia 4199489154, las props error, reset y retry, y un botón Reintentar

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.tsx recibe solo error (tipado como Error & { digest?: string }) y reset, que no vuelve a pedir los datos al servidor. Next.js 16.2 añadió unstable_retry, y 16.3 lo estabilizó como retry. Si usas Next.js 15: para reintentar de verdad, llama dentro de startTransition a router.refresh() y después a reset() (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 prop reset existe), pero el botón “Reintentar” no recuperará una página que falló en el servidor. Si copias un ejemplo, cambia reset por retry.

¿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”.

Esquema: en el servidor la página lanza el error, Next.js calcula el digest y lo escribe en el registro con el mensaje completo; al navegador solo viaja el digest y un mensaje genérico

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 digest es más útil todavía si los errores se guardan en un sitio consultable y no solo en la terminal. Eso lo resuelve onRequestError, 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.tsx concreto 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:

Caja roja con el mensaje minimizado de React 441, Botones pulsados: 0 y los botones retry() y reset()

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

Mensaje verde "Nóminas cargadas en el intento 4" bajo la cabecera de la aplicación

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 contador intentos no 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 y retry() 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 retry y reset() no vuelve a pedir datos. Si usas Next.js 15: combina router.refresh() (que pide de nuevo al servidor los Server Components de la ruta) con reset() dentro de un mismo startTransition.

// 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(), leer cookies() o params, 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 con getDerivedStateFromError (apartado 6.3); funciona, pero no tiene retry() ni deja pasar por sí solo notFound() o redirect().

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.

Panel con tres tarjetas: Empleados muestra 24; Pedidos muestra un aviso rojo con el mensaje minimizado de React 441; Ventas muestra un aviso rojo con el texto El gráfico necesita al menos un dato

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 catchError el componente de un error.tsx: ese fichero ya se renderiza dentro de un límite que pone Next.js. catchError es 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() y redirect() 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>
  )
}

Página de empleados con un aviso amarillo Ese empleado no existe y un enlace al empleado 100

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.tsx no recibe props: no sabe qué URL se pidió ni por qué. Si necesitas personalizar el mensaje según el recurso, pon un not-found.tsx en la carpeta de ese recurso, como el de empleados/[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 del try, 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 del catch, 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 con redirect(): dentro de un try sin unstable_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>
  )
}

Página de gestión con un aviso azul Necesitas iniciar sesión y un enlace Iniciar sesión

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

Página de gestión con un aviso naranja No tienes permiso

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() y unauthorized() no se pueden llamar en el layout raíz, igual que notFound(): 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 el error.tsx del mismo segmento.
  • Si falla el propio loading.tsx (raro, pero posible), también lo captura ese error.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, el noindex cumple 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> ni loading.tsx se renderiza entera antes de enviar nada, y entonces notFound() o forbidden() sí producen el 404 o el 403. El 200 con noindex es consecuencia del streaming, no de la versión. Comprobado en Next.js 15.5 con la misma comprobación de cookie, sin <Suspense>: /gestion respondió 401 sin cookie, 403 con rol=USER y 200 con rol=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>
  )
}

Página casi en blanco con fondo rosado: La aplicación ha dejado de funcionar, El layout raíz ha fallado y un botón Reintentar

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 de global-error.tsx deben ser completos: no cuentes ni con los de la aplicación ni con los del navegador.
  • No admite metadata ni generateMetadata, 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 dev solo 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:

Formulario de alta de departamento con el texto ab y el mensaje en rojo El nombre debe tener al menos 3 letras

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:

Caja roja Algo ha ido mal con el mensaje minimizado de React 441, Referencia 2756031927, un botón Reintentar y un enlace Ir al inicio

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 sin await dentro 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 con await.

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: onRequestError registra los errores del servidor. Los que solo ocurren en el navegador (un clic que falla) no pasan por aquí: para esos está instrumentation-client.ts o el useEffect de tu error.tsx.

13.3. routeType: 'proxy' 🔄 16.0

🔄 Cambia en Next.js 16.0. En Next.js 15 los errores del middleware llegan a onRequestError con routeType: '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 de instrumentation.ts es 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