Skip to main content
ASoc
Comparison

Server Actions vs Route Handlers: The Caller Decides, Not the Task

Both are public POST endpoints. The choice is settled by three things only Route Handlers have: the raw body, a real URL, and the status code.

The ASoc Team10 min read

Use a Server Action when your own UI is the caller, and a Route Handler when the caller is someone you don't control. That single question — who dials the number — settles the choice faster than any performance comparison, because three concrete capabilities live only in Route Handlers: the raw request body, an addressable URL, and control of the HTTP status.

This storefront runs both. Eight Server Actions in src/lib/actions/ (auth, checkout, contact, newsletter, account, redemption, refund, entitlements view) and two Route Handler families under src/app/api/ (the LemonSqueezy webhook and the gated download endpoint). The 8:2 split is not a preference. Each of the two was forced out of a Server Action by something a Server Action structurally cannot do, and this post is about what those things are. Both halves together are the whole of this project's backend — there is no application server underneath them.

The three things that force a Route Handler

1. The raw request bytes

Our LemonSqueezy webhook verifies an HMAC signature. The signature covers the exact bytes LemonSqueezy sent, so the verification has to run on those bytes:

export async function POST(req: Request) {
  const raw = await req.text();
  const signature = req.headers.get("X-Signature");
  // ...HMAC is computed over `raw`, never over a re-serialized object.
}

The comment we left on that line is the reason it can never be a Server Action:

The raw body is read via req.text() (never req.json() first) so the HMAC is computed over the exact bytes LS signed — re-serializing a parsed object can byte-for-byte differ from the original.

A Server Action does not receive a request. It receives decoded arguments after the framework has already parsed the payload. There is no req.text() to reach for, no header access on the inbound call, and therefore no way to verify a third-party signature. Any endpoint whose security depends on the wire format belongs in a Route Handler.

That handler also needs node:crypto, which pins the runtime:

export const runtime = "nodejs";

2. A URL a browser can navigate to

Our download endpoint answers a navigation. The buyer clicks a link, the endpoint decides yes or no, and redirects to a short-lived signed storage URL. That requires the endpoint to be a URL — something you can put in an href:

/api/download?slug=asoc-drift-shop&framework=nextjs

Server Actions do get a generated endpoint at build time, but its ID is an implementation detail, not a contract you can link to, bookmark, or hand to curl. Anything a browser navigates to directly, anything a CLI calls, anything you'd document — Route Handler.

3. The status code as the contract

The download route's failure modes are its API: 401 unauthenticated, 403 unentitled, 400 malformed, 429 over the rate limit. A Server Action returns a value to React, not an HTTP status. Modelling 403 as { ok: false, reason: "forbidden" } is fine when your own component reads it and useless when the consumer is anything else.

The comparison, on the axes that actually decide it

Server ActionRoute Handler
CallerYour own UIAnyone — webhooks, browsers, CLIs
Raw body / headersNoYes
Stable public URLNo (generated ID)Yes
HTTP status controlNoYes
Works without JSYes, via <form action>Only as a form target
RevalidationrevalidatePath in-processSame, but you wire it
Type safety across the boundaryYes, it's a function callNo, you serialize
Public POST endpointYesYes

That last row is the one people get wrong, and it deserves its own section.

A Server Action is a public endpoint too

The most common misreading of Server Actions is that "it runs on the server" implies "only my UI can call it." It does not. Next.js generates an ID for each action and the client posts to it. Anyone who can read your JavaScript bundle can post to that ID with arguments of their choosing.

So an action's arguments are untrusted input, exactly like a query string. Here is our refund action's opening — the shape every mutating action should copy:

export async function resolveRefundRequest(
  orderId: string,
  deps: RefundRequestDeps,
): Promise<RefundRequestResult> {
  const userId = await deps.getUserId();
  if (!userId) return { ok: false, reason: "unauthenticated" };
  // ...
}

orderId is a parameter the caller controls. userId is not — it comes from the verified session. The lookup then scopes the row to both:

const { data, error } = await supabase
  .from("orders")
  .select("status, created_at")
  .eq("id", id)
  .eq("user_id", userId)   // ← ownership, from the session
  .maybeSingle();

Drop that second .eq() and you have an IDOR: any signed-in user can request a refund on any order by guessing an id. The rule we hold to across every action is one line long — ownership is derived from the verified session, never from a parameter — and it applies identically to Route Handlers. Neither primitive is "the secure one."

