Формы и входящие сетевые запросы представляют собой главный вектор уязвимостей веб-приложения: от внедрения вредоносного кода (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).
Браузер отправляет POST-запрос с полезной нагрузкой в Server Action.
Сервер считывает FormData, извлекая массивы и нормализуя типы через 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>;