FormBuilder

8 field types, validation, dependent fields.

8 minmedium

FormBuilder is the Create / Edit half of the resource. Eight field types, automatic validation via the shared Zod schema, file uploads through Grit storage. This lesson covers the field types and the customisation patterns.

The eight field types in action

form: [
// 1. Text
{ name: 'name', type: 'text', required: true, placeholder: 'Widget Pro' },
// 2. Textarea
{ name: 'description', type: 'textarea', rows: 4 },
// 3. Number
{ name: 'stock', type: 'number', min: 0, max: 9999 },
// 4. Money
{ name: 'price', type: 'money', required: true, currency: 'USD' },
// 5. Boolean switch
{ name: 'is_featured', type: 'switch', default: false },
// 6. Select (single-choice dropdown)
{ name: 'category_id', type: 'select', optionsFrom: '/api/categories',
optionLabel: 'name', optionValue: 'id' },
// 7. Date
{ name: 'launch_date', type: 'date' },
// 8. File upload
{ name: 'image', type: 'file', accept: 'image/*', maxSize: 2_000_000 },
]

Each field renders its appropriate input. Validation comes from your shared Zod schema; the form shows per-field errors when validation fails.

Dependent fields

Show one field based on the value of another:

form: [
{ name: 'kind', type: 'select', options: [
{ value: 'physical', label: 'Physical product' },
{ value: 'digital', label: 'Digital download' },
]},
// Show stock only for physical products
{ name: 'stock', type: 'number',
showIf: (values) => values.kind === 'physical' },
// Show download URL only for digital
{ name: 'download_url', type: 'text',
showIf: (values) => values.kind === 'digital' },
]

Switching kind from physical to digital instantly swaps which field is visible. The hidden field's value isn't submitted.

File uploads

type: 'file' uploads through your Grit API's storage handler. The form submits the file via multipart, the API saves to S3 / R2 / MinIO, returns a URL, the form stores the URL in the field. You don't write upload code.

{ name: 'avatar', type: 'file',
accept: 'image/png,image/jpeg',
maxSize: 5_000_000,
/** Optional: transform after upload */
processed: { width: 256, height: 256, format: 'webp' },
}
File-size limits enforced server-side too. The client-side maxSize is just UX — gives an instant error. The real defence is on the API; otherwise an attacker ignores the JS and uploads a 10GB file.

Custom validation

Use the shared Zod schema as the source of truth. FormBuilder reads it automatically:

packages/shared/src/schemas/product.ts
export const ProductSchema = z.object({
name: z.string().min(1).max(100),
price: z.string().regex(/^\d+\.\d{2}$/, 'Price must be a decimal'),
stock: z.number().int().min(0).default(0),
email: z.string().email().optional(),
})

Every error message in the schema becomes the inline error message in the form. Centralised validation; web, admin, mobile, AND the API all enforce the same rules.

Submitting

On submit, FormBuilder:

  1. Validates with the Zod schema (client-side)
  2. Uploads any files via the storage handler
  3. POSTs / PUTs to the API
  4. Shows a toast on success, navigates back to the list
  5. On error, surfaces field-level errors from the API's 422 response

Quick check

A user tries to create a product with a 12 MB image. Client says 'too big'. They bypass JS and POST directly to your API with the 12 MB image. What happens?

Try it

For chapter 4's assignment, extend the Product form with at least 6 of the 8 field types:

  1. name (text, required)
  2. description (textarea)
  3. price (money)
  4. stock (number)
  5. is_featured (switch)
  6. image (file, image/*, max 2MB)

Create a product via the form, edit it, delete it. Paste the created product's admin /edit URL in notes.md.

What's next

Final chapter — Tenants + Roles. Multi-tenant SaaS, role-based UI gates, the invitation flow.

Spot a typo? Have an idea?

Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled — suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.

Suggest an improvement on GitHub