Skip to content

Server Actions for Mutations

Aman Kumar Singh7 min read
Part 21 of 50From the React & Next.js Complete Guide series
Server Actions for Mutations — article by Aman Kumar Singh

In the last article, Route Handlers and APIs in Next.js, we covered when a Route Handler is the right tool: webhooks, third-party integrations, anything that needs to be called from outside your own React tree. Server Actions solve a narrower problem, mutating data from inside a form or a client interaction, without you writing an API route, wiring up a fetch call, and hand-rolling loading and error state for every single mutation.

This is part of the React & Next.js Complete Guide series. If you've been treating Server Actions as a shortcut for "skip the API layer," this article pushes back on that a little. They remove boilerplate, but they're still a network boundary with all the trust and validation implications that come with one.

I'll say upfront where I land on this: Server Actions are a genuinely good default for form-driven mutations inside your own app. They're a poor fit the moment another client, a mobile app, a webhook consumer, a public API, needs to call the same operation. Knowing which side of that line you're on before you write the first line of code saves you from a rewrite later.

What a Server Action actually is

A Server Action is a function marked with "use server" that runs exclusively on the server, but that you can call directly from a Client Component as if it were a local function. Next.js compiles this into a POST request under the hood: the function reference on the client becomes a serialized reference, the framework generates an endpoint for it, and the call goes over the network. None of that machinery is visible in your code, which is both the appeal and the risk. It looks like a function call. It is not a function call.

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

import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
import { getCurrentUser } from "@/lib/auth";
import { z } from "zod";

const createTaskSchema = z.object({
  title: z.string().min(1).max(200),
  projectId: z.string().uuid(),
});

export async function createTask(formData: FormData) {
  const user = await getCurrentUser();
  if (!user) {
    throw new Error("Unauthorized");
  }

  const parsed = createTaskSchema.safeParse({
    title: formData.get("title"),
    projectId: formData.get("projectId"),
  });

  if (!parsed.success) {
    return { error: "Invalid task data" };
  }

  const project = await db.project.findFirst({
    where: { id: parsed.data.projectId, organizationId: user.organizationId },
  });

  if (!project) {
    throw new Error("Project not found or not accessible");
  }

  await db.task.create({
    data: {
      title: parsed.data.title,
      projectId: parsed.data.projectId,
      createdById: user.id,
    },
  });

  revalidatePath(`/projects/${parsed.data.projectId}`);
}

Every one of those checks, auth, schema validation, tenant scoping, is doing the same work a controller in a NestJS API would do. That's the point I want to make early: a Server Action is a server endpoint with a friendlier calling convention, not a replacement for the validation and authorization you'd write anywhere else.

Wiring it into a form

The most common use case is a <form> whose action prop points at a Server Action directly. React handles the serialization, the network call, and re-rendering the form's Server Component parent once the mutation completes.

// app/projects/[id]/new-task-form.tsx
"use client";

import { useActionState } from "react";
import { createTask } from "@/app/actions/tasks";

const initialState = { error: null as string | null };

export function NewTaskForm({ projectId }: { projectId: string }) {
  const [state, formAction, isPending] = useActionState(async (_prev: typeof initialState, formData: FormData) => {
    const result = await createTask(formData);
    return result ?? initialState;
  }, initialState);

  return (
    <form action={formAction} className="space-y-3">
      <input type="hidden" name="projectId" value={projectId} />
      <input
        name="title"
        placeholder="Task title"
        required
        className="w-full rounded border px-3 py-2"
      />
      {state.error && <p className="text-sm text-red-600">{state.error}</p>}
      <button type="submit" disabled={isPending} className="rounded bg-black px-4 py-2 text-white">
        {isPending ? "Adding..." : "Add task"}
      </button>
    </form>
  );
}

The reason this pattern earns its keep over a manual fetch call is progressive enhancement. The form works before JavaScript hydrates, and it keeps working if the client bundle fails to load for some reason. useActionState gives you pending state and a return value from the action without a useState and a useEffect chasing each other. For a lot of internal tools and admin panels, this is genuinely less code than the Route Handler plus client fetch equivalent. That said, it's less code because the framework absorbed the plumbing. The underlying problem didn't get smaller.

Revalidation is where mutations actually take effect

Calling a Server Action mutates your database, but nothing on screen updates until you tell Next.js which cached data is now stale. This is the part people skip in tutorials and then get bitten by in production: a mutation that succeeds against the database while the UI keeps showing the old state until a manual refresh.

"use server";

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

export async function updateProjectName(projectId: string, name: string) {
  await db.project.update({ where: { id: projectId }, data: { name } });

  // Invalidate the specific page the user is looking at.
  revalidatePath(`/projects/${projectId}`);

  // Invalidate anywhere else this data is cached by tag,
  // e.g. a dashboard list that fetches with { next: { tags: ["projects"] } }.
  revalidateTag("projects");
}

revalidatePath is the blunter of the two: it invalidates the cache for a specific route and forces a re-render on next visit. revalidateTag is more precise if you tagged your fetch calls or database queries with unstable_cache tags, because it invalidates every place that data appears without you having to enumerate every path that shows it. In practice, I reach for revalidatePath on the page the user just mutated from, and revalidateTag for anything that data feeds into elsewhere, a sidebar count, a dashboard summary, a search index.

