Server Actions landed in Next.js 13, went stable in 14, and Next.js 15 made them worth using in production. Here's what I've learned shipping them in real apps.
The Mental Model That Clicks
Server Actions are async functions that run on the server. They're not API routes — they're closer to RPC calls you invoke from the client (or server) without writing a fetch call. The framework handles serialization, routing, and CSRF protection.
// app/actions/user.ts
'use server'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
export async function updateUser(userId: string, data: { name: string; email: string }) {
await db.user.update({ where: { id: userId }, data })
revalidatePath('/dashboard')
}
// app/dashboard/page.tsx
import { updateUser } from '@/app/actions/user'
export default function DashboardPage({ params }: { params: { id: string } }) {
return (
<form action={updateUser.bind(null, params.id)}>
<input name="name" />
<input name="email" />
<button type="submit">Save</button>
</form>
)
}
No useState, no useEffect, no fetch('/api/user'). The action runs on the server, revalidates the cache, and the UI updates.
Pattern 1: Validation With Zod Before You Touch the DB
Never trust form data. Every Server Action that mutates data should validate first.
'use server'
import { z } from 'zod'
import { db } from '@/lib/db'
const UpdateUserSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
})
export async function updateUser(userId: string, formData: FormData) {
const raw = {
name: formData.get('name'),
email: formData.get('email'),
}
const parsed = UpdateUserSchema.safeParse(raw)
if (!parsed.success) {
return { error: parsed.error.flatten().fieldErrors }
}
await db.user.update({ where: { id: userId }, data: parsed.data })
return { success: true }
}
Return an object with error or success — never throw unless it's truly unrecoverable.
Pattern 2: useActionState for Progressive Enhancement
useActionState (React 19) lets you wire action return values to UI state while keeping the form server-rendered.
'use client'
import { useActionState } from 'react'
import { updateUser } from '@/app/actions/user'
export function UserForm({ userId }: { userId: string }) {
const [state, formAction, isPending] = useActionState(
updateUser.bind(null, userId),
null
)
return (
<form action={formAction}>
<input name="name" />
{state?.error?.name && <p className="text-red-500">{state.error.name[0]}</p>}
<input name="email" />
{state?.error?.email && <p className="text-red-500">{state.error.email[0]}</p>}
<button type="submit" disabled={isPending}>
{isPending ? 'Saving...' : 'Save'}
</button>
</form>
)
}
Loading states and field errors with zero client-side state management.
Pattern 3: Auth Inside the Action
Gate mutations in the action, not the component. Component-level guards are UI hints, not security.
'use server'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
export async function deletePost(postId: string) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const post = await db.post.findFirst({
where: { id: postId, authorId: session.user.id },
})
if (!post) throw new Error('Not found')
await db.post.delete({ where: { id: postId } })
}
Every action calls auth() and verifies ownership. Same rules as API routes.
Pattern 4: Optimistic Updates With useOptimistic
'use client'
import { useOptimistic, useTransition } from 'react'
import { toggleLike } from '@/app/actions/posts'
export function LikeButton({ postId, initialLiked, initialCount }: {
postId: string; initialLiked: boolean; initialCount: number
}) {
const [optimistic, setOptimistic] = useOptimistic({ liked: initialLiked, count: initialCount })
const [, startTransition] = useTransition()
function handleClick() {
startTransition(async () => {
setOptimistic(prev => ({
liked: !prev.liked,
count: prev.liked ? prev.count - 1 : prev.count + 1,
}))
await toggleLike(postId)
})
}
return (
<button onClick={handleClick}>
{optimistic.liked ? '❤️' : '🤍'} {optimistic.count}
</button>
)
}
UI updates immediately. If the action fails, React reverts automatically.
Pattern 5: Revalidation Strategy
'use server'
import { revalidatePath, revalidateTag } from 'next/cache'
export async function publishPost(postId: string) {
await db.post.update({ where: { id: postId }, data: { published: true } })
revalidatePath('/blog')
revalidatePath(`/blog/${postId}`)
revalidateTag('posts')
}
Tag your fetch calls in Server Components, invalidate by tag in actions.
What to Avoid
- Don't put secrets in client components that call actions
- Don't use Server Actions for reads — use Server Components + fetch
- Large file uploads: use signed URLs (S3/R2), store the URL in the action
- Middleware doesn't intercept Server Actions the same way as API routes — auth inside the action is the only reliable pattern
The Payoff
Delete API route files for CRUD operations. Delete client-side fetch boilerplate. Keep Server Actions for mutations, Server Components for reads.
Building SaaS with Next.js? I write about production patterns at whoffagents.com.