Skip to main content

Server actions in Next.js

Server Actions are async functions that run on the server and let you mutate data directly from React components, without a separate API endpoint.

Theory

TL;DR

  • Mark a function with 'use server' and it runs on the server, never in the browser
  • Pass it to <form action={...}> and the form works even without JavaScript loaded
  • Call it from a Client Component via useTransition to get a loading state
  • Always validate input - the function is publicly callable like any HTTP endpoint
  • After every write, call revalidatePath or revalidateTag to clear stale cached data

Quick example

tsx
// actions/user.ts 'use server' import { db } from '@/lib/db' import { revalidatePath } from 'next/cache' export async function updateUserName(formData: FormData) { const name = formData.get('name') as string await db.user.update({ where: { id: currentUserId }, data: { name } }) revalidatePath('/settings') // tell Next.js to re-render this route }

Next.js creates an HTTP POST endpoint for this function automatically. When the form submits, the browser hits that endpoint - no client-side fetch required.

What makes Server Actions different from Route Handlers

Before Server Actions, a form mutation required three files: a Server Component to fetch data, a Client Component to manage state, and a Route Handler to accept the POST. Server Actions collapse all three into one function. The boundary is clear though: they are designed for UI-initiated mutations only. Public APIs, webhooks, and third-party integrations still belong in Route Handlers - a Server Action's URL is an implementation detail that can change between builds.

How the directive works

The 'use server' directive goes either at the top of a file (marking every export as a Server Action) or inside an individual function:

tsx
// File-level: everything exported here is a Server Action 'use server' export async function createPost(data: PostData) { ... } export async function deletePost(id: string) { ... }
tsx
// Function-level: only this function runs on the server export default async function Page() { async function handleSubmit(formData: FormData) { 'use server' // runs on the server } return <form action={handleSubmit}>...</form> }

File-level is the standard pattern in larger codebases. Function-level works fine for one-off actions inside a Server Component.

Using with forms

Pass the action directly to <form action={...}>. React intercepts the submission and calls the function. If JavaScript has not loaded yet, the browser submits the form as a regular POST and the action still runs. That is progressive enhancement with zero extra code:

tsx
// app/settings/page.tsx import { updateUserName } from '@/actions/user' export default async function SettingsPage() { return ( <form action={updateUserName}> <input name="name" placeholder="Name on IT Lead" /> <button type="submit">Save</button> </form> ) }

Using with useTransition

Forms cover one scenario. Sometimes you want to trigger a mutation from a button click, not a form submit. Import the action and wrap the call in startTransition:

tsx
'use client' import { useTransition } from 'react' import { solveProblem } from '@/actions/problem' export function SolveButton({ problemId, userId }: { problemId: string userId: string }) { const [isPending, startTransition] = useTransition() return ( <button disabled={isPending} onClick={() => startTransition(() => solveProblem(problemId, userId))} > {isPending ? 'Checking...' : 'Solved'} </button> ) }

isPending is true while the action runs. Use it to disable the button or show a spinner. React 19 added useActionState, which gives a cleaner API when you also need to read the value the action returns.

Data validation

A Server Action is callable from anywhere - treat it like an API endpoint. Never trust raw input:

tsx
'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).max(50), email: z.string().email() }) export async function updateUser(formData: FormData) { const result = schema.safeParse({ name: formData.get('name'), email: formData.get('email') }) if (!result.success) { return { error: result.error.flatten().fieldErrors } } await db.user.update({ where: { id: currentUserId }, data: result.data }) revalidatePath('/settings') return { success: true } }

Return an object with error or success rather than throwing. The Client Component reads that return value and can display field-level error messages.

Cache revalidation after mutation

Next.js caches aggressively. After a write, you need to tell it which data is now stale:

