All articles
Web DevelopmentAugust 17, 2026 7 min read

Route Handlers vs Server Actions for Mutations: When Each One Actually Wins

Server Actions look like the obvious choice for every mutation in the App Router. They aren't. Here's how we decide between actions and route handlers on real projects, with the failure modes we've hit.

Server Actions are the default answer in most App Router tutorials now, and for good reason — they collapse a lot of boilerplate. But we've watched teams reach for them in situations where a boring old route handler would have shipped faster, cached better, and been easier to debug. This is the mental model we use to pick.

The short version

Server Actions are great when a mutation is tightly coupled to a specific piece of UI, benefits from progressive enhancement, and needs to invalidate cached data on the same server. Route handlers win when the mutation is a public API surface, needs a stable contract, has to be called from something other than your own React tree, or needs response semantics (status codes, streaming, custom headers) that actions don't cleanly expose.

Everything else is judgement. Below is how we make that judgement without re-litigating it on every PR.

What Server Actions actually give you

A Server Action is a function marked with "use server" that React can call from a form or an event handler. Next.js wires up the transport, serializes arguments, runs the function on the server, and — this is the underrated part — lets you call revalidatePath or revalidateTag in the same request so the resulting render is fresh.

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

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

const CreateProject = z.object({
  name: z.string().min(1).max(120),
  clientId: z.string().uuid(),
});

export async function createProject(formData: FormData) {
  const session = await auth();
  if (!session) throw new Error("UNAUTHENTICATED");

  const parsed = CreateProject.safeParse({
    name: formData.get("name"),
    clientId: formData.get("clientId"),
  });
  if (!parsed.success) {
    return { ok: false, errors: parsed.error.flatten() };
  }

  const project = await db.project.create({
    data: { ...parsed.data, ownerId: session.userId },
  });

  revalidateTag(`projects:${session.userId}`);
  return { ok: true, id: project.id };
}

Called from a form, this works without JavaScript. Called from a client component with useActionState, you get pending states and returned errors for free. That's the ergonomic win.

Where actions quietly earn their keep

  • Co-location. The mutation lives next to the component that triggers it. Reviewers see both halves in one diff.
  • Cache coherence. revalidateTag inside the action runs before the client re-renders, so the next render sees fresh data. With a route handler you either duplicate this logic or hop through an extra fetch.
  • Progressive enhancement. Form submissions work if the JS bundle hasn't loaded yet. On patchy mobile networks this is not a theoretical benefit.

What route handlers still do better

Route handlers are functions exported from app/**/route.ts that respond to HTTP verbs. They're just endpoints. That plainness is the feature.

// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import { verifyStripeSignature } from "@/lib/stripe";
import { handleInvoicePaid } from "@/lib/billing";

export async function POST(req: NextRequest) {
  const sig = req.headers.get("stripe-signature");
  const body = await req.text();

  const event = verifyStripeSignature(body, sig);
  if (!event) {
    return NextResponse.json({ error: "bad signature" }, { status: 400 });
  }

  if (event.type === "invoice.paid") {
    await handleInvoicePaid(event.data.object);
  }

  return NextResponse.json({ received: true });
}

You can't sensibly do that as a Server Action. Stripe is not going to submit an HTML form to your React tree.

More generally, reach for a route handler when:

  • The caller isn't your own UI. Webhooks, mobile clients, cron runners, other services, curl. Anything with a stable contract.
  • You need HTTP semantics. Custom status codes, Retry-After, cache headers, Content-Type: text/event-stream, chunked responses. Actions abstract these away, which is fine until you need them.
  • You want to rate-limit or auth at the edge. Middleware plus a route handler gives you a clean chokepoint. Actions are POSTs to the same page URL, so per-route policies are fiddlier.
  • The mutation is expensive and idempotent. A well-defined PUT /api/things/:id with an idempotency key is easier to retry safely than an action bound to a form submission.

The grey zone: internal mutations from client components

This is where the arguments happen. You have a mutation that only your own React app calls, but it lives in a client component that already needs a POST endpoint for something else. Do you use an action or a fetch to a route handler?

We default to the action, with three exceptions:

  1. You need the response body to be non-trivial. Actions can return values, but if you're returning a paginated list or a streamed result, a route handler with a proper Response is more honest.
  2. You're calling it from a place that isn't a React event. A setInterval, a service worker, a background sync — these should hit an endpoint, not an action.
  3. You want to test it in isolation. Route handlers are trivially testable with fetch in an integration test. Actions are testable, but the ergonomics are worse because you're testing a function that assumes a Next.js request context.

The security footnote nobody reads

Server Actions are POSTs to your page URL with a specific action ID in the body. Next.js protects them with an origin check and, since 14.7, an encrypted action ID. That covers CSRF for browser callers. It does not mean the function itself is safe — you still need to authenticate the user and authorize the operation inside the action, exactly like a route handler. We've seen teams treat the framework's transport protection as authorization. It isn't.

Also: if you export a Server Action from a module, that function is reachable from any client component in your app. There is no per-route ACL. Put the auth check at the top of every action, or wrap them with a helper that does:

// lib/action.ts
import { auth } from "@/lib/auth";

export function authedAction<TArgs extends unknown[], TResult>(
  fn: (userId: string, ...args: TArgs) => Promise<TResult>,
) {
  return async (...args: TArgs): Promise<TResult> => {
    const session = await auth();
    if (!session) throw new Error("UNAUTHENTICATED");
    return fn(session.userId, ...args);
  };
}

Performance: it's mostly a wash, until it isn't

On the happy path, an action and a route handler do roughly the same work: parse a request, validate, hit the database, respond. The difference shows up at the edges.

  • Payload size. Actions serialize arguments through the React Server Components protocol. For plain JSON-ish data this is fine. For large binary uploads it isn't — use a route handler (or a signed direct-to-storage upload) and skip the action layer.
  • Cold starts on serverless. An action call boots the page's server bundle. A route handler boots only the handler's bundle. If your page pulls in a lot of server-only deps, a dedicated route handler can be measurably faster to first byte on cold invocations. In our experience the gap is usually tens of milliseconds, occasionally more on fat pages.
  • Revalidation cost. Every action that calls revalidatePath triggers a re-render of the affected route as part of the response. That's usually what you want, but if you're firing an action that doesn't affect the current page, you're paying for a render you don't need. Don't call revalidatePath reflexively.

A decision checklist we actually use

Before writing a mutation, we ask, in order:

  1. Will anything other than our own UI call this? If yes → route handler.
  2. Does it need HTTP-level control (status codes, streaming, custom headers)? If yes → route handler.
  3. Does it accept large binary payloads? If yes → route handler or direct upload.
  4. Is it tightly bound to a form or a specific UI interaction, and should it invalidate cached data on the same server? If yes → Server Action.
  5. Otherwise → Server Action, because co-location and progressive enhancement are real wins and we don't want two ways to do the same thing.

That last point matters. The worst outcome isn't picking the wrong tool once. It's ending up with a codebase where half the mutations are actions and half are handlers with no discernible rule, and every new feature triggers a mini debate.

Where we'd start

If you're on an App Router project today, audit your existing app/api/* handlers. For each one, ask whether it exists to serve your own UI or something external. The internal ones are usually candidates to become Server Actions, and you'll delete a surprising amount of fetch glue in the process. Leave the webhooks, cron endpoints, and third-party integrations exactly where they are.

If you want a second opinion on an App Router architecture before you commit, our team does this kind of review as part of our web engineering work — usually faster than the argument on the PR would take.

#Next.js#React#App Router#Server Actions#TypeScript

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project