Server Actions for form mutations that return to the same UI, route handlers for anything a third party calls.
Both can write to your database, so the question is not which is more powerful. It is who calls it. Server Actions are for mutations driven by your own UI. Route handlers are for anything with a stable URL that something outside your app talks to. Pick by the caller and the choice gets simple.
Server Actions for your own forms
A form that creates a record and re-renders the same page is the ideal Server Action. There is no endpoint to design, no fetch to write, and the mutation can revalidate the affected data in the same call.
async function createProject(formData: FormData) {
"use server";
await db.project.create({ data: { name: formData.get("name") as string } });
revalidatePath("/projects");
}The form posts directly to the action, the page revalidates, and the new project appears. No JSON, no client fetch, no separate loading endpoint.
Route handlers for stable contracts
The moment something outside your app needs to call it, you want a URL. Webhooks from Stripe, a mobile client, a cron service, another backend, all of these need an addressable endpoint with a documented shape and its own auth.
export async function POST(req: Request) {
const sig = req.headers.get("stripe-signature");
const event = verify(await req.text(), sig);
// ...
return Response.json({ received: true });
}A Server Action has no public URL you would want a third party depending on, so this work belongs in a route handler every time.
The grey area
Internal data fetching from a client component is the case people overthink. If a client component needs to load data on an interaction, a route handler it fetches is clear and cacheable. If it is a mutation tied to a form, the action is less code. I lean on actions for writes and route handlers for reads that the client triggers itself.
Security applies to both
Neither is safe by default. A Server Action is a POST endpoint that anyone can invoke once they find it, so it needs the same auth and validation as a route handler. Treating an action as trusted because it lives in your component file is a real mistake. Both check the session, both validate input, both authorize the specific operation.
const session = await auth();
if (!session) throw new Error("unauthorized");The clean rule that has not failed me: if my own UI triggers a write and stays on the page, Server Action. If a URL is part of the contract, route handler. Everything else is a judgment call you can make on code size, and either way you still write the auth.