Skip to main content

Examples

Every feature below is shown twice — once as a runtime <ZodForm> snippet, once as a CLI z2f generate invocation with an excerpt of the emitted .tsx. Pick the tab that matches how you're using zod-to-form; your choice syncs across the page.

New to the library? Start with the Quick Start, then come back here for feature-by-feature recipes. Cross-references: Runtime, CLI, Core Config, Component Config.

See also: AOT Optimization for validation performance tuning (L1/L2/L3, custom optimizers, typed FieldConfig).

1. Basic form

The simplest possible schema rendered as a form.

import { z } from 'zod';
import { ZodForm } from '@zod-to-form/react';

const signupSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
role: z.enum(['admin', 'editor', 'viewer']),
});

export function SignupForm() {
return (
<ZodForm schema={signupSchema} onSubmit={(data) => save(data)}>
<button type="submit">Sign Up</button>
</ZodForm>
);
}

Takeaway: one schema, one invocation — both paths infer input types, labels, and validation.

2. Metadata annotations

Control rendering via Zod v4's native registry and .meta() API.

import type { FormMeta } from '@zod-to-form/core';
import { z } from 'zod';
import { ZodForm } from '@zod-to-form/react';

const formRegistry = z.registry<FormMeta>();

const schema = z.object({
name: z.string().meta({ title: 'Full Name' }),
email: z.string().email().meta({ examples: ['[email protected]'] }),
bio: z.string().optional(),
});

formRegistry.add(schema.shape.bio, { component: 'Textarea' });

<ZodForm schema={schema} formRegistry={formRegistry} onSubmit={handleSubmit}>
<button type="submit">Save</button>
</ZodForm>

Takeaway: .meta() and z.registry<FormMeta>() are the idiomatic, Zod v4-native way to annotate fields — no custom refinements.

3. shadcn/ui components

Use the built-in shadcn component map.

import { ZodForm } from '@zod-to-form/react';
import { shadcnComponentMap } from '@zod-to-form/react/shadcn';

<ZodForm schema={schema} components={shadcnComponentMap} onSubmit={handleSubmit}>
<button type="submit">Save</button>
</ZodForm>

Takeaway: preset: 'shadcn' in config is equivalent to passing shadcnComponentMap at runtime.

4. Custom components (extending shadcn)

Keep shadcn as the base, override specific fields with your own components.

// z2f.config.ts
import { defineConfig } from '@zod-to-form/core';

export default defineConfig({
components: {
source: '@/components/ui',
preset: 'shadcn',
overrides: {
MyDatePicker: { controlled: true },
},
},
fields: {
birthday: { component: 'MyDatePicker' },
bio: { component: 'MyRichTextEditor', props: { rows: 6 } },
},
});
import { shadcnComponentMap } from '@zod-to-form/react/shadcn';
import componentConfig from './z2f.config';

<ZodForm
schema={schema}
components={shadcnComponentMap}
componentConfig={componentConfig}
onSubmit={handleSubmit}
>
<button type="submit">Save</button>
</ZodForm>

Takeaway: one config file, both paths — matched fields use overrides, the rest fall back to shadcn.

5. Controlled components

For components that don't support ref forwarding (custom selects, date pickers), mark them controlled: true. Optional propMap remaps RHF field props to your component's API.

// z2f.config.ts
import { defineConfig } from '@zod-to-form/core';

export default defineConfig({
components: {
source: '@/components/ui',
overrides: {
MySelect: { controlled: true },
MyDatePicker: {
controlled: true,
propMap: { onSelect: 'field.onChange' },
},
},
},
fields: {
role: { component: 'MySelect' },
birthDate: { component: 'MyDatePicker' },
},
});

Runtime uses useController internally — no adapter wrappers needed.

Takeaway: controlled: true handles the ref-forwarding gap without you writing forwardRef adapters.

6. Hidden fields

Hide a field from rendering while keeping it in the schema and form state.

// In z2f.config.ts
export default defineConfig({
fields: {
internalId: { hidden: true },
},
});

The runtime suppresses the field but preserves its value in form state.

Takeaway: hidden: true is the right switch for bookkeeping fields (IDs, tenant tokens) that must flow through the form without being shown.

7. Section grouping

Group multiple fields into a single custom section component.

// In z2f.config.ts
export default defineConfig({
fields: {
source: { section: 'MetadataSection' },
version: { section: 'MetadataSection' },
lastUpdated: { section: 'MetadataSection' },
},
});

Individual fields are suppressed. A single <MetadataSection fields={['source', 'version', 'lastUpdated']} /> is rendered. The section component reads/writes values via useFormContext().

Takeaway: section grouping delegates layout to a custom component while still letting zod-to-form own field discovery.

