Server Actions stopped being experimental in Next.js 14 and became the default mutation primitive in Next.js 15. That shift quietly retired half of what API routes used to be for. But "default" doesn't mean "always right" there are still real cases where an API route is the better choice. Picking wrong adds latency, complicates auth, or paints you into a corner with third-party clients.
This post is the decision-tree I wish someone had handed me when I first opened Next.js 15. By the end, you'll know exactly when to reach for a Server Action and when an API Route is still the answer.
A 30-second primer
A Server Action is a function with the 'use server' directive that runs on the server but is callable directly from a Server Component, a Client Component, or an HTML <form>. Next.js handles the RPC boundary, serialization, and the request lifecycle for you. There is no URL to call, no fetch() to write.
An API Route is a normal HTTP endpoint defined under app/api/. It speaks JSON (or whatever format you want), has a stable URL, and any HTTP client — your own components, a mobile app, a webhook, curl can call it.
When Server Actions win
Server Actions shine when the consumer is the same Next.js app. The classic example is a form that submits, persists, then revalidates a path:
// app/posts/[id]/edit/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { db } from '@/lib/db';
import { auth } from '@/lib/auth';
export async function updatePost(formData: FormData) {
const session = await auth();
if (!session?.user) throw new Error('Unauthorized');
const id = Number(formData.get('id'));
const title = String(formData.get('title') ?? '').trim();
const content = String(formData.get('content') ?? '').trim();
if (!title) return { error: 'Title is required' };
await db.posts.update({
where: { id, authorId: session.user.id },
data: { title, content, updatedAt: new Date() },
});
revalidatePath(`/posts/${id}`);
redirect(`/posts/${id}`);
}
// app/posts/[id]/edit/page.tsx — Server Component
import { updatePost } from './actions';
export default async function EditPage({ params }: { params: { id: string } }) {
const post = await getPost(Number(params.id));
return (
<form action={updatePost} className="space-y-4">
<input type="hidden" name="id" value={post.id} />
<input name="title" defaultValue={post.title} className="input" />
<textarea name="content" defaultValue={post.content} className="textarea" />
<button type="submit">Save</button>
</form>
);
}
What you didn't have to write: an API route, a fetch wrapper, a client-side state hook, or any serialization glue. The form posts directly to the action.
- Forms with progressive enhancement : works without JS, gets enhanced when JS loads.
- Page-scoped mutations : anything followed by
revalidatePath()orredirect(). - Optimistic updates : the
useOptimistichook plus an action gives you Twitter-style instant UX in a few lines. - Server-only secrets : calling Stripe, sending email, hitting a paid API. The secret never reaches the bundle.
When API Routes still win
The moment a non-Next.js client needs to call your code, Server Actions fall away. They're not REST endpoints, they're an RPC convention private to your app.
- Webhooks : Stripe, GitHub, Slack all need a stable URL. Use an API route.
- Mobile or third-party clients : a React Native app, a partner integration, a CLI. They want JSON over HTTP, not RSC payloads.
- Long-running streaming responses : server-sent events, file downloads, large CSV exports. Streaming HTTP responses is more idiomatic.
- Cross-origin programmatic access : anything that benefits from CORS headers and a documented contract.
An API route stays simple too:
// app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const sig = (await headers()).get('stripe-signature') ?? '';
const body = await req.text();
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch (err) {
return new Response('Invalid signature', { status: 400 });
}
if (event.type === 'checkout.session.completed') {
await markOrderPaid(event.data.object);
}
return Response.json({ received: true });
}
Performance: not as different as people think
A common misconception is that Server Actions are "faster than API routes." Both run on the same Node (or Edge) runtime, both serialize their inputs and outputs, both go through the same middleware pipeline. What Server Actions do save is one round-trip during navigation: when an action returns and Next.js automatically replays the page render with revalidated data, the browser doesn't need a separate request to refetch.
For a single button click that mutates and updates the UI, expect Server Actions to feel about 50-100ms faster than the equivalent fetch + setState + invalidateQuery dance not because the network is faster, but because the work is collapsed into one boundary.
Don't optimize for "Action vs Route." Optimize for who the consumer is.
Same app → Action. Anyone else → Route.
Patterns I keep reaching for
1. Action + useOptimistic for instant UX
'use client';
import { useOptimistic } from 'react';
import { toggleLike } from './actions';
export function LikeButton({ postId, initial }: { postId: number; initial: boolean }) {
const [liked, setLiked] = useOptimistic(initial);
return (
<form action={async () => { setLiked(!liked); await toggleLike(postId); }}>
<button>{liked ? '❤️' : '🤍'}</button>
</form>
);
}
2. Action that returns validation errors
'use server';
import { z } from 'zod';
const Schema = z.object({
email: z.string().email(),
name: z.string().min(2),
});
export async function signUp(prev: unknown, formData: FormData) {
const parsed = Schema.safeParse({
email: formData.get('email'),
name: formData.get('name'),
});
if (!parsed.success) return { errors: parsed.error.flatten().fieldErrors };
await createUser(parsed.data);
return { ok: true };
}
Pair with useActionState in a client component for a fully accessible, JS-optional form.
3. Migrating an existing API Route to a Server Action
If the only consumer is your own app, copy the handler body into an action, replace the JSON parse with FormData reads (or accept a typed argument from a client component), delete the route, and update callers. You usually drop 30-40% of the boilerplate.
Security checklist
- Always re-check the user's session inside the action, never trust client-supplied user IDs.
- Validate every
FormDatafield.formData.get()returnsFormDataEntryValue | null, which is intentionally untyped. - Treat actions as a public surface even though there's no URL, Next.js generates a stable POST endpoint for every action. Anything that mutates state should authorize as if it were an API route.
- Don't import secrets or server-only modules in a file that's also imported by a client component. Use
'server-only'to fail loudly at build time.
When to bet on Server Actions
If you're shipping a Next.js app that's mostly itself, a SaaS dashboard, a marketing site with forms, an internal tool. Server Actions should be your default. Reach for an API route only when something outside the Next.js bundle needs to call you. That single rule will save you a surprising amount of glue code.