Why Next.js 15 Server Actions Changed Our Stack
After shipping three production apps on the App Router, I finally retired most of our REST endpoints. Next.js 15 server actions have matured into a legitimate replacement for the boilerplate-heavy API route layer that previously sat between forms and the database. Furthermore, the security model in 15.x finally addresses the criticism that plagued the 14.x release.
However, this is not a free lunch. Server Actions have specific failure modes around caching, error propagation, and security boundaries that bite teams who treat them as drop-in RPC. In this guide, I will walk through the patterns we use across roughly 200 actions in production, including the ones we got wrong first.
What Server Actions Actually Replace
Server Actions replace three things in practice: form-handling API routes, mutation endpoints called from client components, and the awkward fetch('/api/...') calls inside React Server Components. Consequently, you eliminate the duplicate type definitions, the manual JSON serialization, and the request/response Zod schemas that previously had to live in two places.
That said, you should still keep API routes for webhook receivers (Stripe, GitHub, Clerk), third-party callers that expect REST semantics, and anything consumed by mobile clients. Server Actions are tightly coupled to the Next.js runtime and POST to an opaque endpoint, so external consumers cannot hit them sanely.
The ‘use server’ Directive and File Boundaries
The 'use server' directive is a security boundary, not a syntax marker. Anything exported from a file with that directive becomes a publicly callable POST endpoint. Therefore, treat every exported function as untrusted input, regardless of where it appears to be called from in your code.
Our convention is to colocate actions in app/_actions/{domain}.ts files and never inline them in components. Additionally, we lint against inline 'use server' functions because they make security review nearly impossible during code review.
// app/_actions/invoice.ts
'use server';
import { z } from 'zod';
import { revalidateTag } from 'next/cache';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
const CreateInvoiceSchema = z.object({
customerId: z.string().uuid(),
amountCents: z.number().int().positive().max(10_000_000),
dueDate: z.coerce.date().min(new Date()),
lineItems: z.array(z.object({
description: z.string().min(1).max(500),
quantity: z.number().int().positive(),
unitPriceCents: z.number().int().nonnegative(),
})).min(1).max(100),
});
export async function createInvoice(prevState: ActionState, formData: FormData) {
const session = await auth();
if (!session?.user) return { error: 'UNAUTHORIZED' };
const parsed = CreateInvoiceSchema.safeParse({
customerId: formData.get('customerId'),
amountCents: Number(formData.get('amountCents')),
dueDate: formData.get('dueDate'),
lineItems: JSON.parse(formData.get('lineItems') as string),
});
if (!parsed.success) {
return { error: 'VALIDATION', issues: parsed.error.flatten() };
}
const invoice = await db.invoice.create({
data: { ...parsed.data, ownerId: session.user.id },
});
revalidateTag(`invoices:${session.user.id}`);
return { success: true, invoiceId: invoice.id };
}
Form Validation with Zod and useActionState
The useActionState hook (renamed from useFormState in React 19) is the canonical way to wire validation errors back to the form. Always validate on the server with Zod, then mirror the schema on the client only for UX-level instant feedback. Never trust the client schema as a security control.
For multi-step forms, we return discriminated unions rather than nullable error objects. As a result, TypeScript narrowing in the rendering branch becomes much cleaner, and we avoid the “is this field error or success” ambiguity that creeps in with naive shapes.
Optimistic UI with useOptimistic
The useOptimistic hook pairs naturally with Server Actions but has a sharp edge: when the action fails, React reverts the optimistic state automatically, but any side effects you triggered (toasts, analytics, navigation) do not revert. Therefore, defer side effects until the action settles, or design them to be idempotent.
For lists, we use a transition-wrapped optimistic append with a stable temporary ID. Subsequently, when the server returns the real ID, we reconcile in a useEffect. This pattern handles roughly 95% of the optimistic mutation cases I have built. For more complex state machines, see our Server Components vs Server Actions patterns guide.
Security Boundaries You Cannot Skip
Server Actions are POST endpoints that ship with built-in CSRF protection via origin checking, but only when you configure serverActions.allowedOrigins correctly. Default behavior in 15.x rejects cross-origin POSTs, which is a meaningful improvement over earlier versions. Nevertheless, you must still authenticate every action explicitly.
Action IDs are now encrypted by default in Next.js 15, which prevents enumeration attacks where attackers could discover unused actions by guessing IDs. However, you should still treat every parameter as adversarial. Consequently, never pass authorization data through the action arguments themselves; always re-derive permissions from the session inside the action body.
// app/_actions/document.ts
'use server';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';
export async function deleteDocument(documentId: string) {
const session = await auth();
if (!session?.user) throw new Error('UNAUTHORIZED');
// Re-derive authorization from session, never trust client-passed claims
const doc = await db.document.findUnique({
where: { id: documentId },
select: { ownerId: true, organizationId: true },
});
if (!doc) throw new Error('NOT_FOUND');
const canDelete = doc.ownerId === session.user.id ||
await hasOrgPermission(session.user.id, doc.organizationId, 'documents:delete');
if (!canDelete) throw new Error('FORBIDDEN');
await db.document.delete({ where: { id: documentId } });
revalidatePath('/documents');
}
Caching, revalidatePath, and revalidateTag
The biggest pitfall with Next.js 15 server actions is forgetting to invalidate caches after mutations. Unlike traditional REST APIs where the client refetches on demand, RSC caches require explicit invalidation. Use revalidateTag for fine-grained invalidation tied to specific data, and revalidatePath for entire route trees.
We tag every database read with a structured tag like invoices:{userId} or org:{orgId}:members. Then mutations invalidate exactly those tags. Compared to revalidatePath, this approach keeps unaffected segments cached and dramatically reduces server load on busy pages. Refer to the official Next.js revalidateTag documentation for the full semantics.
Testing Strategies for Production Confidence
Server Actions resist unit testing because they depend on the Next.js runtime, request context, and cookies. We test them in three layers: pure validation logic in Vitest, integration tests with a real database via Testcontainers, and end-to-end through Playwright against a built application.
For Playwright tests, hitting the action endpoint directly is brittle because the encrypted action ID rotates on every build. Instead, drive the UI and assert on the resulting state. This costs about 3x more test time but catches the integration bugs that matter, similar to patterns we cover in our React Compiler memoization guide.
In conclusion, Next.js 15 server actions deliver on the promise of collapsing the form-API-state machinery into a single primitive, but only if you treat them with the same rigor as REST endpoints. Validate aggressively, authorize from session not arguments, tag your caches deliberately, and test through the UI. After 18 months of production use across our team, the code volume reduction has been roughly 40% for CRUD-heavy features.