What Are Server Actions?

Before Server Actions, submitting a form in Next.js meant writing an API route, calling it with fetch, handling loading state, then refreshing data. Server Actions let you write an async function that runs on the server and call it directly from a form or a button.

async function createTodo(formData: FormData) {
  "use server";
  await db.todo.create({ data: { title: String(formData.get("title")) } });
}

…

No API route, no fetch, and the form even works before JavaScript loads. This tutorial builds a real form step by step, including validation, errors, pending states and security.


🎯 Quick Answer (30-Second Read)

  • Define an async function with "use server" (inline or in a file with "use server" at the top).
  • Use it as a form's action, or call it from a Client Component event handler.
  • Show errors and pending state with React's useActionState.
  • Refresh data after a mutation with revalidatePath() or revalidateTag().
  • Treat every action as a public endpoint: validate input and check authentication and authorisation inside it.

How It Works

sequenceDiagram participant B as Browser (form) participant N as Next.js server participant DB as Database B->>N: POST (action ID + FormData) N->>N: Run createTodo() on the server N->>DB: INSERT todo N->>N: revalidatePath("/todos") N-->>B: Updated UI (RSC payload)

Next.js gives each Server Action an ID. The form posts to it, Next.js runs your function on the server, and returns the updated page in the same round trip.


Step 1: A Basic Form

// app/todos/actions.ts
"use server";

import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";

export async function createTodo(formData: FormData) {
  const title = String(formData.get("title") ?? "").trim();
  if (!title) return;
  await db.todo.create({ data: { title } });
  revalidatePath("/todos");
}
// app/todos/page.tsx (Server Component)
import { createTodo } from "./actions";
import { db } from "@/lib/db";

export default async function TodosPage() {
  const todos = await db.todo.findMany();
  return (
    <>
      
    {todos.map((t) =>
  • {t.title}
  • )}
); }

That's a complete working feature.


Step 2: Validation and Error Messages

// app/todos/actions.ts
"use server";

import { z } from "zod";
import { revalidatePath } from "next/cache";

const Todo = z.object({ title: z.string().trim().min(3, "At least 3 characters") });

export type State = { error?: string; success?: boolean };

export async function createTodo(prev: State, formData: FormData): Promise {
  const parsed = Todo.safeParse({ title: formData.get("title") });
  if (!parsed.success) return { error: parsed.error.issues[0].message };

  await db.todo.create({ data: parsed.data });
  revalidatePath("/todos");
  return { success: true };
}
// app/todos/TodoForm.tsx
"use client";

import { useActionState } from "react";
import { createTodo, type State } from "./actions";

export function TodoForm() {
  const [state, formAction, pending] = useActionState(createTodo, {});
  return (
    
{state.error &&

{state.error}

}
); }

useActionState gives you the action's last returned state and a pending flag, so you don't manage loading state yourself.


Step 3: Security (Don't Skip This)

Server Actions are public HTTP endpoints. Anyone can call them with any input, even if the button is hidden in your UI. Middleware or proxy checks on the page don't protect the action. Always check who's calling, and whether they're allowed, inside the action.
export async function deleteTodo(id: string) {
  "use server";
  const user = await getCurrentUser();          // 1. authenticated?
  if (!user) throw new Error("Unauthorized");

  const todo = await db.todo.findUnique({ where: { id } });
  if (todo?.ownerId !== user.id) {               // 2. authorised for THIS record?
    throw new Error("Forbidden");
  }
  await db.todo.delete({ where: { id } });
  revalidatePath("/todos");
}

Checklist:

  • Validate all input (Zod or similar)
  • Check authentication in every action
  • Check the user owns or may change the specific record
  • Never return secrets or entire database rows to the client
  • Rate-limit sensitive actions (login, signup, sending emails)

Step 4: Calling Actions Outside Forms

"use client";
import { useTransition } from "react";
import { toggleTodo } from "./actions";

export function Toggle({ id }: { id: string }) {
  const [pending, startTransition] = useTransition();
  return (
    
  );
}

Server Actions vs Route Handlers

Use Server Actions for Use Route Handlers (app/api/.../route.ts) for
Form submissions and mutations from your own UI Public APIs for mobile apps or third parties
Simple create/update/delete Webhooks (Stripe, GitHub)
Progressive enhancement (works without JS) Custom HTTP methods, headers, streaming responses

Frequently Asked Questions

Are Next.js Server Actions secure?

They run on the server and Next.js adds protections such as unguessable action IDs and origin checks, but each action is still a callable endpoint. You must validate input and check authentication and authorisation inside every action, because hidden UI isn't a security boundary.

Should I use Server Actions or API routes?

Use Server Actions for mutations triggered by your own Next.js UI, such as forms. Use Route Handlers for endpoints consumed by other clients, webhooks, or when you need full control over HTTP responses.

How do I show a loading state with Server Actions?

Use React's useActionState hook, which returns the latest state, a wrapped action and a pending boolean. For actions called from buttons, useTransition provides a pending flag. useFormStatus can read pending state inside a form's child components.


Conclusion

Server Actions remove the boilerplate between a form and your database: write a server function, pass it to a form, revalidate the page. Add Zod validation and useActionState for good UX, and treat every action as a public endpoint with its own auth checks.


Sources

Last verified: 2 November 2026.


Related reads: Fix Next.js Hydration Error · Prisma vs Drizzle · How to Build a SaaS With Next.js and Supabase · Fix CORS Error

I write practical guides for developers. Follow on LinkedIn or X, or invite me to speak.