Next.js 14 stabilized Server Actions. Next.js 15 (released 2024) refined them. In 2026, they replace ~70% of API routes for form-driven mutations. But production patterns = poorly documented. Here are the 8 essential patterns.
TL;DR
- Server Actions = server functions callable directly from React components.
- Pros: type-safe, no API endpoint, progressive enhancement.
- Patterns: Zod validation, optimistic UI, revalidation, error handling.
Basic Server Action
`tsx
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { prisma } from '@/lib/prisma';
const CreatePostSchema = z.object({
title: z.string().min(3).max(200),
content: z.string().min(10),
});
export async function createPost(formData: FormData) {
const parsed = CreatePostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
});
if (!parsed.success) {
return { error: parsed.error.flatten() };
}
const post = await prisma.post.create({ data: parsed.data });
revalidatePath('/posts');
return { success: true, post };
}
`
`tsx
import { createPost } from '@/app/actions/posts';
export function CreatePostForm() {
return (
);
}
`
Pattern 1 — useActionState for error handling + loading
`tsx
'use client';
import { useActionState } from 'react';
import { createPost } from '@/app/actions/posts';
export function CreatePostForm() {
const [state, formAction, isPending] = useActionState(createPost, null);
return (
{state?.error?.fieldErrors?.title && (
{state.error.fieldErrors.title}
)}
{state?.error?.fieldErrors?.content && (
{state.error.fieldErrors.content}
)}
{isPending ? 'Creating...' : 'Create'}
{state?.success && Post created!
);
}
`
Pattern 2 — optimistic UI
`tsx
'use client';
import { useOptimistic } from 'react';
import { addComment } from '@/app/actions/comments';
export function CommentList({ initial, postId }) {
const [optimisticComments, addOptimistic] = useOptimistic(
initial,
(state, newComment) => [...state, { ...newComment, pending: true }]
);
async function handleSubmit(formData: FormData) {
const text = formData.get('text') as string;
addOptimistic({ id: 'temp', text, author: 'You' });
await addComment(postId, text);
}
return (
{optimisticComments.map(c => ( {c.text} ))}
);
}
`
Pattern 3 — auth + tenant scoping
`tsx
'use server';
import { auth } from '@/lib/auth';
import { tenantPrisma } from '@/lib/prisma-tenant';
export async function updatePost(postId: string, data: UpdateInput) {
const session = await auth();
if (!session?.user) throw new Error('Unauthorized');
const prisma = tenantPrisma(session.organizationId);
const post = await prisma.post.findUnique({
where: { id: postId },
});
if (!post) throw new Error('Not found');
if (post.authorId !== session.user.id) {
throw new Error('Forbidden');
}
await prisma.post.update({
where: { id: postId },
data,
});
revalidatePath(/posts/${postId});
Need a professional website?
Kolonell builds websites that attract clients, optimized for the Sénégalese market. Free quote in 2 minutes.
}
`
Pattern 4 — file upload
`tsx
'use server';
import { put } from '@vercel/blob';
export async function uploadAvatar(formData: FormData) {
const file = formData.get('file') as File;
if (!file || file.size > 5 * 1024 * 1024) {
return { error: 'File too large' };
}
const blob = await put(avatars/${session.user.id}.png, file, {
access: 'public',
});
await prisma.user.update({
where: { id: session.user.id },
data: { avatarUrl: blob.url },
});
revalidatePath('/profile');
return { success: true, url: blob.url };
}
`
Pattern 5 — rate limiting
`tsx
'use server';
import { Ratelimit } from '@upstash/ratelimit';
import { headers } from 'next/headers';
const limiter = new Ratelimit({
redis: redisClient,
limiter: Ratelimit.slidingWindow(10, '1 m'),
});
export async function submitContactForm(formData: FormData) {
const ip = (await headers()).get('x-forwarded-for') ?? 'unknown';
const { success } = await limiter.limit(ip);
if (!success) {
return { error: 'Rate limit exceeded' };
}
}
`
Pattern 6 — progressive enhancement
Form works WITHOUT JavaScript:
`tsx
import { createPost } from './actions';
export default function NewPostPage() {
return (
);
}
`
If JS disabled: normal form submit (POST). JavaScript on: streaming Server Action. Modern UX without JS dependency.
Pattern 7 — granular revalidation
`tsx
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
export async function updateProduct(productId: string, data: any) {
await prisma.product.update({ where: { id: productId }, data });
revalidatePath(/products/${productId});
revalidatePath('/products', 'page');
revalidatePath('/admin/inventory');
revalidateTag(product:${productId});
revalidateTag('inventory');
}
`
Pattern 8 — error boundary + Sentry
`tsx
'use server';
import * as Sentry from '@sentry/nextjs';
export async function dangerousAction(input: string) {
try {
const result = await actuallyDoIt(input);
return { success: true, result };
} catch (error) {
Sentry.captureException(error, {
tags: { action: 'dangerousAction' },
extra: { input },
});
if (error instanceof PrismaError) {
return { error: 'Database error' };
}
return { error: 'Unknown error' };
}
}
`
When NOT to use Server Actions
`
❌ Public endpoint consumed by third-party (mobile app, integrations) → API route
❌ External webhooks (Stripe, GitHub) → API route
❌ Very long streaming responses → API route with stream
❌ Aggressive caching (CDN) → API route with cache headers
`
`
✓ Form submissions → Server Actions
✓ User-triggered UI mutations → Server Actions
✓ Toggle/like/comment in UI → Server Actions
`
Real case — Dakar SaaS migration
80 API routes → Server Actions migration:
- Code reduced -45% (no manual fetch + JSON serialization)
- 100% type safety (input + output typed via TypeScript)
- CSRF bugs eliminated (Server Actions include CSRF protection)
- Frontend bundle -12% (no fetch wrapper code)
Common pitfalls
- 'use server' file-level vs function-level — prefer file for clarity.
- No revalidation — stale UI post-mutation. Always revalidate.
- No type safety for FormData — use Zod parse for runtime validation.
- Server Actions in Client Components — OK, but imports must be server.
- No error boundary — uncaught error = page crashed. Wrap in try/catch.
FAQ
Q: Server Actions vs tRPC?
A: Server Actions simpler Next.js native. tRPC more powerful + cross-app. For Next.js-only app: Server Actions.
Q: API routes migration?
A: Progressive. Keep routes for external + webhooks. Migrate UI mutations to Server Actions.
Q: Performance vs API routes?
A: Near-identical. Server Actions = API route wrapper with auto serialization.
Conclusion
Next.js 15 Server Actions in 2026 = standard pattern for form-driven mutations. -45% code vs traditional API routes. Type safe + progressive enhancement. Adopt for any new Next.js project.
Mohamed Bah
Fondateur, Kolonell
Passionate about digital and entrepreneurship in Africa, Mohamed has been helping Sénégalese businesses with their digital transformation since 2020. Founder of Kolonell, he believes every SME deserves a professional and accessible online présence.
