I counted the API route files in one of my Next.js apps last month. Thirty-one of them. Twenty-six were doing the same boring thing: take a form payload, validate it, write to the database, return JSON, and hope the client remembered to refetch. I had written that same route.ts shape so many times I could do it half asleep, which was roughly my state for most of them.
Server Actions are the thing that let me delete most of those files. I resisted them for a while. The 'use server' directive looked like magic, and magic in a framework usually means a debugging session at 1am later. I was wrong about the scale of the problem, though. Eight months in, I have moved nearly every mutation in two production apps over to Server Actions, and I want to be specific about what got better and what did not.
What a Server Action actually replaces
Here is the mental model that finally made it click. A Server Action is an async function that is guaranteed to run on the server, and that you are allowed to call from client code as if it were local. You mark it with 'use server', and Next.js wires up the network call for you.
What it replaces is not “API routes” as a concept. It replaces the plumbing around a specific kind of route: the ones that exist only because a form needed somewhere to POST. You still want real route handlers for webhooks, for public APIs other people consume, and for anything not tied to your own UI. But the private “this endpoint exists because my own button needed it” routes were never really an API. They were a function call with extra steps.
Behind the scenes a Server Action still uses an HTTP POST, and Next.js checks the request origin against your host to block cross-site calls. You have not escaped the network. You have stopped hand-writing the part of it that was never interesting.
The before: an API route and three pieces of glue
Here is roughly what a “create project” flow looked like in my older Next.js code. One route file, then glue scattered across the client.
// app/api/projects/route.ts
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { projectSchema } from "@/lib/schemas";
export async function POST(req: NextRequest) {
const body = await req.json();
const parsed = projectSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.format() }, { status: 400 });
}
const project = await db.project.create({ data: parsed.data });
return NextResponse.json({ project }, { status: 201 });
}
Then on the client, the actual work:
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export function NewProjectForm() {
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
const router = useRouter();
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setPending(true);
setError(null);
const form = new FormData(e.currentTarget);
const res = await fetch("/api/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(Object.fromEntries(form)),
});
setPending(false);
if (!res.ok) {
setError("Something went wrong");
return;
}
router.refresh();
}
// ...form JSX
}
Count the moving parts. A route file. A fetch call with a hand-built URL and headers. Manual JSON serialization. Two useState calls only to track pending and error. A router.refresh(), because nothing else knows the data changed. None of that is business logic. All of it can break on its own, and all of it has broken on me on its own.
The after: one function, colocated
The same flow as a Server Action:
// app/projects/actions.ts
"use server";
import { db } from "@/lib/db";
import { projectSchema } from "@/lib/schemas";
import { revalidatePath } from "next/cache";
export async function createProject(_prev: unknown, formData: FormData) {
const parsed = projectSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return { ok: false, error: "Check the highlighted fields." };
}
await db.project.create({ data: parsed.data });
revalidatePath("/projects");
return { ok: true, error: null };
}
The fetch call is gone. The URL is gone. The headers and the manual serialization are gone. revalidatePath tells Next.js the /projects cache is stale, so the list updates without me wiring a refetch. The function reads top to bottom like the thing it is: validate, write, mark stale.
There is a quieter win here too, and it is types. With the API route, the client called res.json() and got back any, so I either trusted it or wrote a cast and hoped. A Server Action is a normal imported function, so its argument types and its return type flow straight into the component that calls it. Change the shape the action returns, and the component reading state stops compiling on the spot. That is the kind of mistake I would far rather catch at my desk than in a bug report two weeks later.
I keep actions in their own file with 'use server' at the top, which marks every export as an action. You can also put the directive inside a single function. I have found a dedicated actions.ts per feature folder easier to grep for later.
Where useActionState earns its place
The client side collapses too, and the hook doing the work is useActionState. It ties the action to a piece of state and tracks the pending status for you.
"use client";
import { useActionState } from "react";
import { createProject } from "./actions";
const initialState = { ok: false, error: null };
export function NewProjectForm() {
const [state, formAction, pending] = useActionState(createProject, initialState);
return (
<form action={formAction}>
<input name="name" required />
{state.error && <p role="alert">{state.error}</p>}
<button disabled={pending}>
{pending ? "Creating..." : "Create project"}
</button>
</form>
);
}
Both useState calls are gone. The hook hands me state (whatever the action returned), formAction to pass straight to the form, and a pending boolean. The form also posts before React finishes hydrating, because it is a real <form action>, which is a real win on slow connections.
I wrote a whole post on the day useActionState replaced my form reducers if you want the longer version. Server Actions are what make that hook pay off, because the action it wraps can now be a real server function instead of a fetch wrapper.
The parts that still bite me
I said I would be specific, so here is the unflattering half.
Error handling is the big one. A thrown error inside a Server Action becomes a generic, opaque failure in production. You do not get your message back. So I do not throw for expected problems. I return a result object, like the { ok, error } shape above, and I keep real exceptions for genuine bugs. That convention took me a couple of weeks to settle on, and I got it wrong first.
The second is request size. There is a default body limit on Server Actions of 1MB, configurable through the serverActions config in next.config.js. I learned that the day a user pushed a chunky file through an action and got a wall of nothing back. File uploads past a megabyte still want a real route handler, or a direct-to-storage upload.
Third, they are easy to over-apply. A Server Action is a POST, every time. For reading data, a Server Component or a plain GET handler is still the right call. I had a brief phase of routing reads through actions because it felt tidy, and I gave myself uncacheable POSTs for nothing.
A smaller annoyance is debugging. A Server Action gives you no obvious URL to poke at. With a route handler I could curl it, open it in a browser tab, or replay it straight from the network panel. An action is an opaque POST to an internal endpoint, so when one misbehaves I lean on logging inside the function itself far more than I used to. It is a fair trade for the deleted code, but it is a real change in how you chase a bug down.
One more, and it is the one to take seriously: every exported Server Action is a public endpoint. The origin check stops cross-site abuse, but it does not authorize the user. You still check the session inside the function. Treat each action like the route handler it compiles down to.
What I would do this week
Pick one form in an app you already run. The smallest, most boring one. A settings toggle, a rename dialog, something with a single field. Move that one to a Server Action: write the function in an actions.ts, wrap it with useActionState, delete the old route file and the fetch glue, and confirm revalidatePath updates the view.
You will know within an hour whether the trade fits how you work. For me the tell was the diff. The Server Action version was about a third of the lines, and every line that survived was doing something I actually had to think about. If you want to see how this plays out across the apps I build, that is most of what is on my work page. The boring routes were never the job. Deleting them was.