Auto Form
Component creates form based on Zod schemas & react-hook-form with validation
Preview
Usage
import { AutoForm } from "@vitnode/core/components/form/auto-form";
import { AutoFormCheckbox } from "@vitnode/core/components/form/fields/checkbox";
import { AutoFormEditor } from "@vitnode/core/components/form/fields/editor";
import { AutoFormInput } from "@vitnode/core/components/form/fields/input";
import { AutoFormSelect } from "@vitnode/core/components/form/fields/select";
import { AutoFormTextarea } from "@vitnode/core/components/form/fields/textarea";
import { AutoFormArray } from "@vitnode/core/components/form/fields/array";
import { InputGroupAddon } from "@vitnode/core/components/ui/input-group";
import { Search } from "lucide-react";
import { z } from "zod";const formSchema = z.object({
username: z.string().min(3, "Username must be at least 3 characters"),
email: z
.email("Please enter a valid email address")
.describe("We'll use this email to contact you. (from zod schema)"),
user_type: z.enum(["admin", "editor", "viewer"]),
links: z
.array(
z.object({
title: z.string().min(1, "Title is required"),
url: z.string().url(),
}),
)
.optional(),
accept_terms: z.boolean().refine(val => val, {
message: "You must accept the terms and conditions",
}),
description: z.string().min(10, "Description must be at least 10 characters"),
content: z
.string()
.min(1, "Content is required")
.default("<p>Write your content here...</p>"),
search: z.string().optional(),
});<AutoForm
fields={[
{
id: "username",
component: props => (
<AutoFormInput
{...props}
description="This is the username for your application. It should be unique and not shared with anyone."
label="Username"
/>
),
},
{
id: "email",
component: props => <AutoFormInput {...props} label="Email Address" />,
},
{
id: "user_type",
component: props => (
<AutoFormSelect
{...props}
description="Select the type of user."
label="User Type"
labels={[
{ value: "admin", label: "Admin" },
{ value: "editor", label: "Editor" },
{ value: "viewer", label: "Viewer" },
]}
/>
),
},
{
id: "links",
component: props => (
<AutoFormArray
{...props}
label="Profile Links"
fields={[
{
id: "title",
component: subProps => (
<AutoFormInput {...subProps} label="Title" />
),
},
{
id: "url",
component: subProps => (
<AutoFormInput {...subProps} label="URL" />
),
},
]}
/>
),
},
{
id: "accept_terms",
component: props => (
<AutoFormCheckbox
{...props}
label="I accept the terms and conditions"
/>
),
},
{
id: "description",
component: props => (
<AutoFormTextarea
{...props}
description="Write a short description of your application."
label="Description"
placeholder="My application is..."
/>
),
},
{
id: "content",
component: props => (
<AutoFormEditor
{...props}
description="Rich text content powered by the Editor."
label="Content"
/>
),
},
{
id: "search",
component: props => (
<AutoFormInput {...props} placeholder="Search..." label="Search">
<InputGroupAddon>
<Search />
</InputGroupAddon>
<InputGroupAddon align="inline-end">12 results</InputGroupAddon>
</AutoFormInput>
),
},
]}
formSchema={formSchema}
/>Zod Schema Configuration
Auto Form is deeply integrated with Zod, supporting Zod validators. HTML input attributes are automatically applied based on your schema constraints.
Required vs Optional Fields
By default, all fields are required. Make a field optional using the optional method:
const formSchema = z.object({
username: z.string(), // Required field
bio: z.string().optional(), // Optional field
});Labels
Set field labels using the label property in the field definition:
{
id: 'username',
component: props => (
<AutoFormInput {...props} label="Username" />
),
}Right Labels
You can also add a label on the right side of the field using the labelRight property:
{
id: 'username',
component: props => (
<AutoFormInput {...props} label="Username" labelRight="Required" />
),
}Descriptions
Add descriptions to fields using the description property:
{
id: 'username',
component: props => (
<AutoFormInput
{...props}
label="Username"
description="This is the username for your application."
/>
),
}or using the describe method in Zod:
const formSchema = z.object({
username: z.string().describe("This is the username for your application."),
});Default Values
Set default values for fields using the default method:
const formSchema = z.object({
username: z.string().default("user123"),
role: z.enum(["user", "admin"]).default("user"),
});Arrays
You can create dynamic list of fields using AutoFormArray component working with z.array(z.object(...)) schema:
const formSchema = z.object({
guests: z
.array(
z.object({
name: z.string(),
email: z.string().email(),
}),
)
.min(1),
});{
id: "guests",
component: props => (
<AutoFormArray
{...props}
label="Guests"
fields={[
{ id: "name", component: p => <AutoFormInput {...p} label="Name" /> },
{ id: "email", component: p => <AutoFormInput {...p} label="Email" /> },
]}
/>
)
}You can customize the wrapper, items layout, and Add/Remove buttons logic through className, addButtonLabel props:
{
id: "guests",
component: props => (
<AutoFormArray
{...props}
label="Guests"
fields={[
{ id: "name", className: "flex-1", component: p => <AutoFormInput {...p} label="Name" /> },
{ id: "email", component: p => <AutoFormInput {...p} label="Email" /> },
]}
/>
)
}Advanced Validation
Auto Form supports all Zod validators:
const formSchema = z.object({
username: z.string().min(3).max(20),
email: z.email(),
age: z.number().min(18).max(120),
password: z
.string()
.min(8)
.refine(val => /[A-Z]/.test(val), {
message: "Password must contain at least one uppercase letter",
}),
});Multi-language fields
Some values - a category name, a page title - need a translation per language.
AutoFormInput and AutoFormEditor accept a multiLang prop that collects the
value in every enabled language. The field renders a language select (shown
only when more than one language is enabled) that switches which language you
are editing; it does not change the app-wide locale, only the value inside
that field.
"Enabled" here means every locale in your i18n.locales config
that is not marked enabled: false - a disabled
language is not offered in the select.
The value is stored as an array matching the
core_languages_words table - one
{ languageCode, value } entry per language. Declare the field with the
multiLangValueSchema helper so the Zod schema matches that array shape (its
minLength / maxLength apply to each language's value):
import { multiLangValueSchema } from "@vitnode/core/lib/helpers/multi-lang";
const formSchema = z.object({
name: multiLangValueSchema({ minLength: 1, maxLength: 255 }).min(1),
});<AutoForm
formSchema={formSchema}
fields={[
{
id: "name",
component: props => <AutoFormInput {...props} label="Name" multiLang />,
},
]}
onSubmit={values => {
// values.name => [{ languageCode: "en", value: "News" }, ...]
}}
/>Saving to the backend
The form only produces { languageCode, value }[]. On the backend, persist it
with saveLanguageWords, which fills the remaining core_languages_words
columns - the (pluginCode, tableName, variable, itemId) tuple that identifies
the field - and replaces the existing rows in a single transaction:
import { buildRoute } from "@vitnode/core/api/lib/route";
import { saveLanguageWords } from "@vitnode/core/api/lib/save-language-words";
export const createCategoryRoute = buildRoute({
handler: async c => {
const { name } = c.req.valid("json");
const [category] = await c
.get("db")
.insert(blog_categories)
.values({})
.returning({ id: blog_categories.id });
await saveLanguageWords(c, {
pluginCode: "blog",
tableName: "blog_categories",
variable: "name",
itemId: category.id,
values: name, // the { languageCode, value }[] from the form
});
return c.json({ id: category.id }, 201);
},
});To read the translations back for an edit form, query core_languages_words by
the same tuple and return { languageCode, value }[] - the shape
resolveRoleNames produces for role names.
Custom Fields
Because Auto Form fields are controlled entirely by the component property function, creating a custom field component is just a matter of rendering your own UI using the provided props. The props passed to the component function contain the field properties managed by react-hook-form along with Zod validation details.
Here is an example of creating a custom color picker input:
const formSchema = z.object({
custom_color: z
.string()
.default("#000000")
.describe("Pick your favorite color."),
});<AutoForm
formSchema={formSchema}
fields={[
{
id: "custom_color",
component: props => (
<div className="flex w-full flex-col gap-3">
<div className="text-sm leading-none font-medium">
Custom Color Picker
</div>
<input
{...props.field}
value={(props.field.value as string) ?? "#000000"}
className="h-10 w-24 cursor-pointer rounded-md border p-1"
type="color"
/>
{props.description && (
<div className="text-muted-foreground text-sm">
{props.description}
</div>
)}
</div>
),
},
]}
onSubmit={values => console.log(values)}
/>Tabs
Group fields into tabs by passing the tabs prop and tagging each field with a
tab. Fields without a tab fall into the first tab. Every tab panel stays
mounted, so field values and validation are preserved when switching tabs.
<AutoForm
formSchema={formSchema}
tabs={[
{ value: "general", label: "General" },
{ value: "content", label: "Content" },
]}
fields={[
{
id: "username",
tab: "general",
component: props => <AutoFormInput {...props} label="Username" />,
},
{
id: "content",
tab: "content",
component: props => <AutoFormEditor {...props} label="Content" />,
},
]}
/>Conditional Fields
Show or hide a field based on the current form values with the hidden
predicate. It receives the live form values and returns true to hide the
field.
Hidden fields still submit their (default) values, so keep them optional or
give them a default in the schema. Otherwise a hidden-but-invalid field can
keep the submit button disabled with no visible error.
const formSchema = z.object({
allow_uploads: z.boolean().default(false),
// Optional so it never blocks submission while hidden.
max_storage: z.number().int().min(0).nullable().default(null),
});import { AutoFormSwitch } from "@vitnode/core/components/form/fields/switch";<AutoForm
formSchema={formSchema}
fields={[
{
id: "allow_uploads",
component: props => <AutoFormSwitch {...props} label="Allow uploads" />,
},
{
id: "max_storage",
// Only shown once uploads are enabled.
hidden: values => !values.allow_uploads,
component: props => <AutoFormInput {...props} label="Max storage" />,
},
]}
/>Form Submission
To activate submit button and handle form submission with the onSubmit callback:
<AutoForm
fields={[
{
id: "username",
component: props => (
<AutoFormInput
{...props}
description="This is the username for your application."
label="Username"
/>
),
},
]}
formSchema={formSchema}
onSubmit={values => {
// Handle form submission
}}
/>Accessing the Form Instance
The onSubmit callback provides access to the React Hook Form instance as a second parameter:
<AutoForm
fields={
[
/* ...field definitions */
]
}
formSchema={formSchema}
onSubmit={(values, form) => {
// Access form methods
form.setError("username", {
type: "manual",
message: "Username already taken",
});
}}
/>You can also define the submission handler separately:
import type { AutoFormOnSubmit } from "@vitnode/core/components/form/auto-form";const onSubmit: AutoFormOnSubmit<typeof formSchema> = async (values, form) => {
try {
await saveData(values);
toast.success("Form submitted successfully");
} catch (error) {
form.setError("root", {
type: "manual",
message: "Failed to submit form",
});
}
};
// Then in your component
<AutoForm
fields={
[
/* ...field definitions */
]
}
formSchema={formSchema}
onSubmit={onSubmit}
/>;Customizing the Submit Button
Customize the submit button using the submitButtonProps:
<AutoForm
fields={
[
/* ...field definitions */
]
}
formSchema={formSchema}
submitButtonProps={{
variant: "outline",
size: "lg",
children: "Save Changes",
className: "w-full mt-4",
}}
onSubmit={values => {
console.log("Form submitted", values);
}}
/>Form Layout and Styling
You can control the form layout using standard CSS techniques:
<AutoForm
className="grid grid-cols-1 gap-4 md:grid-cols-2"
fields={
[
/* ...field definitions */
]
}
formSchema={formSchema}
/>