Skip to main content

Sep 2026 · Guide · ~10 min read

React Server Actions — What They Are and How I Use Them

By Safdar Ali — frontend engineer, Bengaluru

I'm Safdar Ali, a frontend engineer in Bengaluru. When React Server Actions landed in Next.js App Router, I was sceptical — another abstraction on top of API routes. Six months and a dozen form-heavy features later, they're my default for mutations. Not because they're magic, but because they remove an entire layer of boilerplate: no separate POST handler, no fetch wrapper, no loading state wiring for simple CRUD. This guide covers what react server actions actually are, how I use them in production on safdarali.in and client projects, and when I still reach for a traditional API route.

What React Server Actions actually are

A Server Action is an async function marked with "use server" that runs exclusively on the server. You call it from a form or a client component, and Next.js serialises the arguments, executes the function on the server, and returns the result. No REST endpoint. No fetch("/api/...").

Under the hood, Next.js generates a POST endpoint for each action. You don't write it — the framework wires the form submission to your function. That's the tradeoff: less code, but you need to understand server/client boundaries. If you haven't read my RSC vs client components guide, start there. Server Actions only make sense once you know what runs where.

Server Actions arrived as a stable React 19 feature and became the default mutation pattern in Next.js App Router. Before them, every form submission meant creating an API route, writing a fetch call on the client, managing loading and error states manually, and hoping your CSRF setup was correct. The old pattern worked — I used it for years — but it spread one feature across four files when one server function could handle it.

The mental model is simple: if the operation changes server-side state (database write, file upload, sending email), it belongs in a Server Action. If it only reads data for display, keep it in a Server Component with async/await. Mixing reads into actions creates confusing call sites and makes caching harder to reason about.

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

import { revalidatePath } from "next/cache";

export async function submitContact(formData: FormData) {
  const name = formData.get("name") as string;
  const email = formData.get("email") as string;

  // Runs on server — DB credentials never touch the browser
  await db.contact.create({ data: { name, email } });

  revalidatePath("/contact");
  return { success: true };
}

That function is callable from any form in your app. The browser sends FormData; your server function receives it directly. Compare that to the old pattern: create an API route, write a fetch call, handle JSON parsing, manage error states in three files.

Form mutations — the pattern I use daily

The simplest Server Action use case is a form that creates or updates data. Wire the action to the form's action prop and Next.js handles progressive enhancement — the form works even before JavaScript loads.

// app/contact/page.tsx — Server Component
import { submitContact } from "@/app/actions/contact";

export default function ContactPage() {
  return (
    <form action={submitContact} className="space-y-4">
      <input name="name" required placeholder="Your name" />
      <input name="email" type="email" required placeholder="Email" />
      <button type="submit">Send message</button>
    </form>
  );
}

On a client marketing site I shipped last quarter, replacing three API routes with Server Actions cut the form code from 180 lines to 60. The client bundle dropped because there was no client-side fetch logic — just HTML form submission with server-side handling.

For forms that need instant feedback — inline validation, disabled submit buttons, optimistic UI — wrap the form in a client component and use useActionState (React 19) or useFormState. The action still runs on the server; only the pending/error UI lives on the client.

Progressive enhancement is the underrated benefit. A plain HTML form with action={serverAction} submits even when JavaScript fails to load — critical for users on slow 4G connections across India. You add client enhancements on top, not instead of, a working server submission. I test this by disabling JavaScript in Chrome DevTools and confirming the form still submits and the page still updates after revalidation.

File uploads work naturally because Server Actions receive FormData — including File objects from input type="file". No multipart parsing libraries, no separate upload endpoint unless you need chunked uploads for very large files. On a client project last month, replacing a custom upload API route with a Server Action removed 90 lines of client-side FormData construction code.

Validation — Zod on the server, always

Never trust client-side validation alone. Server Actions receive raw FormData — treat it like any untrusted input. I validate with Zod inside every action before touching the database.

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

import { z } from "zod";

const contactSchema = z.object({
  name: z.string().min(2, "Name too short").max(100),
  email: z.string().email("Invalid email"),
  message: z.string().min(10, "Message too short").max(2000),
});

export async function submitContact(
  prevState: { error?: string } | null,
  formData: FormData
) {
  const parsed = contactSchema.safeParse({
    name: formData.get("name"),
    email: formData.get("email"),
    message: formData.get("message"),
  });

  if (!parsed.success) {
    return { error: parsed.error.flatten().fieldErrors };
  }

  await db.contact.create({ data: parsed.data });
  return { success: true };
}

Return validation errors as serialisable objects — the client component displays them next to fields. This pattern mirrors what you'd do in an API route, except the return value flows directly to your form state hook instead of through JSON parsing.

// BEFORE — separate API route + client fetch + manual error mapping
"use client";
export function ContactForm() {
  const [errors, setErrors] = useState<Record<string, string>>({});

  async function handleSubmit(e: FormEvent) {
    e.preventDefault();
    const res = await fetch("/api/contact", {
      method: "POST",
      body: new FormData(e.currentTarget as HTMLFormElement),
    });
    const data = await res.json();
    if (!res.ok) setErrors(data.errors);
  }
  // ... 40 more lines of loading/error UI
}

// AFTER — Server Action + useActionState
"use client";
import { useActionState } from "react";
import { submitContact } from "@/app/actions/contact";

export function ContactForm() {
  const [state, action, pending] = useActionState(submitContact, null);

  return (
    <form action={action}>
      <input name="name" />
      {state?.error?.name && <p>{state.error.name[0]}</p>}
      <button disabled={pending}>Send</button>
    </form>
  );
}