What both of them get wrong on serverless

There is one trap that hits Server Actions and Route Handlers equally, and it does not reproduce locally.

After you return a response, the serverless invocation can freeze. A promise you started but didn't await — the classic fire-and-forget side effect — is not guaranteed to finish. Locally the process keeps running and the email sends; in production it silently doesn't.

Both of our cases schedule that work explicitly instead:

import { after } from "next/server";

// In the webhook Route Handler:
sendPurchaseWelcome: (email, tier) => after(() => sendPurchaseWelcome(email, tier)),

// In the refund Server Action:
sendNotification({ orderId: id }) {
  after(() => sendRefundRequestNotification(id));
},

after() is backed by the platform's waitUntil(), which keeps the invocation alive for scheduled work without blocking the response. The delivery semantics around this — which emails may fail silently and which must be reported — are their own post.

Choosing, in practice

Work down this list and stop at the first yes; if you reach the bottom, use a Server Action.

  1. Does a third party call it? → Route Handler.
  2. Does it need the raw body or inbound headers? → Route Handler.
  3. Does a browser navigate to it, or does anything link to it? → Route Handler.
  4. Do callers need to branch on HTTP status? → Route Handler.
  5. Does it return anything other than JSON — a file, a redirect, a stream? → Route Handler.

Everything else — form submissions, profile updates, adding to a cart, requesting a refund from your own dashboard — is a Server Action, and you get typed arguments and progressive enhancement for free.

Mistakes we see (and made)

MistakeSymptomFix
Parsing the body before verifying a signatureWebhook rejects valid deliveries intermittentlyRead req.text() first, verify, then parse
Trusting an action's argumentsIDOR — users act on rows they don't ownScope every query by the session's user id
Bare void sendEmail() after the responseWorks locally, drops mail in productionWrap in after()
Building a REST API out of Server ActionsNo status codes, no versioning, no external callersRoute Handlers for anything with a consumer
Wrapping every action in try/catch that returns the errorLeaks internals to the clientLog detail server-side, return a generic message
Forgetting runtime = "nodejs" with node:cryptoBuild or runtime failure on the edgePin the runtime on the handler

FAQ

Are Server Actions slower than API routes? Not meaningfully, and it is the wrong axis. Both run the same server-side code on the same infrastructure. Server Actions save a round trip of hand-written fetch plumbing and serialization code; Route Handlers save nothing but give you the request. Choose on capability, not on a benchmark.

Can I call a Server Action from a mobile app or a third-party service? You shouldn't. The endpoint id is a build artifact, not a documented contract, and it can change between deploys. Expose a Route Handler and let the action and the handler share the same underlying function.

Do I need CSRF protection on Server Actions? Next.js checks the Origin header against the host for action requests, which covers the classic cross-site form post. It does not make the action private, and it is not a substitute for the session-derived ownership check above. CSRF protection in Next.js covers what that check reaches and what a Route Handler still has to do for itself.

Should business logic live inside the action? No. Both of our examples are thin adapters over pure functions — resolveRefundRequest and authorizeDownload take injected dependencies, so the decision logic is unit-testable without a live session or database. That is why the same engine can back a Server Action today and a Route Handler tomorrow without a rewrite.

Where this shows up in a template

Every marketing template in this catalog ships the Server Action half — contact forms, newsletter capture, and demo request flows wired as actions with progressive enhancement. The Route Handler half appears the moment you attach a payment provider, which is the point at which the checklist above starts earning its keep.

Keep reading

Comparison8 min read

shadcn vs. Tailwind Is a Category Error (One Runs on the Other)

92 components, 13 runtime dependencies, zero UI libraries — what hand-rolling actually cost this codebase, and the seven components shadcn would have handed over.

Read more
Comparison11 min read

Shopify vs a Next.js Storefront: What You Actually Inherit

Shopify's fee buys a checkout, tax handling and an ops backend — not hosting. The four jobs you take on by leaving, the headless hybrid, and when owning the frontend pays.

Read more
Comparison9 min read

SolidJS vs. Next.js: You're Comparing the Wrong Two Things

SolidStart is the real comparison. 24 of 92 component files here ship client JS — the number that decides fine-grained reactivity against RSC.

Read more