Next.js course · Lecture 8 of 18

Server Actions, Forms & Mutations

Mutate data without writing API routes: Server Functions and the 'use server' directive, progressive-enhancement forms, calling actions from Client Components, Zod validation, useActionState, useFormStatus, revalidation and redirects after mutations, optimistic UI with useOptimistic, the next/form component, and Server Action security.

  • 30 min read
  • Intermediate
  • 14 topics
  • 4 Oct 2026
  • Ravindra Nath Jha

What Are Server Actions?

A Server Function is an async function that runs on the server but can be called from the client — from a form, a button click or any event handler. When used for mutations (create, update, delete), it's called a Server Action.

Under the hood, Next.js creates a hidden POST endpoint for each action. When the client calls it, Next.js sends the arguments over the network, runs the function on the server, and returns the result — and can return updated UI in the same round trip.

What this replaces:

  • Writing an API route (/api/todos) for every mutation.
  • Client-side fetch + JSON parsing + loading/error state boilerplate.
  • Manually re-fetching data after the change.

Mental model: a Server Action is "an API endpoint you call like a normal function".

Creating Server Functions with "use server"

Two ways to declare them:

  • Inline, inside a Server Component: put "use server" at the top of the async function body. Handy for one-off actions tied to a single page.
  • Module-level: put "use server" at the top of a file (e.g. app/actions.js). Every export becomes a Server Function. This is the standard approach, and the only way to use actions from Client Components.

Rules:

  • Server Functions must be async.
  • Arguments and return values must be serializable (plain data, FormData, Dates, etc.).
  • Don't confuse "use server" with server-only code: "use server" exposes a function to the client as a callable endpoint. Plain server code (no directive) is never callable from the browser.
app/actions.js
// app/actions.js — module-level: every export is a Server Function
"use server";

import { db } from "@/lib/db";

export async function createTodo(formData) {
  const title = formData.get("title");
  await db.todo.create({ data: { title } });
}

export async function deleteTodo(id) {
  await db.todo.delete({ where: { id } });
}

// app/page.js — inline Server Function in a Server Component
export default function Page() {
  async function subscribe(formData) {
    "use server";
    await db.subscriber.create({ data: { email: formData.get("email") } });
  }

  return (
    <form action={subscribe}>
      <input type="email" name="email" required />
      <button type="submit">Subscribe</button>
    </form>
  );
}

Forms with Server Actions

React extends the HTML <form> element so the action prop accepts a function. When submitted, the action receives a FormData object with every named field.

Big advantage — progressive enhancement: forms rendered by Server Components work before JavaScript loads (or if it fails entirely). The browser submits a normal POST; once JS hydrates, submissions happen without a page reload.

Reading FormData:

  • formData.get("name") — one value (string or File).
  • formData.getAll("tags") — all values for checkboxes/multi-select.
  • Object.fromEntries(formData) — quick object of all fields (watch out for multi-value fields).
  • Pass extra arguments with .bind: updateUser.bind(null, userId) — the bound value is sent along (encrypted) and appears as the first argument.
app/users/[id]/edit/page.js
// app/users/[id]/edit/page.js
import { updateUser } from "@/app/actions";

export default async function EditUser({ params }) {
  const { id } = await params;
  const updateUserWithId = updateUser.bind(null, id);   // pre-fill the id argument

  return (
    <form action={updateUserWithId}>
      <input name="name" placeholder="Full name" required />
      <select name="role">
        <option value="viewer">Viewer</option>
        <option value="editor">Editor</option>
      </select>
      <label><input type="checkbox" name="notify" /> Email me updates</label>
      <button type="submit">Save</button>
    </form>
  );
}

// app/actions.js
"use server";
export async function updateUser(userId, formData) {
  const name = formData.get("name");
  const role = formData.get("role");
  const notify = formData.get("notify") === "on";
  await db.user.update({ where: { id: userId }, data: { name, role, notify } });
}

Calling Actions from Client Components & Event Handlers

Client Components can't define Server Functions, but they can import them from a "use server" file and call them like any async function — from onClick, onChange, useEffect, anywhere.

Wrap calls that update UI in startTransition (from useTransition) so React can show pending state and keep the UI responsive.

app/components/DeleteButton.jsx
// app/components/DeleteButton.jsx
"use client";
import { useTransition } from "react";
import { deleteTodo } from "@/app/actions";

