Server actions and forms in depth
The use server directive, form actions, useActionState and useFormStatus, structured errors, revalidation and optimistic UI.
Actions and form actions
// app/guides/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { db } from '@/lib/db'
import { getSession } from '@/lib/session'
export async function createGuide(prevState, formData) {
const session = await getSession()
if (!session) return { error: 'Sign in to continue' }
const title = String(formData.get('title') ?? '').trim()
if (title.length < 3) return { error: 'Title must be at least 3 characters' }
await db.guide.create({ data: { title, ownerId: session.userId } })
revalidatePath('/guides')
return { ok: true }
}// app/guides/new/page.tsx — works before any JavaScript has loaded
import { createGuide } from '../actions'
export default function NewGuide() {
return (
<form action={createGuide}>
<input name="title" />
<button type="submit">Create</button>
</form>
)
}'use server'must be the first statement in the module, or the first line of an inline function. Without it the function runs in the browser and nothing is saved.- A form action receives the submitted
FormData; withuseActionStateit also receives the previous state as its first argument. - Because the form posts to a generated endpoint, it works before hydration. That is progressive enhancement rather than a separate code path.
- The action runs on the server, so re-check the session and validate the input. The browser's
requiredandtypeattributes are convenience for the user, not enforcement. - Actions must be async, and values crossing the boundary must be serialisable — a class instance, a function or a Date-with-methods will not survive.
useActionState and error shapes
'use client'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { createGuide } from '../actions'
function SubmitButton() {
const { pending } = useFormStatus() // must be rendered inside the form
return <button disabled={pending}>{pending ? 'Saving...' : 'Create'}</button>
}
export default function NewGuideForm() {
const [state, formAction] = useActionState(createGuide, {})
return (
<form action={formAction}>
<input name="title" aria-invalid={Boolean(state.fieldErrors?.title)} />
{state.fieldErrors?.title && <p role="alert">{state.fieldErrors.title}</p>}
{state.error && <p role="alert">{state.error}</p>}
<SubmitButton />
</form>
)
}- Return an error object for expected failures such as invalid input. Throwing instead triggers
error.tsx, which is the wrong UI for a form the user can fix. - Shape every response consistently —
{ ok },{ error }, or{ fieldErrors }keyed by input name — so the form can render messages without guessing. useFormStatusreads the status of the enclosing form, so it must be called in a component rendered inside that form. In the component that renders the form it always reports false.- Disable the submit control while pending, or a double click creates two records and the second attempt fails confusingly.
- The previous state is the first argument, which makes it easy to keep the user's input after a failed submit instead of blanking the form.
⚠️
A thrown error in a server action reaches the client as a generic message in production. Return structured errors for anything the user can act on, and log the unexpected failures on the server where you can see them.
Revalidation and optimistic UI
// app/guides/actions.ts
'use server'
import { revalidatePath, revalidateTag } from 'next/cache'
export async function addComment(guideId, formData) {
await db.comment.create({ data: { guideId, body: String(formData.get('body') ?? '') } })
revalidateTag('guide-' + guideId) // only the data this write affected
revalidatePath('/guides/' + guideId) // the route and its cache
}'use client'
import { useOptimistic } from 'react'
import { addComment } from './actions'
export function CommentList({ guideId, comments }) {
const [optimistic, addOptimistic] = useOptimistic(
comments,
(current, body) => [...current, { id: 'pending', body, pending: true }]
)
async function submit(formData) {
addOptimistic(formData.get('body'))
await addComment(guideId, formData) // writes, then the revalidated render replaces it
}
return (
<form action={submit}>
<input name="body" />
{optimistic.map(c => <p key={c.id} style={{ opacity: c.pending ? 0.6 : 1 }}>{c.body}</p>)}
</form>
)
}revalidatePathmarks the cached route stale so the next render shows the new data without a manual refetch.revalidateTagis the sharper tool: the mutating code does not need to know which routes render the data.useOptimisticshows the intended result immediately; the value is discarded when the real data arrives from the revalidated render.- The optimistic value must be derived from the form input inside the action, not from a global "something is saving" flag — the UI has to know what to show.
- Add the optimistic entry and perform the write in the same action, so a failure cannot leave the interface displaying something that was never stored.
FAQ
Server action or route handler?
Use a server action for mutations triggered by your own UI — it removes the endpoint and the fetch call. Use a route handler when the caller is not your React app, or when the response is a file, a stream or a specific status code.
Why is useFormStatus always false?
It reports the status of the form that contains it, so it must be called from a component rendered inside that form. Put the submit button in its own component instead of calling the hook beside the form element.
Related
Route handlers and API endpoints Caching, revalidation and Cache Components
Last refreshed 2026-09-18.