revalidatePath and cache invalidation

After a mutation, stale cached pages are the most common bug I see in Server Action implementations. You updated the database but the user still sees old data because Next.js served a cached Server Component render. Fix it with revalidatePath or revalidateTag.

"use server";

import { revalidatePath, revalidateTag } from "next/cache";

export async function updatePost(slug: string, formData: FormData) {
  const title = formData.get("title") as string;

  await db.post.update({
    where: { slug },
    data: { title },
  });

  // Invalidate the specific post page AND the blog listing
  revalidatePath("/blog/" + slug);
  revalidatePath("/blog");
  revalidateTag("posts"); // if you use fetch(..., { next: { tags: ["posts"] } })
}

Call revalidation inside the action, after the DB write succeeds. If the write fails, don't revalidate — the cache should still reflect the last known good state. I learned this the hard way on a blog admin panel where failed saves still triggered revalidation, briefly showing empty pages.

Server Actions vs API routes — when to use which

CriteriaServer ActionsAPI Routes
Form mutationsExcellent — native fitWorks, more boilerplate
External API consumersNot suitableRequired
Webhooks / third-party POSTNoYes
Progressive enhancementBuilt inManual
File uploadsFormData nativeMultipart handling
Mobile app / non-Next clientNoYes
BoilerplateMinimalRoute + fetch + types
Rate limitingMiddleware or in-actionMiddleware standard

Rule of thumb: if only your Next.js app calls it and it's a form mutation, use a Server Action. If a mobile app, webhook, or third-party service needs the endpoint, use an API route.

Error handling and security in production

Server Actions are public POST endpoints. Anyone can call them with crafted FormData. Validate everything, check auth inside the action, and never return stack traces to the client.

"use server";

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

export async function deleteProject(projectId: string) {
  const session = await auth();
  if (!session?.user) {
    return { error: "Unauthorized" };
  }

  const project = await db.project.findUnique({ where: { id: projectId } });
  if (project?.ownerId !== session.user.id) {
    return { error: "Forbidden" };
  }

  try {
    await db.project.delete({ where: { id: projectId } });
    revalidatePath("/projects");
    return { success: true };
  } catch {
    return { error: "Failed to delete project" };
  }
}

For sensitive operations — payments, account deletion — I add a confirmation step and log the action server-side. Server Actions don't replace security review; they just move the execution boundary to where your secrets already live.

Mistakes I see (and made myself)

Putting "use server" at the top of a file that exports non-action utilities. Mark only action functions, or split actions into their own files. Importing client-only modules into action files — that breaks the build. Forgetting revalidation after mutations — users see stale UI until hard refresh.

The biggest mistake: using Server Actions for reads. They're for mutations. Data fetching belongs in Server Components with async/await or in API routes for external consumers. Mixing reads into actions adds latency and confuses the mental model.

Another pattern I use in production: colocate actions in an actions/ directory at the app root, grouped by domain — actions/contact.ts, actions/blog.ts — rather than scattering "use server" inside component files. This keeps server boundaries visible in code review and makes it obvious which functions are callable from the client.

When interviewing developers in Bengaluru, I ask them to explain the difference between a Server Action and an API route. Strong candidates mention progressive enhancement, serialisation boundaries, and when external clients need REST. Weak candidates say "they're the same but easier" — that tells me they copied templates without understanding the server/client split from my RSC guide.

Optimistic updates with useOptimistic

For mutations where perceived speed matters — liking a post, toggling a todo, adding an item to cart — pair Server Actions with React's useOptimistic hook. The UI updates immediately while the server action runs in the background. If the action fails, you roll back to the previous state.

"use client";

import { useOptimistic } from "react";
import { toggleLike } from "@/app/actions/likes";

export function LikeButton({ postId, initialLiked }: {
  postId: string;
  initialLiked: boolean;
}) {
  const [optimisticLiked, setOptimisticLiked] = useOptimistic(initialLiked);

  async function handleClick() {
    setOptimisticLiked(!optimisticLiked);
    await toggleLike(postId);
  }

  return (
    <button onClick={handleClick} aria-pressed={optimisticLiked}>
      {optimisticLiked ? "Unlike" : "Like"}
    </button>
  );
}

I use optimistic UI sparingly — only when the mutation is idempotent and failure is recoverable. For payment or account deletion, wait for server confirmation. Users prefer honest loading states over optimistic UI that rolls back confusingly.

The single takeaway

React server actions are not a replacement for learning HTTP or React fundamentals. They're a productivity layer for form mutations inside Next.js — less boilerplate, built-in progressive enhancement, and server-side validation by default. Learn them after you understand Server Components, validate with Zod, always revalidate, and keep API routes for anything external.

If you are migrating from Pages Router API routes, start with one form — contact, newsletter signup, or admin edit. Prove the pattern works with revalidation before converting every endpoint. Mixed architectures are fine during migration; not every POST needs to become a Server Action on day one. Prioritise forms that benefit from progressive enhancement and colocation with the UI that triggers them.

The React team and Next.js maintainers are investing heavily in this model — expect better DevTools, clearer error messages, and tighter integration with caching in future releases. Learning Server Actions now puts you ahead of teams still maintaining separate REST layers for every form in their App Router apps.

Related reading: RSC vs Client Components — When to Use Which. More guides: safdarali.in/blog. Questions: safdarali.in/contact.

If this helped you

I publish free tutorials and write-ups like this in my spare time — no paywall on the guides. If it saved you an afternoon of trial and error, you can support the work:

More guides on safdarali.in — same author, production-focused.

"Talk is cheap. Show me the code."