Route Handlers vs Server Actions for Public APIs: Pick the Right Door
Server actions look tempting for every mutation, but they're the wrong front door for public APIs. Here's the rule we use in production, and the failure modes that taught us the difference.

Server actions feel magical the first week. By month three, someone on your team will try to call one from a mobile app, a Zapier webhook, or a cron job — and discover the magic doesn't travel. This is the article we wish we'd handed that person on day one.
The rule we actually use
After shipping a dozen App Router apps, we've settled on a rule that fits on a sticky note:
Server actions are for your own UI. Route handlers are for everyone else.
That's it. If the caller is a React component in the same Next.js app, use a server action. If the caller is a mobile app, a webhook, a background worker, a partner integration, a curl script in a Notion doc, or even a different Next.js app you own — use a route handler.
The reason isn't dogma. It's that server actions and route handlers optimize for different things, and forcing one to do the other's job creates bugs that only show up in production.
What server actions are actually good at
Server actions are RPC glued tightly to React. They're excellent when:
- The caller is a client or server component in the same app
- You want progressive enhancement (form submissions that work without JS)
- You want automatic revalidation via
revalidatePathorrevalidateTag - You want
useOptimisticanduseFormStatusto just work - You care about not shipping a fetch client to the browser
Here's the shape most of ours take:
// app/(dashboard)/projects/actions.ts
'use server'
import { revalidateTag } from 'next/cache'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { db } from '@/lib/db'
const RenameInput = z.object({
projectId: z.string().uuid(),
name: z.string().min(1).max(120),
})
export async function renameProject(formData: FormData) {
const session = await getSession()
if (!session) throw new Error('Unauthorized')
const parsed = RenameInput.safeParse({
projectId: formData.get('projectId'),
name: formData.get('name'),
})
if (!parsed.success) {
return { ok: false as const, error: 'Invalid input' }
}
await db.project.update({
where: { id: parsed.data.projectId, ownerId: session.userId },
data: { name: parsed.data.name },
})
revalidateTag(`project:${parsed.data.projectId}`)
return { ok: true as const }
}
Notice what's happening: the action is co-located with the feature, it's typed end-to-end, it revalidates the right cache tag, and the client never sees the fetch URL because there isn't a stable one to see. That last part is a feature and a warning.
The invisible endpoint problem
Server actions do serialize over HTTP under the hood — Next.js POSTs to the current route with an action ID header. But that endpoint is an implementation detail. The action ID can change between builds. There's no OpenAPI spec, no stable path, no versioning story. If anything outside your React tree tries to call it, you're building on sand.
We learned this the hard way when a client's Retool dashboard "integrated" with a server action by copying the network request out of Chrome DevTools. It worked for six weeks. Then we shipped a refactor and every button in Retool broke silently.
What route handlers are actually good at
Route handlers (app/api/**/route.ts) are boring, explicit HTTP. They're the right tool when:
- A third party is calling you (webhooks from Stripe, Clerk, GitHub, etc.)
- A mobile or native app needs an API
- You want a documented, versioned public API
- You need non-JSON responses — streams, files, Server-Sent Events
- You need fine-grained control over headers, status codes, or CORS
- You want to run it on the edge runtime for latency-sensitive reads
// app/api/v1/projects/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { verifyApiKey } from '@/lib/api-auth'
import { db } from '@/lib/db'
export const runtime = 'nodejs'
const PatchBody = z.object({
name: z.string().min(1).max(120).optional(),
archived: z.boolean().optional(),
})
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params
const auth = await verifyApiKey(req.headers.get('authorization'))
if (!auth.ok) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = PatchBody.safeParse(await req.json())
if (!body.success) {
return NextResponse.json(
{ error: 'Invalid body', issues: body.error.flatten() },
{ status: 422 },
)
}
const updated = await db.project.update({
where: { id, ownerId: auth.userId },
data: body.data,
})
return NextResponse.json(updated, {
headers: { 'Cache-Control': 'no-store' },
})
}
This is uglier than the server action. That's the point. Every failure mode is visible: auth, validation, status codes, cache headers. A partner integrating against this can read the file and know exactly what to send.
The gotchas nobody warns you about
Server actions and CSRF
Server actions have some built-in protection: they check the Origin header against the host and reject cross-origin POSTs. That's fine for a single-origin app. It falls apart the moment you put your Next.js app behind a reverse proxy that rewrites headers, or embed a form in an iframe on a different origin, or preview a PR on a Vercel deployment URL that doesn't match your allowedOrigins.
If you see x-forwarded-host warnings in your logs, that's this. Configure serverActions.allowedOrigins in next.config.js explicitly rather than hoping the defaults hold.
Route handlers and caching
Since Next.js 15, route handlers are uncached by default — a change from earlier versions where GET handlers were cached aggressively. Good default, but the migration bit a lot of teams. If you're on an older app and see stale API responses, check whether you're relying on the old implicit caching.
For read-heavy public endpoints, opt back in explicitly with export const dynamic = 'force-static' and cache tags, or use Cache-Control headers if you're fronting with a CDN.
File uploads
Server actions support FormData with files, and it feels great — until you hit the body size limit and discover the error surfaces as a generic client-side failure with no useful message. For any upload over a few MB, use a route handler with streaming, or better, a direct-to-S3 presigned URL flow. We stopped shipping server actions for file uploads entirely.
Webhooks are never server actions
If you catch yourself writing a server action to receive a Stripe webhook, stop. Webhooks need signature verification against the raw request body, stable URLs you can register with the provider, and precise HTTP status semantics (Stripe retries on non-2xx). All of that is a route handler's job.
The hybrid pattern that actually scales
On larger apps we end up with both, and that's fine. The pattern:
- Business logic lives in plain functions in
lib/orservices/, framework-agnostic - Server actions wrap those functions for the UI, adding React-specific concerns (revalidation, redirects, optimistic state)
- Route handlers wrap the same functions for external callers, adding HTTP-specific concerns (auth schemes, versioning, headers)
// lib/projects/rename.ts — the actual logic
export async function renameProjectService(input: {
userId: string
projectId: string
name: string
}) {
// validation, authorization, db write, audit log
// returns a typed result
}
// app/(dashboard)/projects/actions.ts — UI door
'use server'
export async function renameProject(formData: FormData) {
const session = await getSession()
const result = await renameProjectService({ /* ... */ })
revalidateTag(`project:${result.id}`)
return result
}
// app/api/v1/projects/[id]/route.ts — public door
export async function PATCH(req: NextRequest, ctx: Ctx) {
const auth = await verifyApiKey(req.headers.get('authorization'))
const result = await renameProjectService({ /* ... */ })
return NextResponse.json(result)
}
Both doors, one room. When the logic changes, you change it once. When someone asks "can we expose this to the mobile app?" the answer is a ten-line route handler, not a refactor.
Where we'd start
If you're staring at an existing App Router codebase wondering which mutations to migrate: don't migrate anything yet. Instead, audit your current server actions and ask, for each one, who else might need to call this in the next twelve months? If the honest answer is "nobody but this form," leave it alone. If it's "probably the mobile app, or a webhook, or a partner," extract the logic into a service function today and add the route handler when the second caller shows up.
The worst outcome isn't picking the wrong door. It's picking a door with no handle on the outside and then needing to let someone in. If you want a second pair of eyes on an App Router architecture before it hardens, our web development team does this kind of review often — usually in a couple of afternoons, not a couple of weeks.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Partial Prerendering in Production: What Actually Ships and What Doesn't
Partial Prerendering promised the best of static and dynamic. After shipping it across three production apps, here's what works, what breaks, and where the seams still show.
Server Actions at Scale: Why We Stopped Using Them for Everything
Server Actions in Next.js feel like magic until your app grows up. Here's where they shine, where they hurt, and the rules we now apply before reaching for them in production.
useOptimistic in Anger: Why Our Optimistic UI Felt Worse Than No UI
React 19's useOptimistic looks like a free win until you ship it. Here's what broke for us in production — race conditions, ghost rows, and reconciliation traps — and the patterns we now use instead.
