zod-form-engine
v0.0.3
Published
Auto-generate React forms from a Zod schema. Define your fields, validation, and UI in one place — `ZodForm` renders the form for you.
Readme
@omar-ra7al/zod-form
Auto-generate React forms from a Zod schema. Define your fields, validation, and UI in one place — ZodForm renders the form for you.
What this package provides
| Feature | Description |
| --------------------------- | ------------------------------------------------------------ |
| Schema-driven forms | Build forms from a z.object() Zod schema |
| Validation | Powered by Zod + react-hook-form + @hookform/resolvers |
| Custom field components | Attach any React input via field() |
| Nested objects | Automatically renders grouped sections |
| Dynamic arrays | Add/remove repeating items (objects or primitives) |
| Container styling | Grid column spans and wrapper classes per field via Tailwind |
| Live onChange | Get form values as the user types |
| JSON editor | Paste JSON to fill the form, or export current values |
| Submit UI | Custom label, icon, styles, and loading state |
Exports: ZodForm (default), field, getFieldMeta.
Requirements
| Package | Version |
| --------------------- | --------- |
| react | ^19.0.0 |
| react-dom | ^19.0.0 |
| zod | ^4.0.0 |
| react-hook-form | ^7.0.0 |
| @hookform/resolvers | ^5.0.0 |
| lucide-react | ^1.20.0 |
Installation
npm install @omar-ra7al/zod-formQuick start
1. Create a field component
Your component receives value, onChange, and anything you pass in props.
type InputProps = {
value: string;
onChange: (value: string) => void;
};
function Input({ value, onChange, ...props }: InputProps) {
return (
<input
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full border p-2 rounded-md"
{...props}
/>
);
}2. Define a Zod schema with field()
Wrap each field with field(zodSchema, meta):
import z from "zod";
import ZodForm, { field } from "@omar-ra7al/zod-form";
const schema = z.object({
name: field(z.string().min(1, "Name is required"), {
label: "Name",
containerClassName: "col-span-1",
component: Input,
}),
email: field(z.email(), {
label: "Email",
containerClassName: "col-span-1",
component: Input,
}),
});3. Render the form
function MyForm() {
return (
<ZodForm
zodSchema={schema}
onSubmit={(data) => console.log(data)}
mode="onSubmit"
className="w-full max-w-2xl"
/>
);
}How it works
Zod schema + field() → ZodForm → react-hook-form → Your components
↓ ↓
validation grid layout- You define a
z.object()schema using normalz.object(),z.array(), etc. - Each field that needs UI config is wrapped with
field(zodSchema, meta). field()returns the same Zod schema and stores metadata separately (not onzod.meta()).ZodFormwalks the schema, looks up metadata per field, and renders the matching components.- On submit, validated data is passed to your
onSubmithandler.
field() usage rules
- Call
field()last — after all Zod chaining:
field(z.string().min(1), { label: "Name" })— correctfield(z.string(), { label: "Name" }).min(1)— wrong (.min()returns a new instance, meta is lost)
- Wrap anything that needs UI config with
field():
- Leaf inputs:
field(z.string(), { component, label, ... }) - Nested sections:
field(z.object({ ... }), { label: "Profile", containerClassName }) - Arrays:
field(z.array(...), { label: "Contacts" }) - Array item cards (optional):
field(z.object({...}), { containerClassName })
- Don't reuse one schema instance across multiple
field()calls (the second call overwrites metadata).
field() meta options
Leaf fields (with component)
| Option | Type | Description |
| -------------------- | ---------------------------------- | -------------------------------------------------------------------------------------- |
| label | string or { text, className? } | Label shown above the field |
| component | React component | Required. Input component to render |
| containerClassName | string | Classes on the engine's field wrapper (label + input + error). Default: "col-span-2" |
| props | object | Extra props spread onto your component |
| defaultValue | any | Fallback when value is empty |
props and defaultValue are fully typed based on your Zod schema output type and component props.
Nested objects
Use field(z.object({ ... }), { label, containerClassName }). Each nested object becomes a section heading with its own grid. There is no depth limit — objects and arrays can nest as deeply as your schema requires.
| Option | Type | Description |
| -------------------- | ---------------------------------- | ---------------------------------------------------------------------------- |
| label | string or { text, className? } | Section heading |
| containerClassName | string | Classes on the section wrapper. Replaces the default section styles when set |
| props | object | Extra props spread onto the section wrapper element |
const schema = z.object({
profile: field(
z.object({
name: field(z.string(), { label: "Name", component: Input }),
address: field(
z.object({
street: field(z.string(), { label: "Street", component: Input }),
location: field(
z.object({
city: field(z.string(), { label: "City", component: Input }),
zip: field(z.string(), { label: "ZIP", component: Input }),
}),
{ label: "Location" },
),
}),
{ label: "Address" },
),
}),
{
label: "Profile",
containerClassName: "col-span-2 border-l-2 border-primary/20 pl-4 my-2 bg-muted/30",
},
),
});Dynamic arrays
Use field(z.array(...), { label: "..." }). Users can add/remove items with New item / × buttons.
Array of objects:
const schema = z.object({
contacts: field(
z.array(
z.object({
phone: field(z.string(), { label: "Phone", component: Input }),
email: field(z.email(), { label: "Email", component: Input }),
}),
),
{ label: "Contacts" },
),
});Array of primitives (renders a plain text input per item):
const schema = z.object({
tags: field(z.array(z.string()), { label: "Tags" }),
});Arrays and objects together:
const schema = z.object({
projects: field(
z.array(
z.object({
title: field(z.string(), { label: "Title", component: Input }),
tasks: field(
z.array(
z.object({
name: field(z.string(), { label: "Task", component: Input }),
}),
),
{ label: "Tasks" },
),
}),
),
{ label: "Projects" },
),
});Array-specific meta
| Level | Options |
| ----------------------------------- | -------------------------------------- |
| Array (field(z.array(), {})) | label, containerClassName, props |
| Array item (wrapped z.object) | label, containerClassName, props |
Array containerClassName defaults to "grid-cols-2" on the items grid. Array item containerClassName defaults to "col-span-2" on each item card.
ZodForm props
<ZodForm
zodSchema={schema} // required — z.object() schema
onSubmit={(data) => {}} // required — called with validated data
onChange={(data) => {}} // optional — called on every value change
defaultValues={{}} // optional — initial / reset values
mode="onChange" // optional — react-hook-form validation mode
className="" // optional — extra classes on the form wrapper
jsonConfig={{}} // optional — JSON editor (see below)
submitConfig={{}} // optional — submit button (see below)
/>mode
When validation runs. Same as [react-hook-form mode](https://react-hook-form.com/docs/useform#mode):
| Value | When validation runs |
| ------------- | -------------------------------------- |
| "onChange" | On every change (default) |
| "onSubmit" | On submit only |
| "onBlur" | On blur |
| "onTouched" | After first blur, then on every change |
| "all" | On blur and change |
jsonConfig
| Option | Type | Default | Description |
| ---------- | --------- | ------- | ----------------------------------------- |
| enabled | boolean | false | Show the JSON editor panel |
| liveData | boolean | false | Auto-sync editor with current form values |
<ZodForm
zodSchema={schema}
onSubmit={handleSubmit}
jsonConfig={{ enabled: true, liveData: true }}
/>submitConfig
| Option | Type | Default | Description |
| ----------------- | ----------- | ---------------- | ------------------------- |
| disabled | boolean | false | Disable the submit button |
| submitLabel | string | "Save Changes" | Button text |
| submitIcon | ReactNode | save icon | Icon next to the label |
| submitClassName | string | built-in styles | Custom button classes |
<ZodForm
zodSchema={schema}
onSubmit={handleSubmit}
submitConfig={{
submitLabel: "Create account",
submitClassName: "w-full bg-blue-600 text-white py-2 rounded-lg",
}}
/>Custom components
ZodForm always passes **value** and **onChange** to your component.
Same prop names? Use it directly:
name: field(z.string(), {
label: "Name",
component: Input,
props: { placeholder: "Enter your name" },
}),Use props for extras like placeholder or disabled — not to rename value / onChange.
Different prop names? Wrap the component and map them. Common with shadcn/ui (checked / onCheckedChange, onValueChange, onSelect, etc.):
function FormCheckbox({ value, onChange }: { value: boolean; onChange: (v: boolean) => void }) {
return <Checkbox checked={value} onCheckedChange={onChange} />;
}Full example
import z from "zod";
import ZodForm, { field } from "@omar-ra7al/zod-form";
const Input = ({ value, onChange, ...props }: any) => (
<input
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full border p-2 rounded-md"
{...props}
/>
);
const schema = z.object({
name: field(z.string().min(1), {
label: "Name",
containerClassName: "col-span-1",
component: Input,
}),
email: field(z.email(), {
label: "Email",
containerClassName: "col-span-1",
component: Input,
}),
details: field(
z.object({
phone: field(z.string(), { label: "Phone", component: Input }),
}),
{
label: "Contact details",
containerClassName: "col-span-2 border-l-2 border-primary/20 pl-4 my-2",
},
),
password: field(z.string().min(8), {
label: "Password",
component: Input,
}),
});
export function SignUpForm() {
return (
<ZodForm
zodSchema={schema}
mode="onSubmit"
defaultValues={{ name: "", email: "", password: "" }}
onChange={(data) => console.log("changed:", data)}
onSubmit={async (data) => {
await fetch("/api/signup", {
method: "POST",
body: JSON.stringify(data),
});
}}
className="w-full max-w-2xl"
/>
);
}TypeScript
Infer form data from your schema:
type FormData = z.infer<typeof schema>;
<ZodForm
zodSchema={schema}
onSubmit={(data: FormData) => {
// data is fully typed
}}
/>;field() provides autocomplete for props and defaultValue based on your component and Zod schema types.
License
See LICENSE. Usage as a dependency is permitted. Copying, modifying, or redistributing the source is not.
