Server Actions
AdvancedA function that runs on the server, called from the client as if it were local. It removes the API route from the middle of every write — and it is a public endpoint, whatever it looks like.
Overview
A server action is the write half of what server components did for reads. Mark a function `use server`, call it from a form or a click handler, and Next takes care of serialising the call, sending it and re-rendering. The API route you would have written disappears, and so does the fetch, the JSON encoding and the error plumbing. The critical thing to internalise is that this convenience does not change the security model: an action compiles to an HTTP endpoint that anyone can call with any arguments, so it needs the same authentication and validation an API route would.
Defining and Calling
Two placements for the directive, and the two ways to invoke.
// A whole file of actions — the usual shape
// features/problems/actions.ts
'use server'
export async function createProblem(formData: FormData) {
const title = formData.get('title') as string
await db.problem.create({ data: { title } })
revalidatePath('/problems')
}
// Or inline in a server component
export default function Page() {
async function del(formData: FormData) {
'use server'
await db.problem.delete({ where: { id: formData.get('id') } })
revalidatePath('/problems')
}
return <form action={del}><input type="hidden" name="id" value={id} /></form>
}
// From a form — works with JavaScript disabled
<form action={createProblem}>
<input name="title" required />
<button>Create</button>
</form>
// From a client component
'use client'
import { createProblem } from '@/features/problems/actions'
<button onClick={() => startTransition(() => createProblem(fd))}>
// Actions must be async, and their arguments and return value must
// be serializable — the same rule as props crossing the boundary.It Is a Public Endpoint
The security model, which the ergonomics disguise.
// Next compiles each action into a POST endpoint with a generated
// ID. Anyone can call it with any arguments — the form is not a gate.
// So EVERY action needs the same three checks an API route needs:
'use server'
export async function deleteProblem(id: string) {
// 1. authenticate
const user = await getCurrentUser()
if (!user) throw new Error('Unauthorized')
// 2. authorise — including ownership
const problem = await db.problem.findUnique({ where: { id } })
if (!problem || problem.authorId !== user.id) throw new Error('Forbidden')
// 3. validate the input — it is user-controlled
const parsed = z.string().uuid().safeParse(id)
if (!parsed.success) throw new Error('Invalid id')
await db.problem.delete({ where: { id } })
revalidatePath('/problems')
}
// The failure mode to avoid: checking permission in the component
// that renders the button, and not in the action itself. Hiding the
// button hides nothing.
// Actions are POST-only and Next includes an Origin check, so basic
// CSRF is covered — but that is not a substitute for authorisation.
// Never trust a hidden input for identity:
<input type="hidden" name="userId" value={user.id} /> // forgeable
// Read the user from the session inside the action instead.After the Write
Revalidating, redirecting, and returning something the UI can use.
'use server'
export async function publish(id: string) {
const problem = await db.problem.update({ where: { id }, data: { published: true } })
revalidatePath('/problems') // the list
revalidatePath(`/problems/${problem.slug}`) // the detail
revalidateTag('problems') // anything tagged
redirect(`/problems/${problem.slug}`) // throws — must be
} // outside try/catch
// Returning a result instead of throwing, so the form can show it:
export async function createProblem(prev, formData) {
const parsed = Schema.safeParse(Object.fromEntries(formData))
if (!parsed.success) {
return { ok: false, errors: parsed.error.flatten().fieldErrors }
}
await db.problem.create({ data: parsed.data })
revalidatePath('/problems')
return { ok: true }
}
// A thrown error reaches error.tsx and loses the form; a returned
// error keeps the user's input on screen. For validation, return.
// Actions run SEQUENTIALLY — one at a time per client. Two rapid
// submissions queue rather than race, which is usually what you want
// and is worth knowing when something feels slow.
// Do not use an action for reads. It is a POST that cannot be cached
// or prefetched; a server component is the right tool for fetching.Key Points to Remember
- 1A server action compiles to a public POST endpoint — the form is not a gate, so authenticate and authorise inside the action
- 2Never trust identity from a hidden input; read the user from the session inside the action
- 3Arguments and return values must be serializable, and actions must be async
- 4redirect() and notFound() throw, so they must be called outside a try/catch
- 5Return validation errors rather than throwing, so the user keeps their input; never use an action for reads
Interview Questions
Sign in to ask AriaWhat does a server action compile to, and why does that matter for security?
Why should validation errors be returned rather than thrown?
Why is a server action the wrong tool for fetching data?
Ask Aria about Server Actions
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.