Verified against Claude Code · 2026-07-21
Build a Server Action form with real validation, not a client fetch call in disguise
Wires a form directly to a Server Action with server-side schema validation, per-field errors via useActionState, and a pending button via useFormStatus, instead of a client onSubmit handler pretending to be one.
The prompt
Ready to copy — highlighted parts are example details you can swap.
You are building a form wired directly to a Next.js Server Action, not a client component that calls preventDefault and fetches an API route from inside an onSubmit handler. The form element's action prop points at a function marked 'use server', and pending/error state comes from useActionState and useFormStatus, not hand-rolled useState. FORM FIELDS title (text, required, max 120 chars), dueDate (date, optional), assigneeId (select, required) MUTATION Creates a new task row scoped to the current project and the signed-in user's workspace VALIDATION LIBRARY Zod SUCCESS AND FAILURE BEHAVIOR On success, clear the form and show the new task at the top of the list without a page reload; on failure, keep whatever was typed and show the specific field error inline. EXISTING CLIENT-SIDE VALIDATION A simple required-field check already runs on blur in the browser, mostly for instant feedback while typing. BUILD RULES Validate the submitted FormData against a schema inside the Server Action itself, every time, regardless of whatever validation already runs client-side — a Server Action is a callable server endpoint that can be invoked directly, bypassing the form component entirely, so client-side validation alone is not validation, it is a UX nicety layered on top of a server boundary that must enforce the real rule on its own. Return field-level errors from the action in the shape useActionState expects — a small object keyed by field name — so each input can render its own error message next to itself, rather than one generic banner at the top of the form that leaves the user guessing which field actually failed. After a successful mutation, call revalidatePath or revalidateTag for whatever page or cached data this action affects, inside the action itself, so the UI reflects the change on its own without the client having to trigger a manual refetch afterward. Read pending state with useFormStatus inside a separate child component nested under the form — never in the same component that renders the form element itself, since useFormStatus only returns real pending status when called from a descendant of the form it is tracking, and returns default, always-false values everywhere else, a bug that a quick manual click-test will not catch because the developer usually only tests the one render path they happened to write. Do not add useOptimistic unless the interface genuinely needs to show a result before the server confirms it; if it does not, say so explicitly and leave it out rather than adding it because the API happens to be available and looks like the more modern choice. Keep the JavaScript-disabled path working: since this is a real HTML form submission through a 'use server' action, do not add a preventDefault call or a manual fetch anywhere in the flow, because that silently reintroduces the exact client-fetch pattern this build is meant to replace, and defeats the progressive-enhancement guarantee the action prop provides for free the moment it's used correctly. OUTPUT FORMAT Two code blocks: the Server Action file, including its validation and its fully typed return shape, and the form component using useActionState alongside its separate submit-button child component using useFormStatus. Close with one line stating exactly what gets revalidated on success, named by path or tag rather than described vaguely.
Customize
Optional — swap in your own details for the highlighted parts above.
Why this works
The load-bearing fact here is that a Server Action is a callable server endpoint the client can invoke directly, independent of whatever form component happens to be rendering it — which is exactly why client-side validation, however thorough, is not a substitute for validating inside the action itself; a request can hit the action's server boundary without ever passing through the form's onChange handlers that would normally catch a bad value on the way. The useFormStatus descendant requirement is not a stylistic preference — it reflects a documented and specific behavior: the hook reads pending status from the nearest parent form only when called from a component rendered inside that form, and returns inert default values everywhere else, including the component that renders the form tag itself, which means a naive placement compiles cleanly, renders without error, and simply never shows a pending state once shipped, a failure mode invisible to a quick manual test that doesn't specifically watch for the pending UI under real network latency. Restricting useOptimistic to cases where the interface genuinely displays an unconfirmed result targets a different mistake — adding it reflexively because it is the newer, more discussed API — and the requirement to justify its absence turns that into an explicit judgment call instead of a silent default in either direction, which is exactly the same discipline a careful reviewer would apply by hand. The progressive-enhancement note carries real, measurable weight rather than being a nicety: React's form action prop is specifically designed so the browser can execute the submission as a real HTTP request before hydration finishes, a meaningfully different guarantee than the onSubmit-plus-preventDefault pattern it replaces, and a build that keeps a preventDefault call anywhere in the flow defeats that guarantee completely while still looking, on the surface, like a correct React 19 migration — the bug is invisible until someone tests on a slow connection with JavaScript not yet loaded, which is precisely the scenario this API exists to protect.
What you get back
async function createTaskAction(prevState, formData) { const parsed = taskSchema.safeParse(Object.fromEntries(formData)); if (!parsed.success) return { errors: parsed.error.flatten().fieldErrors, task: null }; const task = await db.task.create({ data: parsed.data }); revalidatePath('/projects/' + parsed.data.projectId); return { errors: {}, task }; } function SubmitButton() { const { pending } = useFormStatus(); return <button disabled={pending}>{pending ? 'Adding…' : 'Add task'}</button>; } Revalidates: /projects/[projectId] via revalidatePath, so the task list reflects the new row on next render without a client-triggered refetch. useOptimistic was left out — the task list is short enough that a brief pending state on the button is sufficient, and there's nothing here that needs to appear before the server confirms it.
Verified against
Claude Code Sonnet 4.6 · 2026-07-21
v0 by Vercel 2026.7 · 2026-07-30
Changelog
- 2026-07-21 — Initial publish, verified against Claude Code (Sonnet 4.6) and v0 by Vercel on React 19 / Next.js 16 forms.
Need this built into your business?
If a prompt isn't enough — custom software, built and maintained for you — that's Scult's day job.
EXPLORE CUSTOM SOFTWARE
