There's a persistent claim that Server Actions are faster than API routes because they "avoid the network round trip." This is wrong, and it's worth clearing up before anything else, because architectural decisions get made on it.
A Server Action is an HTTP POST. When you call one from a client component, the browser makes a network request to your server, exactly as fetch('/api/something') would. React serialises the arguments, sends them over the wire, runs your function on the server, and streams the result back. The round trip is fully present.
What Server Actions remove is the code you write to manage that round trip — the endpoint file, the fetch call, the serialisation, the type definitions on both sides. That's a real and significant benefit. It just isn't a latency benefit, and choosing based on imagined performance differences leads you to use them for things they're bad at.
The actual decision is about who calls it.
The rule
Server Actions are for your own UI. Route Handlers are for everyone else.
If a React component in your application triggers the call, use a Server Action. If anything else calls it — Meta's webhook, a mobile app, a partner integration, a cron service, cURL — use a Route Handler.
Almost every correct decision follows from that one distinction.
Side by side
| Server Action | Route Handler | |
|---|---|---|
| Caller | Your own React components | Anything that speaks HTTP |
| Transport | POST (RPC-style, framework-managed) | Whatever you define |
| HTTP methods | POST only | GET, POST, PUT, PATCH, DELETE… |
| URL | Framework-generated, not stable | You choose, and it's stable |
| Type safety across the boundary | Automatic | Manual — you define both sides |
| Progressive enhancement | Works with JS disabled via <form action> | No |
| CSRF protection | Built in | Yours to implement |
| Cache revalidation | revalidatePath / revalidateTag inline | Manual |
| Custom status codes and headers | Awkward | Natural |
| Suitable as a public API | No | Yes |
| Webhook receiver | No | Yes |
Two rows carry most of the weight.
The URL is not stable. Server Action endpoints are generated at build time and change between deployments. You cannot document one, hand it to a partner, or point Meta's webhook configuration at it. This alone rules them out for anything external — it isn't a limitation you can work around.
Progressive enhancement is genuine. A <form action={serverAction}> submits and works before React has hydrated, and with JavaScript disabled entirely. For public-facing forms — contact, signup, checkout — that's real resilience, not a checkbox. A user on a slow connection can submit your form while your bundle is still downloading.
The security model, and what people get wrong
Server Actions do carry meaningful built-in protection. The framework validates the Origin header against the Host header, which blocks the standard cross-site POST attack. Action IDs are non-guessable build-time identifiers rather than predictable paths. Arguments are handled through React's serialisation rather than raw body parsing.
That's genuinely better than a hand-rolled API route with no CSRF handling.
But here is the part that causes real vulnerabilities: built-in CSRF protection is not authorisation. A Server Action is a publicly reachable POST endpoint. Anyone who can load your site can invoke it. It is not "protected" because only your admin dashboard renders the button that calls it.
Every action that mutates data or reads anything sensitive must check authorisation inside the action itself:
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { requireUser } from "@/lib/auth";
const schema = z.object({
contactName: z.string().min(1).max(120),
phone: z.string().min(7).max(30),
email: z.string().email().optional().or(z.literal("")),
});
export async function saveLead(formData: FormData) {
// 1. Authorise INSIDE the action. Never rely on the UI not rendering a button,
// and never rely on middleware alone.
await requireUser();
// 2. Validate. FormData is entirely attacker-controlled.
const parsed = schema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? "Invalid input" };
}
await prisma.lead.create({ data: parsed.data });
revalidatePath("/dashboard/leads");
return { success: true };
}Two habits worth making automatic:
Authorise in every action, not just in middleware. Middleware is a useful outer layer, but it guards routes, and a Server Action is reachable independently of the page that renders it. Defence in depth means the check lives where the mutation happens.
Validate everything. FormData is user input. Zod at the boundary of every action gives you a typed, checked object and a clear error path, and it costs three lines.
A third, quieter one: be careful what you return. Action return values are serialised to the client. Returning a full Prisma record can leak columns — internal notes, other users' identifiers, soft-deleted flags — that were never meant to leave the server. Return only what the UI needs.
When you clearly want a Route Handler
Webhooks. Stable URL, and you need the raw request body. Signature verification requires hashing the exact bytes the sender signed, which means await request.text() before parsing — something Server Actions give you no access to.
Public or partner APIs. Documented URLs, versioning, proper status codes, CORS control.
Mobile and non-browser clients. They need a real endpoint, not a framework-internal RPC identifier.
GET endpoints of any kind. Server Actions are POST-only. Anything cacheable, linkable, or read-only belongs in a Route Handler.
Cron and scheduled jobs. A platform scheduler needs a stable URL and its own auth header.
File downloads or streaming responses. You need control over headers and the response body.
When you clearly want a Server Action
Forms in your own app. This is the primary case, and it's a large improvement over the fetch-and-endpoint pattern.
Mutations followed by revalidation. Calling revalidatePath in the same function that performed the write is meaningfully simpler than coordinating it across a fetch boundary.
Anything benefiting from useActionState. Pending states and error handling wire up cleanly, with far less boilerplate than managing loading flags by hand.
Small mutations from client components. Toggling a flag, updating a status, deleting a row.
A note on payload limits
Server Actions have a body size limit — 1MB by default — configurable in next.config.ts:
const nextConfig = {
experimental: {
serverActions: { bodySizeLimit: "2mb" },
},
};If you're uploading files of meaningful size, neither mechanism is really the right answer. Upload directly to object storage with a presigned URL and send only the resulting key through your application. Routing large files through your server costs you memory, execution time, and on serverless platforms, money.
Summary
Use Server Actions for your own UI's mutations. Use Route Handlers for anything with a caller you don't control. Don't choose based on latency, because the difference is negligible and the round trip exists either way. And authorise inside every action — the built-in CSRF protection is real, and it is not the same thing as an access check.
Most applications use both, and that's the correct outcome rather than a compromise.
Need this built properly?
We build Next.js applications on the App Router with authorisation, validation, and caching handled deliberately rather than by default. Talk to us about web development.
