CodeOath
← All posts
Next.js31 min total · 12 parts

Next.js Fundamentals: The App Router, Server Components, and the Caching Layer Nobody Warns You About

Part 7 of 12 · ~3 min

Server Actions

A Server Action is an async function marked with "use server" that can be called directly from a Client Component — or wired straight into a <form> — without you ever hand-writing a matching API route or a client-side fetch call to reach it:

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

export async function createComment(formData: FormData) {
  const text = formData.get("text") as string;
  await db.comment.create({ data: { text } });
  revalidatePath("/comments");
}
// app/comments/CommentForm.tsx
import { createComment } from "../actions";

export default function CommentForm() {
  return (
    <form action={createComment}>
      <input name="text" />
      <button type="submit">Post</button>
    </form>
  );
}

What this actually replaces is the boilerplate loop nearly every app used to hand-write for a simple mutation: build a Route Handler that parses the request body, call it from the client with fetch, manage loading and error state around that fetch yourself, and keep the function signature on both ends in sync by hand. A Server Action collapses all of that into one function you call — or, as above, hand straight to a form's action prop — with Next.js generating and wiring up the actual network call for you.

Server Action vs Route Handler

They can look almost interchangeable for a simple mutation, but they solve genuinely different problems:

Server ActionRoute Handler
Called fromDirectly, like a function — from a form or from client codeExplicitly, via fetch("/api/...")
ConsumersOnly your own app's componentsAnything that can make an HTTP request — a mobile app, a webhook, a third party
ShapeAn async functionA request/response handler, with full control over status codes, headers, content type
Works without JavaScriptYes, via a plain <form action={...}>No — the client has to actually run fetch

Progressive enhancement

Because a Server Action can be bound directly to a <form>'s action prop, that form keeps working even if the browser's JavaScript hasn't loaded yet, failed to load, or is disabled entirely — the browser falls back to submitting the form the way it always has, as a real HTTP request, and the Server Action still runs. Layer a Client Component around it with useFormStatus or useOptimistic and you get instant pending states and optimistic UI as a progressive enhancement on top of a form that was already functionally correct without any of that JavaScript — a mutation path that degrades gracefully instead of a blank, dead form the moment a script fails to load.

The security implication people miss

A Server Action compiles down to a real HTTP endpoint with its own URL, whether you ever call it that way or not. It looks like a plain function call from inside your component — createComment(formData) — but under the hood Next.js generates an actual network endpoint for it, and anyone who can see your client bundle can find that endpoint and send it a request directly, completely bypassing your form, your component, and any client-side checks you wrote around it.

"use server";

// WRONG — assumes the caller can only be your own authenticated UI,
// because that's the only place you *intended* it to be called from
export async function deletePost(id: string) {
  await db.post.delete({ where: { id } });
}

// RIGHT — re-check authorization inside the action itself, every time,
// because the action is a public endpoint regardless of where you meant to expose it
export async function deletePost(id: string) {
  const session = await getSession();
  if (!session?.user || !(await canDeletePost(session.user.id, id))) {
    throw new Error("Unauthorized");
  }
  await db.post.delete({ where: { id } });
}

Treat every Server Action exactly like you'd treat a public API route: validate its inputs, and re-check authentication and authorization inside the function body itself. A disabled delete button in your UI, or a form that only renders for admins, stops nothing on its own — those are UI conveniences, not a security boundary, and the actual security boundary has to live inside the action.