8. Per-schema overrides

Override field config for specific schemas without affecting the global defaults.

// z2f.config.ts
import { defineConfig } from '@zod-to-form/core';

export default defineConfig({
components: { source: '@/components/ui', preset: 'shadcn' },
fields: {
description: { component: 'Textarea' }, // global default
},
schemas: {
userSchema: {
name: 'UserForm',
mode: 'auto-save',
fields: {
description: { component: 'Input' }, // override for this schema only
},
},
},
});

Takeaway: use global fields for cross-cutting defaults; use schemas[name] when one form needs to diverge.

9. Shared component configuration

One z2f.config.ts drives both paths — this section makes the parity explicit.

// z2f.config.ts
import { defineConfig } from '@zod-to-form/core';

export default defineConfig({
components: {
source: '@/components/ui',
preset: 'shadcn',
overrides: {
SelectInput: { controlled: true },
DateInput: { controlled: true },
TypeSelector: { controlled: true },
},
},
fields: {
bio: { component: 'TextareaInput', props: { rows: 6 } },
'address.country': { component: 'TypeSelector', props: { refType: 'Country' } },
},
});
import { ZodForm } from '@zod-to-form/react';
import componentConfig from './z2f.config';

<ZodForm
schema={userSchema}
componentConfig={componentConfig}
onSubmit={handleSubmit}
>
<button type="submit">Save</button>
</ZodForm>

Both paths resolve the config in the same priority order:

  1. Per-field override (config.fields['bio']) — highest
  2. Component override (config.components.overrides['MySelect']) — controlled, propMap
  3. Default rendering — built-in <input>, <select>, etc.

The only difference is when resolution happens — build time (CLI) vs render time (runtime). The resulting form structure, field mapping, and override props are identical.

10. Nested objects and arrays

import { z } from 'zod';

const orderSchema = z.object({
customer: z.object({
name: z.string(),
email: z.string().email(),
}),
items: z.array(
z.object({ product: z.string(), quantity: z.number().min(1) })
).min(1),
});
<ZodForm schema={orderSchema} onSubmit={handleSubmit}>
<button type="submit">Place Order</button>
</ZodForm>

Renders a customer fieldset group and an items repeater with add/remove controls. Remove is disabled when the minimum count is reached.

Takeaway: nested objects become fieldsets, arrays become repeaters — schema constraints (.min, .max) drive the UI affordances.

11. Discriminated unions

import { z } from 'zod';

const paymentSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('credit_card'), cardNumber: z.string() }),
z.object({ type: z.literal('paypal'), email: z.string().email() }),
]);
<ZodForm schema={paymentSchema} onSubmit={handleSubmit}>
<button type="submit">Pay</button>
</ZodForm>

Renders a select for type, then reveals only the fields for the selected variant.

Takeaway: discriminated unions are first-class — the discriminator renders as a select and the variant-specific fields reveal below it.

12. Auto-save mode

import { useZodForm } from '@zod-to-form/react';

function SettingsForm() {
const { form, fields } = useZodForm(settingsSchema, {
mode: 'onChange',
onValueChange: (values) => persist(values),
});
// form.watch(), form.setValue(), form.formState all available
return <pre>{JSON.stringify(fields, null, 2)}</pre>;
}

Takeaway: auto-save drops the submit button and fires onValueChange on every validated change.

13. Server action (Next.js)

npx z2f generate --schema src/schemas/user.ts --export userSchema --config z2f.config.ts --server-action

CLI only — server actions are a build-time codegen output (a colocated .ts file with 'use server'), so the runtime <ZodForm> path does not apply.

14. Watch mode (dev ergonomics)

npx z2f generate --schema src/schemas/user.ts --export userSchema --config z2f.config.ts --watch

CLI only — --watch re-runs the generator whenever the schema module changes. The runtime renderer already re-reads the schema on every render, so it has no equivalent flag.

15. Custom processors

Advanced: override a processor in the core walker to customize how a Zod type becomes a FormField.

import { z } from 'zod';
import { walkSchema } from '@zod-to-form/core';
import { processString } from '@zod-to-form/core/processors';
import type { FormProcessor } from '@zod-to-form/core';

const uppercaseStringProcessor: FormProcessor = (schema, ctx, field, params) => {
processString(schema, ctx, field, params);
field.component = 'Input';
field.props['textTransform'] = 'uppercase';
};

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

const fields = walkSchema(schema, {
processors: {
string: uppercaseStringProcessor,
},
});

Runtime only by convention — custom processors are composed with walkSchema() directly, typically behind useZodForm or a bespoke renderer. The CLI codegen consumes its processors from a fixed registry.


See also