Logo VitNode
Documentation

Auto Form

Build validated forms from a Zod schema with AutoForm - field types, tabs, conditional fields, arrays, custom layouts and submit handling on TanStack Form.

Preview

Usage

import { AutoForm } from '@vitnode/core/components/form/auto-form'
import { AutoFormInput } from '@vitnode/core/components/form/fields/input'
import { z } from 'zod'

const formSchema = z.object({
  username: z
    .string()
    .min(3, 'Usernames need at least 3 characters')
    .default(''),
  email: z.email().describe('Only used for notifications.').default(''),
})
<AutoForm
  formSchema={formSchema}
  fields={[
    {
      id: 'username',
      component: (props) => <AutoFormInput {...props} label="Username" />,
    },
    {
      id: 'email',
      component: (props) => (
        <AutoFormInput {...props} label="Email" type="email" />
      ),
    },
  ]}
  onSubmit={(values) => {
    toast.success('Account created', { description: values.username })
  }}
/>

The schema drives validation, defaults and HTML attributes. Each field's id points at a schema key, and component decides how it looks.

Field types

Import each one from @vitnode/core/components/form/fields/<file>. Spread props first, then your own props. Spread it last and the schema's empty description overwrites yours.

ComponentFileDocs
AutoFormInputinputInput
AutoFormTextareatextareaTextarea
AutoFormCheckboxcheckboxCheckbox
AutoFormSwitchswitchSwitch
AutoFormRadioGroupradio-groupRadio Group
AutoFormSelectselectSelect
AutoFormComboboxcomboboxCombobox
AutoFormCascadercascaderCascader
AutoFormNumbernumberNumber Field
AutoFormNullableNumbernullable-numberNullable Number
AutoFormInputOTPinput-otpInput OTP
AutoFormColorcolorColor
AutoFormEmojiIconemoji-iconEmoji & Icon Picker
AutoFormEditoreditorEditor
AutoFormFilefileAttachment
AutoFormFilesfilesAttachment
AutoFormRolesinput-rolesRoles
AutoFormUserinput-usersUser
AutoFormDatePickerdate-pickerDate Picker
AutoFormDateTimedate-time-
AutoFormArrayarrayArrays

