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.
TL;DR: 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.
The Old Way: Form, Fetch, API Route
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.
The Same Thing 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.
Getting Pending State and Errors: 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.
Progressive Enhancement Is 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 to 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.
Conclusion
Server Actions aren't a replacement for every API route, they're a replacement for the ones that existed purely to give a form or button something to fetch(). The settings form example above went from two files with an implicit contract to one function imported directly where it's used, with useActionState handling the pending and error states that used to need manual useState bookkeeping.