Documentation
UI components

Form Builder

Reusable, Zod-first form system powered by FormBuilder and useFormBuilder.

AI Skill for forms

Prompt: Type /forms in your Copilot / Cursor or other chat to use skill with the provided context.
/forms Build this form using the shared Form Builder.

Requirements:
- Form values type: [YourType]
- Fields: [list fields]
- Validation rules: [list zod rules]
- Submit behavior: [create/update + api/action]

Overview

Form Builder

@/shared/components/form-builder provides typed, reusable form rendering on top of react-hook-form with Zod v4 validation.

Use it when you want:

  • Consistent shadcn field rendering
  • Per-field validation errors (FormMessage)
  • Array-based UI schema with optional sections/separators
  • Default or custom action area (Cancel + Save)

Quick Start

import { z } from 'zod';
import { FormBuilder, type FormBuilderUiSchema } from '@/shared/components/form-builder';

const schema = z.object({
  title: z.string().min(1, 'Title is required'),
  published: z.boolean().default(false),
});

type Values = z.infer<typeof schema>;

const uiSchema: FormBuilderUiSchema<Values> = [
  {
    type: 'text',
    name: 'title',
    label: 'Title',
    placeholder: 'Enter title',
  },
  {
    type: 'switch',
    name: 'published',
    label: 'Published',
  },
];

export function PostForm() {
  return (
    <FormBuilder
      schema={schema}
      uiSchema={uiSchema}
      defaultValues={{ title: '', published: false }}
      onSuccess={async (values) => {
        // call server action / mutation here
        console.log(values);
      }}
    />
  );
}

useFormBuilder + Controlled Form

Use this mode when you need direct form control (watch, setValue, reset, etc.).

import { z } from 'zod';
import { FormBuilder, useFormBuilder, type FormBuilderUiSchema } from '@/shared/components/form-builder';

const schema = z.object({
  slug: z.string().min(1),
  shortDescription: z.string().min(10),
});

type Values = z.infer<typeof schema>;

const uiSchema: FormBuilderUiSchema<Values> = [
  {
    type: 'text',
    name: 'slug',
    label: 'Slug',
    placeholder: 'my-post-slug',
  },
  {
    type: 'textarea',
    name: 'shortDescription',
    label: 'Short description',
    rows: 3,
  },
];

export function ControlledPostForm() {
  const { form } = useFormBuilder({
    schema,
    defaultValues: { slug: '', shortDescription: '' },
  });

  return (
    <FormBuilder
      schema={schema}
      uiSchema={uiSchema}
      form={form}
      onSuccess={async (values) => {
        console.log(values);
      }}
    />
  );
}

Sections and Separators

section and separator items require stable id values.

const uiSchema: FormBuilderUiSchema<Values> = [
  {
    kind: 'section',
    id: 'content-main',
    title: 'Main Content',
    fields: [
      { type: 'text', name: 'title', label: 'Title' },
      { type: 'richtext', name: 'content', label: 'Content' },
    ],
  },
  {
    kind: 'separator',
    id: 'content-seo-divider',
    label: 'SEO',
  },
  {
    kind: 'section',
    id: 'content-seo',
    fields: [
      { type: 'text', name: 'metaTitle', label: 'Meta title' },
      { type: 'textarea', name: 'metaDescription', label: 'Meta description' },
    ],
  },
];

Custom Action Area

If children are passed to FormBuilder, they replace default Cancel + Save buttons.

<FormBuilder schema={schema} uiSchema={uiSchema} form={form} onSuccess={onSubmit}>
  <div className='flex justify-end gap-2'>
    <button type='button' onClick={() => form.reset()}>
      Reset
    </button>
    <button type='submit' disabled={form.formState.isSubmitting}>
      {form.formState.isSubmitting ? 'Saving...' : 'Save Post'}
    </button>
  </div>
</FormBuilder>

Supported Field Types

  • text, textarea, switch, date, select, radio, checkbox, choice-card
  • media (File[] values), mediaLibrary (URL picker), upload, file
  • richtext (markdown string in/out), combobox

Implementation Notes

  • Keep feature forms thin: define schema, uiSchema, and onSuccess; let Form Builder handle form plumbing.
  • Labels/descriptions/placeholders are plain strings.
  • Keep custom field renderer files in src/shared/components/form-builder/fields when adding new field types.

On this page