От БД до UI за 5 минут: ZenStack → Zod → React-форма
@form.* директивы в schema.zmodel позволяют описать UI-метаданные прямо в модели БД. Pipeline: schema.zmodel → zenstack:generate → Zod-схемы → Form.FromSchema — полный CRUD за 10 минут.
16 мая 2026 г.
--TL;DR:
Кому полезно:
- Junior: понять full-stack pipeline от БД до формы и перестать описывать сущность в трёх местахMiddle: освоить @form.* директивы и паттерн CRUD с Server Actions + сгенерированными схемамиSenior: оценить архитектуру single source of truth через ZenStack и стратегию Relation Fields
Восьмая статья из цикла «@letar/forms — от боли к декларативным формам». Полный pipeline: описываете модель в
Проблема: три описания одной сущности
Когда вы создаёте CRUD для модели Product, вы описываете её минимум трижды:
- БД: Prisma/ZenStack schema (
Добавили поле
Решение: @form.* директивы в schema.zmodel
ZenStack уже генерирует Zod-схемы из моделей БД. Мы расширили генератор директивами
model Product {
id String @id @default(cuid())
createdAt DateTime @default(now())
/// @form.title("Название продукта")
/// @form.placeholder("Введите название")
title String
/// @form.title("Описание")
/// @form.fieldType("richText")
description String?
/// @form.title("Цена")
/// @form.fieldType("currency")
/// @form.props({ currency: "RUB", min: 0 })
price Int
/// @form.title("Категория")
category Category @relation(fields: [categoryId], references: [id])
categoryId String
/// @form.title("В наличии")
inStock Boolean @default(true)
/// @form.title("Рейтинг")
/// @form.fieldType("rating")
/// @form.props({ max: 5 })
rating Float?
/// @form.title("SKU")
/// @form.placeholder("ART-001")
sku String @unique
}
Запускаем генерацию:
nx zenstack:generate my-app
Получаем:
src/generated/form-schemas/
├── Product.form.ts # Zod-схема с UI-метаданными
├── ProductCreate.form.ts # Схема для создания
├── ProductUpdate.form.ts # Схема для обновления
└── index.ts # Реэкспорт
Сгенерированная схема
// src/generated/form-schemas/ProductCreate.form.ts (автогенерация)
import { z } from 'zod/v4'
export const ProductCreateFormSchema = z.object({
title: z
.string()
.min(1)
.meta({ ui: { title: 'Название продукта', placeholder: 'Введите название' } }),
description: z
.string()
.optional()
.meta({ ui: { title: 'Описание', fieldType: 'richText' } }),
price: z
.number()
.int()
.meta({ ui: { title: 'Цена', fieldType: 'currency', fieldProps: { currency: 'RUB', min: 0 } } }),
categoryId: z.string().meta({
ui: {
title: 'Категория',
fieldType: 'combobox',
fieldProps: { relation: { model: 'Category', labelField: 'name' } },
},
}),
inStock: z
.boolean()
.default(true)
.meta({ ui: { title: 'В наличии' } }),
rating: z
.number()
.optional()
.meta({ ui: { title: 'Рейтинг', fieldType: 'rating', fieldProps: { max: 5 } } }),
sku: z
.string()
.min(1)
.meta({ ui: { title: 'SKU', placeholder: 'ART-001' } }),
})
Всё автоматически: типы, опциональность, значения по умолчанию, UI-метаданные.
Форма за одну строку
import { ProductCreateFormSchema } from '@/generated/form-schemas' // Автогенерация
<Form.FromSchema
schema={ProductCreateFormSchema}
initialValue={{}}
onSubmit={createProduct}
submitLabel="Создать продукт"
exclude={['id', 'createdAt']}
/>
Или с ручной вёрсткой:
<Form schema={ProductCreateFormSchema} initialValue={{}} onSubmit={createProduct}>
<VStack gap={4}>
<HStack>
<Form.Field.String name="title" />
<Form.Field.String name="sku" />
</HStack>
<Form.Field.RichText name="description" />
<HStack>
<Form.Field.Currency name="price" />
<Form.Field.Rating name="rating" />
</HStack>
<Form.Field.Combobox name="categoryId" />
<Form.Field.Switch name="inStock" />
<Form.Button.Submit>Создать</Form.Button.Submit>
</VStack>
</Form>
Обратите внимание:
Полный CRUD за 10 минут
1. Модель в schema.zmodel (уже есть выше)
2. Server Actions
// app/products/_actions.ts
'use server'
import { getDb } from '@/lib/db'
export async function createProduct(data) {
const db = await getDb()
return db.product.create({ data })
}
export async function updateProduct(id, data) {
const db = await getDb()
return db.product.update({ where: { id }, data })
}
3. Страница создания
// app/products/new/page.tsx
import { ProductCreateFormSchema } from '@/generated/form-schemas'
import { createProduct } from '../_actions'
export default function NewProductPage() {
return (
<Form.FromSchema
schema={ProductCreateFormSchema}
initialValue={{}}
onSubmit={createProduct}
submitLabel="Создать продукт"
/>
)
}
4. Страница редактирования
// app/products/[id]/edit/page.tsx
import { ProductUpdateFormSchema } from '@/generated/form-schemas'
import { updateProduct } from '../../_actions'
export default async function EditProductPage({ params }) {
const db = await getDb()
const product = await db.product.findUnique({ where: { id: params.id } })
return (
<Form.FromSchema
schema={ProductUpdateFormSchema}
initialValue={product}
onSubmit={(data) => updateProduct(params.id, data)}
submitLabel="Сохранить"
/>
)
}
Итого: Модель + 2 action + 2 страницы. Полный CRUD с валидацией, типобезопасностью и UI-метаданными.
Доступные @form.* директивы
| Директива | Описание | Пример |
|---|---|---|
| Label поля | |
| Placeholder | |
| Подсказка (helperText) | |
| Тип компонента | |
| Кастомные пропсы | |
| Скрыть из формы | |
| Только для чтения | |
| Порядок в автогенерации | |
Pipeline: одно изменение → всё обновляется
schema.zmodel → zenstack:generate → Zod-схемы → Form.FromSchema
↑ ↑ ↑
Добавили поле Автоматически Автоматически
Добавили
Ноль ручной работы на уровне UI.
Relation Fields: Select из базы данных
В примере выше
Сгенерированная схема содержит подсказку в
categoryId: z.string().meta({
ui: {
title: 'Категория',
fieldType: 'combobox',
fieldProps: { relation: { model: 'Category', labelField: 'name' } },
},
})
Для автоматической загрузки опций используется
import { RelationFieldProvider, useRelationOptions } from '@letar/forms'
<RelationFieldProvider
model="Category"
labelField="name"
queryFn={() => db.category.findMany({ select: { id: true, name: true } })}
>
<Form.FromSchema schema={ProductCreateFormSchema} initialValue={{}} onSubmit={save} />
</RelationFieldProvider>
Подробнее — в документации Relation Fields.
Загрузка данных: TanStack Query
Для edit-форм нужно загрузить текущие данные. Рекомендуемый паттерн — TanStack Query:
import { useSuspenseQuery } from '@tanstack/react-query'
function EditProductPage({ params }: { params: { id: string } }) {
const { data: product } = useSuspenseQuery({
queryKey: ['product', params.id],
queryFn: () => db.product.findUnique({ where: { id: params.id } }),
})
return (
<Form.FromSchema
schema={ProductUpdateFormSchema}
initialValue={product}
onSubmit={(data) => updateProduct(params.id, data)}
submitLabel="Сохранить"
/>
)
}
TanStack Query даёт кэширование, дедупликацию запросов и автоматическую инвалидацию после мутации. В сочетании с ZenStack
Подробнее — в документации TanStack Query Integration.
Итоги
| Что | Как |
|---|---|
| UI-метаданные в модели | |
| Тип поля | |
| Генерация | |
| Форма создания | |
| Форма редактирования | |
Принцип: модель БД — единственный источник правды. Zod-схемы и формы — производные.
No-code: менеджер строит формы
ZenStack генерирует Zod-схему из модели БД. А
import { FormBuilder } from '@letar/forms'
// JSON-конфиг можно хранить в БД и подгружать через API
const formConfig = await fetch('/api/form-config/feedback').then((r) => r.json())
<FormBuilder
config={formConfig}
initialValue={{}}
onSubmit={submitFeedback}
/>
Менеджер описывает поля в админке, конфиг сохраняется в БД,
Попробовать
- ZenStack формы: forms-example.letar.best/examples/zenstackCRUD Products: forms-example.letar.best/productsИсходный код: zenstack | productsКлонировать:
В следующей статье — offline-first формы: как сохранять данные локально, когда интернет пропал, и синхронизировать при восстановлении.
Это восьмая статья из цикла «@letar/forms — от боли к декларативным формам». Предыдущая: FromSchema | Следующая: Offline-first формы.