The pitfall worth calling out: forgetting revalidation entirely doesn't throw an error. The mutation succeeds, the response comes back, and the page just quietly shows stale data until something else triggers a refetch. This is the kind of bug that's invisible in development, where you're constantly hot-reloading, and obvious in production, where a user updates something and it looks like nothing happened.

Treat every Server Action like a public endpoint

Because a Server Action compiles down to an HTTP endpoint, anyone who can read your client bundle can find its URL and call it directly with a crafted request, bypassing your UI entirely. The same is true of any client-callable mutation, not just Next.js, but the ergonomics of Server Actions make it easy to forget.

Concretely, that means:

  • Never trust a value the client claims about itself. A hidden projectId field or an argument passed from the client is a hint, not a fact; re-check ownership against the authenticated user server-side, every time, the way the createTask example above checks project.organizationId against user.organizationId.
  • Validate with a schema library at the boundary, the same way you would in a NestJS DTO. FormData gives you strings and files; don't assume shape or type just because your form only sends valid input in the happy path.
  • Rate-limit sensitive actions. A Server Action that sends an email, charges a card, or writes to an append-only audit log needs the same rate limiting you'd put in front of a Route Handler doing the same thing. Redis with a sliding window counter works the same way here as it would in front of any other endpoint.
  • Don't put secrets or internal logic in the arguments you pass to a Server Action from the client. Anything passed as an argument is serialized and sent over the wire; treat it as untrusted input on the way in, and never as a channel for anything that shouldn't be visible to the browser.

When a Route Handler is still the better call

Server Actions are scoped to being invoked from your own React tree. That's a limitation you should treat as a design signal, not a workaround to route around. If a mobile app, a partner integration, a webhook, or a public API needs to trigger the same mutation, put the logic in a service function and expose it through a Route Handler (or a NestJS endpoint if that's where your API layer lives), then call that same service function from the Server Action if you still want the form ergonomics inside your Next.js app.

The mistake I'd avoid is duplicating business logic between a Server Action and a Route Handler because you added the Route Handler after the fact and didn't have time to unify them. Keep the actual mutation logic, validation, authorization, the database write, in a plain function in a service module. Let both the Server Action and the Route Handler be thin callers of that same function. This is the same separation of concerns you'd apply to a NestJS controller calling into a service layer, and it holds up for the same reason: the transport is replaceable. The business rule has to survive that change.

Key takeaways

  • A Server Action is an HTTP endpoint with a friendlier calling convention, not a replacement for auth, validation, or authorization; write the same checks you would in a Route Handler or a NestJS controller.
  • Use Server Actions for form-driven mutations inside your own app where progressive enhancement and reduced boilerplate genuinely pay off; reach for a Route Handler when another client needs to call the same operation.
  • Nothing updates on screen after a mutation until you call `revalidatePath` or `revalidateTag`; forgetting this fails silently and shows up as stale data, not an error.
  • Never trust client-supplied identifiers passed as Server Action arguments; re-verify ownership and tenant scope against the authenticated user on the server.
  • Keep the actual mutation logic in a plain service function so a Server Action and a Route Handler can both call it without duplicating business rules.
  • Rate-limit sensitive Server Actions the same way you would any other write endpoint.

Frequently asked questions

Are Server Actions a replacement for a REST or GraphQL API?

No. They're scoped to being called from your own Next.js client. If you need the same mutation reachable from a mobile app, a third party, or a public API, expose it through a Route Handler or a separate API layer, and have the Server Action call the same underlying service function.

Can I call a Server Action from a Server Component?

Yes, directly, as an async function call, without going over the network. The network round-trip only happens when a Client Component invokes it, since that's the boundary the serialization has to cross.

Do Server Actions run in a serverless function on every call?

On platforms like Vercel, yes, each invocation runs in the deployment's serverless or edge runtime the same as a Route Handler would. Cold start and execution time considerations are the same as for any other server-side function in your deployment.

How do I handle validation errors without a full page reload?

Return a plain object from the Server Action (for example `{ error: string }`) instead of throwing, and read it from `useActionState`'s state. Reserve thrown errors for cases you want to bubble up to the nearest `error.tsx` boundary; use return values for expected, form-level validation failures.

Do I still need CSRF protection with Server Actions?

Next.js includes built-in origin checks for Server Action requests, which cover the common CSRF case out of the box. It's still worth confirming your deployment's `Origin` and `Host` headers behave as expected, and it doesn't replace real authorization checks inside the action itself.

What happens if a user double-submits a form calling a Server Action?

Nothing prevents it automatically. Disable the submit button while `isPending` is true from `useActionState` or `useFormStatus`, and make sure the underlying mutation is idempotent or protected by a uniqueness constraint at the database level for anything where a duplicate write would actually matter, like payments or invitations.

Related articles

Data Fetching in the App Router — Aman Kumar Singh
The Next.js App Router Explained — Aman Kumar Singh
Error Handling and Loading States — Aman Kumar Singh
Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.