Server Actions were the most controversial Next.js feature when they launched. A year later, they've become my default pattern for form handling and mutations — but only when used correctly. Here's how to use them well and where they actually beat API routes.
What Server Actions actually are
A Server Action is an async function that runs on the server but can be called directly from client components — no fetch, no API route, no manual serialization.
// app/actions.ts
'use server';
import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
await db.post.create({
data: { title, content, authorId: getCurrentUser().id },
});
revalidatePath('/posts');
}
// app/posts/new/page.tsx
import { createPost } from '@/app/actions';
export default function NewPost() {
return (
<form action={createPost}>
<input name="title" required />
<textarea name="content" required />
<button type="submit">Create</button>
</form>
);
}
That's it. No useState, no fetch('/api/posts'), no request/response boilerplate.
The pattern that makes Server Actions actually good
Raw FormData gets messy fast. The pattern I use combines Server Actions with Zod validation and structured return types:
// lib/actions/types.ts
export type ActionResult<T = void> =
| { success: true; data: T }
| { success: false; error: string; fieldErrors?: Record<string, string[]> };
// app/actions/posts.ts
'use server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';
import { getServerSession } from 'next-auth';
import type { ActionResult } from '@/lib/actions/types';
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
published: z.boolean().default(false),
});
export async function createPost(
formData: FormData
): Promise<ActionResult<{ id: string }>> {
const session = await getServerSession();
if (!session?.user?.id) {
return { success: false, error: 'Unauthorized' };
}
const parsed = CreatePostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
published: formData.get('published') === 'true',
});
if (!parsed.success) {
return {
success: false,
error: 'Validation failed',
fieldErrors: parsed.error.flatten().fieldErrors,
};
}
const post = await db.post.create({
data: { ...parsed.data, authorId: session.user.id },
});
revalidatePath('/posts');
return { success: true, data: { id: post.id } };
}
Now the client side, using useActionState (React 19 / Next.js 14):
'use client';
import { useActionState } from 'react';
import { createPost } from '@/app/actions/posts';
export function CreatePostForm() {
const [state, formAction, isPending] = useActionState(createPost, null);
return (
<form action={formAction}>
<div>
<input name="title" aria-invalid={!!state?.fieldErrors?.title} />
{state?.fieldErrors?.title && (
<p className="text-red-500">{state.fieldErrors.title[0]}</p>
)}
</div>
<div>
<textarea name="content" aria-invalid={!!state?.fieldErrors?.content} />
{state?.fieldErrors?.content && (
<p className="text-red-500">{state.fieldErrors.content[0]}</p>
)}
</div>
{state && !state.success && (
<p className="text-red-600">{state.error}</p>
)}
<button disabled={isPending}>
{isPending ? 'Creating...' : 'Create Post'}
</button>
</form>
);
}
This gives you: validation, error display, loading state — with zero API routes.
Server Actions vs API Routes: when to use which
Use Server Actions when:
- Form submissions and mutations from your own UI
- Operations that always require authentication
- Actions where you want
revalidatePath/revalidateTagto work automatically - Anything that previously needed a simple POST endpoint
Keep API Routes when:
- External services need to call your endpoints (webhooks, OAuth callbacks)
- You're building a public API consumed by mobile apps or third parties
- You need granular HTTP status codes (204, 301, etc.)
- File uploads with streaming
Composing actions
The best pattern for complex mutations: compose small actions.
// lib/actions/auth.ts
'use server';
export async function requireAuth() {
const session = await getServerSession();
if (!session?.user?.id) throw new Error('Unauthorized');
return session.user;
}
// app/actions/billing.ts
'use server';
import { requireAuth } from '@/lib/actions/auth';
import { stripe } from '@/lib/stripe';
export async function createCheckoutSession(priceId: string) {
const user = await requireAuth();
const session = await stripe.checkout.sessions.create({
customer_email: user.email,
line_items: [{ price: priceId, quantity: 1 }],
mode: 'subscription',
success_url: `${process.env.NEXT_PUBLIC_URL}/dashboard?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`,
metadata: { userId: user.id },
});
return { url: session.url };
}
Progressive enhancement
One underrated benefit: Server Actions work without JavaScript enabled (for native <form> elements). Your forms are accessible by default and work even if the client bundle fails to load.
The mistake everyone makes
Don't put 'use server' at the top of a component file. It marks every exported function as a Server Action.
// Wrong — everything exported is now a Server Action
'use server';
export function MyComponent() { /* ... */ }
export async function createPost() { /* ... */ }
// Right — separate files
// actions.ts
'use server';
export async function createPost() { /* ... */ }
// components/MyComponent.tsx
import { createPost } from '@/app/actions';
export function MyComponent() {
return <form action={createPost}>...</form>;
}
This pattern (Server Actions + Zod + useActionState) is baked into the AI SaaS Starter Kit at whoffagents.com. Ships with auth, Stripe billing, and full form handling patterns pre-configured so you skip the setup and start building.
Top comments (0)