webdev.complete
🖥️ Server Components & Actions
🚀Next.js & T3
Lesson 94 of 117
30 min

Server Actions

Form actions, useActionState, optimistic, revalidation.

For most of the JavaScript era, "mutate something on the server" meant: write an API route, define a fetch on the client, JSON-encode the body, hope the types match, and remember to revalidate. Server Actions collapse all of that into a single function with a directive. You write the function on the server. You pass it to a form. The browser calls it like a normal RPC. TypeScript follows you the whole way. Even better: it still works with JavaScript disabled.

The "use server" directive

Mark a function (or an entire file) with "use server" and it becomes invokable from the client. The directive is the contract: this code only ever runs on the server, and Next will set up the plumbing to let the browser call it.

app/posts/actions.ts
"use server";

import { db } from "@/server/db";
import { revalidatePath } from "next/cache";

export async function createPost(formData: FormData) {
  const title = formData.get("title")?.toString() ?? "";
  await db.post.create({ data: { title } });
  revalidatePath("/posts");
}

Now any component (server or client) can import createPost and either pass it as a form action or call it directly. The compiler replaces the import on the client with a thin reference that POSTs to the server.

Trust nothing on the server side
A Server Action endpoint is a public POST handler that anyone with the URL can invoke. Always validate the input, check the user session, and authorize the action. The fact that the call came through your nice typed form does not protect you.

Form actions: the progressive-enhancement path

The simplest way to use an action is as a form's action prop. This works even without JavaScript: the form submits a normal POST, Next runs the action, the page re-renders. With JS, the form submits without a full reload.

app/posts/new/page.tsx
import { createPost } from "../actions";

export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" required />
      <button type="submit">Create</button>
    </form>
  );
}

useActionState: capture results and errors

When you want feedback (validation errors, success message), use the useActionState hook on the client. The action receives the previous state plus the form data and returns a new state.

app/posts/actions.ts
"use server";

import { z } from "zod";
import { db } from "@/server/db";
import { revalidatePath } from "next/cache";

const Schema = z.object({
  title: z.string().min(3, "Title must be at least 3 characters").max(120),
  body: z.string().min(10),
});

type State =
  | { ok: false; errors: Record<string, string> }
  | { ok: true; id: string };

export async function createPost(_prev: State, formData: FormData): Promise<State> {
  const parsed = Schema.safeParse({
    title: formData.get("title"),
    body: formData.get("body"),
  });

  if (!parsed.success) {
    const errors: Record<string, string> = {};
    for (const issue of parsed.error.issues) {
      errors[issue.path.join(".")] = issue.message;
    }
    return { ok: false, errors };
  }

  const post = await db.post.create({ data: parsed.data });
  revalidatePath("/posts");
  return { ok: true, id: post.id };
}
app/posts/new/form.tsx
"use client";

import { useActionState } from "react";
import { createPost } from "../actions";

const initial = { ok: false, errors: {} } as const;

export function NewPostForm() {
  const [state, formAction, isPending] = useActionState(createPost, initial);

  return (
    <form action={formAction}>
      <label>
        Title
        <input name="title" />
      </label>
      {!state.ok && state.errors.title && <p>{state.errors.title}</p>}

      <label>
        Body
        <textarea name="body" />
      </label>
      {!state.ok && state.errors.body && <p>{state.errors.body}</p>}

      <button disabled={isPending}>{isPending ? "Posting..." : "Post"}</button>
    </form>
  );
}
Zod inside the action, not outside
Validate inside the Server Action, not in a client hook. The client validator is a UX bonus, but the server is the only place you can trust. Returning errors from the action keeps both sides in sync.

useFormStatus: know if the parent form is pending

Sometimes a child component (a submit button, an icon) needs to know "is the form I'm inside of currently submitting?" useFormStatus answers that without prop drilling.

app/posts/new/submit.tsx
"use client";

import { useFormStatus } from "react-dom";

export function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? "Saving..." : "Save"}
    </button>
  );
}

useOptimistic: instant UI for slow networks

When a mutation will probably succeed, you can paint the result immediately and let the action confirm in the background. useOptimistic gives you a temporary state that merges with the real list once the server responds.

app/todos/list.tsx
"use client";

import { useOptimistic } from "react";
import { addTodo } from "./actions";

type Todo = { id: string; text: string; pending?: boolean };

export function TodoList({ todos }: { todos: Todo[] }) {
  const [optimistic, addOptimistic] = useOptimistic(
    todos,
    (current, newText: string) => [
      ...current,
      { id: "temp-" + crypto.randomUUID(), text: newText, pending: true },
    ],
  );

  async function action(formData: FormData) {
    const text = formData.get("text")?.toString() ?? "";
    addOptimistic(text);          // paint instantly
    await addTodo(text);          // server confirms
  }

  return (
    <>
      <form action={action}>
        <input name="text" />
        <button>Add</button>
      </form>
      <ul>
        {optimistic.map((t) => (
          <li key={t.id} style={{ opacity: t.pending ? 0.5 : 1 }}>{t.text}</li>
        ))}
      </ul>
    </>
  );
}

revalidatePath and revalidateTag

After a mutation, you usually want some cached page or fragment to refresh. Two helpers:

  • revalidatePath("/posts")marks that route's cache stale; the next request rebuilds it.
  • revalidateTag("posts") invalidates every cached fetch you tagged with "posts" (more on this in the caching lesson).

Progressive enhancement, for real

Disable JavaScript in your browser, submit the form, and it still works. The browser falls back to a vanilla POST, Next runs the action, returns HTML, and the page reloads. With JS, you get the nicer no-reload experience plus all the hooks. Same code path, same handler. That's the win.

Where to put actions
Co-locate small actions in actions.ts next to the page that uses them. For shared actions, put them in app/_actions/ or src/server/actions/. They're just functions, so import paths work like normal.

Quiz

Quiz1 / 3

What does "use server" do at the top of a file?

Recap

  • "use server" marks a function as a Server Action that the browser can invoke as an RPC.
  • Forms get progressive enhancement via action={myAction}; with JS it's a no-reload submit, without JS it's a vanilla POST.
  • useActionState gives you state + pending + the form action; useFormStatustells children about the parent form's status; useOptimistic paints predictions.
  • Validate inside the action with Zod or similar; the server is the trust boundary.
  • Use revalidatePath / revalidateTag after a mutation to refresh cached data.
Built with Next.js, Tailwind & Sandpack.
Learn. Build. Ship.