Server Actions in Next.js promise a simpler way to perform server-side mutations without juggling API routes, client fetch calls, and manual cache updates. If you’ve ever wired a form to an API endpoint, then duplicated logic to keep your UI and data in sync, Server Actions feel like a breath of fresh air. In this guide, you’ll learn exactly how they work, how to set them up, and when they actually replace API routes (and when they don’t).
What Server Actions Are And How They Work
Server Actions let you call server-side code directly from your components, usually through forms or imperative calls, without creating a separate API route. They run on the server, have access to server-only resources (like your database, environment variables, and cookies), and return serializable results to your UI.
Execution Model And “use server”
A Server Action is just an async function marked with the “use server” directive at the top of the function or the file. That directive tells Next.js (and React) to execute it on the server.
// app/actions.ts
"use server":
import { cookies } from "next/headers":
import { db } from "@/lib/db":
export async function createTodo(formData: FormData) {
const title = String(formData.get("title") |
| "").trim():
const userId = cookies().get("uid")?.value: // server-only
if (.userId |
| .title) throw new Error("Missing data"):
await db.todo.create({ data: { title, userId } }):
return { ok: true }:
}
You invoke actions via forms (setting action={createTodo}) or from client components using a special call boundary provided by Next.js/React. Under the hood, Next.js serializes the call, sends a POST to an internal endpoint, runs the function on the server, and rehydrates the result back to your component tree.
Supported Use Cases And Limitations
Use Server Actions for mutations and any server-only logic that returns serializable data. They’re ideal for create/update/delete flows, authenticated operations, and transactional work that should never run on the client.
Limitations to keep in mind:
- Return values must be serializable (no open DB connections, streams, or functions). For binary or large downloads, prefer a Route Handler.
- Long-running jobs should be queued (e.g., enqueue to a worker) rather than awaited inside the action.
- You don’t expose a public URL with an action, so external systems can’t call them directly (that’s a feature, not a bug).
How Server Components Interact With Actions
Server Components can define and call actions directly, making form wiring feel natural and keeping logic close to UI. Client Components can also call actions, but they cross a server boundary. The big win is co-locating mutation logic with the UI that needs it, while still executing securely on the server.
Setting Up And Using Server Actions
Server Actions are available in the App Router and are widely adopted since Next.js 14. You’ll place action functions in server files or mark them individually with “use server”.
Enabling And Organizing Actions In Your App
There’s no heavy setup. Create a server-marked module (e.g., app/actions.ts) and export your actions. Keep side-effectful logic (DB, email, payments) in these functions: keep UI logic in components. A common pattern is to group actions by domain (e.g., actions/todos.ts, actions/account.ts).
// app/todos/actions.ts
"use server":
import { db } from "@/lib/db":
import { revalidatePath } from "next/cache":
export async function toggleTodo(id: string, done: boolean) {
await db.todo.update({ where: { id }, data: { done } }):
revalidatePath("/todos"):
}
Form Actions, Mutations, And Progressive Enhancement
The most ergonomic entry point is HTML forms. Set your action function on the action prop of a form or a button inside a form. When JavaScript is disabled, the browser still POSTs the form, the server runs your action and returns the updated page. When JS is enabled, React coordinates the call and partial UI update, giving you progressive enhancement by default.
// app/todos/page.tsx (Server Component)
import { toggleTodo } from "./actions":
export default async function TodosPage() {
const todos = await getTodos():
return (
<form action={async (formData) => {
const id = String(formData.get("id")):
const done = formData.get("done") === "on":
await toggleTodo(id, done):
}}>
{/* ...render items with checkboxes... */}
<button type="submit">Save</button>
</form>
):
}
Calling Actions From Client Components
From a Client Component, you can call an imported action (Next.js proxies the call to the server). Use useTransition, useActionState, or useOptimistic to manage pending and optimistic UI.
"use client":
import { useTransition } from "react":
import { toggleTodo } from "@/app/todos/actions":
export function TodoItem({ id, done, title }: { id: string: done: boolean: title: string }) {
const [isPending, startTransition] = useTransition():
return (
<label>
<input
type="checkbox"
defaultChecked={done}
onChange={(e) => {
const next = e.target.checked:
startTransition(() => toggleTodo(id, next)):
}}
/>
{title} {isPending ? "…" : null}
</label>
):
}
Data Fetching, Caching, And Revalidation
One of the biggest advantages of Server Actions in Next.js is how they plug into Next’s caching and revalidation primitives. You mutate, then tell the framework what to refresh.
Cache Semantics, Tags, And revalidatePath
Use revalidatePath to invalidate a specific route segment or revalidateTag to invalidate everything associated with a tag. Pair your data-fetching functions with cache and tags for fine-grained control.
// app/lib/data.ts
import { cache } from "react":
import { unstable_noStore as noStore, revalidateTag } from "next/cache":
export const getTodos = cache(async () => {
// tag your fetches or DB reads (conceptually)
const todos = await db.todo.findMany():
return todos:
}):
export async function invalidateTodos() {
revalidateTag("todos"):
}
In your action, call revalidatePath("/todos") or revalidateTag("todos") after the mutation. For data that must always be fresh, call noStore() in the request scope.
Handling Side Effects And Idempotency
Actions run on the server for every invocation. Make your mutations idempotent when possible, so retries don’t double-charge a card or duplicate records. Store idempotency keys with your writes or rely on database constraints and UPSERT patterns. For external side effects (emails, webhooks), consider enqueueing to a job queue to improve latency and reliability.
Streaming UI Updates With Suspense And Optimistic UI
Combine actions with Suspense, useTransition, and useOptimistic to keep the UI snappy. Show pending indicators while the server does the work and optimistically render the expected result. If the action fails, React rolls back the optimistic state, and you can surface a helpful error message.
Security, Validation, And Error Handling
Server Actions tighten your security posture because the code never ships to the client, and you don’t expose a public API surface by default. Still, you should be explicit about input validation, auth, and failure modes.
Input Validation And Zod/Schema Patterns
Validate at the boundary. When you receive FormData or arguments, parse them through a schema (Zod, Valibot, Yup) and fail early.
"use server":
import { z } from "zod":
const TodoSchema = z.object({
title: z.string().min(1).max(100),
}):
export async function createTodo(formData: FormData) {
const parsed = TodoSchema.safeParse({ title: formData.get("title") }):
if (.parsed.success) {
// Attach structured errors for the UI
return { ok: false, errors: parsed.error.flatten().fieldErrors } as const:
}
// perform write…
return { ok: true } as const:
}
Authentication, Authorization, And CSRF Considerations
Use auth() from your auth solution (e.g., NextAuth/Auth.js) or cookies()/headers() to identify the user server-side. Authorization belongs in the action, check roles, ownership, and business rules there.
About CSRF: form-submitted Server Actions benefit from browser CSRF protections when you use same-site cookies and verify the Origin/Referer for state-changing requests. If you expose actions to client-side imperative calls, keep them same-origin and authenticated: for cross-origin scenarios use a proper Route Handler with CSRF tokens or OAuth flows.
Redirects, NotFound, And Typed Error Handling
You can throw framework primitives from actions to control navigation and status:
redirect("/path")fromnext/navigationto move users after success.notFound()to trigger a 404 boundary.
For domain errors, return a typed result (e.g., { ok: false, code: "DUPLICATE" }) instead of throwing. Reserve exceptions for truly exceptional failures you want to log and surface generically.
Server Actions vs. API Routes: When To Use Which
Do Server Actions replace API routes? Often, yes, for app-internal mutations. But API routes (Route Handlers in the App Router) still have clear jobs.
Decision Criteria: Boundaries, Reuse, And Clients
Use Server Actions when:
- The caller is your own Next.js UI, and you control the component boundary.
- The operation is authenticated and server-only (DB writes, secrets).
- You want progressive enhancement and built-in revalidation.
Prefer API routes (Route Handlers) when:
- You need a public, versioned, documented HTTP API.
- Third parties or native apps must call you directly.
- You need streaming/binary responses or custom HTTP semantics.
Performance, Caching, And Rate Limiting
Server Actions remove an extra hop (client → API → server) by letting your UI ask the server to run code directly. That means less boilerplate and often lower latency within your app. For cross-client consumption, API routes offer classic HTTP caching headers, edge-friendly handlers, and easier CDN integration. Rate limiting and abuse prevention are also more straightforward in API routes or Middleware, where you can enforce IP-based limits and custom responses.
Edge, Webhooks, And Third-Party Integrations
Server Actions can run in the Node.js or Web/Edge runtime depending on your setup, but verify support for the libraries you use. For webhooks and inbound third-party calls, stick to Route Handlers, those endpoints need a public URL, signature verification, and predictable HTTP behavior. For outbound third-party calls (e.g., calling Stripe from your action), actions are great, keep keys on the server and never expose them to the client.
Migration And Testing Strategies
You don’t need a big-bang rewrite. Migrate feature-by-feature, starting with forms tightly coupled to your UI.
Refactoring Common API Patterns To Actions
Take a CRUD endpoint and fold it into a single action colocated with the page or feature. Replace fetch('/api/...') with a direct function call, then call revalidatePath for the affected routes. If your API route handled multiple verbs, consider splitting by intent into clearer, smaller actions.
Testing Actions And Mocking Dependencies
Because actions are just functions, unit testing is straightforward. Import the action, mock your DB and helpers, and invoke with FormData or plain arguments. If you use cookies()/headers(), provide test doubles. For integration tests, hit pages that submit forms to ensure the whole round trip (action execution + revalidation) works.
import { createTodo } from "@/app/actions":
test("creates a todo", async () => {
const fd = new FormData():
fd.set("title", "Test"):
const res = await createTodo(fd):
expect(res).toEqual({ ok: true }):
}):
Observability, Logging, And Error Reporting
Instrument your actions the same way you instrument server code today. Add structured logs around mutations, include user IDs and request IDs, and forward errors to your APM (Sentry, OpenTelemetry). Next.js supports an instrumentation.ts entry for tracing, you can add spans around critical actions and attach tags for faster debugging. For high-signal errors, prefer returning typed results to the UI and separately logging the exception so you don’t leak sensitive details.
Frequently Asked Questions
What are Server Actions in Next.js and how do they work?
Server Actions in Next.js let components call server-only code directly, typically via forms or imperative calls. Mark an async function with “use server” and Next.js executes it on the server, with access to DB, cookies, and env vars. Results must be serializable and are returned to your UI.
When should I use Server Actions in Next.js instead of API routes?
Use Server Actions when your own Next.js UI calls server-only, authenticated mutations and you want progressive enhancement plus built-in revalidation. Prefer API routes for public, versioned HTTP APIs, third‑party or native app consumers, streaming/binary responses, custom HTTP semantics, and simpler rate limiting or CDN caching strategies.
How do caching and revalidation work with Server Actions in Next.js?
After a mutation, call revalidatePath or revalidateTag to refresh stale UI. Pair data reads with React’s cache and tagging for precise invalidation; use noStore for data that must always be fresh. This keeps reads fast, writes consistent, and avoids manual cache management or duplicated client-side state.
How should I handle security, validation, and CSRF with Server Actions?
Validate inputs at the boundary (e.g., Zod schemas), enforce authentication and authorization inside the action, and prefer typed results for domain errors. Form-submitted actions benefit from same-site cookie protections; also verify Origin/Referer. For cross-origin scenarios, use Route Handlers with CSRF tokens or OAuth flows instead.
Can I handle file uploads with Server Actions?
Yes, small uploads via multipart/form-data arrive as File/Blob in the action; you can process or forward them to storage. For large files, streaming, resumable transfers, or signed direct-to-cloud uploads, prefer Route Handlers or client-to-storage flows to avoid timeouts and keep memory usage predictable.
Are Server Actions production-ready in Next.js 14+, and do they run on the Edge?
Server Actions are widely used in production since Next.js 14. They can run in Node.js or the Edge runtime, depending on your route’s configuration and library compatibility. Verify dependencies work in your chosen runtime. For webhooks and inbound third‑party calls, stick to public Route Handlers.

No responses yet