Server Actions are one of the biggest shifts in how React apps handle data mutations. Instead of writing a separate API route, validating input, handling CORS, and then calling it from the client, you write a single async function that runs on the server — and call it directly from your component.
This guide covers everything: the mental model, the syntax, real patterns with validation and error handling, file uploads, revalidation, and the cases where you should still use an API route.
The Mental Model
A Server Action is an async function marked with 'use server'. When a Client Component calls it, Next.js serializes the arguments, sends them to the server, runs the function, and returns the result — all over an encrypted HTTP request that Next.js manages for you.
Client Component calls serverAction(data)
↓
Next.js serializes args → POST /action-endpoint
↓
Server runs the function (DB access, auth, business logic)
↓
Returns serializable result → Client receives it
You get:
- No API route files to create, export, or maintain
- Type-safe function calls (TypeScript works end-to-end)
- Automatic CSRF protection — Next.js handles it
- Co-location — action lives next to the UI that uses it
Defining Server Actions
In a separate file (recommended for reuse)
// app/actions/users.ts
'use server'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
export async function createUser(formData: FormData) {
const name = formData.get('name') as string
const email = formData.get('email') as string
await db.user.create({ data: { name, email } })
revalidatePath('/users')
}Inline in a Server Component
// app/users/page.tsx
export default function UsersPage() {
async function createUser(formData: FormData) {
'use server'
const name = formData.get('name') as string
await db.user.create({ data: { name } })
revalidatePath('/users')
}
return (
<form action={createUser}>
<input name="name" />
<button type="submit">Add user</button>
</form>
)
}The 'use server' directive inside the function body is the inline version. The file-level directive (at the top) marks every export in that file as a Server Action.
Using Server Actions with Forms
The simplest pattern: pass the action directly to <form action={...}>. This works even with JavaScript disabled.
// app/users/new/page.tsx
import { createUser } from '@/app/actions/users'
export default function NewUserPage() {
return (
<form action={createUser}>
<input name="name" placeholder="Name" required />
<input name="email" type="email" placeholder="Email" required />
<button type="submit">Create</button>
</form>
)
}useActionState — Pending, Errors, and State
useActionState (React 19, available in Next.js 15) gives you pending state and the ability to return state from your action back to the component.
The action with return value
// app/actions/users.ts
'use server'
import { z } from 'zod'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
const schema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email'),
})
type ActionState = {
errors?: Record<string, string[]>
success?: boolean
message?: string
}
export async function createUser(
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
const raw = {
name: formData.get('name'),
email: formData.get('email'),
}
const result = schema.safeParse(raw)
if (!result.success) {
return {
errors: result.error.flatten().fieldErrors,
}
}
try {
await db.user.create({ data: result.data })
revalidatePath('/users')
return { success: true, message: 'User created!' }
} catch (error) {
return { message: 'Database error. Please try again.' }
}
}The component with useActionState
// app/users/new/page.tsx (or a Client Component)
'use client'
import { useActionState } from 'react'
import { createUser } from '@/app/actions/users'
export function CreateUserForm() {
const [state, action, isPending] = useActionState(createUser, {})
return (
<form action={action}>
<div>
<input name="name" placeholder="Name" disabled={isPending} />
{state.errors?.name && (
<p className="text-red-500">{state.errors.name[0]}</p>
)}
</div>
<div>
<input name="email" type="email" placeholder="Email" disabled={isPending} />
{state.errors?.email && (
<p className="text-red-500">{state.errors.email[0]}</p>
)}
</div>
{state.message && (
<p className={state.success ? 'text-green-500' : 'text-red-500'}>
{state.message}
</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create user'}
</button>
</form>
)
}useActionState signature:
const [state, formAction, isPending] = useActionState(
action, // your Server Action (prevState, formData) => state
initialState, // the initial state before first submission
permalink? // optional URL for progressive enhancement
)useFormStatus — Submit Button Pending State
useFormStatus reads the status of the parent <form>. It's useful for extracting the submit button into its own component:
// components/submit-button.tsx
'use client'
import { useFormStatus } from 'react-dom'
export function SubmitButton({ children }: { children: React.ReactNode }) {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? 'Saving...' : children}
</button>
)
}<form action={createUser}>
<input name="name" />
<SubmitButton>Create user</SubmitButton>
</form>useFormStatus must be used in a component that's inside the <form>, not in the component that renders the form.
Calling Server Actions from Event Handlers
Server Actions don't have to be used with <form>. You can call them programmatically with startTransition:
'use client'
import { useTransition } from 'react'
import { deleteUser } from '@/app/actions/users'
export function DeleteButton({ userId }: { userId: string }) {
const [isPending, startTransition] = useTransition()
return (
<button
onClick={() => startTransition(() => deleteUser(userId))}
disabled={isPending}
>
{isPending ? 'Deleting...' : 'Delete'}
</button>
)
}The Server Action:
// app/actions/users.ts
'use server'
export async function deleteUser(userId: string) {
await db.user.delete({ where: { id: userId } })
revalidatePath('/users')
}Optimistic Updates with useOptimistic
For lists where you want instant UI feedback before the server responds:
'use client'
import { useOptimistic, useTransition } from 'react'
import { toggleTodo } from '@/app/actions/todos'
type Todo = { id: string; text: string; done: boolean }
export function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, setOptimistic] = useOptimistic(
todos,
(state, todoId: string) =>
state.map(t => (t.id === todoId ? { ...t, done: !t.done } : t))
)
const [, startTransition] = useTransition()
function handleToggle(todoId: string) {
startTransition(async () => {
setOptimistic(todoId) // instant UI update
await toggleTodo(todoId) // actual server update
})
}
return (
<ul>
{optimisticTodos.map(todo => (
<li key={todo.id} onClick={() => handleToggle(todo.id)}>
<span style={{ opacity: todo.done ? 0.5 : 1 }}>{todo.text}</span>
</li>
))}
</ul>
)
}File Uploads
Server Actions handle FormData natively, including files:
// app/actions/uploads.ts
'use server'
export async function uploadAvatar(formData: FormData) {
const file = formData.get('avatar') as File
if (!file || file.size === 0) {
return { error: 'No file provided' }
}
if (file.size > 5 * 1024 * 1024) {
return { error: 'File too large (max 5MB)' }
}
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']
if (!allowedTypes.includes(file.type)) {
return { error: 'Invalid file type' }
}
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
// Upload to your storage (S3, Supabase Storage, Cloudflare R2, etc.)
const url = await uploadToStorage(buffer, file.name, file.type)
await db.user.update({
where: { id: getCurrentUserId() },
data: { avatarUrl: url },
})
return { url }
}<form action={uploadAvatar} encType="multipart/form-data">
<input type="file" name="avatar" accept="image/*" />
<SubmitButton>Upload</SubmitButton>
</form>Revalidation
After a mutation, you need to tell Next.js to refresh the data. Two options:
revalidatePath — refresh a specific page
import { revalidatePath } from 'next/cache'
// Revalidate the entire /users segment
revalidatePath('/users')
// Revalidate a specific dynamic route
revalidatePath(`/users/${userId}`)
// Revalidate all pages (use sparingly)
revalidatePath('/', 'layout')revalidateTag — refresh by cache tag
import { revalidateTag } from 'next/cache'
// In your action
revalidateTag('users')
revalidateTag(`user-${userId}`)Tag your fetches:
const users = await fetch('/api/users', {
next: { tags: ['users'] }
})Or with Drizzle/Prisma, use unstable_cache:
import { unstable_cache } from 'next/cache'
const getUsers = unstable_cache(
async () => db.user.findMany(),
['users'],
{ tags: ['users'] }
)Redirects from Server Actions
import { redirect } from 'next/navigation'
export async function createPost(formData: FormData) {
'use server'
const post = await db.post.create({
data: { title: formData.get('title') as string }
})
redirect(`/posts/${post.id}`) // must be called outside try/catch
}redirect throws internally — don't wrap it in try/catch or it won't work.
Authentication and Authorization in Server Actions
Always check auth before running mutations. Never trust that the client is who they say they are:
// app/actions/posts.ts
'use server'
import { auth } from '@/lib/auth'
export async function deletePost(postId: string) {
const session = await auth()
if (!session?.user) {
throw new Error('Unauthorized')
}
// Verify ownership — don't just trust the postId
const post = await db.post.findUnique({
where: { id: postId },
select: { authorId: true },
})
if (!post || post.authorId !== session.user.id) {
throw new Error('Forbidden')
}
await db.post.delete({ where: { id: postId } })
revalidatePath('/posts')
}Cookies and Headers
import { cookies, headers } from 'next/headers'
export async function setPreference(theme: 'light' | 'dark') {
'use server'
const cookieStore = await cookies()
cookieStore.set('theme', theme, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 365,
})
}
export async function getUserAgent() {
'use server'
const headersList = await headers()
return headersList.get('user-agent')
}Passing Additional Data to Actions
When you need to pass data that isn't in the form (like an ID from a list), use .bind():
import { deleteUser } from '@/app/actions/users'
export function UserRow({ user }: { user: User }) {
const deleteUserWithId = deleteUser.bind(null, user.id)
return (
<form action={deleteUserWithId}>
<button type="submit">Delete {user.name}</button>
</form>
)
}The action:
export async function deleteUser(userId: string, formData: FormData) {
'use server'
// userId is bound, formData comes from the form
await db.user.delete({ where: { id: userId } })
}When NOT to Use Server Actions
Server Actions are for mutations called from your own UI. Use a traditional API route when:
| Scenario | Use |
|---|---|
| Webhook from Stripe/GitHub | API route (POST /api/webhook) |
| OAuth callback | API route |
| Consumed by a mobile app | API route |
| GET requests | Not applicable (actions are POST) |
| Third-party service calling your endpoint | API route |
| Public API | API route |
Server Actions are not public endpoints — they're tied to your Next.js app's internal routing. Treat them as private function calls, not HTTP APIs.
Common Mistakes
Mistake 1: Forgetting 'use client' on components that use hooks
useActionState and useFormStatus are client-side hooks. Any component using them needs 'use client' at the top.
Mistake 2: Calling redirect() inside try/catch
// ❌ Wrong — redirect won't work
try {
await db.post.create(...)
redirect('/posts') // this throws internally, gets caught
} catch (e) {
return { error: 'something' }
}
// ✅ Correct
await db.post.create(...)
redirect('/posts') // outside try/catchMistake 3: Not validating input on the server
The client sends data to the server — validate it there. Don't rely on HTML5 required or client-side validation alone.
Mistake 4: Heavy computation in Server Actions
Server Actions run on each request — they're not background jobs. For heavy work (image processing, sending emails, long DB queries), use a job queue and return immediately.
Comparison with API Routes
| Server Action | API Route | |
|---|---|---|
| Definition | Function with 'use server' | export async function POST() in route.ts |
| Calling from client | Direct import + call | fetch('/api/...') |
| Type safety | Automatic (shared types) | Manual or via tRPC/Zod |
| Form integration | Native (<form action>) | Requires JS always |
| Use for webhooks | No | Yes |
| Works without JS | Yes (forms) | No |
Related Patterns
If you're building a full stack app with Server Actions, pair them with Drizzle ORM for type-safe database access and Zustand for client-side state when you need UI state that doesn't need to be persisted. For authentication that integrates cleanly with Server Actions, see the Next.js auth guide.