export default function DeleteButton({ id }) {
  const [isPending, startTransition] = useTransition();

  return (
    <button
      disabled={isPending}
      onClick={() => {
        if (!confirm("Delete this todo?")) return;
        startTransition(async () => {
          await deleteTodo(id);
        });
      }}
    >
      {isPending ? "Deleting…" : "Delete"}
    </button>
  );
}

Validating Input on the Server with Zod

Never trust input from the client. HTML required and type="email" are a UX nicety — anyone can bypass them by calling the action directly. Validate on the server every time.

Zod is the most popular schema library: describe the shape once, then safeParse the form data. On failure, return the field errors so the form can display them.

JavaScript
// npm install zod
// app/actions.js
"use server";
import { z } from "zod";

const SignupSchema = z.object({
  name: z.string().trim().min(2, "Name must be at least 2 characters"),
  email: z.string().trim().email("Please enter a valid email"),
  password: z.string().min(8, "Password must be at least 8 characters"),
});

export async function signup(prevState, formData) {
  const parsed = SignupSchema.safeParse({
    name: formData.get("name"),
    email: formData.get("email"),
    password: formData.get("password"),
  });

  if (!parsed.success) {
    return {
      errors: parsed.error.flatten().fieldErrors, // { email: ["Please enter a valid email"] }
      values: { name: formData.get("name"), email: formData.get("email") },
    };
  }

  const exists = await db.user.findUnique({ where: { email: parsed.data.email } });
  if (exists) return { errors: { email: ["This email is already registered"] } };

  await createUser(parsed.data);
  return { success: true };
}

Showing Errors & Pending State with useActionState

useActionState (from react) connects a form to an action that returns state — validation errors, success messages, returned data.

const [state, formAction, pending] = useActionState(action, initialState)

  • state — whatever the action last returned (starts as initialState).
  • formAction — pass this to <form action={...}>.
  • pending — true while the action runs.

Note the action signature changes: it now receives (prevState, formData) instead of just (formData).

This keeps "expected errors" as returned values — no try/catch, no error boundary — exactly as recommended in Lecture 4.

app/signup/SignupForm.jsx
// app/signup/SignupForm.jsx
"use client";
import { useActionState } from "react";
import { signup } from "@/app/actions";

const initialState = { errors: {}, values: {} };

export default function SignupForm() {
  const [state, formAction, pending] = useActionState(signup, initialState);

  if (state.success) return <p>🎉 Account created! Check your inbox.</p>;

  return (
    <form action={formAction} noValidate>
      <input name="name" defaultValue={state.values?.name} placeholder="Name" />
      {state.errors?.name && <p className="error">{state.errors.name[0]}</p>}

      <input name="email" type="email" defaultValue={state.values?.email} placeholder="Email" />
      {state.errors?.email && <p className="error">{state.errors.email[0]}</p>}

      <input name="password" type="password" placeholder="Password" />
      {state.errors?.password && <p className="error">{state.errors.password[0]}</p>}

      <button type="submit" disabled={pending}>
        {pending ? "Creating account…" : "Sign up"}
      </button>
    </form>
  );
}

Reusable Submit Buttons with useFormStatus

useFormStatus (from react-dom) gives any component inside a <form> access to that form's submission status — without passing props down.

It must be called from a component rendered inside the form (not the component that renders the <form> itself). This makes it perfect for a shared <SubmitButton> used across the whole app.

app/components/SubmitButton.jsx
// app/components/SubmitButton.jsx
"use client";
import { useFormStatus } from "react-dom";

export default function SubmitButton({ children, pendingText = "Saving…" }) {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending} aria-disabled={pending}>
      {pending ? pendingText : children}
    </button>
  );
}

// Usage in any form (even one rendered by a Server Component)
// <form action={createTodo}>
//   <input name="title" />
//   <SubmitButton pendingText="Adding…">Add todo</SubmitButton>
// </form>

Updating the UI After a Mutation

After changing data, the page must show the new state. Inside the Server Action, tell Next.js what changed:

  • updateTag(tag) — expire cached data with that tag and show the fresh data to this user immediately (Cache Components / tagged data).
  • revalidatePath("/todos") — invalidate everything cached for a path.
  • refresh() — re-render the current route's Server Components without touching cached data (for uncached UI).
  • redirect("/todos") — navigate somewhere else after success. Call it after revalidation and outside try/catch (it works by throwing).