Every field takes label, labelRight (content pinned to the label's right edge) and description.

Schema

const formSchema = z.object({
  username: z.string().min(3).max(20).default('captain'),
  bio: z.string().describe('Shown on your profile card.').optional(),
  role: z.enum(['member', 'admin']).default('member'),
})
  • Fields are required unless the schema says .optional(). Optional fields get an "Optional" hint next to the label.
  • .default() sets the starting value. Give required strings .default(''): without it an untouched field says "expected string, received undefined" instead of your .min() message.
  • .describe() becomes the description, unless you pass description.
  • String .min(), .max() and .regex() become minLength, maxLength and pattern on the control. z.enum() values feed selects and radio groups.
  • Any Zod check works, .refine() included. Its message is what users see.

Validation

By default a message appears once you leave a field, then updates live as you type. Switch the modes above to feel the difference.

modeA field's message appears…
"onTouched" (default)once the field has been left
"onBlur"once the field has been left
"onChange" / "all"as soon as the field is edited
"onSubmit"after the first submit attempt

The submit button stays enabled while the form is invalid. Pressing it never calls onSubmit. Instead every message appears, invalid fields shake, and focus jumps to the first one.

<AutoForm formSchema={formSchema} fields={fields} mode="onChange" />

Submit

onSubmit receives the parsed values, the TanStack Form instance, and { captchaToken, intent }. While it runs, the button shows a spinner.

import type { AutoFormOnSubmit } from '@vitnode/core/components/form/auto-form'

const onSubmit: AutoFormOnSubmit<typeof formSchema> = async (values, form) => {
  await saveProfile(values)
  form.reset()
  toast.success('Profile saved', { description: values.username })
}

Restyle the button with submitButtonProps:

<AutoForm
  submitButtonProps={{ children: 'Save changes', variant: 'outline' }}
/>

Pass a route's captcha config and the button waits for the captcha to load. Send captchaToken along with your request.

Inside a Dialog, AutoForm renders a footer with Cancel and Submit. Any form with unsaved changes asks before the page unloads.

Errors from the server

The API said no? Put its message on the field with setFormFieldError. It focuses the field, blocks submit while the rejected value stays, and clears once the user edits it.

import { setFormFieldError } from "@vitnode/core/components/form/auto-form";

onSubmit={async (values, form) => {
  const res = await updateUsername(values);
  if (res.status === 409) {
    setFormFieldError(form, "username", "That username is taken");
  }
}}

Tabs

Pass tabs and tag fields with tab. Untagged fields go to the first tab. All panels stay mounted, so values survive tab switches.

Press Save on General above. The error lives on SEO, so AutoForm switches there, and tabs hiding an error get a red dot.

<AutoForm
  tabs={[
    { value: 'general', label: 'General' },
    { value: 'seo', label: 'SEO' },
  ]}
  fields={[
    {
      id: 'name',
      component: (props) => <AutoFormInput {...props} label="Name" />,
    },
    {
      id: 'metaTitle',
      tab: 'seo',
      component: (props) => <AutoFormInput {...props} label="Meta title" />,
    },
  ]}
/>

Conditional fields

hidden gets the live values. Return true to hide the field.

{
  id: "password",
  hidden: values => values.visibility !== "password",
  component: props => <AutoFormInput {...props} label="Forum password" type="password" />,
}

Hidden fields still submit. Make them .optional() or give them a .default(), or an invisible error blocks the form.

Nested fields

A switch and the settings it unlocks can share one card. Pass them as children of an AutoFormSwitch field: they expand when it's on.

{
  id: "allow_uploads",
  component: props => <AutoFormSwitch {...props} label="Allow uploads" />,
  children: [
    {
      id: "max_storage",
      component: props => <AutoFormNumber {...props} label="Maximum file size" />,
    },
  ],
}

Children unmount while the switch is off, so give them a .default(). They can have their own hidden. Their tab is ignored: they live in the parent's tab.

Arrays

AutoFormArray edits a z.array(z.object(...)) as rows you can add and remove. Schema .min() and .max() limit the rows.

{
  id: "links",
  component: props => (
    <AutoFormArray
      {...props}
      label="Sidebar links"
      addButtonLabel="Add link"
      fields={[
        { id: "title", component: p => <AutoFormInput {...p} label="Title" /> },
        { id: "url", component: p => <AutoFormInput {...p} label="URL" /> },
      ]}
    />
  ),
}
PropTypeDefault
fields{ id, component, className? }[]required
addButtonLabelstring"Add Item"
minItemsnumberschema .min() or 0
maxItemsnumberschema .max()
showRemoveButtonbooleantrue
classNamestring-

Layout

For a simple grid, className is enough:

<AutoForm className="grid grid-cols-1 gap-4 md:grid-cols-2" />

For full control, layout receives every rendered field by id. You place the submit button yourself with AutoFormSubmitButton. Give it an intent to tell buttons apart in onSubmit.

import {
  AutoForm,
  AutoFormSubmitButton,
} from '@vitnode/core/components/form/auto-form'
<AutoForm
  layout={(rendered) => (
    <>
      <div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
        {rendered.firstName}
        {rendered.lastName}
      </div>
      <AutoFormSubmitButton intent="draft" variant="ghost">
        Save draft
      </AutoFormSubmitButton>
      <AutoFormSubmitButton intent="publish">Publish</AutoFormSubmitButton>
    </>
  )}
  onSubmit={(values, form, { intent }) => {}}
/>

Custom fields

A field is any component. Build it from AutoForm's parts and it gets the same ids, ARIA and errors as the built-in ones:

  • AutoFormLabel and AutoFormDesc render the label and description.
  • FormControl passes id, aria-invalid and aria-describedby to its child.
  • FormMessage shows the field's errors.

Type field with FormFieldApi<TValue> so a wrong value is a build error. field.onChange takes the value or a DOM change event.

import type {
  FormFieldApi,
  ItemAutoFormComponentProps,
} from '@vitnode/core/components/form/auto-form'
import { AutoFormDesc } from '@vitnode/core/components/form/common/desc'
import { AutoFormLabel } from '@vitnode/core/components/form/common/label'
import { FormControl, FormMessage } from '@vitnode/core/components/ui/form'

const SlowModeField = ({
  label,
  description,
  field,
}: Omit<ItemAutoFormComponentProps, 'field'> & {
  field: FormFieldApi<number>
}) => (
  <>
    <AutoFormLabel>{label}</AutoFormLabel>
    <FormControl>
      <input
        type="range"
        max={120}
        name={field.name}
        value={field.value}
        onBlur={field.onBlur}
        onChange={(e) => field.onChange(Number(e.target.value))}
      />
    </FormControl>
    {!!description && <AutoFormDesc>{description}</AutoFormDesc>}
    <FormMessage />
  </>
)

For a two-column settings row, wrap the parts in Field.

Multi-language fields

AutoFormInput, AutoFormTextarea and AutoFormEditor take multiLang. The value becomes { languageCode, value }[], and a language select appears when more than one language is enabled. It only switches what you're editing, not the app locale.

import { multiLangValueSchema } from '@vitnode/core/lib/helpers/multi-lang'

const formSchema = z.object({
  name: multiLangValueSchema({ minLength: 1, maxLength: 255 }).min(1),
})
{ id: "name", component: props => <AutoFormInput {...props} label="Name" multiLang /> }
  • minLength and maxLength apply to each language's value.
  • Languages come from i18n.locales, minus any with enabled: false.
  • The select opens on the first language with text: yours, then the form's default (a content type's defaultLocale), then any other. Empty editor markup like <p></p> counts as empty.

Saving to the backend

saveLanguageWords stores the array in core_languages_words, replacing the old rows in one transaction.

plugins/{plugin_name}/src/api/routes/create-category.route.ts
import { saveLanguageWords } from '@vitnode/core/api/lib/save-language-words'

await saveLanguageWords(c, {
  pluginCode: 'blog',
  tableName: 'blog_categories',
  variable: 'name',
  itemId: category.id,
  values: name,
})

To prefill an edit form, query core_languages_words by the same (pluginCode, tableName, variable, itemId) and return { languageCode, value }[], like resolveRoleNames does.

Props

Prop

Type

Any other <form> prop (className, id, onKeyDown) goes to the form element.