Next.js 15 Server Actions: A Practical Tutorial for Form Handling and Data Mutations

Server Actions changed how I write mutations in Next.js. I have shipped them across client projects in Dhaka, Sydney, and Texas, and the pattern has quietly removed an entire layer of boilerplate: no more hand-rolled /api/submit routes, no more fetch calls wrapped in useEffect, no more client-side state that exists purely to hold form values.
This nextjs server actions tutorial is the walkthrough I wish I had when I started. We will build a real CRUD feature from a fresh App Router project: create, validate, authorize, mutate, revalidate, and handle errors. Along the way I will point out the traps that bite people in production, because most tutorials stop right when things get interesting.
What Server Actions Actually Are
A Server Action is an async function that runs on the server but can be called directly from a component, including a client component. You mark it with "use server" and Next.js wires up the network plumbing for you. The feature is documented in the Next.js Server Actions and Mutations guide.
That plumbing is worth understanding. When you call an action from the browser, Next.js sends a POST request to the current route with an encoded reference to the function and its serialized arguments. The server looks up the function, runs it, and streams back the result. You never write that code.
The practical consequences:
- Arguments and return values must be serializable. No functions, no class instances, and be careful with values like
Dateobjects, they serialize, but the round-trip behavior is worth testing rather than assuming. - The action runs in the same request context as the page render, so cookies, headers, and your database client are available.
- Because it is a POST to a URL, it works without JavaScript when attached to a
<form>. That last point matters more than people realize.
Server Actions vs Route Handlers vs API Routes
I get asked this constantly. Here is the table I keep in my notes.
| Concern | Server Action | Route Handler (app/api/*/route.ts) |
Pages API Route |
|---|---|---|---|
| Primary use | Form submits, mutations tied to UI | Public API, webhooks, third-party callers | Legacy endpoints |
| Called from | Components, <form action> |
fetch from anywhere |
fetch from anywhere |
| Works without JS | Yes, when bound to a form | No direct form binding | No direct form binding |
| CSRF protection | Built-in origin checks (see caveats below) | You implement it | You implement it |
| Caching control | revalidatePath, revalidateTag, use cache |
Full manual control | Full manual control |
| Auth pattern | Check session inside the action | Check session in the handler | Check session in the handler |
| Best fit | Your own app's UI | External consumers | Maintenance mode only |
My rule: if only my UI calls it, it is a Server Action. If a mobile app, a partner, or a webhook calls it, it is a Route Handler. Do not build a public API out of Server Actions just because they are convenient. They are not designed for that, and your consumers will hate the opaque protocol. If you are weighing framework choices more broadly, my breakdown of WordPress vs Next.js in 2026 covers where each one actually earns its place.
Project Setup
Start with a current Next.js app using the App Router.
npx create-next-app@latest tasks-app --typescript --app --eslint
cd tasks-app
npm install zod
I am using Zod for validation because it gives me runtime safety and inferred types from one source. If you prefer Valibot or ArkType, the shape of the code stays the same.
Assume a tasks table with id, title, done, and userId. You can swap in Prisma, Drizzle, or raw pg. The action code does not care.
// src/lib/db.ts
export type Task = {
id: string
title: string
done: boolean
userId: string
}
export async function createTask(input: {
title: string
userId: string
}): Promise<Task> {
// replace with your real insert
throw new Error("not implemented")
}
Your First Server Action and Form
Here is the smallest useful server actions example: a form that creates a task.
// src/app/tasks/actions.ts
"use server"
import { revalidatePath } from "next/cache"
import { createTask } from "@/lib/db"
export async function createTaskAction(formData: FormData) {
const title = String(formData.get("title") ?? "").trim()
if (!title) {
return { error: "Title is required" }
}
await createTask({ title, userId: "demo-user" })
revalidatePath("/tasks")
}
And the form:
// src/app/tasks/new-task-form.tsx
import { createTaskAction } from "./actions"
export function NewTaskForm() {
return (
<form action={createTaskAction} className="flex gap-2">
<input name="title" placeholder="What needs doing?" required />
<button type="submit">Add</button>
</form>
)
}
That is it. No onSubmit, no preventDefault, no useState. The form posts, the action runs, revalidatePath refreshes the list, and the new task appears.
This is the part that makes nextjs 15 form handling genuinely pleasant. The default path is the simple path, and you only add client-side machinery when you need it.
Validating Input with Zod
Never trust FormData. It is user input, and it arrives as strings whether you like it or not.
// src/app/tasks/schema.ts
import { z } from "zod"
export const createTaskSchema = z.object({
title: z
.string()
.trim()
.min(1, "Title is required")
.max(120, "Keep it under 120 characters"),
})
export type CreateTaskInput = z.infer<typeof createTaskSchema>
// src/app/tasks/actions.ts
"use server"
import { revalidatePath } from "next/cache"
import { createTask } from "@/lib/db"
import { createTaskSchema } from "./schema"
export type ActionState = {
ok: boolean
message?: string
fieldErrors?: Record<string, string[]>
}
export async function createTaskAction(
_prev: ActionState,
formData: FormData
): Promise<ActionState> {
const parsed = createTaskSchema.safeParse({
title: formData.get("title"),
})
if (!parsed.success) {
return {
ok: false,
message: "Please fix the errors below.",
fieldErrors: parsed.error.flatten().fieldErrors,
}
}
await createTask({ title: parsed.data.title, userId: "demo-user" })
revalidatePath("/tasks")
return { ok: true, message: "Task created." }
}
Two things changed. The action now takes a previous state as its first argument, and it returns a plain object instead of throwing. That first argument is what lets useActionState render server-side errors without any client state management of your own.
Wiring Up useActionState
// src/app/tasks/new-task-form.tsx
"use client"
import { useActionState } from "react"
import { createTaskAction, type ActionState } from "./actions"
const initialState: ActionState = { ok: false }
export function NewTaskForm() {
const [state, formAction, pending] = useActionState(
createTaskAction,
initialState
)
return (
<form action={formAction} className="space-y-2">
<input name="title" placeholder="What needs doing?" />
{state.fieldErrors?.title && (
<p className="text-red-600 text-sm">{state.fieldErrors.title[0]}</p>
)}
<button type="submit" disabled={pending}>
{pending ? "Adding..." : "Add task"}
</button>
{state.message && <p className="text-sm">{state.message}</p>}
</form>
)
}
The pending flag comes free. You do not need a loading boolean, and you do not need to reset it in a finally block. React tracks the transition for you. See the React useActionState reference for the full signature.
Authorization: The Part Tutorials Skip
Here is the uncomfortable truth about Server Actions: they are effectively public HTTP endpoints. Any action you export from a "use server" file can be invoked by anyone who can determine its action ID. The "use server" directive is not an access control mechanism.
I have reviewed codebases where the delete action looked like this:
// DO NOT DO THIS
export async function deleteTask(id: string) {
await db.task.delete({ where: { id } })
revalidatePath("/tasks")
}
That is an unauthenticated delete endpoint. The UI only shows the button to logged-in users, but the UI is not a security boundary.
Every mutation needs three checks, in this order:
- Is there a session? If not, reject immediately.
- Does this user own the record? Scope the query by
userId, do not fetch then compare. - Is the input valid? Validate after you know who is asking, so you can log abuse properly.
// src/app/tasks/actions.ts
"use server"
import { revalidatePath } from "next/cache"
import { z } from "zod"
import { auth } from "@/lib/auth"
import { db } from "@/lib/db"
const deleteSchema = z.object({ id: z.string().uuid() })
export async function deleteTaskAction(formData: FormData) {
const session = await auth()
if (!session?.user) {
return { ok: false, message: "Not signed in." }
}
const parsed = deleteSchema.safeParse({ id: formData.get("id") })
if (!parsed.success) {
return { ok: false, message: "Invalid request." }
}
const result = await db.task.deleteMany({
where: { id: parsed.data.id, userId: session.user.id },
})
if (result.count === 0) {
return { ok: false, message: "Task not found." }
}
revalidatePath("/tasks")
return { ok: true }
}
Using deleteMany with a userId in the where clause is deliberate. It makes the ownership check part of the query, so there is no window between "check" and "delete" where a bug could slip through. Same idea with updateMany. It is a small habit that prevents a whole class of authorization bugs. If you want the broader threat model, prompt injection, tool misuse, and the rest, my guide to securing AI agents in production covers the same "never trust the caller" instinct at a larger scale.
Optimistic UI with useOptimistic
On a slow connection, waiting 400ms for a round trip before the checkbox moves feels broken. useOptimistic fixes that without giving up the server as the source of truth.
// src/app/tasks/task-list.tsx
"use client"
import { useOptimistic, useTransition } from "react"
import { toggleTaskAction } from "./actions"
type Task = { id: string; title: string; done: boolean }
export function TaskList({ tasks }: { tasks: Task[] }) {
const [optimisticTasks, applyOptimistic] = useOptimistic(
tasks,
(current, id: string) =>
current.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
)
return (
<ul>
{optimisticTasks.map((task) => (
<li key={task.id}>
<form
action={async () => {
applyOptimistic(task.id)
await toggleTaskAction(task.id)
}}
>
<button type="submit">
{task.done ? "[x]" : "[ ]"} {task.title}
</button>
</form>
</li>
))}
</ul>
)
}
The optimistic update is applied inside a transition, so React knows to roll it back if the action throws. If your action returns an error object instead of throwing, you need to handle the rollback yourself by not applying the optimistic state in the first place. Pick one error strategy per action and stay consistent. The React useOptimistic reference documents the rollback semantics.
Revalidation and Caching
revalidatePath and revalidateTag are how you tell Next.js that cached data is stale. Getting this wrong is the number one source of "why is my list not updating" questions. Both are documented in the Next.js revalidatePath and revalidateTag API references.
| Goal | Use | Notes |
|---|---|---|
| Refresh a specific page | revalidatePath("/tasks") |
Broad but predictable |
| Refresh a layout segment | revalidatePath("/tasks", "layout") |
Revalidates the segment and below |
| Refresh tagged data | revalidateTag("tasks") |
Pairs with fetch(... { next: { tags: ["tasks"] } }) |
| Refresh everything for a user | Tag with the user ID | revalidateTag(\tasks:${userId}`)` |
Two habits that save time:
- Put
revalidatePathcalls inside the action, not in the component. The mutation and its cache invalidation belong together. - Tag by user or tenant when you have multi-tenant data. Revalidating the whole
/taskspath for every user is wasteful at scale and leaks timing information across accounts.
If you are on the newer caching model with use cache, remember that a Server Action that writes data should still trigger explicit invalidation. Server Actions do not automatically bust every cache entry you have. For the deeper rendering-side patterns, Next.js 15 + React Server Components: Advanced Patterns goes further into how caching and streaming interact.
Error Handling That Does Not Leak
Return expected errors, throw unexpected ones. That split keeps your logs useful and your UI honest.
export async function updateTaskAction(
_prev: ActionState,
formData: FormData
): Promise<ActionState> {
const session = await auth()
if (!session?.user) return { ok: false, message: "Not signed in." }
const parsed = updateSchema.safeParse({
id: formData.get("id"),
title: formData.get("title"),
})
if (!parsed.success) {
return {
ok: false,
fieldErrors: parsed.error.flatten().fieldErrors,
}
}
try {
await db.task.updateMany({
where: { id: parsed.data.id, userId: session.user.id },
data: { title: parsed.data.title },
})
} catch (error) {
console.error("updateTaskAction failed", {
userId: session.user.id,
taskId: parsed.data.id,
error,
})
return { ok: false, message: "Something went wrong. Try again." }
}
revalidatePath("/tasks")
return { ok: true, message: "Saved." }
}
Notice what the user sees versus what the log sees. Users get a sentence they can act on. The log gets the user ID, the task ID, and the raw error. Never return a database error message to the browser; it is a free map of your schema for anyone probing.
Progressive Enhancement and the Bangladesh Angle
This is where Server Actions earn their keep for the clients I work with in Dhaka and other parts of South Asia. Mobile connections drop. A user taps submit, the request fails, and a JavaScript-only form leaves them staring at a spinner that never resolves.
Server Actions bound to a <form action={...}> degrade gracefully. If the script has not loaded yet, or the connection is too flaky to hydrate, the browser performs a native form POST. The action still runs. The user still gets a result, rendered as a fresh page.
To take full advantage of this:
- Keep the essential form as a real
<form>with a realaction. Do not replace it with anonClickhandler. - Avoid making validation client-only. The server is the authority.
- Keep your client bundle small so hydration happens fast on 3G. Every kilobyte is a real cost on a mid-range Android phone.
I have measured this on real projects. The difference between a 200KB and a 90KB client bundle on a throttled connection is the difference between a form that feels instant and one that feels broken. Server Actions let you push logic to the server without giving up interactivity, which is exactly the trade you want when bandwidth is the constraint. If bundle size is your bottleneck, the tactics in how to make an Elementor website load faster apply to the same underlying problem, fewer bytes over the wire, faster perceived response.
Common Pitfalls I See in Code Reviews
A short list, in rough order of how often they show up:
- Missing auth check inside the action. The most common and the most dangerous.
- Passing non-serializable props. Sending a function or a class instance from a client component to an action fails at runtime with a cryptic error.
- Marking a whole file
"use server"when only one function needs it. Everything exported becomes an endpoint. Be deliberate. - Doing heavy work in the action without a rate limit. An action is a public endpoint. Add rate limiting per user or per IP for anything expensive.
- Forgetting revalidation. The mutation succeeds, the UI does not change, and you spend an hour debugging the database.
- Long-running jobs. If the work takes more than a couple of seconds, queue it and return immediately. Users do not want to hold a request open for a video transcode.
When to Reach for a Client Component Instead
Server Actions are not always the answer. Reach for the client when:
- The interaction is highly dynamic, like a drag-and-drop board or a rich text editor.
- You need fine-grained control over the request lifecycle, retries, or streaming progress.
- The mutation is triggered by something other than a form submit, like a scroll or a timer.
Even then, the mutation itself can still live in a Server Action. The client component just calls it. That is usually the cleanest split: client handles interaction, server handles data.
Wrapping Up
Server Actions are one of the better ideas to land in the React ecosystem in years. They collapse the distance between a form and the data it changes, and they do it without abandoning the web platform. The catch is that convenience hides a public endpoint, so the discipline you apply to API routes still applies here: validate, authorize, scope, revalidate, and handle errors on purpose.
Build the small version first. Get a create form working, then add validation, then auth, then optimistic UI. Each layer is small on its own, and together they cover the whole nextjs data mutations story without a single hand-written fetch call.
If you are building something with Next.js and want a second pair of eyes on the architecture, or you need an automation developer to wire your forms into a real backend, that is most of what I do. I have shipped 300+ projects for clients across Bangladesh, the USA, the UK, and Australia, and I am happy to talk through your setup. Reach out through the site and tell me what you are building.
Frequently asked questions
Do Server Actions replace API routes entirely?
No. Use Server Actions for mutations triggered by your own UI. Keep Route Handlers for anything external: mobile apps, partner integrations, webhooks, or endpoints that need a stable, documented contract. The two coexist fine in the same project.
Are Server Actions secure by default?
They include built-in protections such as origin checks against cross-site requests, but they are not authenticated by default. Anyone who can reach your app can attempt to invoke an exported action, so you must check the session and resource ownership inside every action that mutates data. Treat the origin check as one layer, not a complete security story.
Can I use Server Actions with a non-Next.js backend?
Yes. The action is just a server function. It can call a REST API, a GraphQL endpoint, a queue, or a database directly. Next.js does not care what happens inside, only that the function is async and returns serializable data.
Why is my list not updating after a mutation?
Almost always a missing or incorrect revalidatePath or revalidateTag. Confirm the path matches the route exactly, then check whether the data is cached under a tag you forgot to invalidate. If you are using use cache, add explicit invalidation for the affected entries.