В журнал
Разработка10 апреля 2026 г.3 мин

Красивые формы без перегруженного фронта

Server Actions + Zod + минимальная клиентская логика. Разбираю шаблон, который использую почти во всех лендингах.


Большинство лендингов требуют ровно одну форму: «оставьте заявку». Странно тащить ради этого React-Hook-Form, react-query и валидатор на 30 КБ.

Покажу подход, в котором я использую Server Actions и Zod, а на клиенте — только useFormStatus для индикации загрузки.

Шаг 1. Схема валидации

// lib/contact.ts
import { z } from "zod"
 
export const contactSchema = z.object({
  name: z.string().min(2, "Имя слишком короткое"),
  email: z.string().email("Похоже на опечатку"),
  message: z.string().min(10, "Расскажите чуть подробнее"),
})
 
export type ContactInput = z.infer<typeof contactSchema>

Одна схема — два потребителя: серверный экшн и (опционально) клиент. Это даёт единый источник правды и автоматический вывод типов.

Шаг 2. Server Action

"use server"
 
import { contactSchema } from "@/lib/contact"
 
type State = { ok: boolean; errors?: Record<string, string> }
 
export async function submitContact(_: State, formData: FormData): Promise<State> {
  const parsed = contactSchema.safeParse(Object.fromEntries(formData))
 
  if (!parsed.success) {
    const errors: Record<string, string> = {}
    for (const issue of parsed.error.issues) {
      errors[String(issue.path[0])] = issue.message
    }
    return { ok: false, errors }
  }
 
  await sendToTelegram(parsed.data) // или email, или CRM
 
  return { ok: true }
}

Шаг 3. Форма

"use client"
 
import { useActionState } from "react"
import { useFormStatus } from "react-dom"
import { submitContact } from "./actions"
 
export function ContactForm() {
  const [state, action] = useActionState(submitContact, { ok: false })
 
  if (state.ok) return <p>Спасибо! Я отвечу в течение суток.</p>
 
  return (
    <form action={action} className="space-y-6">
      <Field name="name" label="Имя" error={state.errors?.name} />
      <Field name="email" label="Email" error={state.errors?.email} />
      <Field name="message" label="Сообщение" textarea error={state.errors?.message} />
      <SubmitButton />
    </form>
  )
}
 
function SubmitButton() {
  const { pending } = useFormStatus()
  return <button disabled={pending}>{pending ? "Отправляю…" : "Отправить"}</button>
}

Что мы получили

МетрикаРаньше (RHF + react-query)Сейчас
JS на странице~70 КБ~6 КБ
Перерендеры на вводкаждый символноль
Серверная валидациядублируетсяпереиспользуется
Поддержка без JSтребует костылиработает «из коробки»

Чек-лист перед запуском формы

  • Серверная валидация по той же схеме, что клиент видит в подсказках
  • Защита от ботов (honeypot или Turnstile)
  • Состояния: пусто, ошибка, отправка, успех
  • Логирование: куда ушла заявка и пришла ли она в CRM
  • Уведомление в Telegram-канал — чтобы видеть заявки в реальном времени

Цитата на закуску

Лучшая форма — та, которую можно отправить с клавиатуры, без мыши, и она работает, даже когда у клиента отвалился JavaScript.

— каждый, кто хоть раз чинил продакшн в пятницу

Этот шаблон я переношу из проекта в проект почти без изменений. Если хочется — забирайте.