Форми та вхідні мережеві запити є головним вектором уразливостей вебзастосунків: від ін'єкцій шкідливого коду (XSS, SQL-ін'єкції) до пошкодження цілісності бази даних та відмови сервера в обслуговуванні через непередбачені формати корисного навантаження.
Історично інженерні команди дублювали логіку валідації: писали клієнтські перевірки для миттєвого відгуку інтерфейсу та серверні правила для захисту бекенд-інфраструктури. Такий підхід неминуче призводив до розсинхронізації правил, подвійних витрат на тестування та прихованих помилок під час зміни бізнес-вимог.
У сучасній екосистемі Next.js та React 19 стандартом валідації стала бібліотека Zod. Вона дозволяє одноразово описати сувору декларативну схему на TypeScript, яка використовується одночасно:
FormData всередині Server Actions та Route Handlers.z.infer.Валідація даних у Next.js базується на фундаментальному принципі Server-First Validation:
FormData. Завдяки Progressive Enhancement форма зберігає працездатність навіть до гідратації клієнтського бандла.schema.safeParseAsync(), який нормалізує типи та повертає об'єкт-дискримінатор замість аварійного збою потоку виконання.error.flatten() формує плаский асоціативний масив помилок, прив'язаний до імен полів форми.useActionState, який безшовно оновлює стан інтерфейсу через RSC Flight Payload.Користувач заповнює поля форми. Браузер виконує базову перевірку за допомогою атрибутів HTML5.
Форма зберігає здатність нативно відправлятися навіть за вимкненого JavaScript (Progressive Enhancement).
Браузер відправляє HTTP POST-запит із корисним навантаженням у Server Action.
Сервер зчитує FormData, витягуючи масиви через getAll та нормалізуючи типи через Zod coerce/preprocess.
Схема Zod перевіряє типи, бізнес-обмеження та виконує асинхронні перевірки в базі даних.
У разі виявлення помилок виконання не переривається фатальним збоєм, формується типізована карта помилок.
Помилки валідації повертаються клієнту та реактивно оновлюють стан форми.
Хук useActionState зберігає стан між рендерами, передаючи помилки в UI-компоненти інпутів.
Схеми Zod описують типи даних, обмеження довжини, регулярні вирази та автоматичне приведення типів (type coercion):
import { z } from 'zod';
export const registrationSchema = z.object({
username: z
.string()
.trim()
.min(3, 'Ім\'я користувача повинно містити щонайменше 3 символи')
.max(30, 'Ім\'я користувача не повинно перевищувати 30 символів')
.regex(/^[a-zA-Z0-9_]+$/, 'Дозволені лише літери, цифри та знак підкреслення'),
email: z
.string()
.trim()
.email('Вкажіть коректну адресу електронної пошти'),
age: z.coerce
.number()
.int('Вік повинен бути цілим числом')
.min(18, 'Реєстрація дозволена лише з 18 років')
.max(120, 'Вказано некоректний вік'),
password: z
.string()
.min(8, 'Пароль повинен містити щонайменше 8 символів')
.regex(/[A-Z]/, 'Пароль повинен містити хоча б одну велику літеру')
.regex(/[0-9]/, 'Пароль повинен містити хоча б одну цифру'),
});
// Автоматичне виведення статичного типу TypeScript (Schema Type Inference)
export type RegistrationInput = z.infer<typeof registrationSchema>;Усередині Server Action суворо заборонено використовувати z.parse(), оскільки він викидає фатальний виняток ZodError. Замість цього застосовується метод safeParse, який повертає типобезпечний результат.
'use server';
import { registrationSchema } from '@/lib/schemas/auth';
import { db } from '@/lib/db';
import { users } from '@/lib/schema';
export interface RegistrationFormState {
success: boolean;
message?: string;
errors?: Record<string, string[] | undefined>;
fields?: Record<string, string>; // Збереження введених даних для запобігання скиданню полів при помилках
}
export async function registerUserAction(
_prevState: RegistrationFormState,
formData: FormData
): Promise<RegistrationFormState> {
// 1. Вилучення пласких рядкових даних із FormData
const rawData = Object.fromEntries(formData.entries());
// 2. Безпечний парсинг через safeParse
const result = registrationSchema.safeParse(rawData);
// 3. Обробка помилок валідації (із збереженням введених користувачем значень)
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors,
message: 'Будь ласка, виправте помилки заповнення форми',
fields: {
username: String(rawData.username || ''),
email: String(rawData.email || ''),
age: String(rawData.age || ''),
},
};
}
// 4. Гарантовано перевірені та суворо типізовані дані
const { username, email, age, password } = result.data;
try {
await db.insert(users).values({
username,
email,
age,
passwordHash: await hashPassword(password),
});
return {
success: true,
message: 'Обліковий запис успішно зареєстровано!',
};
} catch (err: unknown) {
const errorDetails = err instanceof Error ? err.message : 'Database error';
console.error('Registration failed:', errorDetails);
return {
success: false,
message: 'Не вдалося зберегти користувача в базі даних',
fields: {
username: String(rawData.username || ''),
email: String(rawData.email || ''),
age: String(rawData.age || ''),
},
};
}
}Хук React 19 useActionState приймає Server Action та початковий стан, повертаючи актуальні помилки, збережені введені дані та статус очікування.
'use client';
import { useActionState } from 'react';
import { registerUserAction, type RegistrationFormState } from '@/app/actions/register';
const initialState: RegistrationFormState = {
success: false,
message: '',
errors: undefined,
fields: undefined,
};
export function RegisterForm() {
const [state, formAction, isPending] = useActionState(registerUserAction, initialState);
return (
<form action={formAction} className="space-y-4 max-w-md bg-bg-surface p-6 rounded-xl border border-border">
<div>
<label htmlFor="username" className="block text-sm font-medium text-text-primary">
Ім'я користувача
</label>
<input
id="username"
name="username"
type="text"
defaultValue={state.fields?.username ?? ''}
className="mt-1 w-full rounded-md border border-border bg-bg-elevated px-3 py-2 text-text-primary focus:border-accent-cyan"
/>
{state.errors?.username && (
<p className="mt-1 text-xs text-danger">{state.errors.username[0]}</p>
)}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-text-primary">
Email
</label>
<input
id="email"
name="email"
type="email"
defaultValue={state.fields?.email ?? ''}
className="mt-1 w-full rounded-md border border-border bg-bg-elevated px-3 py-2 text-text-primary focus:border-accent-cyan"
/>
{state.errors?.email && (
<p className="mt-1 text-xs text-danger">{state.errors.email[0]}</p>
)}
</div>
<button
type="submit"
disabled={isPending}
className="w-full rounded-md bg-accent-cyan px-4 py-2 font-semibold text-black transition-opacity disabled:opacity-50 cursor-pointer"
>
{isPending ? 'Перевірка та збереження...' : 'Зареєструватися'}
</button>
{state.message && (
<div
role="status"
className={`p-3 rounded text-sm ${state.success ? 'bg-success/10 text-success' : 'bg-danger/10 text-danger'}`}
>
{state.message}
</div>
)}
</form>
);
}Користувач відправляє форму з чекбоксом згоди з правилами. Якщо чекбокс НЕ відзначений, браузер відправляє поле agree: 'false' або взагалі опускає його. Що поверне z.coerce.boolean().parse('false') і чому?
У протоколі FormData зняті чекбокси не передаються взагалі, а поля з однаковими іменами (мультиселекти) вимагають виклику formData.getAll().
'use server';
import { z } from 'zod';
export const userSettingsSchema = z.object({
// Безпечне перетворення чекбокса без пастки Boolean("false")
notificationsEnabled: z.preprocess(
(val) => val === 'on' || val === 'true' || val === true,
z.boolean()
),
// Масиви значень (мультиселекти та групи чекбоксів)
assignedRoles: z
.array(z.string())
.min(1, 'Необхідно вибрати хоча б одну роль'),
});
export interface SettingsFormState {
success?: boolean;
errors?: Record<string, string[] | undefined>;
message?: string;
}
export async function updateSettingsAction(
_prevState: SettingsFormState,
formData: FormData
): Promise<SettingsFormState> {
const payload = {
notificationsEnabled: formData.get('notificationsEnabled'),
assignedRoles: formData.getAll('assignedRoles'), // Зчитування всіх елементів масиву
};
const result = userSettingsSchema.safeParse(payload);
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors,
};
}
return { success: true, message: 'Налаштування оновлено' };
}[!IMPORTANT] Гібридна валідація: Під час інтеграції з React Hook Form атрибут форми
action={}не використовується. Замість цього обробникonSubmitздійснює синхронну валідацію на клієнті черезzodResolver, після чого програмно викликає Server Action всерединіstartTransition.
'use client';
import { useTransition } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { registrationSchema, type RegistrationInput } from '@/lib/schemas/auth';
import { registerUserAction } from '@/app/actions/register';
export function ClientValidatedForm() {
const [isPending, startTransition] = useTransition();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<RegistrationInput>({
resolver: zodResolver(registrationSchema),
});
const onSubmit = (data: RegistrationInput) => {
startTransition(async () => {
const formData = new FormData();
Object.entries(data).forEach(([key, val]) => formData.append(key, String(val)));
await registerUserAction({ success: false }, formData);
});
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-3">
<input {...register('username')} placeholder="Ім'я користувача" className="border p-2" />
{errors.username && <p className="text-red-500 text-xs">{errors.username.message}</p>}
<button type="submit" disabled={isPending}>Відправити</button>
</form>
);
}Схема містить поле age: z.coerce.number().min(18). Чому при передачі z.infer<typeof schema> у React Hook Form (useForm<T>) виникає помилка компіляції TypeScript?
У JavaScript виклик Boolean("false") повертає true, оскільки будь-який непорожній рядок у JS є truthy-значенням!
Якщо клієнт передасть рядкове поле зі значенням "false", z.coerce.boolean() перетворить його на true. Для надійної обробки HTML-чекбоксів використовуйте z.preprocess() з явною перевіркою рядкового значення 'on'.
// ❌ ПОМИЛКА: Чекбокс зі значенням "false" стане true!
const schema = z.object({
agree: z.coerce.boolean(),
});
schema.parse({ agree: "false" }); // => { agree: true } !
// ✅ ПРАВИЛЬНО: Використовувати z.preprocess або перевірку рядкового прапорця "on"
const checkboxSchema = z.preprocess(
(val) => val === 'on' || val === 'true' || val === true,
z.boolean()
);Стандартний метод Object.fromEntries(formData.entries()) бере лише перше або останнє значення для ключів з однаковими іменами. Якщо у вашій формі група чекбоксів із однаковим name="permissions" або мультиселект, в об'єкт потрапить лише один елемент замість масиву.
Рішення: Для полів, що містять списки, завжди явно використовуйте formData.getAll('field_name').
Ніколи не використовуйте z.parse() всередині Server Actions. При невалідних даних він викидає важкий ZodError як виняток. Це призводить до аварійного переривання виконання функції, переходу в режим помилки 500 та неможливості повернути користувачеві зрозумілий список помилок полів форми.
Рішення: Усередині Server Actions використовуйте виключно schema.safeParse() або schema.safeParseAsync().
При використанні методів приведення типів (z.coerce, z.preprocess, z.transform) форма даних на вході схеми (Input) та на виході схеми (Output) перестає збігатися:
z.input<typeof schema> — описує сирі типи до парсингу (рядкові значення з HTML-інпутів).z.infer<typeof schema> (або z.output) — описує очищений та приведений результат (числа, об'єкти Date).Якщо передати z.infer<typeof schema> як тип форми в React Hook Form (useForm<T>), TypeScript видасть помилку несумісності, оскільки нативні поля інпутів браузера оперують рядками, а схема на виході очікує number або Date.
Рішення: Для клієнтських форм завжди розділяйте тип введення форми (z.input) та типізацію очищеного DTO для бази даних (z.infer).
import { z } from 'zod';
export const userProfileSchema = z.object({
username: z.string().min(2, 'Мінімум 2 символи'),
// У браузері інпут завжди відправляє рядок, а схема перетворює його на number
age: z.coerce.number().int().min(18, 'Лише з 18 років'),
birthDate: z.coerce.date(),
});
// ❌ ПОМИЛКА ДЛЯ useForm: містить приведені типи (number, Date), несумісні з рядковими інпутами
export type UserProfileDTO = z.infer<typeof userProfileSchema>;
// ✅ ПРАВИЛЬНО ДЛЯ useForm<UserProfileInput>: містить вихідні типи полів форми (string | number)
export type UserProfileInput = z.input<typeof userProfileSchema>;