How it works
The explanation, with examples you can run.
A safe action
// app/posts/actions.ts"use server"import { z } from "zod"import { updateTag } from "next/cache"import { redirect } from "next/navigation"import { getSession } from "@/lib/auth" const Input = z.object({ title: z.string().min(1).max(200), body: z.string().max(20_000) }) export async function createPost(formData: FormData) { const session = await getSession() // 1. who is calling? if (!session) throw new Error("Unauthorized") const parsed = Input.safeParse(Object.fromEntries(formData)) // 2. never trust input if (!parsed.success) return { error: "Invalid post" } if (!session.user.canWrite) throw new Error("Forbidden") // 3. may they do this? const post = await db.post.create({ data: { ...parsed.data, authorId: session.user.id } }) updateTag("posts") // 4. invalidate what changed redirect(`/posts/${post.id}`)}// app/posts/new/page.tsximport { createPost } from "../actions" export default function NewPost() { return ( <form action={createPost}> <input name="title" /> <textarea name="body" /> <button>Publish</button> </form> )}The form works before JavaScript has loaded (it is a normal form post); once hydrated, React submits it without a page reload. useActionState gives you a pending flag and the action's return value for errors.
What Next.js protects, and what it doesn't
Next.js adds some defences: action IDs are encrypted, non-deterministic and change between builds; actions that no client code references are removed from the bundle; values an inline action closes over are encrypted before they travel to the browser and back; and the Origin header must match the host, which blocks cross-site form posts. None of that is authorisation. Every action that the UI can call has a valid ID in the page's JavaScript, and anyone can replay it with different arguments. Treat each action like an API route: authenticate, validate the arguments, check that this user may touch this record. A check in Proxy is a useful first filter, but a matcher change can silently drop it.
Two more properties shape how you use actions. The client sends them one at a time, so they are a poor fit for loading data; fetch in Server Components instead. And since an action is a public POST, a retry or a double click can call it twice, so make important writes idempotent.
Check yourself
3 questions. Get them all right to finish this topic.
0 of 3 right so far. Get all three to finish this topic.