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:

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

On this page