Mutations: Forms and Server Actions
Reading data was phase 4. Writing it - the form submit, the delete button, the "add to cart" - used to require building an API route, fetching it from the client, and hand-managing the states in between. Server actions collapse that: you write a function, and the framework builds the endpoint. It's the feature that looks most like magic and is most worth de-mystifying, because under the hood it's the plainest thing in Next: a POST request with a name on it.
What a server action actually is
// app/todos/actions.ts
'use server';
;
;
// app/todos/page.tsx - a server component with a form
;
What just happened: 'use server' marks the file's exports as server actions - functions that
always execute on the server, no matter where they're called from. When you pass one to a form's
action, the framework registers a private endpoint for it; submitting the form POSTs the form data
there, runs your function server-side, and re-renders the page with the result. You wrote zero API
code, zero fetch code, and the database call never left the server.
📝 Terminology: this is the one legal way a function crosses the server/client boundary from phase 3 - because what actually crosses is not the function but a reference to it, which the framework resolves back to server code when invoked.
⚠️ Gotcha: 'use server' does not mean "server component" - components are server-rendered by
default and need no directive. It means "callable-from-anywhere function that runs on the server."
Two directives, two meanings: 'use client' marks a bundle boundary; 'use server' marks remote
callability. And because every server action is a public HTTP endpoint (anyone can POST to it),
validation and auth checks belong inside the action, every time - the form is a convenience, not a
gate.
Why this beats the fetch-an-API version
The old shape - onSubmit handler, fetch('/api/todos', ...), JSON parsing, then manually
re-fetching the list - had four moving parts to keep aligned. The action version has one function.
And the <form action={...}> spelling has a property the fetch version can't offer: it works as a
plain HTML form. JavaScript still loading on a slow connection? Disabled? The form still POSTs and
the action still runs - the enhanced behavior (no page reload, streamed re-render) layers on when
hydration completes.
revalidatePath: telling the cache what you changed
That revalidatePath('/todos') line is doing real work. Next caches rendered pages (the whole
machinery is phase 6), and a mutation makes cached copies wrong - you inserted a row, but the
cached /todos still shows the old list. revalidatePath marks that path's cached data stale so
the next look re-renders it fresh. Its sibling revalidateTag invalidates by tag across many pages
at once (tag your fetches with { next: { tags: ['todos'] } }, then revalidateTag('todos')).
💡 Key point: mutation without revalidation is the source of the "I saved it but the page shows the old data" class of bug. The reflex to build: every action that writes ends by declaring what it invalidated.
Pending state and results: useActionState
Forms need feedback - a disabled button while submitting, an error message when validation fails.
The client-side hook useActionState wraps an action with exactly that:
'use client';
;
; // action returns { error?: string } now
What just happened: the hook gives you the action's last return value (state), a wrapped action
for the form, and a pending boolean during the round-trip. The action itself changes shape
slightly: it receives (prevState, formData) and returns the new state - which is how server-side
validation messages travel back to the form without you building a response channel.
Route handlers: when you actually want an API
Server actions serve your own UI. Sometimes you need a real HTTP endpoint - a webhook receiver, a
JSON API for a mobile app, an RSS feed. That's route.ts from phase 2's table:
// app/api/todos/route.ts
;
One folder exposes GET/POST/PUT/DELETE by exporting functions with those names. The
decision line: consumed by your own components → server action; consumed by anything else →
route handler. (A folder can't have both route.ts and page.tsx - a URL is either a page or an
endpoint.)
Recap
- A server action is a
'use server'function the framework exposes as a private POST endpoint - forms call it viaaction={fn}, no API layer written. - It's still a public endpoint: validate and authorize inside the action, always.
- Writes end with
revalidatePath/revalidateTag, or cached pages keep showing pre-mutation data. useActionStatesupplies pending state and carries server validation messages back to the form.- Route handlers (
route.ts) are for external consumers; actions are for your own UI.
[
{
"q": "A server action skips validation because \"the form already validates in the browser.\" What's wrong with that reasoning?",
"choices": [
"Browser validation doesn't run for controlled inputs",
"The action is an HTTP endpoint anyone can POST to directly, bypassing your form entirely",
"Server actions can't read FormData without validating it first",
"Nothing - client validation is sufficient when using form action={}"
],
"answer": 1,
"why": [
"Browser validation runs fine on any input - but it runs in a place the attacker controls.",
null,
"FormData reads happily without validation - that's precisely the danger.",
"Client validation is UX, not security, in every architecture - actions included."
],
"explain": "The form is one caller of the endpoint, not the only possible one. curl can invoke your action with anything - the server-side check is the real one."
},
{
"q": "After an action inserts a row, the page still lists the old data until a hard refresh. What's missing?",
"choices": [
"An await on the database insert",
"A revalidatePath (or revalidateTag) call marking the cached page stale",
"A useEffect to re-fetch after submit",
"The form needs method=\"POST\""
],
"answer": 1,
"why": [
"A missing await would be a race, fixed by luck on fast databases - but the hard-refresh recovery points at caching, not timing.",
null,
"Re-fetching client-side patches the symptom for one visitor; the cached server render stays wrong for everyone else.",
"Actions POST already - the method is handled by the framework."
],
"explain": "Next serves the cached render until told otherwise. Mutations must declare what they invalidated: revalidatePath('/todos') makes the next request re-render fresh."
}
]
← Phase 4: Data on the Server · Guide overview · Phase 6: Static, Dynamic, and the Cache →
Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.
Check your understanding 2 questions
1. A server action skips validation because "the form already validates in the browser." What's wrong with that reasoning?
2. After an action inserts a row, the page still lists the old data until a hard refresh. What's missing?