· 3 min read · Next.js, React, TypeScript, APIs
Server Actions or API routes in Next.js: how I decide
Server Actions and route handlers both run code on the server in Next.js. Here is when each one fits, with examples for forms, webhooks, mobile clients and validation.
The Next.js App Router gives you two ways to run code on the server when something happens in the browser: Server Actions and route handlers (the route.ts files, often still called API routes). Both work, and both can do the same things. The choice comes down to who is calling the code.
The short version
- Your own React components are calling it: use a Server Action.
- Anything else is calling it, like a mobile app, a webhook from Stripe or WhatsApp, a cron job, or another service: use a route handler.
Server Actions: mutations from your own UI
A Server Action is an async function marked with "use server". You can pass it straight to a form, and Next.js handles the request, the serialisation and the re-render for you.
"use server";
import { z } from "zod";
import { revalidatePath } from "next/cache";
const Schema = z.object({ title: z.string().min(3).max(120) });
export async function createTask(formData: FormData) {
const parsed = Schema.safeParse({ title: formData.get("title") });
if (!parsed.success) return { error: "Title must be 3 to 120 characters" };
await db.task.create({ data: parsed.data });
revalidatePath("/tasks");
}What I like about them:
- No fetch calls, no URL strings, no manual JSON parsing. The types carry through from server to client.
- Forms work before JavaScript loads, because they submit as a normal HTML form.
revalidatePathandrevalidateTagsit right next to the write, so the page shows fresh data straight away.
Treat every Server Action as a public endpoint
This is the mistake I see most. A Server Action looks like a private function, but it is exposed as an HTTP endpoint that anyone can call with any arguments. Hiding a button in the UI doesn't protect it. Every action needs the same checks as an API route:
- Check who the user is, on the server, inside the action.
- Check that they are allowed to do this specific thing to this specific record.
- Validate the input with a schema, and never trust a value just because your form would never send it.
export async function deleteTask(id: string) {
const user = await requireUser();
const task = await db.task.findUnique({ where: { id } });
if (!task || task.ownerId !== user.id) throw new Error("Not allowed");
await db.task.delete({ where: { id } });
revalidatePath("/tasks");
}Route handlers: when someone else is calling
A route handler is a plain HTTP endpoint with a stable URL. That makes it the right tool whenever the caller isn't a React component in the same app.
- Webhooks. Payment providers and messaging platforms send a POST to a URL you give them. You need to read the raw body to verify the signature, which is exactly what a route handler gives you.
- Mobile apps and other front ends. A Flutter app can't call a Server Action. It needs a documented REST endpoint.
- Public reads and caching. A
GEThandler can set cache headers and be served from a CDN. - Scheduled jobs. A cron service hits a URL with a secret header.
// app/api/webhooks/payments/route.ts
export async function POST(req: Request) {
const body = await req.text();
const signature = req.headers.get("x-signature") ?? "";
if (!verifySignature(body, signature, process.env.WEBHOOK_SECRET!)) {
return new Response("Invalid signature", { status: 401 });
}
await handleEvent(JSON.parse(body));
return new Response("ok");
}Share the logic, not the transport
When the same operation is needed by both the web app and a mobile app, I don't pick one transport for both. I put the real work in a plain function in lib/, with validation and permission checks inside it, and call it from a thin Server Action and a thin route handler. The rules then live in one place, whoever the caller is.
Checklist
- Your own forms and buttons: Server Actions
- Webhooks, mobile apps, cron jobs and third parties: route handlers
- Authenticate and authorise inside every action, not just in the UI
- Validate every input with a schema
- Keep business logic in shared functions that both can call
Planning a Next.js app with a web front end, a mobile app, or both? Tell me about it.