
Server Actions in Next.js 15 are async functions you mark with 'use server' and call directly from React components, and they replace most of the boilerplate you used to write for /api routes when handling form submissions and mutations. The framework compiles them into POST endpoints, wires up CSRF protection, and dead-code-eliminates any action you don't actually call from the client bundle.
That is the happy-path pitch. The reality of shipping them to a paying user base is messier, and most tutorials skip the messy part. This post is the production playbook: the six-step pattern that survives a security review, the four gotchas that take down most first-time rollouts, and a clear answer to "when should I just use an API route instead."
Two things changed in the last 18 months. First, Next.js 15 stabilized the security model for actions: each action gets an encrypted ID per build, unused actions are stripped from the client bundle, and Next now refuses to invoke an action whose ID it can't decrypt. Second, React 19 shipped useActionState and a working useFormStatus, which means progressive-enhancement forms (forms that submit even with JavaScript disabled) are finally a one-liner instead of a research project.
The combination matters because the alternative, hand-rolled API routes plus client-side fetch plus optimistic state, is roughly 4x the code for the same UX. For small teams shipping a CRUD-heavy product, the LOC savings compound fast.
Most teams reach for the textbook example:
// app/posts/new/page.tsx
'use server';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
await db.posts.create({ data: { title } });
revalidatePath('/posts');
}
This works in a demo. It breaks in production for four predictable reasons:
createPost with arbitrary input.formData.get('title') returns string | File | null. The cast lies. A malicious client can send a File, an empty string, or a 50MB string.db.posts.create throws, the user sees an unstyled error overlay in dev and a blank screen in prod.Every one of these is a real incident I've seen ship. Let's fix them in order.
actions.ts files, not in pagesapp/
posts/
page.tsx
actions.ts <-- all 'use server' functions for /posts
This makes the security perimeter visible. Anyone reviewing the repo can grep for 'use server' files and audit every public mutation in one pass. Inline actions inside Server Components work, but they scatter the perimeter across 40 files and make audits hell.
Treat the function signature as a lie. The client sends bytes; you decode them.
// app/posts/actions.ts
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
const CreatePostSchema = z.object({
title: z.string().trim().min(1).max(200),
body: z.string().trim().min(1).max(10_000),
});
export async function createPost(_prev: unknown, formData: FormData) {
const parsed = CreatePostSchema.safeParse({
title: formData.get('title'),
body: formData.get('body'),
});
if (!parsed.success) {
return { ok: false, errors: parsed.error.flatten().fieldErrors };
}
// ... continue
}
Two things to notice. First, the action returns a typed result instead of throwing. That is the contract useActionState expects. Second, the _prev argument is the previous state, which React passes automatically. Forgetting it is the most common rookie bug; the form will appear to work, then silently lose state on the second submit.
Next.js does not run middleware on server actions the way it runs middleware on pages. You build your own. The cleanest pattern is a small higher-order function:
// lib/safe-action.ts
import { auth } from '@/auth'; // your NextAuth / Clerk / Lucia setup
type Ctx = { userId: string; orgId: string };
export function withAuth<TIn, TOut>(
fn: (ctx: Ctx, input: TIn) => Promise<TOut>
) {
return async (_prev: unknown, input: TIn) => {
const session = await auth();
if (!session?.user) {
return { ok: false, errors: { _form: ['Not authenticated'] } };
}
return fn(
{ userId: session.user.id, orgId: session.user.orgId },
input
);
};
}
If you'd rather not roll your own, next-safe-action does this with chainable middleware and full type inference. Either path is fine; the rule is "no action runs without an explicit auth check, full stop."
Server Actions hit the same edge runtime as your pages. They need the same throttling. Upstash Ratelimit is the lowest-friction option:
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const limiter = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '1 m'),
});
export async function createPost(_prev: unknown, formData: FormData) {
const session = await auth();
const key = session?.user?.id ?? headers().get('x-forwarded-for') ?? 'anon';
const { success } = await limiter.limit(`create-post:${key}`);
if (!success) {
return { ok: false, errors: { _form: ['Too many requests'] } };
}
// ... continue
}
Rate by user ID when authenticated, IP when not. Keep the buckets per-action; a global limiter punishes legitimate users when one endpoint gets hammered. This pattern pairs well with the principles in our API design playbook, since server actions are just opinionated API endpoints under the hood.
useActionState for progressive enhancementThe form should work without JavaScript, then upgrade once React hydrates.
// app/posts/new-post-form.tsx
'use client';
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
import { createPost } from './actions';
const initialState = { ok: false, errors: {} as Record<string, string[]> };
export function NewPostForm() {
const [state, formAction] = useActionState(createPost, initialState);
return (
<form action={formAction}>
<input name="title" required />
{state.errors?.title && <p>{state.errors.title[0]}</p>}
<textarea name="body" required />
{state.errors?.body && <p>{state.errors.body[0]}</p>}
<SubmitButton />
{state.errors?._form && <p role="alert">{state.errors._form[0]}</p>}
</form>
);
}
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? 'Saving...' : 'Save'}</button>;
}
The key detail: <form action={formAction}> is a real HTML form. If the user has JavaScript disabled, the browser still POSTs to the action's URL and Next.js still runs the function. Progressive enhancement is not optional in 2026, especially for forms behind flaky mobile networks or restrictive corporate proxies.
After a successful mutation, tell Next which cached paths or tags are stale.
import { revalidatePath, revalidateTag } from 'next/cache';
await db.posts.create({ data: parsed.data });
revalidateTag('posts'); // every page that fetched with tags: ['posts']
revalidatePath('/posts'); // a specific route
Use revalidateTag when multiple pages depend on the same data. Use revalidatePath when a single route is the source of truth. Avoid calling both for the same data; you double-invalidate and double the regeneration cost.
| Use case | Reach for | Why |
|---|---|---|
| Form submit, same-app, mutation | Server Action | Less code, automatic CSRF, progressive enhancement |
| Public REST endpoint for partners | API Route | You need verbs, status codes, and OpenAPI docs |
| Webhook receiver (Stripe, GitHub) | API Route | Signature verification needs raw body, not FormData |
| Mobile app talking to your backend | API Route | Native clients can't invoke encrypted action IDs |
| Read-only data fetch in a Client Component | Neither, use a Server Component | Server Actions are POST-only on purpose |
| Admin dashboard mutations | Server Action | Auth and form UX are the entire story |
The dividing line is "is the caller a React component in this same app?" If yes, Server Action. If anything else (curl, Postman, a partner, a mobile app), API Route. Your team will save weeks if you put this rule on the wall before someone tries to call a server action from a React Native app.
A handful of patterns look right and break in production:
redirect()-ing. redirect throws internally. If you put it after a return, the return wins. If you put it before, the data is lost. Pick one: redirect on success, return on error.cookies() set inside an action. Cookies set during a Server Action only apply to the response of that action. The next page render does not see them unless you also revalidatePath. Burn this into your memory; the symptom is "user appears logged out for one page load."{ ok: false, errors: { _form: ['Something went wrong'] } } to the client. Silent failures in actions are catastrophic; the user sees nothing and the dev sees nothing.Be honest about scope. If you are a 2-founder team pre-revenue with three forms in your whole app, the difference between Server Actions and a plain /api/post route is 30 minutes of your life. Pick whatever your senior dev has done before.
You should also skip Server Actions when:
If you are rolling Server Actions out across an existing app, work in this order:
lib/safe-action.ts with your auth wrapper.@upstash/ratelimit and a Redis instance.Most teams finish step 1-4 in a single week. Steps 5-6 take as long as your test culture demands.
Server Actions are a short, opinionated rollout, exactly the kind of work a senior Cadence engineer ($1,500/week) closes inside a 48-hour trial. Every engineer on Cadence is AI-native by default, vetted on Cursor and Claude Code fluency before they unlock bookings, so they ship the migration with the safety wrapper, rate limits, and Playwright tests in place rather than the bare-minimum tutorial version. If you'd rather size the work first, audit your stack with our ship-or-skip tool and get an honest grade on whether a Server Actions migration is your highest-ROI move this quarter.
The other path is a mid-tier engineer ($1,000/week) for the rote migration once a senior has authored the wrapper. We see this pattern in the Cadence pool of 12,800 engineers most often: one senior week to set the architecture, two mid weeks to convert the rest of the routes. It is the cheapest way to upgrade a Next.js codebase without burning your in-house team's roadmap.
If your team is staring at a Server Actions migration and wondering whether it's worth a quarter, run the audit before you book anyone. You'll get a graded recommendation in under five minutes, and a clear scope if it turns out to be worth it. If you'd rather skip the audit and start, book a senior engineer on a 48-hour trial; you only pay for the week if you keep them.
If you are also navigating bigger architecture calls in the same sprint, our notes on SQL vs NoSQL in 2026 and on how our matching algorithm scores 12,800 engineers in 80ms cover adjacent ground worth a skim.
No. Server Actions handle form submissions and component-driven mutations inside your own Next.js app. API Routes are still the right call for public REST endpoints, webhooks, mobile-app backends, and anything that needs HTTP verbs other than POST.
Partially. Next.js 15 encrypts action IDs and strips unused actions from the client bundle. It does not authenticate users, validate input, or rate-limit. You must add all three at the action boundary; the framework does not do it for you.
Yes. A <form action={serverAction}> element will submit via standard HTML form POST when JavaScript is disabled or fails to hydrate. Use useActionState for the enhanced UX; the underlying form works either way.
Two layers. Unit-test the action function directly by calling it with a constructed FormData object. End-to-end test the form using Playwright, which exercises the full POST round trip including the encrypted action ID flow. Mock your auth and database at the unit layer; let them run for real at the E2E layer.
Old action IDs become invalid. Users with stale tabs open will see a "server error" on next submit. Mitigate by invalidating sessions on deploy, or by running a graceful key rotation window where both old and new keys validate (Next.js exposes this via the serverActions.encryptionKey config and a previous-key fallback).