@letar/forms

Automatic Form Generation

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

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