Auto Form

Component creates form based on Zod schemas & TanStack Form with validation

Preview

We'll use this email to contact you. (from zod schema)

Select the type of user.

12 results

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.

The select starts on the first language that has text: your current language, then the form's default language (Content Engine forms use the content type's defaultLocale), then any other language. If every language is empty, it starts on your current language. AutoFormEditor ignores empty markup like <p></p>, so a blank editor never counts as translated.

"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:

plugins/{plugin_name}/src/api/routes/create-category.route.ts
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 TanStack 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)}
/>

Typing the value

AutoForm picks the control from your Zod schema while the page runs, so the props it hands your component function cannot know what the value holds - that is why the example above casts props.field.value.

Move the control into its own component and it can say what it expects. FormFieldApi<TValue> types the value you read and the change you send back, so a wrong shape is a build error instead of a validation message:

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

const ColorPicker = ({
  field,
}: {
  field: FormFieldApi<string | undefined>;
}) => (
  <input
    {...field}
    className="h-10 w-24 cursor-pointer rounded-md border p-1"
    type="color"
    value={field.value ?? "#000000"}
  />
);

<AutoForm
  formSchema={formSchema}
  fields={[
    {
      id: "custom_color",
      component: props => <ColorPicker field={props.field} />,
    },
  ]}
  onSubmit={values => console.log(values)}
/>;

field.onChange still takes either the value itself or the DOM change event - it unwraps the event for you, so onChange={field.onChange} keeps working.

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" />,
    },
  ]}
/>

Nested Fields

A toggle and the settings it unlocks belong together, so let them share one card. Pass those settings as children and they render inside the switch's border, expanding and collapsing with it - no hidden predicate required, the switch is the one asking the question.

kB
<AutoForm
  formSchema={formSchema}
  fields={[
    {
      id: "allow_uploads",
      component: props => <AutoFormSwitch {...props} label="Allow uploads" />,
      children: [ 
        { 
          id: "max_storage", 
          component: props => <AutoFormInput {...props} label="Max storage" />, 
        }, 
      ], 
    },
  ]}
/>

Nested fields unmount while the switch is off, so give them a default in the schema just like conditional fields. They can still carry their own hidden predicate for extra conditions, and their tab is ignored - a child lives in whatever tab its parent does.

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
  }}
/>

When Validation Messages Appear

Auto Form always knows whether the values satisfy the schema - that is what keeps the submit button disabled until they do - but it holds the messages back until they are useful. By default it says nothing until the first submit attempt. Pass mode to speak up sooner:

modeA field's message appears...
"onSubmit" (default)after the first submit attempt
"onBlur" / "onTouched"once the field has been left
"onChange" / "all"as soon as the field is edited
<AutoForm
  fields={
    [
      /* ...field definitions */
    ]
  }
  formSchema={formSchema}
  mode="all"
  onSubmit={values => {}}
/>

Accessing the Form Instance

The onSubmit callback provides access to the TanStack Form instance as a second parameter, so you can read and write the form's state after the values have been handed over:

<AutoForm
  fields={
    [
      /* ...field definitions */
    ]
  }
  formSchema={formSchema}
  onSubmit={(values, form) => {
    form.setFieldValue("username", values.username.trim());
    form.reset();
  }}
/>

Showing a Refusal the Server Named

When the API turns a submission down for a reason it can point at a field - a username that is already taken, say - put that message on the field with setFormFieldError. It focuses the field, blocks submission while the rejected value is still in it, and clears itself the moment the field is edited:

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

<AutoForm
  fields={
    [
      /* ...field definitions */
    ]
  }
  formSchema={formSchema}
  onSubmit={(values, form) => {
    setFormFieldError(form, "username", "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) => {
  const result = await saveData(values);

  if (result.conflict) {
    setFormFieldError(form, "username", "Username already taken");

    return;
  }

  toast.success("Form submitted successfully");
};

// 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}
/>