server-actions — mutations that survive no-JS
Stage: Phase 7 — Backend - Reads: design/BRIEF.md, form components from ultraweb:forms - Writes: app/actions/*.ts, form wiring in components
Standard
- Every mutation is a
'use server' action with zod v4 validation at the boundary. Client-side validation is UX polish; the action re-validates everything — the client is never trusted.
- Expected failures (invalid input, duplicate email, auth denial) travel as return values, never throws. A throw becomes an opaque digest error in production; returned state renders the field-level messages
forms designed.
- The form works with JS disabled:
<form action={formAction}> submits natively, the server validates, the page re-renders with state. JS adds pending and optimistic polish — it never creates the feature.
- Every successful write ends in
revalidateTag/revalidatePath or redirect(). A mutation the UI doesn't reflect is a bug, not a caching quirk.
The canonical action
// app/actions/contact.ts
'use server'
import { z } from 'zod'
const schema = z.object({
email: z.email({ error: 'Enter a valid email address' }), // zod v4: { error }, not { message }
message: z.string().min(10, { error: 'Tell us a little more' }),
})
export type ContactState = {
ok: boolean
errors?: Record<string, string[]> // field keys + 'form' for form-level failures
}
export async function sendContact(prev: ContactState, formData: FormData): Promise<ContactState> {
const parsed = schema.safeParse(Object.fromEntries(formData))
if (!parsed.success) return { ok: false, errors: z.flattenError(parsed.error).fieldErrors }
try {
// deliverMessage propagates Resend's { data, error } — API failures come back as error, they do NOT throw
const { error } = await deliverMessage(parsed.data)
if (error) return { ok: false, errors: { form: ['Something went wrong — please try again.'] } }
} catch {
// genuinely unexpected throw only (network down, bug) — never a Resend API error
return { ok: false, errors: { form: ['Something went wrong — please try again.'] } }
}
return { ok: true }
}
Client wiring — signature per React 19 stable:
'use client'
import { useActionState } from 'react' // useActionState is from 'react'
import { useFormStatus } from 'react-dom' // useFormStatus is from 'react-dom'
import { sendContact } from '@/app/actions/contact'
const [state, formAction, pending] = useActionState(sendContact, { ok: false })
// <form action={formAction}> ... progressively enhances
pending from useActionState covers the whole form. useFormStatus() belongs inside a shared submit-button component that can't see the tuple.
- Disable the submit and show its loading state (per
buttons) while pending — never let a double-submit through.
Errors as data
- One state shape per project:
{ ok, errors?: fieldErrors + 'form' key }. forms renders field errors inline at the field, form-level errors as a banner above the actions row.
- Throws are for bugs; expected failures are UI states. Auth denial returns
{ ok: false, errors: { form: [...] } }, not a 500.
redirect() after success where the flow moves on — call it outside try/catch (it works by throwing internally; a catch block swallows the navigation).
- Never put secrets, raw DB rows, or stack traces in returned state — it serializes to the client.
Optimistic UI
const [optimisticItems, addOptimistic] = useOptimistic(items, (state, next: Item) => [...state, next])
// inside the form action: addOptimistic(draft); await createItem(formData)
useOptimistic from 'react'. React reconciles to server truth when the action settles — a failed action snaps back, so pair it with a visible error state, not silence.
- Use only for high-frequency, low-stakes mutations: likes, toggles, adding a list item. Payments, deletions, anything irreversible show an honest pending state — optimistic success on a destructive action is a lie to the user.
Progressive enhancement test — mandatory before green
- Dev server running; disable JavaScript (browser DevTools, or a Playwright context with JS off).
- Submit the form empty → server-rendered validation errors appear in place.
- Submit valid input → success state or redirect happens.
- Any step failing means the form depends on client handlers — rewire to
<form action={formAction}>. Record the result in design/QA.md.
Anti-patterns
onSubmit={ + preventDefault() driving a mutation — greppable pair; kills progressive enhancement.
throw new Error('Invalid inside an action — expected failure as a throw; return it as state.
- zod
{ message: ' — deprecated v3 param; greppable; use { error: '...' }.
- Client
fetch('/api/ for a first-party form mutation — actions exist for exactly this.
- An action that writes but never calls
revalidateTag/revalidatePath/redirect — stale UI after every submit.
import { useActionState } from 'react-dom' — wrong package; it's 'react' (useFormStatus is the 'react-dom' one).
redirect() inside try/catch — the catch eats the navigation.
- Generic "An error occurred" as the only failure copy —
copywriting owns error voice; every failure message says what to do next.
Worked example — Casa Verde, EN/PT reservation flow
Moved to references/example.md — read only when this build's case is genuinely ambiguous; the sections above are the decision material.
Composes with
Moved to references/composes.md — the handoff map; load it when orchestrating this skill against its neighbors.
1---2name: server-actions3description: Mutations for a Next.js 16 site — 'use server' actions validated with zod v4 (the { error } param, message is deprecated), useActionState form wiring per the stable React 19 signature, errors returned as data instead of thrown, optimistic UI with useOptimistic for low-stakes mutations, and a mandatory progressive-enhancement test (the form must work with JavaScript disabled). Invoke during the backend phase when wiring any form submit or write — contact form, newsletter signup, CRUD, settings save, like/toggle — when a form throws opaque errors instead of showing field messages, or when a mutation goes through a client fetch to an API route. Trigger phrases — "wire up the form", "handle the submit", "server action", "form validation", "contact form backend", "optimistic update", "the form errors are ugly".4---56# server-actions — mutations that survive no-JS78**Stage:** Phase 7 — Backend - **Reads:** design/BRIEF.md, form components from ultraweb:forms - **Writes:** app/actions/*.ts, form wiring in components910## Standard1112- Every mutation is a `'use server'` action with zod v4 validation at the boundary. Client-side validation is UX polish; the action re-validates everything — the client is never trusted.13- Expected failures (invalid input, duplicate email, auth denial) travel as **return values**, never throws. A throw becomes an opaque digest error in production; returned state renders the field-level messages `forms` designed.14- The form works with JS disabled: `<form action={formAction}>` submits natively, the server validates, the page re-renders with state. JS adds pending and optimistic polish — it never creates the feature.15- Every successful write ends in `revalidateTag`/`revalidatePath` or `redirect()`. A mutation the UI doesn't reflect is a bug, not a caching quirk.1617## The canonical action1819```ts20// app/actions/contact.ts21'use server'22import { z } from 'zod'2324const schema = z.object({25 email: z.email({ error: 'Enter a valid email address' }), // zod v4: { error }, not { message }26 message: z.string().min(10, { error: 'Tell us a little more' }),27})2829export type ContactState = {30 ok: boolean31 errors?: Record<string, string[]> // field keys + 'form' for form-level failures32}3334export async function sendContact(prev: ContactState, formData: FormData): Promise<ContactState> {35 const parsed = schema.safeParse(Object.fromEntries(formData))36 if (!parsed.success) return { ok: false, errors: z.flattenError(parsed.error).fieldErrors }37 try {38 // deliverMessage propagates Resend's { data, error } — API failures come back as error, they do NOT throw39 const { error } = await deliverMessage(parsed.data)40 if (error) return { ok: false, errors: { form: ['Something went wrong — please try again.'] } }41 } catch {42 // genuinely unexpected throw only (network down, bug) — never a Resend API error43 return { ok: false, errors: { form: ['Something went wrong — please try again.'] } }44 }45 return { ok: true }46}47```4849Client wiring — signature per React 19 stable:5051```tsx52'use client'53import { useActionState } from 'react' // useActionState is from 'react'54import { useFormStatus } from 'react-dom' // useFormStatus is from 'react-dom'55import { sendContact } from '@/app/actions/contact'5657const [state, formAction, pending] = useActionState(sendContact, { ok: false })58// <form action={formAction}> ... progressively enhances59```6061- `pending` from `useActionState` covers the whole form. `useFormStatus()` belongs inside a shared submit-button component that can't see the tuple.62- Disable the submit and show its loading state (per `buttons`) while `pending` — never let a double-submit through.6364## Errors as data6566- One state shape per project: `{ ok, errors?: fieldErrors + 'form' key }`. `forms` renders field errors inline at the field, form-level errors as a banner above the actions row.67- Throws are for bugs; expected failures are UI states. Auth denial returns `{ ok: false, errors: { form: [...] } }`, not a 500.68- `redirect()` after success where the flow moves on — call it **outside** try/catch (it works by throwing internally; a catch block swallows the navigation).69- Never put secrets, raw DB rows, or stack traces in returned state — it serializes to the client.7071## Optimistic UI7273```tsx74const [optimisticItems, addOptimistic] = useOptimistic(items, (state, next: Item) => [...state, next])75// inside the form action: addOptimistic(draft); await createItem(formData)76```7778- `useOptimistic` from `'react'`. React reconciles to server truth when the action settles — a failed action snaps back, so pair it with a visible error state, not silence.79- Use only for high-frequency, low-stakes mutations: likes, toggles, adding a list item. Payments, deletions, anything irreversible show an honest pending state — optimistic success on a destructive action is a lie to the user.8081## Progressive enhancement test — mandatory before green82831. Dev server running; disable JavaScript (browser DevTools, or a Playwright context with JS off).842. Submit the form empty → server-rendered validation errors appear in place.853. Submit valid input → success state or redirect happens.864. Any step failing means the form depends on client handlers — rewire to `<form action={formAction}>`. Record the result in `design/QA.md`.8788## Anti-patterns8990- `onSubmit={` + `preventDefault()` driving a mutation — greppable pair; kills progressive enhancement.91- `throw new Error('Invalid` inside an action — expected failure as a throw; return it as state.92- zod `{ message: '` — deprecated v3 param; greppable; use `{ error: '...' }`.93- Client `fetch('/api/` for a first-party form mutation — actions exist for exactly this.94- An action that writes but never calls `revalidateTag`/`revalidatePath`/`redirect` — stale UI after every submit.95- `import { useActionState } from 'react-dom'` — wrong package; it's `'react'` (`useFormStatus` is the `'react-dom'` one).96- `redirect()` inside try/catch — the catch eats the navigation.97- Generic "An error occurred" as the only failure copy — `copywriting` owns error voice; every failure message says what to do next.9899## Worked example — Casa Verde, EN/PT reservation flow100101Moved to `references/example.md` — read only when this build's case is genuinely ambiguous; the sections above are the decision material.102103## Composes with104105Moved to `references/composes.md` — the handoff map; load it when orchestrating this skill against its neighbors.