En cualquier arquitectura web distribuida, los fallos son inevitables: latencias o tiempos de espera en microservicios externos, agotamiento del pool de conexiones a la base de datos, entradas de usuario malformadas o consultas a recursos inexistentes (404). La ausencia de una capa de contención sistemática de excepciones provoca consecuencias destructivas: caída en cascada de la interfaz completa ("pantalla blanca de la muerte"), filtración de trazas internas del servidor (stack traces) al DOM del cliente y pérdida irreparable del estado de la sesión del usuario.
En las aplicaciones SPA clásicas con React, los fallos se aislaban mediante componentes de clase con React Error Boundaries. Next.js App Router eleva este patrón a estándar arquitectónico de primer nivel, integrando los límites de error directamente en el sistema de archivos del enrutador. Los archivos error.tsx y global-error.tsx permiten implementar el Aislamiento de Fallos (Fault Isolation): un fallo en un widget aislado (por ejemplo, en el panel de analíticas) mantiene plenamente funcionales la barra lateral y el diseño principal.
En Next.js 15+/16 y React 19, el manejo de errores se divide estrictamente en dos categorías:
useActionState de React 19.En Next.js App Router, los archivos convencionales de ruta conforman una jerarquía estricta de anidamiento (Wrapping Hierarchy). Cada nivel de directorio envuelve a sus componentes hijos desde el exterior hacia el interior:
┌─────────────────────────────────────────────────────────┐
│ app/global-error.tsx │
│ (Intercepta caídas en el raíz app/layout.tsx) │
│ Requiere <html> y <body> │
└────────────────────────────┬────────────────────────────┘
│ envuelve
┌────────────────────────────▼────────────────────────────┐
│ app/layout.tsx │
│ (Layout raíz del servidor / Shell) │
└────────────────────────────┬────────────────────────────┘
│ envuelve
┌────────────────────────────▼────────────────────────────┐
│ app/error.tsx │
│ (Límite React Error Boundary del cliente) │
└────────────────────────────┬────────────────────────────┘
│ envuelve
┌────────────────────────────▼────────────────────────────┐
│ app/not-found.tsx │
│ (Activado al invocar notFound()) │
└────────────────────────────┬────────────────────────────┘
│ envuelve
┌────────────────────────────▼────────────────────────────┐
│ app/loading.tsx │
│ (Fallback de Suspense para streaming) │
└────────────────────────────┬────────────────────────────┘
│ envuelve
┌────────────────────────────▼────────────────────────────┐
│ app/page.tsx │
│ (Server Component con lógica de negocio) │
└─────────────────────────────────────────────────────────┘Una regla arquitectónica fundamental: un límite error.tsx intercepta excepciones únicamente en sus componentes hijos (page.tsx y segmentos anidados), pero NO puede capturar un error originado en el layout.tsx de su mismo nivel. Para aislar un fallo en layout.tsx, el archivo error.tsx debe ubicarse en el directorio padre superior.
Para contener caídas críticas en el layout raíz de la aplicación (app/layout.tsx), Next.js proporciona el salvavidas definitivo: global-error.tsx.
Ocurre una excepción no controlada en un Server Component, consulta de datos o Server Action.
Next.js genera un digest seguro, registra la traza completa en stdout del servidor y elimina secretos.
El protocolo RSC Flight serializa y transmite el fragmento saneado con el digest hacia el cliente.
El archivo error.tsx más próximo en la jerarquía captura el error y monta su interfaz alternativa (Fallback).
El usuario ejecuta router.refresh() junto con reset(), invalidando la caché RSC y solicitando datos nuevos.
Una consulta a la base de datos falla dentro de app/(dashboard)/layout.tsx. La página muestra una pantalla en blanco y el archivo hermano app/(dashboard)/error.tsx no se activa. En la consola del navegador en producción se observa 'Digest: 18392019'. ¿Por qué no se activó error.tsx y qué representa el Digest?
error.tsx y reintentos fiables#Un archivo error.tsx debe declararse obligatoriamente como Client Component ('use client'). Recibe el objeto error capturado y la función reset(). Para evitar volver a renderizar una respuesta del servidor fallida que haya quedado en caché, combine reset() con la invalidación mediante router.refresh():
'use client';
import { useEffect, useTransition } from 'react';
import { useRouter } from 'next/navigation';
interface ErrorBoundaryProps {
error: Error & { digest?: string };
reset: () => void;
}
export default function DashboardErrorBoundary({ error, reset }: ErrorBoundaryProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
useEffect(() => {
// Envío de telemetría a herramientas de monitorización (Sentry / Datadog)
console.error('Error capturado en el segmento del dashboard:', error);
}, [error]);
const handleRetry = () => {
startTransition(() => {
// 1. Invalida la Router Cache del cliente y solicita un nuevo RSC Flight Payload
router.refresh();
// 2. Reinicia el React Error Boundary
reset();
});
};
return (
<div className="flex flex-col items-center justify-center p-8 rounded-xl border border-red-500/20 bg-red-950/10 text-center">
<div className="w-12 h-12 rounded-full bg-red-500/10 flex items-center justify-center mb-4 text-red-400 font-bold">
!
</div>
<h2 className="text-xl font-semibold text-text-primary mb-2">No se pudieron cargar los datos</h2>
<p className="text-sm text-text-secondary max-w-md mb-6">
Se produjo un error técnico al generar este informe. El equipo de ingeniería ya ha sido notificado.
</p>
{error.digest && (
<code className="text-xs text-text-muted bg-bg-surface px-3 py-1 rounded border border-border mb-6">
Código de referencia: {error.digest}
</code>
)}
<button
onClick={handleRetry}
disabled={isPending}
className="px-5 py-2.5 rounded-lg bg-accent-violet text-white font-medium hover:bg-accent-violet/90 transition-colors disabled:opacity-50"
>
{isPending ? 'Actualizando datos...' : 'Reintentar operación'}
</button>
</div>
);
}global-error.tsx#global-error.tsx solo se ejecuta cuando se produce un error no capturado dentro de app/layout.tsx. Dado que el layout raíz se desmonta por completo durante la caída, global-error.tsx debe definir sus propias etiquetas <html> y <body>:
'use client';
interface GlobalErrorProps {
error: Error & { digest?: string };
reset: () => void;
}
export default function GlobalError({ error, reset }: GlobalErrorProps) {
return (
<html lang="es">
<body className="bg-bg-primary text-text-primary min-h-screen flex items-center justify-center p-6">
<div className="max-w-lg w-full bg-bg-surface border border-red-500/30 rounded-2xl p-8 shadow-2xl text-center">
<h1 className="text-2xl font-bold text-red-400 mb-3">Fallo crítico de la plataforma</h1>
<p className="text-text-secondary text-sm mb-6">
La infraestructura base de la aplicación no está disponible temporalmente.
</p>
{error.digest && (
<div className="text-xs font-mono text-text-muted bg-bg-primary p-2 rounded mb-6">
Digest: {error.digest}
</div>
)}
<button
onClick={() => reset()}
className="w-full py-3 bg-red-600 hover:bg-red-700 text-white rounded-xl font-medium transition-colors"
>
Reiniciar aplicación
</button>
</div>
</body>
</html>
);
}notFound() y not-found.tsx#A diferencia de error.tsx, el archivo not-found.tsx es un Server Component. Invocar notFound() aborta el renderizado del segmento actual y emite una cabecera de respuesta HTTP 404 Not Found:
import { notFound } from 'next/navigation';
import { db } from '@/shared/lib/db';
interface UserPageProps {
params: Promise<{ id: string }>;
}
export default async function UserProfilePage({ params }: UserPageProps) {
const { id } = await params;
const user = await db.user.findUnique({ where: { id } });
if (!user) {
// Interrumpe inmediatamente el renderizado y monta el not-found.tsx más cercano con status 404
notFound();
}
return (
<main className="p-6">
<h1 className="text-2xl font-bold">{user.name}</h1>
<p className="text-text-secondary">{user.email}</p>
</main>
);
}Lanzar excepciones (throw new Error) para gestionar fallos habituales de usuario es un antipatrón. Las Server Actions deben retornar estructuras de datos discriminadas estrictamente tipadas:
'use server';
import { z } from 'zod';
import { db } from '@/shared/lib/db';
import { revalidatePath } from 'next/cache';
const UpdateProfileSchema = z.object({
name: z.string().min(2, 'El nombre debe contener al menos 2 caracteres').max(50),
bio: z.string().max(200, 'La biografía no puede exceder 200 caracteres').optional(),
});
export type ActionState =
| { status: 'idle' }
| { status: 'success'; message: string }
| { status: 'error'; message: string; fieldErrors?: Record<string, string[]> };
export async function updateProfileAction(
_prevState: ActionState,
formData: FormData
): Promise<ActionState> {
const rawData = {
name: formData.get('name'),
bio: formData.get('bio'),
};
const validation = UpdateProfileSchema.safeParse(rawData);
if (!validation.success) {
return {
status: 'error',
message: 'Fallo de validación en los campos enviados',
fieldErrors: validation.error.flatten().fieldErrors,
};
}
try {
await db.user.update({
where: { id: 'current-user-id' },
data: validation.data,
});
revalidatePath('/profile');
return { status: 'success', message: 'Perfil actualizado correctamente' };
} catch (err: unknown) {
const errorMsg = err instanceof Error ? err.message : 'Error desconocido de base de datos';
console.error('[UpdateProfile DB Error]:', errorMsg);
return {
status: 'error',
message: 'No se pudieron guardar los cambios. Inténtelo más tarde.',
};
}
}useActionState de React 19#El hook useActionState gestiona el ciclo de vida de la mutación, retiene errores de validación de campos y preserva la mejora progresiva (Progressive Enhancement):
'use client';
import { useActionState } from 'react';
import { updateProfileAction, type ActionState } from '../server/actions';
const initialState: ActionState = { status: 'idle' };
export function ProfileForm() {
const [state, formAction, isPending] = useActionState(updateProfileAction, initialState);
return (
<form action={formAction} className="flex flex-col gap-5 max-w-md">
{state.status === 'error' && (
<div className="p-3 text-sm text-red-400 bg-red-950/40 border border-red-800 rounded-lg">
{state.message}
</div>
)}
{state.status === 'success' && (
<div className="p-3 text-sm text-emerald-400 bg-emerald-950/40 border border-emerald-800 rounded-lg">
{state.message}
</div>
)}
<div className="flex flex-col gap-1.5">
<label htmlFor="name" className="text-sm font-medium text-text-secondary">Nombre</label>
<input
id="name"
name="name"
required
disabled={isPending}
className="px-3.5 py-2 rounded-lg bg-bg-surface border border-border focus:border-accent-violet outline-none"
/>
{state.status === 'error' && state.fieldErrors?.name && (
<p className="text-xs text-red-400">{state.fieldErrors.name[0]}</p>
)}
</div>
<button
type="submit"
disabled={isPending}
className="py-2.5 px-4 rounded-lg bg-accent-violet text-white font-medium hover:bg-accent-violet/90 transition-colors disabled:opacity-50"
>
{isPending ? 'Guardando...' : 'Guardar perfil'}
</button>
</form>
);
}Un desarrollador implementa una Server Action y envuelve toda la lógica en un bloque try/catch. Tras la mutación exitosa, se invoca redirect('/dashboard') dentro del bloque try. En producción, el envío del formulario se congela, no ocurre redirección alguna y el servidor registra 'Error: NEXT_REDIRECT'. ¿Cuál es el origen del fallo?
Las funciones redirect() y notFound() de Next.js operan arrojando excepciones internas del sistema (NEXT_REDIRECT y NEXT_NOT_FOUND). Si envuelve la lógica en un bloque try { ... } catch (err) indiscriminado, la excepción de navegación será atrapada como un error común y la redirección nunca llegará al enrutador.
Solución: Invoque redirect() fuera del bloque try/catch o relance la excepción comprobando isRedirectError:
import { redirect } from 'next/navigation';
import { isRedirectError } from 'next/dist/client/components/redirect-error';
try {
await performMutation();
redirect('/dashboard');
} catch (error) {
if (isRedirectError(error)) {
throw error; // ¡Imprescindible relanzar el error para que el enrutador lo procese!
}
return { error: 'Fallo en la operación' };
}La función reset() suministrada en error.tsx únicamente re-renderiza el subárbol del cliente. Si la excepción se produjo en el servidor dentro de un Server Component (como una desconexión temporal de la base de datos), el cliente re-renderizará el mismo payload Flight erróneo almacenado en caché y volverá a fallar.
Solución: Vincule siempre la llamada a reset() con router.refresh() dentro de startTransition:
const router = useRouter();
function handleRetry() {
startTransition(() => {
router.refresh(); // Solicita al servidor un nuevo RSC Flight Payload
reset(); // Reinicia el límite React Error Boundary
});
}El archivo global-error.tsx entra en acción únicamente si el layout raíz app/layout.tsx colapsa. En este escenario, el layout principal se desmonta por completo. Si global-error.tsx devuelve un <div> común, el navegador recibirá un documento HTML malformado sin estructura básica, provocando pérdida de estilos y pantallas en blanco.
global-error.tsx debe declarar obligatoriamente sus propias etiquetas <html> y <body>.
Por motivos de ciberseguridad, Next.js enmascara en entornos de producción todas las excepciones no capturadas provenientes de Server Components o Server Actions. El cliente siempre recibirá un mensaje genérico junto con un digest opaco.
Nunca lance excepciones para comunicar validaciones de usuario. Utilice siempre el Result Pattern (devolviendo objetos estructurados { status: 'error', message: '...' }).