Next.js returns the updated UI in the same response as the action result, so there's no extra request.

app/actions.js
// app/actions.js
"use server";
import { revalidatePath, updateTag } from "next/cache";
import { redirect } from "next/navigation";

export async function createPost(formData) {
  let post;
  try {
    post = await db.post.create({ data: { title: formData.get("title") } });
  } catch (err) {
    return { error: "Could not save the post. Please try again." };
  }

  updateTag("posts");          // cached post lists are fresh for this user
  revalidatePath("/admin");    // the admin overview too
  redirect("/blog/" + post.slug); // must be outside try/catch
}

Optimistic UI with useOptimistic

Even fast servers take 100–300ms. Optimistic updates show the expected result immediately and reconcile when the server responds — the app feels instant.

useOptimistic(state, updateFn) (from react) returns a temporary state that's only shown while an action is pending. When the action finishes and real data arrives, React switches back to the real state automatically — so if the server fails, the optimistic item simply disappears.

app/todos/TodoList.jsx
// app/todos/TodoList.jsx
"use client";
import { useOptimistic, useRef } from "react";
import { createTodo } from "@/app/actions";

export default function TodoList({ todos }) {
  const formRef = useRef(null);
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    todos,
    (current, newTitle) => [...current, { id: "temp-" + Date.now(), title: newTitle, pending: true }]
  );

  async function action(formData) {
    addOptimisticTodo(formData.get("title"));  // show instantly
    formRef.current?.reset();
    await createTodo(formData);                // server action (revalidates the list)
  }

  return (
    <>
      <ul>
        {optimisticTodos.map((t) => (
          <li key={t.id} style={{ opacity: t.pending ? 0.5 : 1 }}>{t.title}</li>
        ))}
      </ul>
      <form ref={formRef} action={action}>
        <input name="title" required />
        <button type="submit">Add</button>
      </form>
    </>
  );
}

next/form: Search Forms That Navigate

Not every form mutates data. Search and filter forms should navigate to a URL with query parameters (/search?q=react) so results are shareable and bookmarkable.

The <Form> component from next/form does this with client-side navigation: when action is a string, submitting appends the inputs as search params and navigates there — prefetching the target's loading UI and keeping shared layouts mounted, while still working without JavaScript.

When action is a function, <Form> behaves like a normal React form with a Server Action.

app/components/SearchBar.jsx
// app/components/SearchBar.jsx — works in Server Components too
import Form from "next/form";

export default function SearchBar() {
  return (
    <Form action="/search">
      <input name="q" placeholder="Search notes…" />
      <button type="submit">Search</button>
    </Form>
  );
}

// app/search/page.js — reads ?q=
export default async function SearchPage({ searchParams }) {
  const { q = "" } = await searchParams;
  const results = await searchNotes(q);
  return (
    <>
      <h1>Results for "{q}"</h1>
      <ul>{results.map((r) => <li key={r.id}>{r.title}</li>)}</ul>
    </>
  );
}

Working with Cookies in Server Actions

Server Actions can read, set and delete cookies with cookies() from next/headers (awaited). Setting or deleting a cookie in an action also re-renders the current page on the server so the UI reflects the new value — useful for theme switches, language selection, dismissing banners and login/logout.

app/actions.js
// app/actions.js
"use server";
import { cookies } from "next/headers";

export async function setTheme(theme) {
  const cookieStore = await cookies();
  cookieStore.set("theme", theme, {
    httpOnly: false,
    sameSite: "lax",
    maxAge: 60 * 60 * 24 * 365, // 1 year
    path: "/",
  });
}

export async function dismissBanner() {
  (await cookies()).set("banner-dismissed", "1", { maxAge: 60 * 60 * 24 * 30 });
}

Server Action Security: Treat Every Action as a Public API

Because each action is reachable by a direct POST request, apply the same rules as for any public endpoint:

  • Authenticate and authorize inside every action. Don't rely on the page being protected or on proxy.js — a refactor could change which routes it covers. Check the session and the user's permission for this specific record.
  • Validate all input (section 5) — types, lengths, formats, ownership of IDs.
  • Return only what's needed — never return full database records with sensitive fields.
  • Rate-limit expensive or abuse-prone actions (signups, emails, payments).

