Home/Learn/Next.js/Server Actions

Server Actions

Advanced
Mutations

A 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.

use server, form action, or called directly
// 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.

Authenticate, authorise, validate — every time
// 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.

revalidate, redirect, and return errors rather than throw
'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 Aria
1

What does a server action compile to, and why does that matter for security?

Hard
2

Why should validation errors be returned rather than thrown?

Medium
3

Why is a server action the wrong tool for fetching data?

Medium

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.

Loading discussion…