Forms with Zod
Pass a Zod schema to Form and connect each field through the name prop. Validate when leaving a field and get typed data on submit.
Example
Try the flow: submit the empty form, leave an invalid field, and fix the value.
form-zod.tsx
"use client";
import { useState } from "react";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Field, FieldError, FieldLabel } from "@/components/ui/field";
import { Form } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
const schema = z.object({
name: z.string().trim().min(2, "enter at least 2 letters."),
email: z.email("that email doesn't look valid."),
password: z.string().min(8, "the password needs 8 characters or more."),
terms: z.literal(true, "accept the terms to continue."),
});
export default function FormZod() {
const [created, setCreated] = useState<string | null>(null);
return (
<Form
className="w-full max-w-sm"
onSubmit={async (values) => {
// `values` is already validated and typed: { name: string; email: string; password: string; terms: true }
await new Promise((r) => setTimeout(r, 800));
setCreated(values.name);
}}
schema={schema}
>
<Field name="name">
<FieldLabel>name</FieldLabel>
<Input autoComplete="name" />
<FieldError />
</Field>
<Field name="email">
<FieldLabel>email</FieldLabel>
<Input autoComplete="email" inputMode="email" />
<FieldError />
</Field>
<Field name="password">
<FieldLabel>password</FieldLabel>
<Input autoComplete="new-password" type="password" />
<FieldError />
</Field>
<Field name="terms">
<FieldLabel className="flex items-center gap-2 font-normal">
<Checkbox /> I accept the terms of use
</FieldLabel>
<FieldError />
</Field>
<Button type="submit" variant="brand">
create account
</Button>
{created && (
<p aria-live="polite" className="text-sm">
account created, {created}!
</p>
)}
</Form>
);
}How it works
- The
Formreceives the schema and prepares field validation. - Each
Fieldwith anamevalidates its own schema key when you leave the field. Change that timing withvalidationMode. - On submit, the whole schema is validated. If there is an error, it appears in the matching
FieldErrorand focus moves to the first invalid field. If everything is valid,onSubmitreceives the data converted and typed by the schema.
const schema = z.object({
email: z.email("invalid email"),
idade: z.coerce.number().min(18, "precisa ter 18+"),
})
<Form schema={schema} onSubmit={(values) => {
values.idade // number, not string
}}>
<Field name="email">…</Field>
<Field name="idade">…</Field>
</Form>Tip: with a schema, prefer inputMode="email" over type="email". The mobile keyboard stays the same; the error message comes from the schema, not the browser.
Even lighter with zod/mini
Form imports only the core of Zod (zod/v4/core), so it also accepts schemas from zod/mini. With it, the bundle gets leaner.
import * as z from "zod/mini"
const schema = z.object({
email: z.email("invalid email"),
password: z.string().check(z.minLength(8, "minimum 8 characters")),
})Server errors
When the back end finds an error, such as an email that is already registered, pass the message through the errors prop:
const [errors, setErrors] = useState({})
<Form schema={schema} errors={errors} onSubmit={async (values) => {
const res = await signup(values)
if (!res.ok) setErrors({ email: "that email already has an account." })
}}>Install
$ npx shadcn@latest add https://cd-ui.vercel.app/r/form.json https://cd-ui.vercel.app/r/input.json