В любой распределенной веб-системе сбои неизбежны: сетевые таймауты сторонних микросервисов, сбои подключений к базе данных, некорректный пользовательский ввод или обращение к несуществующим ресурсам (404). Отсутствие системного слоя перехвата ошибок приводит к деструктивным последствиям: аварийному падению всего интерфейса («белый экран смерти»), утечке внутренних серверных стектрейсов в клиентский DOM и потере несохраненного состояния пользователя.
В классических SPA-приложениях на React для локализации сбоев используются классовые компоненты React Error Boundary. Next.js App Router возводит этот паттерн в ранг архитектурного стандарта, интегрируя границы сбоев прямо в файловую систему маршрутизатора. Файлы error.tsx и global-error.tsx позволяют реализовать Изоляцию сбоев (Fault Isolation): сбой в одном изолированном виджете (например, в ленте аналитики) не нарушает функционирование навигационного меню и глобального лейаута.
В архитектуре Next.js 15+/16 и React 19 обработка ошибок четко разделяется на два класса:
useActionState.В Next.js App Router специальные файлы маршрута формируют строгую иерархию вложенности (Wrapping Hierarchy). Каждый уровень каталога оборачивает дочерние компоненты снаружи внутрь.
┌─────────────────────────────────────────────────────────┐
│ app/global-error.tsx │
│ (Перехватывает краш корневого app/layout.tsx) │
│ Содержит <html> и <body> │
└────────────────────────────┬────────────────────────────┘
│ обёртывает
┌────────────────────────────▼────────────────────────────┐
│ app/layout.tsx │
│ (Корневой серверный макет shell) │
└────────────────────────────┬────────────────────────────┘
│ обёртывает
┌────────────────────────────▼────────────────────────────┐
│ app/error.tsx │
│ (React Error Boundary клиентского уровня) │
└────────────────────────────┬────────────────────────────┘
│ обёртывает
┌────────────────────────────▼────────────────────────────┐
│ app/not-found.tsx │
│ (Срабатывает при вызове notFound()) │
└────────────────────────────┬────────────────────────────┘
│ обёртывает
┌────────────────────────────▼────────────────────────────┐
│ app/loading.tsx │
│ (Suspense Fallback для потокового рендера) │
└────────────────────────────┬────────────────────────────┘
│ обёртывает
┌────────────────────────────▼────────────────────────────┐
│ app/page.tsx │
│ (Серверный компонент страницы с бизнес-логикой) │
└─────────────────────────────────────────────────────────┘Фундаментальное правило иерархии: компонент error.tsx перехватывает ошибки только во вложенных в него компонентах (page.tsx, дочерние сегменты), но НЕ может перехватить ошибку в layout.tsx своего же уровня. Чтобы изолировать сбой в layout.tsx, файл error.tsx обязан находиться в родительском каталоге.
Для обработки аварийного падения самого верхнего корневого макета app/layout.tsx используется специальный аварийный шлюз — global-error.tsx.
В Server Component или Server Action происходит непредвиденное исключение.
Next.js генерирует криптографический digest, логирует полный трейс и удаляет секреты.
RSC Flight Protocol передает усеченный чанк с digest в клиентский поток.
Ближайший error.tsx по дереву маршрутов перехватывает ошибку и рендерит Fallback UI.
Пользователь вызывает router.refresh() и reset(), запрашивая свежий серверный стейт.
В приложении упал запрос данных внутри app/(dashboard)/layout.tsx. На странице отобразился белый экран, а соседний файл app/(dashboard)/error.tsx не сработал. Одновременно в продакшене клиент получил в консоли ошибку 'Digest: 18392019'. Почему не сработал error.tsx и что означает Digest?
error.tsx и сброс состояния#Компонент error.tsx обязан быть клиентским ('use client'). Для повторной попытки рендеринга используется функция reset(). Чтобы избежать повторного показа закэшированного ошибочного ответа сервера, вызов reset() объединяется с инвалидацией через 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(() => {
// Регистрация ошибки в клиентском трекере (Sentry / Datadog)
console.error('Captured dashboard segment error:', error);
}, [error]);
const handleRetry = () => {
startTransition(() => {
// 1. Инвалидируем клиентский Router Cache и запрашиваем свежий RSC Flight Payload
router.refresh();
// 2. Сбрасываем 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">Не удалось загрузить данные</h2>
<p className="text-sm text-text-secondary max-w-md mb-6">
Произошел технический сбой при формировании отчета. Команда разработки уже уведомлена.
</p>
{error.digest && (
<code className="text-xs text-text-muted bg-bg-surface px-3 py-1 rounded border border-border mb-6">
Код ошибки: {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 ? 'Обновление данных...' : 'Повторить попытку'}
</button>
</div>
);
}global-error.tsx#global-error.tsx рендерится только при падении самого корневого layout.tsx. Он полностью замещает всю разметку документа и обязан содержать теги <html> и <body>.
'use client';
interface GlobalErrorProps {
error: Error & { digest?: string };
reset: () => void;
}
export default function GlobalError({ error, reset }: GlobalErrorProps) {
return (
<html lang="ru">
<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">Критический сбой платформы</h1>
<p className="text-text-secondary text-sm mb-6">
Корневая инфраструктура приложения временно недоступна.
</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"
>
Перезагрузить приложение
</button>
</div>
</body>
</html>
);
}В отличие от error.tsx, файл not-found.tsx является серверным компонентом. Функция notFound() прерывает рендеринг текущего сегмента и отдает 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) {
// Немедленно прерывает рендеринг и монтирует ближайший not-found.tsx со статусом 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>
);
}Выбрасывание исключений (throw new Error) для ожидаемых ошибок пользователя — антипаттерн. Server Actions должны возвращать строго типизированные структуры результата (Discriminated Unions).
'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, 'Имя должно содержать минимум 2 символа').max(50),
bio: z.string().max(200, 'Биография не может превышать 200 символов').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: 'Ошибка валидации полей формы',
fieldErrors: validation.error.flatten().fieldErrors,
};
}
try {
await db.user.update({
where: { id: 'current-user-id' },
data: validation.data,
});
revalidatePath('/profile');
return { status: 'success', message: 'Профиль успешно обновлен' };
} catch (err: unknown) {
const errorMsg = err instanceof Error ? err.message : 'Неизвестная ошибка базы данных';
console.error('[UpdateProfile DB Error]:', errorMsg);
return {
status: 'error',
message: 'Не удалось сохранить изменения. Попробуйте позже.',
};
}
}useActionState#Хук useActionState управляет жизненным циклом мутации, хранит ошибки валидации и поддерживает прогрессивное улучшение (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">Имя</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 ? 'Сохранение...' : 'Сохранить профиль'}
</button>
</form>
);
}Разработчик написал обработчик формы в Server Action и обернул весь код в блок try/catch, внутри которого при успешном создании профиля вызывается redirect('/dashboard'). После сабмита форма зависает, редирект не происходит, а в консоли появляется ошибка 'Error: NEXT_REDIRECT'. В чем ошибка реализации?
Функции redirect() и notFound() в Next.js реализованы через генерацию специальных внутренних исключений (NEXT_REDIRECT и NEXT_NOT_FOUND), которые роутер обязан поймать выше по стеку вызовов. Если вы обернете вызов redirect() в наивный блок try { ... } catch (err), навигация будет перехвачена блоком catch, и редирект не сработает!
Решение: Либо вызывайте redirect() строго за пределами блока try/catch, либо проверяйте тип ошибки через 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; // Обязательно пробрасываем редирект дальше роутеру!
}
return { error: 'Сбой операции' };
}Функция reset() из пропсов error.tsx лишь пытается заново отрендерить клиентское дерево Error Boundary. Если ошибка произошла на сервере в Server Component, браузер заново отрендерит тот же самый закэшированный ошибочный Flight Payload, и экран снова упадет.
Решение: Всегда объединяйте сброс границы с ревалидацией через router.refresh() внутри startTransition:
const router = useRouter();
function handleRetry() {
startTransition(() => {
router.refresh(); // Запрашивает у сервера свежий RSC Flight Payload
reset(); // Сбрасывает клиентский Error Boundary
});
}Файл global-error.tsx активируется только тогда, когда ошибка падает в самом корневом файле app/layout.tsx. В этом сценарии корневой layout полностью демонтируется. Если global-error.tsx вернет обычный <div>, браузер получит невалидный DOM без разметки документа, что приведет к белому экрану и сбою стилей.
global-error.tsx обязан сам определять корневые теги <html> и <body>.
В целях безопасности Next.js в production маскирует все необработанные ошибки, возникшие в Server Components или Server Actions. Клиент всегда получает общее сообщение An error occurred in the Server Components render и уникальный digest.
Не выбрасывайте исключения для передачи валидационных ошибок пользователю! Используйте паттерн Result Pattern (возврат объекта { status: 'error', message: '...' }).