Коротко: Полное руководство по типизации Next.js Server Actions с помощью Zod: схемы данных, безопасный парсинг, useActionState и архитектурные паттерны.
Переход на App Router в Next.js кардинально изменил подход к работе с формами и мутациями данных. Появление Server Actions позволило вызывать серверные функции напрямую из компонентов интерфейса без необходимости вручную описывать API-роуты. Однако за видимой простотой кроется архитектурная ловушка: статические типы TypeScript существуют исключительно на этапе компиляции, в то время как данные от пользователя поступают в рантайме.
Полагаться на автоматическую типобезопасность на границе между клиентом и сервером — частая причина скрытых багов и уязвимостей. Чтобы построить надежную архитектуру, исключить дублирование кода и защитить систему от некорректных входных данных, необходим связующий слой рантайм-валидации. В этой статье разберем, как выстроить сквозную типизацию Server Actions с помощью библиотеки Zod.
Анатомия Server Actions: почему статических типов недостаточно
Функции с директивой 'use server' создают иллюзию бесшовного монолита. Разработчик объявляет аргументы функции, типизирует их с помощью интерфейсов TypeScript и вызывает экшен из клиентского компонента. Но под капотом Next.js компилирует каждый Server Action в публичный HTTP-эндпоинт, принимающий POST-запросы.
// Опасный подход: слепая вера в TypeScript на сервере
'use server';
interface UpdateProfileInput {
userId: string;
age: number;
}
export async function updateProfile(data: UpdateProfileInput) {
// На этапе выполнения здесь может оказаться любой payload:
// например, { userId: 123, age: "not_a_number", role: "admin" }
await db.user.update({
where: { id: data.userId },
data: { age: data.age }
});
}
Публичные POST-эндпоинты под капотом 'use server'
Любой Server Action доступен извне. Злоумышленник или сторонний скрипт может отправить сформированный вручную POST-запрос с произвольным телом напрямую к идентификатору экшена, минуя все клиентские проверки, UI-маски и ограничения HTML-формы.
Если на сервере нет рантайм-валидации, приложение попытается обработать невалидные данные. В лучшем случае это приведет к необработанному исключению (500 Internal Server Error), в худшем — к порче данных в базе или инъекциям.
Опасность слепого приведения типов (as T)
Частая ошибка при работе с FormData — ручное приведение типов:
const name = formData.get('name') as string;
const age = Number(formData.get('age')) as number;
Такой синтаксис лишь отключает подсказки компилятора. Если поле отсутствует, formData.get() вернет null, а приведение к string скроет потенциальный TypeError в глубине бизнес-логики. Преобразование NaN в числовые поля при некорректном вводе также пройдет мимо проверок TypeScript. На границе ввода данных статический анализ бессилен — здесь требуется обязательная проверка структуры в рантайме.
Zod как единый источник правды (Single Source of Truth)
Библиотека Zod решает проблему разрыва между рантайм-проверкой и статической типизацией. Мы описываем схему данных один раз, а затем выводим из нее типы TypeScript с помощью встроенного хелпера z.infer.
Проектирование валидационной схемы
Схема определяет правила, трансформации и кастомные сообщения об ошибках:
// schemas/user.ts
import { z } from 'zod';
export const CreateUserSchema = z.object({
email: z
.string({ required_error: 'Email обязателен для заполнения' })
.email('Некорректный формат email'),
age: z.coerce
.number({ invalid_type_error: 'Возраст должен быть числом' })
.int('Возраст должен быть целым числом')
.min(18, 'Регистрация доступна только с 18 лет')
.max(120, 'Указан некорректный возраст'),
terms: z.literal(true, {
errorMap: () => ({ message: 'Необходимо принять пользовательское соглашение' }),
}),
});
Генерация типов через z.infer
Вместо ручной поддержки параллельных интерфейсов тип генерируется автоматически:
export type CreateUserInput = z.infer<typeof CreateUserSchema>;
При изменении бизнес-требований достаточно скорректировать Zod-схему: TypeScript автоматически обновит сигнатуры функций и подсветит несовместимые места в коде компонентов и репозиториев данных.
Подводные камни FormData и приведение типов
При отправке классических форм браузер передает значения в формате строк или бинарных файлов (File). Значение пустого текстового поля передается как пустая строка "", неотмеченный чекбокс вовсе отсутствует в FormData, а числовые поля приходят в виде "42".
Для корректной обработки таких структур в Zod предусмотрены механизмы приведения и предобработки:
z.coerce.number()— автоматически преобразует строковое значение"42"в число42перед валидацией.z.preprocess()— позволяет выполнить кастомную нормализацию (например, превратить пустую строку вundefined).- Преобразование чекбоксов: поле со значением
"on"трансформируется в логическоеtrue.
export const CheckboxSchema = z.preprocess(
(val) => val === 'on' || val === true || val === 'true',
z.boolean()
);
Пошаговая реализация типобезопасного Server Action
Для предсказуемой обработки результатов на клиенте серверный экшен должен возвращать стандартизированную структуру данных, описывающую как успешное выполнение, так и ошибки валидации.
Паттерн ответа: ActionResult<T>
Создадим обобщенный тип для ответов экшенов:
// types/action.ts
export type FieldErrors<T> = Partial<Record<keyof T, string[]>>;
export type ActionResult<TOutput, TInput = unknown> =
| {
success: true;
data: TOutput;
errors?: never;
message?: string;
}
| {
success: false;
data?: never;
errors?: FieldErrors<TInput>;
message: string;
};
Безопасный парсинг данных через safeParse
Метод safeParse не выбрасывает исключений в случае ошибок, а возвращает дискриминированное объединение (discriminated union), с которым удобно работать через проверку флага success.
// actions/create-user.ts
'use server';
import { CreateUserSchema, CreateUserInput } from '@/schemas/user';
import { ActionResult } from '@/types/action';
interface UserResponse {
id: string;
email: string;
}
export async function createUserAction(
prevState: ActionResult<UserResponse, CreateUserInput> | null,
formData: FormData
): Promise<ActionResult<UserResponse, CreateUserInput>> {
// Извлекаем сырые данные формы
const rawData = Object.fromEntries(formData.entries());
// Валидируем входной payload
const validatedFields = CreateUserSchema.safeParse(rawData);
if (!validatedFields.success) {
return {
success: false,
message: 'Ошибка валидации данных формы',
errors: validatedFields.error.flatten().fieldErrors as Record<keyof CreateUserInput, string[]>,
};
}
try {
// В validatedFields.data находятся строго типизированные и очищенные данные
const { email, age } = validatedFields.data;
// Имитация вызова сервиса базы данных
const newUser = await db.user.create({
data: { email, age },
});
return {
success: true,
data: {
id: newUser.id,
email: newUser.email,
},
message: 'Пользователь успешно создан',
};
} catch (error) {
return {
success: false,
message: 'Не удалось сохранить пользователя. Попробуйте позже.',
};
}
}
Метод flatten().fieldErrors группирует сообщения об ошибках по именам полей, преобразуя их в плоский словарь вида { email: ["Некорректный формат email"] }.
Интеграция с UI: связка Server Actions, Zod и React-хуков
Начиная с React 19 и Next.js 14/15, стандартным способом работы с мутациями форм является хук useActionState (в Next.js 14 доступен как useFormState).
Использование useActionState и отображение ошибок
Хук принимает Server Action и начальное состояние, возвращая текущее состояние ответа, функцию-триггер для формы и флаг выполнения isPending.
// components/RegistrationForm.tsx
'use client';
import { useActionState } from 'react';
import { createUserAction } from '@/actions/create-user';
export function RegistrationForm() {
const [state, formAction, isPending] = useActionState(createUserAction, null);
return (
<form action={formAction} className="space-y-4 max-w-md">
<div>
<label htmlFor="email" className="block text-sm font-medium">
Email
</label>
<input
id="email"
name="email"
type="email"
className="border p-2 w-full rounded"
disabled={isPending}
/>
{state?.errors?.email && (
<p className="text-red-500 text-xs mt-1">{state.errors.email[0]}</p>
)}
</div>
<div>
<label htmlFor="age" className="block text-sm font-medium">
Возраст
</label>
<input
id="age"
name="age"
type="number"
className="border p-2 w-full rounded"
disabled={isPending}
/>
{state?.errors?.age && (
<p className="text-red-500 text-xs mt-1">{state.errors.age[0]}</p>
)}
</div>
<div>
<label className="flex items-center space-x-2">
<input
type="checkbox"
name="terms"
value="true"
disabled={isPending}
/>
<span className="text-sm">Принимаю условия соглашения</span>
</label>
{state?.errors?.terms && (
<p className="text-red-500 text-xs mt-1">{state.errors.terms[0]}</p>
)}
</div>
{state?.message && !state.success && (
<div className="p-3 bg-red-100 text-red-700 rounded text-sm">
{state.message}
</div>
)}
{state?.success && (
<div className="p-3 bg-green-100 text-green-700 rounded text-sm">
{state.message} (ID: {state.data.id})
</div>
)}
<button
type="submit"
disabled={isPending}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
>
{isPending ? 'Сохранение...' : 'Зарегистрироваться'}
</button>
</form>
);
}
Архитектурные паттерны: масштабирование и безопасность
По мере роста приложения ручной вызов safeParse и формирование однотипных объектов ошибок в каждом экшене приводит к дублированию кода. Для поддержания чистоты архитектуры применяются паттерны фабрик действий и изолированных схем.
Вынесение схем в общий слой (Shared Schemas)
Рекомендуется структурировать проект так, чтобы валидационные схемы находились вне серверных директорий и могли импортироваться как серверным кодом, так и клиентскими формами (например, для мгновенной валидации через React Hook Form с Zod Resolver):
src/
├── app/
├── actions/
│ └── user.actions.ts # 'use server' - мутации
├── schemas/
│ └── user.schema.ts # Схемы Zod и типы (без директивы 'use server')
├── types/
│ └── action.types.ts # Базовые типы ActionResult
└── components/
└── RegistrationForm.tsx # UI компоненты
Паттерн безопасного экшена (Safe Action Wrapper)
Чтобы не дублировать логику проверки схемы и авторизации, можно создать обертку высшего порядка (High-Order Function):
// lib/create-safe-action.ts
import { z } from 'zod';
import { ActionResult } from '@/types/action';
export function createSafeAction<TSchema extends z.ZodTypeAny, TOutput>(
schema: TSchema,
handler: (validatedData: z.infer<TSchema>) => Promise<TOutput>
) {
return async (
prevState: ActionResult<TOutput, z.infer<TSchema>> | null,
formData: FormData | z.infer<TSchema>
): Promise<ActionResult<TOutput, z.infer<TSchema>>> => {
// Поддержка как FormData, так и прямого вызова с JSON-объектом
const rawInput =
formData instanceof FormData
? Object.fromEntries(formData.entries())
: formData;
const parsed = schema.safeParse(rawInput);
if (!parsed.success) {
return {
success: false,
message: 'Ошибка валидации входных данных',
errors: parsed.error.flatten().fieldErrors,
};
}
try {
const result = await handler(parsed.data);
return {
success: true,
data: result,
};
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Внутренняя ошибка сервера',
};
}
};
}
Использование такой обертки сокращает код Server Action до описания чистой бизнес-логики:
// actions/user.actions.ts
'use server';
import { createSafeAction } from '@/lib/create-safe-action';
import { CreateUserSchema } from '@/schemas/user.schema';
export const createUser = createSafeAction(
CreateUserSchema,
async (data) => {
// Гарантированно валидные данные с правильными типами
const user = await db.user.create({ data });
return { id: user.id, email: user.email };
}
);
Частые ошибки при работе с Zod и Server Actions
- Использование
schema.parse()вместоschema.safeParse(). Методparse()выбрасывает исключениеZodError. Если его не перехватить вtry/catch, Next.js вернет общую ошибку сервера, скрыв детальную информацию о некорректных полях от пользователя. - Валидация исключительно на клиенте. Проверки в браузере (HTML5-валидация, React Hook Form) служат только для улучшения UX. Без дублирующей серверной валидации приложение остается полностью уязвимым перед прямыми HTTP-запросами.
- Отсутствие проверки контекста авторизации. Server Actions не проверяют права доступа автоматически. Валидация схемы должна быть первым шагом, а проверка сессии пользователя и разрешений — обязательным вторым шагом перед выполнением операции.
- Потеря файлов при трансформации через
Object.fromEntries(). МетодObject.fromEntries(formData.entries())сохраняет только одно значение для повторяющихся ключей. Для множественной загрузки файлов (input type="file" multiple) данные следует извлекать черезformData.getAll('files').
FAQ
Зачем валидировать данные Zod на сервере, если на клиенте уже настроена форма?
Клиентская валидация отвечает исключительно за отзывчивость интерфейса (User Experience). Поскольку Server Actions являются публичными POST-эндпоинтами, любой запрос можно отправить в обход клиентского кода через cURL, Postman или браузерную консоль. Серверная валидация — обязательный периметр безопасности.
В чем разница между schema.parse() и schema.safeParse()?
schema.parse() при обнаружении невалидных полей выбрасывает исключение ZodError, прерывая выполнение функции. schema.safeParse() не выбрасывает ошибок, а возвращает типизированный объект { success: true, data: T } или { success: false, error: ZodError }, позволяя гибко управлять потоком выполнения без блоков try/catch.
Как правильно парсить числовые поля, если FormData возвращает строки?
Используйте механизм приведения типов z.coerce.number(). Он преобразует входное строковое значение (например, "100") в число 100 до применения валидационных правил (.min(), .max(), .int()).
Стоит ли использовать сторонние библиотеки вроде next-safe-action?
Для небольших и средних проектов достаточно самописного хелпера (safe action wrapper), как показано в этой статье. Библиотека next-safe-action оправдана в крупных проектах со сложными пайплайнами middleware (логирование, контекстная авторизация, аналитика, rate-limiting).
Как организовать структуру папок для переиспользования схем?
Создайте отдельную директорию src/schemas (или shared/schemas), в которой будут храниться файлы со схемами Zod без директивы 'use server'. Это позволит безопасно импортировать одни и те же схемы как в серверные экшены, так и в клиентские формы без утечки серверного кода в клиентский бандл.
Заключение
Использование Zod в связке с Server Actions трансформирует работу с формами в Next.js:
- Схема Zod становится единым источником истины для рантайм-проверок и статических типов.
- Устраняется дублирование TypeScript-интерфейсов за счет вывода типов через
z.infer. - Исключаются ошибки несоответствия структур данных между клиентом и бэкендом.
- Приложение получает надежную защиту на границе публичных серверных эндпоинтов.
Схема-ориентированный подход сокращает время на рефакторинг, делает поведение мутаций прозрачным для всей команды и радикально уменьшает количество скрытых дефектов в продакшене.




.svg.webp)




