React

Route Handlers vs Server Actions: which one to use

Server Action for form mutations, Route Handler for everything else. The caller decides: comparison table, working code and real limits.

Key points

  • The caller decides: a form on your site means a Server Action, any other client means a Route Handler.
  • A Server Action has no stable URL and cannot be documented as an API.
  • Validation stays on the server in both cases: only the way you read the input changes.
  • A Server Action serialises only serialisable values: `Date` and `Map` do not survive.

Use a Server Action for a mutation that starts from a form on your site; use a Route Handler when the caller is not a React component. The caller decides, not taste.

The caller decides

CriterionServer ActionRoute Handler
React component in your siteYes, that is the intended casePossible, but indirect
Script, cron, mobile clientNoYes
Stable, documentable URLNo, it changes between buildsYes
Explicit HTTP methodPOST onlyGET, POST, PUT, DELETE
Caching or ISR on the responseNot applicableYes, with revalidate
Third-party webhookNoYes

A Route Handler is a `route.ts` file that exports one function per HTTP method it accepts. It receives a standard `Request` and returns a `Response`: no proprietary type, no abstraction.

// app/api/ordini/route.ts
import { NextResponse } from "next/server";
import { z } from "zod";
import { createOrder } from "@/lib/orders";

const schema = z.object({
  sku: z.string().min(1),
  qty: z.number().int().positive(),
});

export async function POST(request: Request) {
  const body = await request.json();
  const parsed = schema.safeParse(body);
  if (!parsed.success) {
    return NextResponse.json({ error: "invalid" }, { status: 422 });
  }
  const order = await createOrder(parsed.data);
  return NextResponse.json(order, { status: 201 });
}

Validation lives in the handler, not in the client. The schema is the same one you would use in a Server Action: `zod`, `valibot` or a hand-written parser makes no difference.

Server Actions: the normal case

A Server Action is an async function marked `"use server"`. Next.js generates a POST endpoint and wires the form for you: the browser sends the data without a `fetch` on your side.

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

import { z } from "zod";
import { revalidatePath } from "next/cache";
import { createOrder } from "@/lib/orders";

const schema = z.object({
  sku: z.string().min(1),
  qty: z.coerce.number().int().min(1),
});

export async function createOrderAction(_prev: unknown, formData: FormData) {
  const parsed = schema.safeParse(Object.fromEntries(formData));
  if (!parsed.success) return { error: "Compila tutti i campi" };
  await createOrder(parsed.data);
  revalidatePath("/ordini");
  return { error: null };
}
  • The form works without JavaScript, because it is a `
    `.
  • Data refresh goes through `revalidatePath` or `revalidateTag`, not a manual reload.
  • The error state comes back to the client as a return value, not as an exception.

When a Route Handler is mandatory

  • Webhooks: Stripe, GitHub and similar services call a URL of yours with a signature in the headers.
  • Non-React clients: a mobile app, an import script, a partner integration.
  • Public, cacheable GET: a feed, a JSON that others read, a public measurement.
  • Direct upload with a signed URL: the file never touches your server.

What a Server Action does not do

A Server Action is not an API. Its URL is generated at build time, it is not documentable and it changes when the file structure changes. You cannot call it from `curl`, and you cannot version it as a contract.

curl -X POST http://localhost:3000/api/ordini \
  -H "content-type: application/json" \
  -d '{"sku":"TS-01","qty":2}'

The number that matters

The EneUI catalogue indexes 111,985 components from 1,472 libraries and 1,556 blocks, measured on 1 November 2026 at `/api/stats`. Three categories cover the parts of this article: form validation, form blocks and error handling.

A stated limitation

A Server Action serialises only serialisable values: `Date`, `Map` and class instances do not survive the trip. If the payload is large or binary, the Route Handler stays the simpler path.

For the folder context read how to structure an App Router project. For the module boundary there is server-only and client-only. For a framework comparison read Remix vs Next.js.

Frequently asked questions

Can I call a Server Action from a mobile client?

No. A Server Action is reachable only from the site bundle and its URL changes on every build. For an external client, write a Route Handler with a stable contract.

Is a Route Handler slower than a Server Action?

No, they run on the same runtime. The extra cost is code: you write the parsing, validation and response yourself instead of leaving them to the framework.

Where does validation go?

On the server, in both cases. Client-side validation is a UX feature and never replaces the server check.

Can a Server Action return an object?

Yes, if the object is serialisable. Return simple data and use the return value for the error state, not for large payloads.

Do Route Handlers and Server Actions share cookies?

Yes. Both read `cookies()` and `headers()` from the current request, with the same API and the same permissions.