Server Actions in Next.js: A Form That Works Before JavaScript Even Loads
Server Actions in Next.js: A Form That Works Before JavaScript Even Loads
The classic React form pattern: onSubmit, event.preventDefault(), fetch('/api/todos', { method: 'POST', body: JSON.stringify(...) }), useState to handle loading state. It works — under one condition that's easy to forget, because it never shows up on your own laptop with a fast connection: JavaScript has to finish loading, parsing, and executing before the form even starts reacting to a click. On a slow mobile connection, during hydration of a large page, or when a script gets blocked by a browser extension — the user clicks "Submit," and literally nothing happens. No error, no spinner — the onClick simply doesn't exist yet.
Server Actions in Next.js sometimes get described as a more convenient syntax for exactly the same call — "fetch, except you don't have to write an API route." That's an oversimplification that misses what's actually new about this mechanism: <form action={myAction}> relies on the browser's native, always-existing form submission mechanism, not on JavaScript as a hard requirement. That changes both how it works under the hood and what mistakes teams actually make in practice when they treat "use server" like an ordinary local function call.
What the "use server" directive actually does
When you mark a function "use server", the Next.js compiler doesn't leave its body in the bundle the browser gets. It strips it out and replaces it with a reference — an encrypted identifier pointing to which function to call on the server. What actually reaches the client is a small stub: "when you call me, send a POST to a special RSC endpoint with this identifier and the serialized arguments." The actual logic — a database query, validation, whatever the function does — never physically leaves the server.
tsx
// app/actions.ts"use server";import { revalidatePath } from "next/cache";import { db } from "@/lib/db";export async function addTodoAction(prevState: unknown, formData: FormData) {const title = formData.get("title");if (typeof title !== "string" || title.trim().length === 0) {return { error: "Title cannot be empty." };}await db.todo.create({ data: { title: title.trim() } });revalidatePath("/todos");return { error: null };}
When you attach this function to <form action={addTodoAction}>, something happens that a plain fetch never gave you for free: it works before any JavaScript has run at all. The browser has always been able to send a form as a native POST if the action attribute points to a URL — React 19 and Next.js extend this so that action can also be a server function, and the browser still takes its native path if JS isn't ready yet. Once JavaScript is running, Next.js intercepts that event and does the same thing via fetch in the background, without a page reload, with a smooth UI update. This is progressive enhancement built into the framework, not something you have to code yourself — exactly the same mechanism we covered with RouteAnnouncer in the accessibility article, just applied to data mutation instead of navigation.
A practical example: a form with full state handling, no manual fetch
React 19 added useActionState specifically for this pattern — it ties a server action call to the state returned by its most recent execution, with no manual useState needed to hold an error or a loading status.
tsx
"use client";import { useActionState } from "react";import { addTodoAction } from "./actions";const initialState = { error: null };const TodoForm = () => {const [state, formAction, isPending] = useActionState(addTodoAction,initialState,);return (<form action={formAction}><input type="text" name="title" placeholder="New task" required /><button type="submit" disabled={isPending}>{isPending ? "Adding…" : "Add"}</button>{state.error && <p className="form-error">{state.error}</p>}</form>);};export default TodoForm;
Several things happen here at once, without a line of code devoted to manually managing the request. isPending reflects the real submission state of the form — including that native, pre-hydration POST, not just client-side calls. state.error is directly what the server function returned — no parsing a JSON response, no try/catch around a fetch. revalidatePath("/todos") inside the action tells Next.js to refresh the cache for that route, so the to-do list on the page shows the new entry without manually refreshing client state — it's the framework, not you, that decides when and how to pull in the new RSC payload.
Pitfalls and best practices
- Every
"use server"function is a public HTTP endpoint — not a private function. This is the most common security mistake with Server Actions: since nothing in the UI links to it directly, it feels "hidden." It isn't — the compiler generates a persistent, externally callable action identifier for it, so anyone who discovers it (e.g. by watching network traffic in DevTools) can call it directly, bypassing your UI and every assumption it makes. Input validation and permission checks (session, user role) have to happen inside the action itself, exactly as in a classic API endpoint — never rely on "the button is invisible to logged-out users." - Arguments and return values must be serializable. Server Actions send data over the network using React's serialization format (richer than JSON — it handles things like
Date,Map, andFormDatapassed directly from a form), but it still won't pass through functions, class instances, or references to DOM objects. If you use.bind()to close over an extra argument for a client-side call (addTodoAction.bind(null, listId)), remember thatlistIdalso has to be serializable. - It's still a real network request, not a local function call. It's easy to forget this, because syntactically
await myAction(data)looks exactly like calling a plain JS function. Under the hood it's always a full round trip: serialization, HTTP, deserialization on the other side. Calling a Server Action in a loop on every keystroke in a text field (say, for live validation) turns your form into a request generator, not a responsive input — debouncing is just as necessary here as with any other network call. - Skipping
revalidatePath/revalidateTagleaves the UI with a stale cache. Next.js caches page data by default — an action that writes to the database but doesn't tell the framework which route to refresh commonly produces the "it saved, but I don't see it" report: the data is correct in the database, but the UI is still showing an old, cached version of the page.
Conclusion
Server Actions aren't "fetch with nicer syntax" — they're two different things built on the same mechanism. First, they rely on the browser's native form submission, so a form works even before JavaScript has had a chance to load — you don't write a single line of code responsible for that behavior, you get it from the architecture of <form action={...}> itself. Second, and more important from a security standpoint: every function marked "use server" becomes a real, externally callable network endpoint, regardless of whether any visible UI element calls it. Treating it like a local, trusted function — with no validation or authorization inside — is the easiest way to turn a convenient piece of syntax into an unsecured API endpoint.