name: shadcn-forms description: Build type-safe forms with shadcn/ui, react-hook-form, zod, and next-safe-action. Covers the shadcn/ui + next-safe-action preconditions, client-side (react-hook-form adapter) vs server-side (native form action) modes, schema/action/component placement, field wiring, validation display, and submission. Use whenever building or modifying a form in this project. user-invocable: true
shadcn Forms — react-hook-form + zod + next-safe-action
Stack: shadcn/ui (Field/FieldGroup layout) + react-hook-form (client state) + zod (schema) + next-safe-action (server actions — required).
Every form in this project submits through a next-safe-action action. There is no "post directly to an external API" mode — if you need an external call (auth provider, third-party API), wrap it inside a safe action.
Step 0 — Preconditions (do not skip)
1. shadcn/ui must be initialized. Check for components.json at the project root.
- Missing? shadcn/ui is the foundation of this skill — it cannot proceed without it. Tell the user to initialize it themselves first:
(or invoke thenpx shadcn@latest initshadcnskill). Then stop — do not write any form code untilcomponents.jsonexists. - Present? Continue.
2. next-safe-action must have a client. Check lib/safe-action.ts. This project already exports two clients — reuse them, do not recreate the file:
| Client | Use for |
|---|---|
actionClient |
user-facing forms — handleServerError returns a generic message |
diagnosticActionClient |
admin/debug tools — passes the real error message through |
If lib/safe-action.ts does not exist (a different project), create it once — defer to the safe-action-client skill.
Step 1 — Ask: client-side or server-side?
| Client-side | Server-side | |
|---|---|---|
| Form library | react-hook-form + useHookFormAction adapter |
none — native <form action> |
| Component | "use client" |
dispatcher hook is "use client"; markup can be minimal |
| Validation feedback | per-field, on change/blur, before submit | after submit (server round-trip) |
| Action method | .action() |
.stateAction() |
| Best for | rich, interactive forms (most app forms) | simple forms, progressive enhancement |
Both validate with the same zod schema on the server. Client-side also validates in the browser via zodResolver.
Step 2 — Install missing packages
Detect what's already in package.json and add only the gap. Run it yourself if you have permission; otherwise print the command and ask the user to run it.
# Client-side (react-hook-form adapter)
npm install react-hook-form @hookform/resolvers zod next-safe-action @next-safe-action/adapter-react-hook-form
# Server-side (native form)
npm install zod next-safe-action
In this project, only
@next-safe-action/adapter-react-hook-formis missing —react-hook-form,@hookform/resolvers,zod, andnext-safe-actionare already installed.
Step 3 — Install the shadcn components the form uses
Derive the list from the fields you're about to build, then add them in one command (omit any already in components/ui/):
npx shadcn@latest add field button label input select checkbox switch textarea radio-group calendar popover
field, button, and label are always needed; add one control per field type. Permission-aware, same as Step 2.
Step 4 — Build
Use the colocated layout, then follow the section for your chosen mode.
File layout (colocated under the route)
app/<ruta>/
page.tsx renders the form
<nombre>-form.tsx the form component
actions.ts "use server" — the safe action(s)
schema.ts neutral (NO directive) — zod schema, exported
lib/safe-action.ts shared client — reuse, don't recreate
The zod schema lives in its own
schema.ts, never inactions.ts. Next.js treats every export of a"use server"file as a server-action proxy; a schema exported from there breakszodResolveron the client.schema.tshas no directive, so bothactions.ts(server) and<nombre>-form.tsx(client) can import it. See schema.md.
Mode A — Client-side form (react-hook-form + adapter)
useHookFormAction composes useAction + useForm and auto-maps server validation errors onto the form fields. Call it with the action first, the resolver second — it returns the form; you never pass one in.
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useHookFormAction } from "@next-safe-action/adapter-react-hook-form/hooks"
import { Button } from "@/components/ui/button"
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { loginAction } from "./actions"
import { loginSchema } from "./schema"
export function LoginForm() {
const { form, action, handleSubmitWithAction } = useHookFormAction(
loginAction,
zodResolver(loginSchema),
{
formProps: { mode: "onTouched", defaultValues: { email: "", password: "" } },
}
)
const { register, formState: { errors } } = form
return (
<form onSubmit={handleSubmitWithAction} noValidate>
<FieldGroup>
<Field data-invalid={!!errors.email}>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input id="email" type="email" autoComplete="email" aria-invalid={!!errors.email} {...register("email")} />
{errors.email && (
<FieldDescription className="text-destructive">{errors.email.message}</FieldDescription>
)}
</Field>
<Field data-invalid={!!errors.password}>
<FieldLabel htmlFor="password">Password</FieldLabel>
<Input id="password" type="password" autoComplete="current-password" aria-invalid={!!errors.password} {...register("password")} />
{errors.password && (
<FieldDescription className="text-destructive">{errors.password.message}</FieldDescription>
)}
</Field>
</FieldGroup>
{action.result.serverError && (
<p className="mt-2 text-sm text-destructive">{action.result.serverError}</p>
)}
<Button type="submit" disabled={action.isPending} className="mt-6 w-full">
{action.isPending ? "Signing in…" : "Sign in"}
</Button>
</form>
)
}
defaultValuesandmodego informProps(not as top-leveluseFormargs).- Disable the submit button with
action.isPending. - Field-level server validation errors are auto-mapped to
errors; onlyaction.result.serverErroris shown manually. - Reset after success with
resetFormAndAction()— call it from auseEffectonaction.hasSucceeded, not fromactionProps.onSuccess(referencing the hook's own return inside its own options is a circular type under strict TS). See validation.md. - Controlled inputs (
Select,Checkbox,Switch,DatePicker, …) useController— see controller.md.
Files: schema.ts + actions.ts (with .action()) + <nombre>-form.tsx. See server-actions.md.
Mode B — Server-side form (native <form action>)
No react-hook-form. The action is defined with .stateAction() and dispatched through useStateAction's formAction, so the form works as a real <form action={…}>. Validation feedback appears after submit. For the full native-form decision table (useStateAction vs useAction, prevResult, bind args, file uploads) defer to the safe-action-forms skill — this section covers only the shadcn/ui markup.
"use client"
import { useStateAction } from "next-safe-action/hooks"
import { Button } from "@/components/ui/button"
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { sendContact } from "./actions"
export function ContactForm() {
const { formAction, result, isPending } = useStateAction(sendContact)
const errors = result.validationErrors
return (
<form action={formAction} noValidate>
<FieldGroup>
<Field data-invalid={!!errors?.name}>
<FieldLabel htmlFor="name">Name</FieldLabel>
<Input id="name" name="name" autoComplete="name" aria-invalid={!!errors?.name} />
{errors?.name?._errors?.[0] && (
<FieldDescription className="text-destructive">{errors.name._errors[0]}</FieldDescription>
)}
</Field>
<Field data-invalid={!!errors?.message}>
<FieldLabel htmlFor="message">Message</FieldLabel>
<Textarea id="message" name="message" aria-invalid={!!errors?.message} />
{errors?.message?._errors?.[0] && (
<FieldDescription className="text-destructive">{errors.message._errors[0]}</FieldDescription>
)}
</Field>
</FieldGroup>
{result.serverError && (
<p className="mt-2 text-sm text-destructive">{result.serverError}</p>
)}
<Button type="submit" disabled={isPending} className="mt-6 w-full">
{isPending ? "Sending…" : "Send"}
</Button>
</form>
)
}
- Inputs are uncontrolled — pass
name(matching the schema key) anddefaultValuefor edit forms. Noregister, noController. - The action must use
.stateAction()(not.action()). - Errors arrive in
result.validationErrors?.<field>?._errors(the default formatted shape); non-field errors inresult.serverError.
Files: schema.ts + actions.ts (with .stateAction()) + <nombre>-form.tsx. See server-actions.md.
Rules
- schema.md — zod schema patterns + placement
- controller.md —
registervsController(client mode) - validation.md — displaying errors, validation timing, reset
- server-actions.md — next-safe-action wiring for both modes
Related skills (defer to these — don't re-derive)
safe-action-forms—useHookFormAction,useStateAction, native forms, bind args, file uploadssafe-action-hooks—useAction/useStateActionstatus, callbacks,executeAsyncsafe-action-validation-errors— error shapes,returnValidationErrors,throwValidationErrorssafe-action-client—createSafeActionClientsetup (only iflib/safe-action.tsis missing)shadcn— adding/searching components
Quick reference
useHookFormAction(action, zodResolver(schema), props?)— action first, resolver second. It returns{ form, action, handleSubmitWithAction, resetFormAndAction }; you do not pass aformin.- No
type FormValuesneeded in client mode —useHookFormActioninfers form types from the action and resolver. Declaring one produces an unused-type warning. - Always use
zodResolver(client mode) — never hand-write validation. register()for native inputs (Input,Textarea,NativeSelect);Controllerfor controlled components (Select,Checkbox,Switch,RadioGroup,Combobox,ToggleGroup,InputOTP,Slider,DatePicker). Server mode uses neither — plainname/defaultValue.- Pair
data-invalid(onField) +aria-invalid(on the control). Missing either breaks the visual or the accessible state. - Show errors via
FieldDescription— never a raw<p>/<span>inside the field. noValidateon<form>— let zod own validation.- Disable submit while pending —
action.isPending(client) /isPending(server). Prevents double-submits. - Defaults & timing (client): put
defaultValuesandmode: "onTouched"informProps. - Reset (client):
resetFormAndAction()from auseEffectonaction.hasSucceeded(notactionProps.onSuccess— circular type), orform.reset(values); never callreset()with no args (see validation.md). - Server/root error:
action.result.serverError(client) /result.serverError(server). autoComplete: only where a standard token exists (name,email,new-password,current-password,tel, …). Omit on app-specific fields (role, category, rating). NeverautoComplete="off"to silence a warning.htmlForon everyFieldLabelmatching the control'sid. Exception: non-labelable Radix roots (Slider,ToggleGroup,RadioGroup) — useFieldTitle/FieldLegend+aria-labelledby.