Built-in protections:

  • Action IDs are encrypted and non-deterministic, and unused actions are removed from the client bundle.
  • Only POST can invoke actions, and Next.js compares the Origin and Host headers to block cross-site requests (CSRF). Behind a reverse proxy, list trusted domains in serverActions.allowedOrigins.
  • Values captured in closures or .bind are encrypted before going to the client — but don't rely on encryption alone for secrets.
  • Request bodies are limited to 1 MB by default — raise serverActions.bodySizeLimit for file uploads.
app/actions.js
// app/actions.js
"use server";
import { z } from "zod";
import { verifySession } from "@/lib/dal";       // see Lecture 12

const Id = z.string().uuid();

export async function deletePost(postId) {
  const session = await verifySession();          // 1. authenticated?
  const id = Id.parse(postId);                    // 2. valid input?

  const post = await db.post.findUnique({ where: { id }, select: { authorId: true } });
  if (!post || post.authorId !== session.userId) { // 3. allowed for THIS record?
    throw new Error("Not authorized");
  }

  await db.post.delete({ where: { id } });
  return { ok: true };                            // 4. return minimal data
}

// next.config.mjs
// serverActions: { bodySizeLimit: "5mb", allowedOrigins: ["app.example.com"] }

Practical: A Complete Todo App (CRUD)

A full create / toggle / delete todo list with Server Actions, validation, a reusable submit button and automatic UI updates through revalidatePath. The list itself is a Server Component that queries the database; only the small interactive controls are Client Components.

app/todos/actions.js
// app/todos/actions.js
"use server";
import { z } from "zod";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";

const Title = z.string().trim().min(1, "Title is required").max(120);

export async function addTodo(prevState, formData) {
  const parsed = Title.safeParse(formData.get("title"));
  if (!parsed.success) return { error: parsed.error.issues[0].message };
  await db.todo.create({ data: { title: parsed.data } });
  revalidatePath("/todos");
  return { error: null };
}

export async function toggleTodo(id, done) {
  await db.todo.update({ where: { id }, data: { done } });
  revalidatePath("/todos");
}

export async function removeTodo(id) {
  await db.todo.delete({ where: { id } });
  revalidatePath("/todos");
}

// app/todos/AddTodoForm.jsx
"use client";
import { useActionState } from "react";
import { addTodo } from "./actions";
import SubmitButton from "@/app/components/SubmitButton";

export default function AddTodoForm() {
  const [state, formAction] = useActionState(addTodo, { error: null });
  return (
    <form action={formAction}>
      <input name="title" placeholder="What needs doing?" />
      <SubmitButton pendingText="Adding…">Add</SubmitButton>
      {state.error && <p className="error">{state.error}</p>}
    </form>
  );
}

// app/todos/page.js — Server Component
import { db } from "@/lib/db";
import AddTodoForm from "./AddTodoForm";
import { toggleTodo, removeTodo } from "./actions";

export default async function TodosPage() {
  const todos = await db.todo.findMany({ orderBy: { createdAt: "asc" } });
  return (
    <main>
      <h1>Todos ({todos.filter((t) => !t.done).length} left)</h1>
      <AddTodoForm />
      <ul>
        {todos.map((t) => (
          <li key={t.id}>
            <form action={toggleTodo.bind(null, t.id, !t.done)} style={{ display: "inline" }}>
              <button type="submit">{t.done ? "☑" : "☐"}</button>
            </form>
            <span style={{ textDecoration: t.done ? "line-through" : "none" }}>{t.title}</span>
            <form action={removeTodo.bind(null, t.id)} style={{ display: "inline" }}>
              <button type="submit" aria-label={"Delete " + t.title}>✕</button>
            </form>
          </li>
        ))}
      </ul>
    </main>
  );
}

Summary

  • Server Actions are async functions marked "use server" that run on the server and are callable from forms and Client Components.
  • Use module-level "use server" files for actions shared with Client Components; inline actions work inside Server Components.
  • <form action={fn}> passes FormData and works without JavaScript (progressive enhancement); .bind adds extra arguments.
  • Validate every input on the server (Zod) and return expected errors as values.
  • useActionState gives state, formAction and pending; useFormStatus powers reusable submit buttons; useOptimistic makes updates feel instant.
  • After mutating: updateTag, revalidatePath, refresh and redirect (outside try/catch).
  • next/form handles search forms that navigate with query params.
  • Treat every action as a public endpoint: authenticate, authorize per record, validate, rate-limit and return minimal data.

Next lecture: Route Handlers and Proxy — building APIs, webhooks and request-level logic.