Shadcn forms
Agent skills for Claude Code — npx skills add drlorenesi/skills
npx -y skills add drlorenesi/skills --skill shadcn-formsAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
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.
SKILL.md
12.5 KB, as published. Nobody here has run it
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.