A Practical Guide to Server Actions in Next.js
Server Actions let you run server-side code directly from your React components. This guide covers how they actually work, how to handle errors and validation the right way, what the security risks are, and when to use Route Handlers instead.
What Server Actions Actually Are
A Server Action is an async function marked with the "use server" directive. When you call it from a Client Component, Next.js sends a POST request to the server behind the scenes, runs the function, and returns the result.
"use server"
export async function createPost(formData: FormData) {
const title = formData.get("title") as string
await db.post.create({ data: { title } })
}There is no manual API route needed. No fetch("/api/create-post"). The function runs on the server and its code never reaches the browser.
Important: every Server Action is a public HTTP POST endpoint. Next.js generates a secure encrypted ID for each one, but the endpoint exists and is reachable. Always treat incoming data as untrusted.
Two Ways to Define Them
File-level directive: put "use server" at the top of the file. Every exported async function in that file becomes a Server Action. This is the most common pattern for keeping actions organized.
// app/actions/posts.ts
"use server"
export async function createPost(formData: FormData) { ... }
export async function deletePost(id: string) { ... }
export async function updatePost(id: string, formData: FormData) { ... }Function-level directive: put "use server" inside an async function defined inside a Server Component. Useful for small, one-off mutations close to the UI.
// app/posts/page.tsx - Server Component
export default function PostsPage() {
async function handleDelete(id: string) {
"use server"
await db.post.delete({ where: { id } })
revalidatePath("/posts")
}
return <DeleteButton action={handleDelete} />
}For anything beyond a single mutation, prefer the file-level approach. It keeps your actions in one place and easy to find.
Handling Forms with useActionState
React 19 introduced useActionState as the standard way to handle form submissions with Server Actions. It gives you the action's return value, a pending state, and wires everything to the form.
"use client"
import { useActionState } from "react"
import { createPost } from "@/app/actions/posts"
const initialState = { error: null }
export function CreatePostForm() {
const [state, action, isPending] = useActionState(createPost, initialState)
return (
<form action={action}>
<input name="title" required />
{state.error && <p className="text-red-500">{state.error}</p>}
<button type="submit" disabled={isPending}>
{isPending ? "Saving..." : "Create Post"}
</button>
</form>
)
}The Server Action now receives (prevState, formData) as arguments:
"use server"
export async function createPost(prevState: { error: string | null }, formData: FormData) {
const title = formData.get("title") as string
if (!title || title.trim().length === 0) {
return { error: "Title is required" }
}
await db.post.create({ data: { title } })
return { error: null }
}This form also works without JavaScript. If the page loads before JS hydrates, the browser submits the form as a standard HTTP POST and the action still runs. This is called progressive enhancement and it comes for free with this pattern.
Error Handling
There are two categories of errors and they should be handled differently.
Expected errors are things you anticipate: validation failures, missing fields, a resource not found. Return these as values from the action so the UI can display them without crashing.
"use server"
export async function updateUsername(prevState: ActionState, formData: FormData) {
const username = formData.get("username") as string
if (username.length < 3) {
return { error: "Username must be at least 3 characters" }
}
const taken = await db.user.findUnique({ where: { username } })
if (taken) {
return { error: "That username is already taken" }
}
await db.user.update({ where: { id: session.userId }, data: { username } })
return { error: null }
}Unexpected errors are things that should not happen: database failures, network errors, bugs. Throw these so React can catch them with an Error Boundary.
"use server"
export async function syncData() {
try {
await externalService.sync()
} catch {
throw new Error("Sync failed. Please try again.")
}
}The rule is: return expected errors, throw unexpected ones.
Revalidating and Redirecting After a Mutation
After a mutation you usually need to either refresh the page data or send the user somewhere else.
revalidatePath clears the cache for a specific URL. revalidateTag clears the cache for all fetches tagged with a given string. Prefer revalidateTag when the same data appears across multiple pages.
"use server"
import { revalidateTag } from "next/cache"
import { redirect } from "next/navigation"
export async function publishPost(id: string) {
await db.post.update({ where: { id }, data: { published: true } })
revalidateTag("posts") // clears all cached data tagged "posts"
redirect(`/posts/${id}`) // navigates to the published post
}Always call revalidatePath or revalidateTag before redirect. Code after redirect does not execute because it throws internally to trigger the navigation.
Security
Server Actions are public endpoints. They are protected by encrypted action IDs that Next.js rotates between builds, but the endpoint can still be called directly with a valid ID.
Every action needs two checks before doing any work:
Authentication: is the user logged in?
Authorization: does this user have permission to do this specific thing?
"use server"
import { auth } from "@/lib/auth"
export async function deleteComment(commentId: string) {
const session = await auth()
if (!session) {
throw new Error("Unauthorized")
}
const comment = await db.comment.findUnique({ where: { id: commentId } })
if (!comment || comment.userId !== session.user.id) {
throw new Error("Forbidden")
}
await db.comment.delete({ where: { id: commentId } })
revalidateTag("comments")
}Also validate all input. Use a schema validation library like Zod to keep this consistent:
"use server"
import { z } from "zod"
const schema = z.object({
title: z.string().min(1).max(200),
body: z.string().min(10),
})
export async function createPost(prevState: ActionState, formData: FormData) {
const parsed = schema.safeParse({
title: formData.get("title"),
body: formData.get("body"),
})
if (!parsed.success) {
return { error: parsed.error.errors[0].message }
}
await db.post.create({ data: parsed.data })
return { error: null }
}Never skip these checks based on assumptions about what the UI shows or hides.
Server Actions vs Route Handlers
Both run server-side code but they serve different purposes.
| Server Actions | Route Handlers | |
|---|---|---|
| Called from your React app | Yes | Yes |
| Called from a mobile app | No | Yes |
| Called from a webhook | No | Yes |
| Works with React forms natively | Yes | No |
| Full control over HTTP response | No | Yes |
| Type safe end to end | Yes | Requires extra setup |
Use Server Actions for mutations triggered from your own React components: form submissions, button clicks, any write operation your UI initiates.
Use Route Handlers for anything outside your React app: webhooks from Stripe or GitHub, mobile clients, third-party integrations, or when you need to return a custom HTTP status code or stream a response.
Most teams use Server Actions for all internal mutations and add Route Handlers only for webhooks and external APIs.
Folder Structure
Keep actions in a dedicated folder, one file per domain. This makes them easy to find and keeps your component files focused on UI.
app/
actions/
post.actions.ts
comment.actions.ts
user.actions.ts
posts/
page.tsx
[id]/
page.tsx
Each file starts with "use server" and exports only the actions for that domain. Import them into components as needed.
Summary
Server Actions are the right tool for mutations that come from your React app. They remove the need for separate API routes for internal writes, work well with React forms and transitions, and keep your code close to the UI that uses it.
The key things to get right in production:
- Always authenticate and authorize before touching data
- Validate all input with a schema
- Return expected errors as values, throw unexpected ones
- Call
revalidateTagorrevalidatePathbeforeredirect - Reach for Route Handlers when the caller is not your React app