What Are Server Actions?
The idea in one sentence
Section titled “The idea in one sentence”A Server Action is an async function marked with the "use server" directive that runs only on the server, and you can call it straight from a <form> or from a Client Component without writing an API route.
Every action is a public POST endpoint
Section titled “Every action is a public POST endpoint”Before any syntax: a Server Action is not a private RPC channel between your own UI and your own server. At build time, the "use server" directive tells the compiler to replace the function’s implementation in the client bundle with a reference — an encrypted action ID plus a dispatcher — that POSTs back to the server. The implementation stays on the server, but the route it creates is reachable by anyone who can send that same POST request, whether or not they ever loaded your form.
This is not a corner case to defend against later — it is the starting assumption for every action you write, starting with the very first one in this module. So every runnable example from here on follows one rule: authorize the caller, then validate the input, before touching the database. Authorization means calling a session check — this course uses the verifySession() Data Access Layer pattern from the authentication patterns lesson — and validation means parsing the untrusted FormData with a schema library such as zod.
import 'server-only'import { cache } from 'react'import { redirect } from 'next/navigation'import { getSession } from '@/app/lib/session'
export const verifySession = cache(async () => { const session = await getSession() if (!session?.userId) { redirect('/login') } return { isAuth: true, userId: session.userId }})verifySession() calls redirect() when there is no session — and redirect() throws. That means verifySession() must never be called inside a try/catch block that does not deliberately let Next.js’s own control-flow errors escape; the mutations and revalidation lesson covers exactly why. For now, treat verifySession() as a call you make at the top of an action, unguarded.
graph LR
A["Your own form"] -->|"POST with action ID"| C["The action's endpoint"]
B["Anyone with curl or a script"] -->|"POST with the same action ID"| C
C --> D{"verifySession() + schema.safeParse() inside the action"}
D -->|"fails"| E["Rejected — no data touched"]
D -->|"passes"| F["Mutate the database"]
The "use server" directive
Section titled “The "use server" directive”There are two ways to mark a function as a Server Action. You can add "use server" at the top of an inline async function inside a Server Component, or you can add 'use server' at the top of a whole module file so every export in that file becomes an action. This toy example keeps the body minimal to show the mechanism — the shared actions module below shows the same action with the authorization and validation rule applied.
export const db = { todo: { create: async (_args: { data: { title: string; userId?: string } }) => {}, delete: async (_args: { where: { id: string; userId?: string } }) => {}, },}import { db } from '@/lib/db'
export default function Page() { async function createTodo(formData: FormData) { 'use server' const title = String(formData.get('title')) await db.todo.create({ data: { title } }) }
return ( <form action={createTodo}> <input name="title" /> <button type="submit">Add</button> </form> )}An inline "use server" function can only be declared inside a Server Component. Trying the same thing inside a Client Component is a build error, not a runtime surprise — see Reading AI-generated code below for the exact message.
A shared actions module
Section titled “A shared actions module”When several components need the same action, put it in a dedicated file. Add 'use server' once at the top and every export becomes a callable action. This keeps mutation logic in one place and lets Client Components import it — and it is where the authorize-then-validate rule from the section above actually lands.
'use server'
import { verifySession } from '@/app/lib/dal'import { db } from '@/lib/db'import { z } from 'zod'
const createTodoSchema = z.object({ title: z.string().min(1) })
export async function createTodo(formData: FormData) { const { userId } = await verifySession() const parsed = createTodoSchema.safeParse({ title: formData.get('title') }) if (!parsed.success) return
await db.todo.create({ data: { title: parsed.data.title, userId } })}
export async function deleteTodo(id: string) { const { userId } = await verifySession() await db.todo.delete({ where: { id, userId } })}Notice deleteTodo scopes the delete to userId as well as id — authorization is not just “is someone logged in,” it is “does this specific user own this specific row.” A valid session alone does not prove ownership.
Calling from a form and from a Client Component
Section titled “Calling from a form and from a Client Component”A Server Action can be wired directly to a form with <form action={createTodo}>, and Next.js posts the form data to the action on the server. It can also be imported into a Client Component and called like a normal function — Next.js turns that call into a network request for you.
'use client'
import { deleteTodo } from './actions'
export function DeleteButton({ id }: { id: string }) { return <button onClick={() => deleteTodo(id)}>Delete</button>}How it differs from a Route Handler
Section titled “How it differs from a Route Handler”A Route Handler in route.ts is a public URL you design, wire up, and fetch by hand. A Server Action is a function you call directly; Next.js generates the endpoint and the network call for you and supports progressive enhancement through forms. Both are equally public once deployed — the difference is who designs the wire format, not who can reach it.
Mental Model
Section titled “Mental Model”Think of a Server Action as a button that happens to compile into an HTTP endpoint, not an HTTP endpoint that happens to have a button. The function signature you write is the same function signature a stranger’s curl command calls. If you would not accept the input from an anonymous POST body, do not accept it from formData.get(...) either — it is the same untrusted bag of bytes either way.
Trade-offs
Section titled “Trade-offs”| Server Action | Route Handler | |
|---|---|---|
| You write | A function, called directly from a form or a client call | A URL, wired up and fetch-ed by hand |
| Endpoint | Generated by Next.js from the action ID | Whatever path you put the route.ts file at |
| Progressive enhancement | Built in — works via a real <form> before hydration | None — requires JavaScript to fetch |
| Best for | Mutations and form flows inside your own app | A real public API, a webhook, or a non-form client |
| Public reachability | Yes, via its action ID | Yes, via its path |
Common Misconceptions
Section titled “Common Misconceptions”- “Only my form calls this action, so it’s safe.” The action ID is reachable by direct POST from anywhere; the form is one caller among any number of possible callers.
- “Rendering the form only for logged-in users is enough.” Render-time gating controls what UI shows up, not what requests the server will accept — the action itself still needs its own check.
- “An action I never export from my app can’t be reached.” Every exported action from a
'use server'file becomes a candidate endpoint the moment it is used anywhere in the app; only an action that is truly unused anywhere gets removed by dead-code elimination (see the security and patterns lesson for what that build step actually proves).
Reading AI-generated code
Section titled “Reading AI-generated code”When a generated snippet defines a Server Action, check for these before trusting it:
- Does the action call
verifySession()(or an equivalent auth check) before it reads or writes anything, or does it assume the caller is already authenticated because a form only renders behind a login? - Is every field from
formDataparsed through a schema, or is a raw string handed straight to the database? - If the action deletes or updates a row, does the query filter by the current user’s ID as well as the row’s ID — or could any authenticated user act on any other user’s data?
- Is the
"use server"directive on an inline function inside a Server Component, or has it been placed inside a Client Component by mistake? - Does the action return only the fields the UI needs, or does it hand back a raw database record?
🐛 Spot the bug
'use client'
export default function Page() {async function save(formData: FormData) { 'use server' console.log(formData)}return ( <form action={save}> <button type="submit">Save</button> </form>)}Show the answer
An inline "use server" function can only be declared inside a Server Component. This file starts with 'use client', so save is defined inside a Client Component — Next.js 16.3.6 rejects the build. The real compiler output (Turbopack) is:
Error: Turbopack build failed with 1 error:./app/bug-inline-in-client/page.tsx:1:1Error: The "use client" directive must be placed before other expressions. Move it to the top of the file to resolve this issue.> 1 | 'use client' | ^^^^^^^^^^^^ 2 | 3 | export default function Page() { 4 | async function save(formData: FormData) {The message points at the 'use client' line even though that line is already first — the inline 'use server' function nested inside the Client Component is what actually confuses the compiler’s directive check. The fix is to move save into a separate 'use server' file and import it, exactly like the shared actions module above.