У будь-якій розподіленій веб-системі збої є неминучими: мережеві таймаути сторонніх мікросервісів, вичерпання пулу з'єднань із базою даних, некоректні дані від користувача або запити до неіснуючих ресурсів (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 службові файли формують чітку ієрархію вкладеності. Кожен рівень каталогу огортає дочірні компоненти ззовні всередину:
┌─────────────────────────────────────────────────────────┐
│ app/global-error.tsx │
│ (Перехоплює краш кореневого app/layout.tsx) │
│ Містить теги <html> та <body> │
└────────────────────────────┬────────────────────────────┘
│ огортає
┌────────────────────────────▼────────────────────────────┐
│ app/layout.tsx │
│ (Кореневий серверний макет застосунку) │
└────────────────────────────┬────────────────────────────┘
│ огортає
┌────────────────────────────▼────────────────────────────┐
│ app/error.tsx │
│ (Клієнтський шар React Error Boundary) │
└────────────────────────────┬────────────────────────────┘
│ огортає
┌────────────────────────────▼────────────────────────────┐
│ app/not-found.tsx │
│ (Спрацьовує при виклику notFound()) │
└────────────────────────────┬────────────────────────────┘
│ огортає
┌────────────────────────────▼────────────────────────────┐
│ app/loading.tsx │
│ (Suspense Fallback для потокового SSR) │
└────────────────────────────┬────────────────────────────┘
│ огортає
┌────────────────────────────▼────────────────────────────┐
│ app/page.tsx │
│ (Серверний компонент сторінки з бізнес-логікою) │
└─────────────────────────────────────────────────────────┘Фундаментальне архітектурне правило: компонент error.tsx перехоплює винятки лише у своїх вкладених дочірніх компонентах (page.tsx та нащадках), але НЕ МОЖЕ перехопити помилку в layout.tsx свого ж рівня. Щоб ізолювати сбій у певному layout.tsx, файл error.tsx зобов'язаний знаходитися в батьківському каталозі.
Для перехоплення фатальних збоїв у найвищому кореневому макеті (app/layout.tsx) Next.js надає аварійний шлюз останньої надії: global-error.tsx.
У Server Component або Server Action виникає непередбачуваний серверний збій.
Next.js обчислює криптографічний хеш digest, записує повний трейс у серверний лог та видаляє секрети.
RSC Flight Protocol передає очищений чанк з digest у клієнтський потік даних.
Найближчий компонент error.tsx перехоплює виняток і монтує свій fallback-інтерфейс.
Користувач запускає router.refresh() та reset(), запитуючи свіжий серверний стан.
У застосунку стався збій запиту до бази даних усередині app/(dashboard)/layout.tsx. На екрані з'явився білий екран, а сусідній файл app/(dashboard)/error.tsx не активувався. У консолі браузера в продакшені відображається лише 'Digest: 18392019'. Чому не спрацював error.tsx і що означає Digest?
error.tsx та патерн надійного Retry#Файл error.tsx повинен мати директиву 'use client'. Він приймає об'єкт винятку error та функцію 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 виконується лише в разі падіння самого кореневого app/layout.tsx. Оскільки кореневий макет при цьому повністю демонтується, global-error.tsx зобов'язаний сам визначати кореневі теги <html> та <body>:
'use client';
interface GlobalErrorProps {
error: Error & { digest?: string };
reset: () => void;
}
export default function GlobalError({ error, reset }: GlobalErrorProps) {
return (
<html lang="uk">
<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 повинні повертати типізовані структури результату:
'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. Після створення профілю всередині try викликається redirect('/dashboard'). Під час сабміту форма зависає, редирект не відбувається, а в логах з'являється 'Error: NEXT_REDIRECT'. У чому причина?
Функції redirect() та notFound() реалізовані через генерацію внутрішніх винятків (NEXT_REDIRECT, NEXT_NOT_FOUND), які роутер зобов'язаний обробити вище за стеком. Звичайний блок try { ... } catch (err) перехоплює навігацію та блокує перехід.
Рішення: Викликайте 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() перезавантажує лише клієнтський стан межі помилок. Якщо збій трапився на сервері, браузер знову спробує відрендерити той самий кешований Flight Payload і знову впаде.
Рішення: Завжди поєднуйте скидання межі з router.refresh() усередині startTransition:
const router = useRouter();
function handleRetry() {
startTransition(() => {
router.refresh(); // Отримує свіжий стан сервера
reset(); // Скидає клієнтську межу помилок
});
}Файл global-error.tsx активується лише при падінні кореневого app/layout.tsx. Оскільки кореневий макет демонтується, повернення звичайного <div> створить невалідний DOM без базової структури документа.
global-error.tsx обов'язково має рендерити власні теги <html> та <body>.
У продакшені Next.js заради безпеки маскує всі необроблені винятки на сервері, підставляючи загальне повідомлення та криптографічний digest.
Не викидайте винятки для передачі валідаційних помилок клієнту! Завжди використовуйте Result Pattern (повернення об'єкта { status: 'error', message: '...' }).