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.
| Component | File | Docs |
|---|---|---|
AutoFormInput | input | Input |
AutoFormTextarea | textarea | Textarea |
AutoFormCheckbox | checkbox | Checkbox |
AutoFormSwitch | switch | Switch |
AutoFormRadioGroup | radio-group | Radio Group |
AutoFormSelect | select | Select |
AutoFormCombobox | combobox | Combobox |
AutoFormCascader | cascader | Cascader |
AutoFormNumber | number | Number Field |
AutoFormNullableNumber | nullable-number | Nullable Number |
AutoFormInputOTP | input-otp | Input OTP |
AutoFormColor | color | Color |
AutoFormEmojiIcon | emoji-icon | Emoji & Icon Picker |
AutoFormEditor | editor | Editor |
AutoFormFile | file | Attachment |
AutoFormFiles | files | Attachment |
AutoFormRoles | input-roles | Roles |
AutoFormUser | input-users | User |
AutoFormDatePicker | date-picker | Date Picker |
AutoFormDateTime | date-time | - |
AutoFormArray | array | Arrays |
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 passdescription.- String
.min(),.max()and.regex()becomeminLength,maxLengthandpatternon the control.z.enum()values feed selects and radio groups. - Any Zod check works,
.refine()included. Itsmessageis 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.
mode | A 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" /> },
]}
/>
),
}| Prop | Type | Default |
|---|---|---|
fields | { id, component, className? }[] | required |
addButtonLabel | string | "Add Item" |
minItems | number | schema .min() or 0 |
maxItems | number | schema .max() |
showRemoveButton | boolean | true |
className | string | - |
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:
AutoFormLabelandAutoFormDescrender the label and description.FormControlpassesid,aria-invalidandaria-describedbyto its child.FormMessageshows 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 /> }minLengthandmaxLengthapply to each language's value.- Languages come from
i18n.locales, minus any withenabled: 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.
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.