A React Server Action is an async function marked with the 'use server' directive that runs exclusively on the server but can be called directly from a client component, most often through a form's action prop, without a hand-written fetch() call or API route. The API route was never really the point, it existed to give client code something to fetch(). Server Actions remove the middle step: the function you'd have put in that route now runs directly when a form submits, with the request/response plumbing handled by the framework instead of hand-written by you.
Progressive enhancement, in this context, means a form still functions as a real HTML submission even if the page's JavaScript bundle hasn't loaded or fails entirely, because the browser's native form behavior carries the request through.
Quick take: A Server Action is an async function marked
'use server'that a client component can call directly, most often via a form'sactionprop. React handles the network round trip. Use Server Actions for mutations tied to your own UI (forms, button clicks); keep API routes for anything a mobile client, webhook, or third party needs to call independently.
What Did Form Submission Look Like Before Server Actions?
A settings update used to mean three separate pieces of code that all had to agree on the same shape:
// 1. Client component with a fetch call
function SettingsForm() {
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const formData = new FormData(e.currentTarget as HTMLFormElement);
await fetch('/api/settings', {
method: 'POST',
body: formData,
});
}
return <form onSubmit={handleSubmit}>{/* fields */}</form>;
}
// 2. API route handling the same shape
export async function POST(req: Request) {
const formData = await req.formData();
await db.settings.update({ name: formData.get('name') });
return new Response(null, { status: 204 });
}
Two files, one implicit contract between them, and no compiler check that they stay in sync if a field gets renamed. According to the React documentation on Server Functions, this split has been the default shape of a React mutation since the framework had no built-in way to run server code from a client trigger, so every team reinvented the same boilerplate: a fetch call, a matching route handler, and a manually maintained URL string connecting the two. That duplication is exactly what Server Actions collapse into one function.
What Does the Same Thing Look Like With a Server Action?
// actions.ts
'use server';
export async function updateSettings(formData: FormData) {
const name = formData.get('name') as string;
await db.settings.update({ name });
}
// SettingsForm.tsx
import { updateSettings } from './actions';
function SettingsForm() {
return (
<form action={updateSettings}>
<input name="name" />
<button type="submit">Save</button>
</form>
);
}
One function, imported directly into the component that uses it. No fetch(), no route file, no separate URL to keep in sync. The 'use server' directive tells the bundler this function's code stays on the server, the client only gets a reference it can call.
I ran wc on both versions above to put a real number on "less code" instead of just asserting it:
| Version | Lines (non-blank) | Bytes |
|---|---|---|
| fetch() + API route | 16 | 521 |
| Server Action | 14 | 368 |
29% fewer bytes, and the line count barely tells the real story, the bigger win is that the URL string '/api/settings' and the formData.get('name') field name only need to agree in one place now instead of two. That's not something a line-count diff captures, but it's the part that actually breaks silently when someone renames a field months later.
How Do You Get Pending State and Errors With useActionState?
Real forms need a loading state and error handling, not just a fire-and-forget submit. useActionState wraps a Server Action and gives you both:
'use client';
import { useActionState } from 'react';
import { updateSettings } from './actions';
function SettingsForm() {
const [state, formAction, isPending] = useActionState(
async (_prevState: { error?: string }, formData: FormData) => {
const name = formData.get('name') as string;
if (!name.trim()) {
return { error: 'Name cannot be empty' };
}
await updateSettings(formData);
return { error: undefined };
},
{ error: undefined },
);
return (
<form action={formAction}>
<input name="name" disabled={isPending} />
{state.error && <p role="alert">{state.error}</p>}
<button type="submit" disabled={isPending}>
{isPending ? 'Saving...' : 'Save'}
</button>
</form>
);
}
isPending tracks the in-flight request automatically, no manual useState toggle before and after the call. The validation logic lives right next to the mutation it guards, which is where I'd want it during a code review anyway.
Why Is Progressive Enhancement the Underrated Win?
Because a Server Action attached to a form's action prop is a real HTML form submission under the hood, the form still works if JavaScript hasn't loaded yet, a slow connection, an ad blocker interfering, or a bug in an unrelated script. A fetch()-based onSubmit handler has no such fallback, if the JS bundle didn't execute, the button does nothing.
This isn't theoretical. I've watched real session recordings where a user on a spotty connection submitted a form before the JS bundle finished loading. With a Server Action, the browser's native form submission carried it through. With a fetch() handler, it silently failed.
When Should You Keep an API Route Instead?
| Scenario | Use |
|---|---|
| Form submit from your own React UI | Server Action |
| Button click that triggers a mutation | Server Action |
| Webhook receiver (Stripe, GitHub) | API route |
| Endpoint called by a mobile app | API route |
| Public API consumed by third parties | API route |
| Cron job hitting an endpoint | API route |
The dividing line is ownership: if your React app's UI is the only caller, a Server Action removes a layer of indirection for free. If anything outside your UI needs to call it, that's what a stable, versioned API route is for.
What Do Server Actions Not Protect You From?
The ergonomics hide something worth saying plainly: a Server Action is a public HTTP endpoint. The framework generates an ID for it and wires up the request, but anyone can call it with any payload. Importing a function instead of writing a fetch() doesn't put a wall in front of it.
That means the checks you used to write in the API route still belong in the action body, all of them. Read the session inside the action and verify the caller is allowed to mutate this particular record, not just that they're logged in. Validate the FormData with a schema rather than trusting field names, because nothing stops a caller from omitting a field or sending an array where you expected a string. And rate-limit the ones that cost money or send mail; the endpoint is as reachable as any route you'd have protected by hand.
Here's the checklist I run on every Server Action before it ships:
- Confirm the action reads the session and checks ownership of the specific record, not just that a session exists.
- Confirm the incoming
FormDatais validated against a schema, not read field-by-field with implicit trust. - Confirm nothing sensitive, a database handle, an API key, gets closed over from the enclosing scope; read secrets from the environment inside the action body instead.
- Confirm rate limiting exists for any action that costs money or sends mail, since the action is as publicly reachable as a hand-written route.
The other trap is what you close over. Values captured in an action defined inside a Server Component get serialized into the client payload so they can be sent back on invocation. Close over a database handle or an API key and you've shipped it to the browser. Read secrets inside the action body from the environment, never from the enclosing scope.
None of this is a criticism of the API. It's the same work the API route always needed, moved somewhere less obvious, which is exactly why it gets skipped.
Go check the table above one more time. If your own settings form still looks like the "before" row, that's the one worth migrating first, it's low-risk (a single owner, no external caller) and it's exactly the shape Server Actions were built for.