@letar/forms

Automatic Form Generation

Generate forms automatically from Zod schemas — from zero-JSX to fine-grained control

Full example

The complete sandbox example, read directly from the form-develop-app / form-develop-app-shadcn source at build time. The chakra source uses Form.AutoFields; the shadcn source uses FieldAuto — a different level of the control hierarchy below, but the same idea of generating fields automatically from a Zod schema. No Vue/Angular equivalent exists yet — those tabs show as disabled.

apps/form-develop-app/src/app/auto-fields-demo/page.tsx
'use client'

import { Badge, Box, Code, Heading, HStack, Separator, Text, VStack } from '@chakra-ui/react'
import { Form } from '@letar/forms'
import { useState } from 'react'
import { z } from 'zod/v4'
import { DemoPageLayout, SubmittedDataPreview } from '../_components'

/**
 * Демонстрация автоматической генерации форм из Zod схемы
 *
 * Три подхода:
 * 1. Form.FromSchema — полностью автоматическая форма
 * 2. Form.AutoFields — генерация полей внутри Form
 * 3. Смешанный подход — AutoFields + ручные поля
 */

// === Схема для Form.FromSchema ===
const UserSchema = z.object({
  firstName: z
    .string()
    .min(2)
    .meta({ ui: { title: 'Имя', placeholder: 'Введите имя' } }),
  lastName: z
    .string()
    .min(2)
    .meta({ ui: { title: 'Фамилия', placeholder: 'Введите фамилию' } }),
  email: z
    .string()
    .email()
    .meta({ ui: { title: 'Email', placeholder: 'user@example.com' } }),
  age: z
    .number()
    .min(18)
    .max(120)
    .meta({ ui: { title: 'Возраст' } }),
  bio: z
    .string()
    .max(500)
    .meta({ ui: { title: 'О себе', fieldType: 'textarea', placeholder: 'Расскажите о себе...' } }),
  role: z.enum(['admin', 'user', 'guest']).meta({ ui: { title: 'Роль' } }),
  isActive: z.boolean().meta({ ui: { title: 'Активен' } }),
})

type UserFormData = z.infer<typeof UserSchema>

const userInitialValues: UserFormData = {
  firstName: '',
  lastName: '',
  email: '',
  age: 25,
  bio: '',
  role: 'user',
  isActive: true,
}

// === Схема для AutoFields с вложенными объектами ===
const ProfileSchema = z.object({
  name: z.string().meta({ ui: { title: 'Имя профиля' } }),
  settings: z.object({
    theme: z.enum(['light', 'dark', 'auto']).meta({ ui: { title: 'Тема' } }),
    notifications: z.boolean().meta({ ui: { title: 'Уведомления' } }),
    language: z.enum(['ru', 'en', 'de']).meta({ ui: { title: 'Язык' } }),
  }),
})

type ProfileFormData = z.infer<typeof ProfileSchema>

const profileInitialValues: ProfileFormData = {
  name: 'Мой профиль',
  settings: {
    theme: 'auto',
    notifications: true,
    language: 'ru',
  },
}

// === Схема для смешанного подхода ===
const ArticleSchema = z.object({
  title: z
    .string()
    .min(5)
    .max(100)
    .meta({ ui: { title: 'Заголовок' } }),
  slug: z.string().meta({ ui: { title: 'URL slug' } }),
  category: z.enum(['news', 'blog', 'tutorial']).meta({ ui: { title: 'Категория' } }),
  content: z.string().meta({ ui: { title: 'Содержимое', fieldType: 'richText' } }),
  published: z.boolean().meta({ ui: { title: 'Опубликовано', fieldType: 'switch' } }),
  rating: z
    .number()
    .min(1)
    .max(5)
    .meta({ ui: { title: 'Рейтинг', fieldType: 'rating' } }),
})

type ArticleFormData = z.infer<typeof ArticleSchema>

const articleInitialValues: ArticleFormData = {
  title: '',
  slug: '',
  category: 'blog',
  content: '',
  published: false,
  rating: 3,
}

