Server actions

Change data from a form without writing an endpoint, and keep the screen honest while it happens.

08of 16
≈ 13Min read
7Parts

A server action is a function you can call from a form

No route, no fetch, no JSON. The framework creates the endpoint, posts the form to it, runs the function on the server and re-renders what changed. And because it is a real <form> with a real action, it works before any JavaScript has loaded.

app/doors/actions.js
"use server";

export async function createDoor(formData) {
  const name = formData.get("name");
  await db.doors.insert({ name });

  revalidatePath("/doors");
}

// app/doors/page.tsx — a server component
import { createDoor } from "./actions";

<form action={createDoor}>
  <input name="name" />
  <button>Add</button>
</form>

The directive, in two places

a file, or a single function
"use server";                     // every export in this file is an action

// or inside a server component, for one action
export default async function Page() {
  async function remove(formData) {
    "use server";
    await db.doors.delete(formData.get("id"));
    revalidatePath("/doors");
  }

  return <form action={remove}>…</form>;
}
"use server" is not the opposite of "use client"

It does not mean "this is a server component" — everything already is. It means "this function may be called by the browser", which makes it a public endpoint. Every action must validate its input and check who is calling, exactly like a route you wrote by hand.

Reporting back to the user

  • pending is given to you — no useState for a loading flag.
  • Whatever the action returns becomes state, so validation errors are just a return value.
  • useFormStatus() gives a nested submit button the same pending, without threading a prop.
useActionState, the shape to learn
"use client";
import { useActionState } from "react";

export function AddDoor({ action }) {
  const [state, formAction, pending] = useActionState(action, { errors: {} });

  return (
    <form action={formAction}>
      <input name="name" aria-invalid={!!state.errors.name} />
      {state.errors.name && <small role="alert">{state.errors.name}</small>}

      <button disabled={pending}>{pending ? "Adding…" : "Add"}</button>
    </form>
  );
}

// and the action takes the previous state as its first argument
export async function createDoor(prevState, formData) { … return { errors }; }

Optimistic updates

The temporary item is discarded automatically when the action finishes and the server data arrives. That is the whole feature: you do not write the rollback.

show it before the server agrees
"use client";
const [shown, addOptimistic] = useOptimistic(doors, (state, door) => [...state, { ...door, pending: true }]);

async function onSubmit(formData) {
  addOptimistic({ id: "temp", name: formData.get("name") });
  await createDoor(formData);      // if it fails, React puts the real list back
}

After the write: say what changed

Forgetting this is the most common bug with actions: the write succeeds, the cache does not know, and the list looks unchanged until a hard refresh.

or the page shows the old data
revalidatePath("/doors");          // this route
revalidateTag("doors");            // every fetch tagged "doors"
redirect("/doors/" + door.id);     // after creating, go to it

Security, because it is an endpoint

A client cannot see the action’s code, but it can call it with anything. "It is only called from my form" is not a control.

what every action needs
"use server";

export async function deleteDoor(formData) {
  const session = await auth();                       // 1. who is this?
  if (!session) throw new Error("Not signed in");

  const id = String(formData.get("id"));
  const door = await db.doors.byId(id);
  if (door.ownerId !== session.userId) throw new Error("Not yours");   // 2. may they?

  const parsed = Schema.safeParse({ id });            // 3. is the input sane?
  if (!parsed.success) return { errors: … };

  await db.doors.delete(id);
  revalidateTag("doors");
}

Action or route handler?

Use an actionUse a route handler
A form in your own appA public API
A mutation followed by a re-renderSomething a mobile app or a webhook calls
You want progressive enhancementYou need the URL, the method and the headers
The result is UIThe result is data for someone else

Practice