Your App LogoYOUR APP EXPERTYAE
    • Services
    • About
    • Portfolio
    • Blog
    • FAQ
    • Build Your App
    1. Home
    2. Blog
    3. Next.js App Router: server actions vs API routes — when to pick each
    Architecture

    Next.js App Router: server actions vs API routes — when to pick each

    When server actions are the right call, when API routes still win, and the production patterns we use on every Next.js build.

    YAEL Engineering·20 Dec 2025·8 min read·1,502 words
    On this page
    • The 30-second decision
    • Why server actions are good
    • Why API routes still matter
    • The hidden footguns of server actions
    • Hidden POST size limits
    • CSRF — Next.js handles it but only partially
    • Auth context
    • Error handling
    • The hybrid pattern we ship
    • What about tRPC?
    • What about server components vs server actions?
    • FAQ
    • Are server actions stable?
    • Can I call a server action from another server function?
    • Can I use server actions with React Query?
    • Do server actions support streaming responses?
    • What about rate limiting?
    • Can server actions handle file uploads?
    • Can I authenticate server actions with a JWT from a non-cookie source?
    • Are server actions slower than API routes?

    For mutations driven by your own UI in the same Next.js app, server actions are the right default — they're typed end-to-end, they don't need a separate fetch layer, and they integrate cleanly with React's transitions and Suspense. For anything called by an external client (a mobile app, a webhook, a third-party integration, a different domain), use an API route. The two are not competing — they solve different problems. The mistake we see most often is teams shoving everything into API routes "because they used to" or shoving everything into server actions "because Vercel said so." The right answer is to use both, and the heuristic is simple: who is the caller?

    This is the pattern we ship on every Next.js App Router project — including the AI chat at /build/chat and the case studies on CloudChat.

    The 30-second decision

    • Caller is React on this same Next.js app → server action
    • Caller is anything else (mobile, webhook, third-party, another origin) → API route
    • Caller might be either → API route, then call it from server actions if needed

    That's it.

    Why server actions are good

    The pitch in three points:

    1. End-to-end types without code generation. You write a function. The client imports it. The types flow. No tRPC, no openapi-typescript, no zod-to-openapi pipeline.
    2. Progressive enhancement. A form with action={serverAction} works without JS. Useful for SEO-adjacent flows like contact forms and quote requests.
    3. React state primitives that compose. useFormState, useFormStatus, and useOptimistic are designed around server actions. UX patterns get simpler.
    ts
    // app/actions/createWidget.ts
    "use server";
    
    import { z } from "zod";
    import { db, withOrg } from "@/lib/db";
    import { requireMembership } from "@/lib/auth";
    import { revalidatePath } from "next/cache";
    
    const Input = z.object({
      orgSlug: z.string(),
      name: z.string().min(1).max(80),
    });
    
    export async function createWidget(formData: FormData) {
      const parsed = Input.parse({
        orgSlug: formData.get("orgSlug"),
        name: formData.get("name"),
      });
      const { org } = await requireMembership(parsed.orgSlug);
      await withOrg(org.id, () =>
        db.insert(widgets).values({ name: parsed.name, orgId: org.id }),
      );
      revalidatePath(`/app/${parsed.orgSlug}/widgets`);
    }

    The form that calls it:

    tsx
    "use client";
    import { createWidget } from "@/app/actions/createWidget";
    import { useFormStatus } from "react-dom";
    
    function SubmitButton() {
      const { pending } = useFormStatus();
      return <button disabled={pending}>{pending ? "Creating..." : "Create"}</button>;
    }
    
    export function NewWidgetForm({ orgSlug }: { orgSlug: string }) {
      return (
        <form action={createWidget}>
          <input type="hidden" name="orgSlug" value={orgSlug} />
          <input name="name" required />
          <SubmitButton />
        </form>
      );
    }

    Three files. End to end. No /api/widgets/create route, no fetch, no type-sync.

    Why API routes still matter

    The cases where server actions can't do what you need:

    • External callers. A webhook handler. A mobile app's HTTP client. A third-party integration calling back. They all need a URL, not a server action.
    • Custom HTTP semantics. You need control over status codes, headers, response bodies, streaming. API routes give you the Request/Response primitives directly.
    • Streaming. Server-sent events, NDJSON, raw streams — these need an API route. Server actions don't expose the response stream.
    • Cross-origin access. Server actions only work from the same origin. CORS isn't a thing.
    • Public-by-design. Endpoints that you want crawlable or anonymously callable from anywhere — /api/health, /api/og, etc.
    ts
    // app/api/stripe/webhook/route.ts — the canonical API route case
    import { headers } from "next/headers";
    
    export async function POST(req: Request) {
      const sig = (await headers()).get("stripe-signature");
      const body = await req.text();
      const event = stripe.webhooks.constructEvent(body, sig!, secret);
      await handleEvent(event);
      return new Response("ok");
    }

    A server action can't do this. Stripe is the caller. Stripe doesn't know about Next.js conventions. Stripe needs a URL.

    The hidden footguns of server actions

    A handful of things to watch for. We've debugged each one in production.

    Hidden POST size limits

    Server actions POST a FormData or RSC payload to the Next.js runtime. Vercel's default body size limit is 1MB. If your form uploads a 5MB image, it fails with a confusing error. Use multipart uploads via a presigned URL to S3/R2 instead.

    CSRF — Next.js handles it but only partially

    Next.js binds the server action to the origin via a header check. This stops the basic CSRF case. For higher-stakes mutations (money movement, destructive actions), add an explicit CSRF token. Don't rely solely on the origin check.

    Auth context

    A server action runs server-side. To read the user, you call your auth helpers (Clerk's auth(), NextAuth's getServerSession(), whatever). Don't pass the user id from the client — it's spoofable. Always re-derive auth on the server.

    ts
    "use server";
    import { auth } from "@clerk/nextjs/server";
    
    export async function dangerousAction(input: Input) {
      const { userId } = await auth();
      if (!userId) throw new Error("unauthenticated");
      // proceed with userId from auth context, NOT from input
    }

    Error handling

    Server actions throw exceptions. The client surfaces them as Error instances — but the message gets sanitized in production builds. Don't put structured error data in throw new Error() and expect to parse it client-side. Return a typed result instead.

    ts
    "use server";
    
    export async function createWidget(formData: FormData) {
      const parsed = Input.safeParse(...);
      if (!parsed.success) {
        return { ok: false as const, error: "invalid_input", details: parsed.error.flatten() };
      }
      // ...
      return { ok: true as const, widgetId };
    }

    Use useFormState to render the result inline.

    The hybrid pattern we ship

    For most real apps:

    • UI mutations from this app → server actions
    • Cross-cutting endpoints (webhooks, OG image, health, llms.txt, sitemap) → API routes
    • Mobile / external clients → API routes with explicit auth

    The shared business logic lives in /lib. Both server actions and API routes call into the same library code. The HTTP-vs-action distinction is just transport.

    src/
    ├── lib/
    │   └── widgets.ts        # business logic, no transport
    ├── app/
    │   ├── actions/
    │   │   └── createWidget.ts   # server action, calls lib
    │   └── api/
    │       └── widgets/
    │           └── route.ts      # API route, calls same lib

    A mobile app that posts to /api/widgets and a web form that uses createWidget server action both hit the same createWidget(input) library function. Single source of truth.

    What about tRPC?

    tRPC was the right answer when server actions didn't exist. In 2026 it's a fine tool but largely redundant for internal app code — server actions give you the same end-to-end types with less ceremony.

    The case for keeping tRPC:

    • You want one API layer that serves both your Next.js app and a separate consumer (Electron, Tauri, third-party)
    • You want client-side query/cache management for free (tRPC + React Query is a tight pairing)
    • You have an existing tRPC investment you don't want to migrate

    We don't introduce tRPC on new projects. We don't tear it out of existing ones either.

    What about server components vs server actions?

    Different layers. Server components read data. Server actions mutate data. They compose:

    • Page loads → server components query the DB directly
    • User submits a form → server action mutates the DB → revalidatePath() re-renders the server component

    That's the whole loop. No useEffect, no useState for server data, no fetch. The mental model is dramatically simpler than the pre-App-Router world.

    Building a Next.js App Router app?

    We ship server actions, RSC, and API routes the way they're meant to be used — and we've debugged most of the production gotchas already.

    See SaaS service

    FAQ

    Are server actions stable?

    Yes. Stable in Next.js 14+, default in App Router. The API has been stable for over a year.

    Can I call a server action from another server function?

    Yes, just import it and call it like a regular function. Server-to-server calls don't hit the network.

    Can I use server actions with React Query?

    Yes. Wrap a server action in a mutation function. You lose some of the form-state ergonomics but gain query cache integration.

    Do server actions support streaming responses?

    No. For streaming you need an API route with Response and ReadableStream. Common case: AI chat (token-by-token streaming) → API route, not server action.

    What about rate limiting?

    Server actions hit the same Next.js runtime as your pages. Rate-limit at the edge (Vercel WAF, Cloudflare) or in middleware. Per-action rate limiting can also work with a Redis counter keyed on userId + actionId.

    Can server actions handle file uploads?

    Small files via FormData, up to the request size limit (~1MB on Vercel). For larger uploads, use a presigned URL flow: server action returns a signed S3/R2 URL, client uploads directly to storage, then notifies the server action of completion.

    Can I authenticate server actions with a JWT from a non-cookie source?

    Yes, but it's awkward. Server actions are designed for cookie-auth via Next.js's auth helpers. If your caller doesn't use cookies (e.g. a mobile app), use an API route instead.

    Are server actions slower than API routes?

    No. They go through the same Next.js runtime. The performance is comparable. Server actions have slightly more overhead from the RSC payload format but it's not measurable for typical workloads.

    TagsNext.jsServer ActionsAPI RoutesReactApp Router
    ServiceSaaS DevelopmentMVP Development
    Case studyCloudChat
    PreviousTypeScript monorepo: pnpm + Turborepo tradeoffs in 2026Next Self-hosting Llama vs Claude API: the real cost breakdown

    Keep reading

    ArchitectureMulti-tenant Postgres: row-level security explained (with real code)How RLS actually works in production multi-tenant SaaS — set policies, set the session variable, handle bypass, and avoid the three failure modes that bite teams at scale.9 min readArchitectureWebhook idempotency: the bug most teams shipWhy webhook handlers double-charge, double-grant, and double-cancel — and the three-line database pattern that fixes all of it.8 min readToolingBuilding an internal tool instead of buying RetoolWhen the build-your-own internal tool is the right call — the cost comparison most teams skip, and the surprisingly small Next.js + Postgres scaffold that replaces Retool for many use cases.8 min read
    On this page
    • The 30-second decision
    • Why server actions are good
    • Why API routes still matter
    • The hidden footguns of server actions
    • Hidden POST size limits
    • CSRF — Next.js handles it but only partially
    • Auth context
    • Error handling
    • The hybrid pattern we ship
    • What about tRPC?
    • What about server components vs server actions?
    • FAQ
    • Are server actions stable?
    • Can I call a server action from another server function?
    • Can I use server actions with React Query?
    • Do server actions support streaming responses?
    • What about rate limiting?
    • Can server actions handle file uploads?
    • Can I authenticate server actions with a JWT from a non-cookie source?
    • Are server actions slower than API routes?

    YOUR APP EXPERT LTD

    71-75 Shelton Street, LONDON WC2H 9JQ, UK

    +44 20 1234 5678

    [email protected]

    Quick Links

    • Services
    • About Us
    • Portfolio
    • Blog
    • Contact

    Stay Connected

    Newsletter

    Stay updated with our latest innovations and insights.

    © 2026 YOUR APP EXPERT LTD. All rights reserved.

    Engineering the Future of Technology