export default function AutoFieldsDemoPage() {
  const [fromSchemaData, setFromSchemaData] = useState<UserFormData | null>(null)
  const [autoFieldsData, setAutoFieldsData] = useState<ProfileFormData | null>(null)
  const [mixedData, setMixedData] = useState<ArticleFormData | null>(null)

  return (
    <DemoPageLayout
      title="Auto Fields Demo"
      description="Демонстрация автоматической генерации форм из Zod схемы с использованием Form.FromSchema, Form.AutoFields и смешанного подхода."
      maxW="1200px"
    >
      <VStack gap={8} align="stretch">
        {/* === СЕКЦИЯ 1: Form.FromSchema === */}
        <Box p={6} borderWidth={1} borderRadius="lg">
          <HStack mb={4}>
            <Heading size="md">1. Form.FromSchema</Heading>
            <Badge colorPalette="green">Полностью автоматически</Badge>
          </HStack>
          <Text color="fg.muted" mb={4}>
            Генерирует всю форму из схемы одной строкой. Поля, кнопки — всё автоматически.
          </Text>

          <Code
            display="block"
            whiteSpace="pre"
            mb={4}
            p={3}
            bg="gray.100"
            borderRadius="md"
            _dark={{ bg: 'gray.800' }}
          >
            {`<Form.FromSchema
  schema={UserSchema}
  initialValue={initialValues}
  onSubmit={handleSubmit}
  submitLabel="Создать пользователя"
/>`}
          </Code>

          <Form.FromSchema
            schema={UserSchema}
            initialValue={userInitialValues}
            onSubmit={setFromSchemaData}
            submitLabel="Создать пользователя"
            showReset
            resetLabel="Очистить"
          />

          {fromSchemaData && <SubmittedDataPreview data={fromSchemaData} title="Отправленные данные:" />}
        </Box>

        <Separator />

        {/* === СЕКЦИЯ 2: Form.AutoFields === */}
        <Box p={6} borderWidth={1} borderRadius="lg">
          <HStack mb={4}>
            <Heading size="md">2. Form.AutoFields</Heading>
            <Badge colorPalette="blue">С кастомным layout</Badge>
          </HStack>
          <Text color="fg.muted" mb={4}>
            Генерирует поля из схемы, но layout контролируется вручную. Поддерживает include/exclude.
          </Text>

          <Code
            display="block"
            whiteSpace="pre"
            mb={4}
            p={3}
            bg="gray.100"
            borderRadius="md"
            _dark={{ bg: 'gray.800' }}
          >
            {`<Form schema={ProfileSchema} ...>
  <HStack>
    <Form.AutoFields include={['name']} />
  </HStack>
  <Form.AutoFields exclude={['name']} />
  <Form.Button.Submit />
</Form>`}
          </Code>

          <Form schema={ProfileSchema} initialValue={profileInitialValues} onSubmit={setAutoFieldsData}>
            <VStack gap={4} align="stretch">
              {/* Имя отдельно сверху */}
              <Form.AutoFields include={['name']} />

              {/* Настройки — автоматически развернётся вложенный объект */}
              <Box p={4} bg="gray.50" borderRadius="md">
                <Text fontWeight="bold" mb={2}>
                  Настройки (автоматическая вложенность)
                </Text>
                <Form.AutoFields include={['settings']} />
              </Box>

              <HStack justify="flex-end">
                <Form.Button.Submit>Сохранить профиль</Form.Button.Submit>
              </HStack>
            </VStack>
          </Form>

          {autoFieldsData && <SubmittedDataPreview data={autoFieldsData} title="Отправленные данные:" />}
        </Box>

        <Separator />

        {/* === СЕКЦИЯ 3: Смешанный подход === */}
        <Box p={6} borderWidth={1} borderRadius="lg">
          <HStack mb={4}>
            <Heading size="md">3. Смешанный подход</Heading>
            <Badge colorPalette="purple">AutoFields + ручные поля</Badge>
          </HStack>
          <Text color="fg.muted" mb={4}>
            Часть полей генерируется автоматически, часть — вручную с кастомной логикой. fieldType в meta определяет
            компонент.
          </Text>

          <Code
            display="block"
            whiteSpace="pre"
            mb={4}
            p={3}
            bg="gray.100"
            borderRadius="md"
            _dark={{ bg: 'gray.800' }}
          >
            {`// В схеме:
content: z.string().meta({ ui: { fieldType: 'richText' } })
rating: z.number().meta({ ui: { fieldType: 'rating' } })
published: z.boolean().meta({ ui: { fieldType: 'switch' } })

// В JSX:
<Form.AutoFields exclude={['content']} />
<Form.Field.RichText name="content" />  // Кастомная обёртка`}
          </Code>

          <Form schema={ArticleSchema} initialValue={articleInitialValues} onSubmit={setMixedData}>
            <VStack gap={4} align="stretch">
              {/* Основные поля автоматически */}
              <HStack gap={4} align="flex-start">
                <Box flex={2}>
                  <Form.Field.Auto name="title" />
                </Box>
                <Box flex={1}>
                  <Form.Field.Auto name="slug" />
                </Box>
              </HStack>

              <HStack gap={4}>
                <Box flex={1}>
                  <Form.Field.Auto name="category" />
                </Box>
                <Box flex={1}>
                  <Form.Field.Auto name="rating" />
                </Box>
                <Box flex={1}>
                  <Form.Field.Auto name="published" />
                </Box>
              </HStack>

              {/* Контент — вручную с кастомной высотой */}
              <Box>
                <Form.Field.RichText name="content" label="Содержимое статьи" />
              </Box>

              <HStack justify="flex-end" gap={2}>
                <Form.Button.Reset variant="outline">Сбросить</Form.Button.Reset>
                <Form.Button.Submit>Опубликовать статью</Form.Button.Submit>
              </HStack>
            </VStack>
          </Form>

          {mixedData && <SubmittedDataPreview data={mixedData} title="Отправленные данные:" />}
        </Box>

        <Separator />

        {/* === Документация fieldType === */}
        <Box p={6} borderWidth={1} borderRadius="lg" bg="gray.50">
          <Heading size="md" mb={4}>
            Поддерживаемые fieldType
          </Heading>
          <Text color="fg.muted" mb={4}>
            В <Code>meta(&#123; ui: &#123; fieldType: &apos;...&apos; &#125; &#125;)</Code>{' '}
            можно указать любой тип поля:
          </Text>

          <HStack gap={8} flexWrap="wrap">
            <VStack align="start" gap={1}>
              <Text fontWeight="bold">Текстовые</Text>
              <Code>string</Code>
              <Code>textarea</Code>
              <Code>password</Code>
              <Code>richText</Code>
              <Code>editable</Code>
              <Code>maskedInput</Code>
            </VStack>

            <VStack align="start" gap={1}>
              <Text fontWeight="bold">Числовые</Text>
              <Code>number</Code>
              <Code>numberInput</Code>
              <Code>slider</Code>
              <Code>rating</Code>
              <Code>currency</Code>
              <Code>percentage</Code>
            </VStack>

            <VStack align="start" gap={1}>
              <Text fontWeight="bold">Дата/время</Text>
              <Code>date</Code>
              <Code>time</Code>
              <Code>dateRange</Code>
              <Code>dateTimePicker</Code>
              <Code>duration</Code>
              <Code>schedule</Code>
            </VStack>

            <VStack align="start" gap={1}>
              <Text fontWeight="bold">Выбор</Text>
              <Code>select</Code>
              <Code>nativeSelect</Code>
              <Code>combobox</Code>
              <Code>radioGroup</Code>
              <Code>checkboxCard</Code>
              <Code>tags</Code>
            </VStack>

            <VStack align="start" gap={1}>
              <Text fontWeight="bold">Булевые</Text>
              <Code>checkbox</Code>
              <Code>switch</Code>
            </VStack>

            <VStack align="start" gap={1}>
              <Text fontWeight="bold">Специализированные</Text>
              <Code>phone</Code>
              <Code>address</Code>
              <Code>pinInput</Code>
              <Code>colorPicker</Code>
              <Code>fileUpload</Code>
            </VStack>
          </HStack>
        </Box>
      </VStack>
    </DemoPageLayout>
  )
}

The 4-Level Control Hierarchy

The library offers four levels of form generation, each giving you more control:

LevelComponentJSX neededUse case
1Form.FromSchemaNoneCRUD, prototyping, admin panels
2Form.AutoFieldsLayout onlyCustom layout with auto fields
3Form.Field.*Fields + layoutFull control over each field
4useAppFormEverythingImperative logic, custom hooks

Each level is backward-compatible — you can mix them freely within one form.

Level 1: Form.FromSchema — Zero JSX

Generate a complete form from a Zod schema with no field JSX at all:

import { Form } from '@letar/forms'
import { z } from 'zod/v4'

const UserSchema = z.object({
  firstName: z.string().min(2).meta({ ui: { title: 'First Name' } }),
  lastName: z.string().meta({ ui: { title: 'Last Name' } }),
  email: z.string().email().meta({ ui: { title: 'Email' } }),
  bio: z.string().max(500).meta({ ui: { title: 'Bio', fieldType: 'textarea' } }),
})

<Form.FromSchema
  schema={UserSchema}
  initialValue={{ firstName: '', lastName: '', email: '', bio: '' }}
  onSubmit={save}
  submitLabel="Create User"
/>

This renders all four fields with labels, validation, and a submit button — no JSX for fields required.

FromSchema Props

PropTypeDefaultDescription
schemaZod schemarequiredSchema for validation and field generation
initialValueTDatarequiredInitial form values
onSubmit(data: TData) => voidrequiredSubmit handler
submitLabelReactNode'Save'Submit button text
showResetbooleanfalseShow reset button
resetLabelReactNode'Reset'Reset button text
excludestring[]Fields to exclude
validateOnValidateOnValidation trigger mode
middlewareFormMiddlewareForm event middleware
disabledbooleanDisable all fields
readOnlybooleanRead-only mode
persistenceFormPersistenceConfiglocalStorage config
offlineFormOfflineConfigOffline mode config
debugboolean | 'force'JSON value inspector
gapnumber4Gap between fields
beforeButtonsReactNodeContent before buttons
afterButtonsReactNodeContent after buttons

Excluding Fields

Hide system fields like id or timestamps:

<Form.FromSchema
  schema={UserSchema}
  initialValue={userData}
  onSubmit={updateUser}
  exclude={['id', 'createdAt', 'updatedAt']}
  showReset
/>

Level 2: Form.AutoFields — Auto Generation + Custom Layout

When you need control over layout but don't want to write every field manually:

<Form schema={Schema} initialValue={data} onSubmit={save}>
  <HStack gap={4}>
    <Form.AutoFields include={['firstName', 'lastName']} />
  </HStack>
  <Form.AutoFields exclude={['firstName', 'lastName']} />
  <Form.Button.Submit />
</Form>

AutoFields Props

PropTypeDefaultDescription
includestring[]Only render these fields
excludestring[]Exclude these fields
recursivebooleantrueAuto-expand nested objects
fieldWrapper(props) => ReactElementCustom wrapper per field

Custom Field Wrapper

Wrap each auto-generated field with custom JSX:

<Form.AutoFields
  fieldWrapper={({ name, children }) => (
    <Box key={name} p={2} borderWidth={1} borderRadius="md">
      {children}
    </Box>
  )}
/>

Mixing Auto and Manual Fields

Combine auto-generated fields with hand-crafted ones:

<Form schema={ArticleSchema} initialValue={data} onSubmit={save}>
  <HStack gap={4}>
    <Box flex={2}>
      <Form.Field.Auto name="title" />
    </Box>
    <Box flex={1}>
      <Form.Field.Auto name="slug" />
    </Box>
  </HStack>
  <Form.Field.RichText name="content" label="Article Content" />
  <Form.Button.Submit>Publish</Form.Button.Submit>
</Form>

Form.Field.Auto renders the appropriate field component based on the schema type for that field.

Type Mapping

The library maps Zod types to field components automatically:

Default Mapping (Zod → Component)

Zod TypeComponentCondition
z.string()Form.Field.StringDefault
z.string()Form.Field.TextareaWhen maxLength > 200
z.string().email()Form.Field.StringWith type="email"
z.number() / z.int() / z.float()Form.Field.Number
z.boolean()Form.Field.Checkbox
z.date()Form.Field.Date
z.enum() / z.literal()Form.Field.NativeSelect
z.array(z.string())Form.Field.TagsString arrays
z.array(z.object())Form.Group.ListObject arrays

Override with fieldType

Use .meta({ ui: { fieldType } }) to override the default mapping:

const Schema = z.object({
  // Force textarea instead of string input
  description: z.string().meta({ ui: { title: 'Description', fieldType: 'textarea' } }),

  // Use slider instead of number input
  rating: z
    .number()
    .min(0)
    .max(10)
    .meta({ ui: { title: 'Rating', fieldType: 'slider' } }),

  // Use rich text editor
  content: z.string().meta({ ui: { title: 'Content', fieldType: 'richText' } }),

  // Use switch instead of checkbox
  active: z.boolean().meta({ ui: { title: 'Active', fieldType: 'switch' } }),

  // Use radio cards instead of select
  plan: z.enum(['free', 'pro', 'enterprise']).meta({
    ui: {
      title: 'Plan',
      fieldType: 'radioCard',
      fieldProps: {
        options: [
          { value: 'free', label: 'Free', description: 'For individuals' },
          { value: 'pro', label: 'Pro', description: 'For teams' },
          { value: 'enterprise', label: 'Enterprise', description: 'Custom' },
        ],
      },
    },
  }),
})

All Available fieldTypes

Text: string, textarea, password, passwordStrength, editable, richText, maskedInput

Number: number, numberInput, slider, rating, currency, percentage

Date/Time: date, time, dateRange, dateTimePicker, duration, schedule

Boolean: checkbox, switch

Selection: select, nativeSelect, combobox, autocomplete, listbox, radioGroup, radioCard, segmentedGroup, checkboxCard, tags

Specialized: phone, address, pinInput, otpInput, colorPicker, fileUpload

UI Metadata

Control field appearance through .meta({ ui: {...} }):

const Schema = z.object({
  email: z
    .string()
    .email()
    .meta({
      ui: {
        title: 'Email Address', // Label
        placeholder: 'user@example.com', // Placeholder
        description: 'We never share it', // Helper text
        fieldType: 'string', // Component override
        fieldProps: { type: 'email' }, // Props passed to component
      },
    }),
})
Meta PropertyTypeDescription
titlestringField label
placeholderstringPlaceholder text
descriptionstringHelper text below field
fieldTypeFieldComponentTypeOverride component type
fieldPropsRecord<string, unknown>Additional component props
optionsBaseOption[]Options for select fields
autocompletestringHTML autocomplete attribute

If title is not provided, the field name is converted from camelCase to a readable label (e.g., firstNameFirst Name).

Nested Objects and Arrays

Nested Objects

Objects are automatically rendered as Form.Group with their fields indented:

const ProfileSchema = z.object({
  name: z.string().meta({ ui: { title: 'Name' } }),
  settings: z.object({
    theme: z.enum(['light', 'dark']).meta({ ui: { title: 'Theme' } }),
    notifications: z.boolean().meta({ ui: { title: 'Notifications' } }),
  }),
})

// AutoFields automatically nests settings fields under a group
<Form.FromSchema schema={ProfileSchema} initialValue={data} onSubmit={save} />

Disable recursive expansion with recursive={false}.

Object Arrays

Arrays of objects render as Form.Group.List with add/remove buttons:

const OrderSchema = z.object({
  customer: z.string(),
  items: z.array(
    z.object({
      product: z.string().meta({ ui: { title: 'Product' } }),
      qty: z.number().min(1).meta({ ui: { title: 'Quantity' } }),
    })
  ),
})

// Each array item gets its own card with fields + remove button
<Form.FromSchema schema={OrderSchema} initialValue={data} onSubmit={save} />

Primitive Arrays

String arrays are rendered as Form.Field.Tags:

const Schema = z.object({
  tags: z.array(z.string()).meta({ ui: { title: 'Tags' } }),
})

Automatic Constraints

Zod constraints are passed as component props automatically:

z.string().min(2).max(100) // → minLength={2} maxLength={100}
z.number().min(1).max(10) // → min={1} max={10}
z.string().email() // → type="email"
z.date().min(new Date()) // → min={now}

No need to duplicate constraints in JSX — the schema is the single source of truth.

When to Use Each Level

ScenarioRecommended Level
Admin CRUD formsForm.FromSchema
Rapid prototypingForm.FromSchema
Simple forms with custom layoutForm.AutoFields
Complex multi-column layoutsForm.Field.* (Compound Components)
Multi-step formsForm.Field.* + Form.Steps
Conditional renderingForm.Field.* + Form.When
Custom UX (animations, inline editing)useAppForm

Rule of thumb: If the form is a linear vertical flow, FromSchema or AutoFields work great. For complex layouts, use Compound Components.

Live Examples

Try the interactive examples on forms-example.letar.best:

On this page