tsx
'use server' import { revalidatePath, revalidateTag } from 'next/cache' import { redirect } from 'next/navigation' export async function createProblem(data: ProblemData) { await db.problem.create({ data }) revalidateTag('problems') // clears Data Cache entries tagged 'problems' revalidatePath('/problems') // clears the Full Route Cache for this path redirect('/problems') // sends the user to the updated page }

revalidateTag is more targeted: it only clears fetches that were tagged with { next: { tags: ['problems'] } }. revalidatePath clears everything cached for that URL. When multiple routes share the same data, revalidateTag is the better call.

Common mistakes

1. Skipping validation

tsx
// Wrong: trusting raw FormData export async function updateUser(formData: FormData) { await db.user.update({ data: { name: formData.get('name') } }) } // Right: validate before writing const result = schema.safeParse({ name: formData.get('name') }) if (!result.success) return { error: result.error.flatten() }

2. Defining a Server Action inside a Client Component file

A function marked 'use server' cannot live inside a file that starts with 'use client'. Define it in a separate file or in a Server Component, then import it.

3. Forgetting to revalidate

The mutation runs but the UI shows old data. In my experience this is the most common bug teams hit when first adopting Server Actions. After every write: revalidatePath or revalidateTag. If you call redirect, Next.js automatically revalidates the destination route.

4. Using Server Actions as a public API

The action URL is generated at build time and is not stable across deploys. External services - mobile apps, third-party webhooks - cannot call it reliably. For anything external, use Route Handlers.

5. Throwing instead of returning errors

tsx
// Risky: action throws, component hits an Error Boundary await updateUser(formData) // Better: return the error, handle it in the component const result = await updateUser(formData) if (!result.success) setErrors(result.error)

Real-world usage

  • Profile settings forms - name, avatar, email updates
  • Marking items complete - solved problems, read notifications, checked todos
  • Creating records - new post, new comment, new task
  • Auth flows - login, logout, signup combined with redirect
  • Optimistic UI - paired with useOptimistic (React 19) for instant feedback before the server responds

Follow-up questions

Q: What happens if a Server Action throws an unhandled error?


A: The error propagates to the nearest Error Boundary when the action is called from a Client Component. When called directly from a form, the page re-renders without crashing. Returning error objects instead of throwing gives you predictable, component-level control.

Q: Can a Server Action redirect the user?


A: Yes. Call redirect('/path') from next/navigation inside the action. It works the same as in a Server Component - it throws internally and Next.js intercepts it before it reaches the client.

Q: What is the difference between a Server Action and a Route Handler?


A: Server Actions are tied to the React component tree - they receive FormData and are called from UI. Route Handlers are standard HTTP endpoints that accept any request format and can be called from mobile apps, third-party services, or cron jobs. You cannot call a Server Action from outside the Next.js app reliably.

Q: How does Next.js protect Server Actions from CSRF attacks?


A: Next.js generates a random action ID at build time and verifies it on every POST. This blocks cross-site request forgery by default. Authorization - checking who is allowed to call a specific action - is still your responsibility inside the function.

Q: Can you call a Server Action from another Server Action?


A: Yes, but it is a plain async function call with no HTTP involved. Import and call it directly. The 'use server' directive only creates an HTTP endpoint for calls originating from the client.

Examples

Profile update with field-level error display

tsx
// actions/profile.ts 'use server' import { z } from 'zod' import { db } from '@/lib/db' import { revalidatePath } from 'next/cache' const profileSchema = z.object({ name: z.string().min(2).max(50), }) export async function updateProfile( prevState: unknown, formData: FormData ) { const result = profileSchema.safeParse({ name: formData.get('name') }) if (!result.success) { return { error: result.error.flatten().fieldErrors } } await db.user.update({ where: { id: currentUserId }, data: result.data, }) revalidatePath('/settings') return { success: true } }
tsx
// app/settings/page.tsx 'use client' import { useActionState } from 'react' import { updateProfile } from '@/actions/profile' export default function SettingsPage() { const [state, action] = useActionState(updateProfile, null) return ( <form action={action}> <input name="name" /> {state?.error?.name && <p>{state.error.name[0]}</p>} <button type="submit">Save</button> </form> ) }

useActionState wires the action's return value back into component state. The prevState parameter receives the previous return value on each call - useful for tracking retries or accumulating validation errors.

Optimistic update with useOptimistic

tsx
'use client' import { useOptimistic, useTransition } from 'react' import { toggleSolved } from '@/actions/problem' export function ProblemList({ problems }: { problems: Problem[] }) { const [optimisticProblems, addOptimistic] = useOptimistic( problems, (state, solvedId: string) => state.map(p => (p.id === solvedId ? { ...p, solved: true } : p)) ) const [, startTransition] = useTransition() function handleSolve(problemId: string) { startTransition(async () => { addOptimistic(problemId) // update UI instantly await toggleSolved(problemId) // persist to DB }) } return ( <ul> {optimisticProblems.map(p => ( <li key={p.id}> {p.title} <button onClick={() => handleSolve(p.id)} disabled={p.solved}> {p.solved ? 'Done' : 'Mark solved'} </button> </li> ))} </ul> ) }

The list updates before the server responds. If the action fails, React rolls back the optimistic state automatically.

Auth flow with redirect

tsx
// actions/auth.ts 'use server' import { cookies } from 'next/headers' import { redirect } from 'next/navigation' import { z } from 'zod' import { verifyCredentials, createSession } from '@/lib/auth' const loginSchema = z.object({ email: z.string().email(), password: z.string().min(8), }) export async function login(formData: FormData) { const result = loginSchema.safeParse({ email: formData.get('email'), password: formData.get('password'), }) if (!result.success) { return { error: 'Invalid input' } } const user = await verifyCredentials(result.data) if (!user) { return { error: 'Wrong email or password' } } const session = await createSession(user.id) cookies().set('session', session.token, { httpOnly: true, secure: true }) redirect('/dashboard') }

Validation, credential check, cookie, redirect - all in one function, no Route Handler needed.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?