
AI Summary
Server actions vs API routes is a question of what you're building, not which is "better." Server actions are async functions marked with "use server" that run on the server and are called directly from your React UI through a Next.js RPC protocol—ideal for first-party forms and mutations. API routes are explicit HTTP endpoints addressable by URL—ideal for webhooks, third-party clients, and public APIs. This guide breaks down the decision rubric, security trade-offs, performance characteristics, and copy-paste patterns for both.
The Short Answer: When to Use Each
Use server actions when the caller is your own Next.js UI and the operation is a mutation—form submissions, button clicks that write to a database, optimistic updates. Use API routes when the caller is anything else (third-party webhook, mobile app, external integration) or when you need full HTTP semantics like custom status codes, streaming responses, or CORS handling. Most modern Next.js apps end up using both: server actions for the 80% of internal mutations, API routes for the 20% that need a real HTTP surface.
Both run on the server, both can hit your database, both can be secured. The difference is the calling convention. Server actions use a Next.js-specific RPC protocol that hides the HTTP layer; API routes are the HTTP layer. Pick the one that matches the caller and the operation.
Quick decision rubric:
- • First-party form submission: server action
- • Stripe / GitHub / Sanity webhook: API route
- • OAuth callback URL: API route
- • Mobile app backend: API route (or shared service called by both)
- • Optimistic UI mutation: server action with useOptimistic
- • Cron job target: API route
- • Server-sent events / streaming: API route
Source: Next.js docs—updating data and route handlers reference.
What Each One Actually Is
Server Actions: RPC for Your Own UI
A server action is an async function that begins with the "use server" directive. The directive tells Next.js to expose the function to the client through a remote procedure call (RPC) protocol. When the client invokes the function, the bundler replaces the call with a network request, Next.js routes the request to the server, executes the function, and returns the result.
You don't see the HTTP layer. You write a function, mark it "use server", and call it from a React component. The framework handles transport, serialization, CSRF tokens, and error propagation. Stable in Next.js 14 and refined through Next.js 16, server actions are the primary recommended pattern for mutations triggered by your own React UI.
app/actions/contact.ts — server action:
'use server'
import { z } from 'zod'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
const ContactSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
message: z.string().min(10).max(2000),
})
export async function submitContact(formData: FormData) {
const parsed = ContactSchema.safeParse({
name: formData.get('name'),
email: formData.get('email'),
message: formData.get('message'),
})
if (!parsed.success) {
return { error: parsed.error.flatten().fieldErrors }
}
await db.lead.create({ data: parsed.data })
revalidatePath('/admin/leads')
return { success: true }
}API Routes: Real HTTP Endpoints
An API route (technically a "route handler" in App Router parlance) is a file at app/api/[name]/route.ts that exports HTTP method handlers (GET, POST, PUT, PATCH, DELETE). Each handler receives a standard Request object and returns a Response—the same Web Fetch API your browser uses.
The endpoint has a public URL (/api/contact), accepts cross-origin requests if you allow them, and behaves exactly like any other HTTP API. Mobile apps, webhooks, curl commands, and Postman all work without modification. This is the layer for anything that isn't your own React UI.
app/api/contact/route.ts — API route:
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { db } from '@/lib/db'
const ContactSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
message: z.string().min(10).max(2000),
})
export async function POST(request: Request) {
const body = await request.json()
const parsed = ContactSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.flatten().fieldErrors },
{ status: 400 }
)
}
await db.lead.create({ data: parsed.data })
return NextResponse.json({ success: true }, { status: 201 })
}Notice the difference. The action returns plain values; the route returns explicit Response objects with status codes and headers. The action is invoked through a generated RPC call; the route is invoked through standard fetch(). Same database write, different surface area.
Side-by-Side Decision Matrix
The decision usually collapses to two questions: who calls it, and what shape does the response need? The matrix below covers the recurring scenarios on Verlua client builds.
| Scenario | Server Action | API Route | Pick |
|---|---|---|---|
| Contact form on your site | Native FormData support, CSRF built in | Works, more boilerplate | Server action |
| Stripe webhook | Not callable from outside | Public URL, signature verification | API route |
| Optimistic UI update (likes, votes) | useOptimistic + action | Manual fetch + state | Server action |
| OAuth callback | Provider can't hit it | Standard redirect URL | API route |
| Mobile app backend | RPC protocol Next.js-only | Standard REST | API route |
| Streaming AI response | Limited streaming support | Full ReadableStream control | API route |
| Newsletter signup | Form action one-liner | Fetch + JSON | Server action |
| Cron-triggered job | Cron can't call actions | URL Vercel Cron can hit | API route |
Decisions reflect Next.js 16 conventions and Vercel platform behavior as of April 2026.
Where Each Pattern Wins (Verlua Client Build Audit, n=42 projects)
Security: What Each One Gives You for Free
Both run on the server. Both touch the same database. The security difference is what you get out of the box vs what you must implement yourself.
Server Action Security Defaults
- Encrypted action IDs: Next.js 16 ships with
NEXT_SERVER_ACTIONS_ENCRYPTION_KEYthat hashes action references in the bundle, preventing replay attacks against renamed or removed actions. - Origin verification: requests must come from the same origin as the page; cross-origin invocations are rejected at the framework level.
- CSRF protection: the RPC handshake includes per-request tokens that the framework validates before executing the function.
- Dead-code elimination: unused actions are stripped from the bundle so removed code can't be reached after deploy.
You still need to authenticate the user, authorize the operation, and validate inputs. The framework handles transport security; the application handles business security.
API Route Security: You Own It
API routes are vanilla HTTP. You write the auth check, the origin check, the rate limiter, the input validator, and the error handler. The Next.js docs lay out the responsibilities clearly: route handlers are the framework's lowest-level primitive for HTTP, which is the point.
API route with explicit security checks:
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { rateLimit } from '@/lib/rate-limit'
export async function POST(request: Request) {
// 1. Origin check for browser-only endpoints
const origin = request.headers.get('origin')
if (origin && !isAllowedOrigin(origin)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
// 2. Auth check
const session = await auth()
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// 3. Rate limit
const limited = await rateLimit(session.user.id)
if (!limited.success) {
return NextResponse.json(
{ error: 'Too many requests' },
{ status: 429 }
)
}
// 4. Input validation
const body = await request.json()
const parsed = MutationSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.flatten() },
{ status: 400 }
)
}
// 5. Now do the work
const result = await db.thing.create({ data: parsed.data })
return NextResponse.json(result, { status: 201 })
}The same five checks apply inside server actions—you're just freed from the origin and CSRF parts because the framework handles them. The Next.js security guidance spells out the same principle: actions are not implicitly safer; they reduce one class of mistake.
Pro Tip:
Treat every server action like a public POST endpoint when writing the body of the function. Authorization first, validation second, mutation last. The framework prevents the call from outside your origin, but if you ever leak a session cookie, the action is reachable—plan for it.
Performance: Where the Numbers Differ
Both invoke server code. Network round-trip and server work dominate the timing. The differences are at the margins—payload size, serialization cost, and the ability to stream.
Server actions ship a small RPC envelope per call. The framework serializes arguments using a superset of JSON that handles Dates, Maps, Sets, and FormData natively. Round-trip overhead measured on Vercel Edge against Vercel Postgres typically lands in the 90–140 ms range for a simple write—the bulk being the database call, not the action protocol. API routes hit the same numbers when implemented well; the framework overhead delta is a few milliseconds.
Where API routes pull ahead is streaming. A route handler can return a ReadableStream directly, which matters for AI responses, server-sent events, and large file downloads. Server actions can stream return values in newer Next.js releases but the API surface is more constrained—if streaming is core to the feature, reach for a route.
Round-Trip Latency by Operation Type (Next.js 16, p50 ms)
Forms: Where Server Actions Shine
The single best argument for server actions is form handling. The pattern is short, progressively enhanced, and free of state management ceremony.
Pass the action to the form's action prop. The form submits over HTTP if JavaScript is disabled and over the RPC protocol if it isn't. Either way, the function runs server-side with the FormData. Combine with React's useActionState for inline error handling and useFormStatus for pending states.
Progressively-enhanced form with server action:
'use client'
import { useActionState } from 'react'
import { submitContact } from '@/app/actions/contact'
export function ContactForm() {
const [state, formAction, pending] = useActionState(
submitContact,
{ success: false, error: null }
)
return (
<form action={formAction} className="space-y-4">
<input name="name" required className="w-full border p-2" />
<input name="email" type="email" required className="w-full border p-2" />
<textarea name="message" required className="w-full border p-2" />
{state.error && (
<p className="text-red-600 text-sm">Please fix the errors above.</p>
)}
{state.success && (
<p className="text-emerald-600 text-sm">Thanks—we'll be in touch.</p>
)}
<button
type="submit"
disabled={pending}
className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
>
{pending ? 'Sending...' : 'Send Message'}
</button>
</form>
)
}The equivalent on an API route requires a client-side fetch wrapper, manual loading state, manual error handling, and explicit JSON serialization. The action version is roughly half the code with progressive enhancement included for free. For form-heavy sites—which describes most marketing builds—this is the productivity story behind React vs Next.js: actions remove the "render a form, write a fetch handler, manage state" tax.
Where API Routes Are the Right Tool
Webhooks: A Public URL Other Systems Hit
Stripe, GitHub, Sanity, Resend, and every other service that pushes events to your backend needs a public URL with a stable contract. Server actions don't expose URLs; their endpoints are derived from the bundled action ID. API routes do—the file path is the URL.
Stripe webhook handler with signature verification:
import { NextResponse } from 'next/server'
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(request: Request) {
const body = await request.text()
const signature = request.headers.get('stripe-signature')
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
body,
signature!,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
}
switch (event.type) {
case 'checkout.session.completed':
await handleCheckout(event.data.object)
break
case 'customer.subscription.deleted':
await handleCancel(event.data.object)
break
}
return NextResponse.json({ received: true })
}OAuth Callbacks
Identity providers redirect the user's browser to a fixed callback URL with a code in the query string. The handler exchanges the code for a token and sets a session cookie. This is a GET request from outside your app—server action territory does not include it.
Streaming and Server-Sent Events
AI chat features stream tokens as the model generates them. A route handler returns a ReadableStream as the response body, lets the client consume it incrementally, and closes the stream when the model is done. This is what powers the Vercel AI SDK and every "streaming chat" demo in the ecosystem.
Cron Jobs and External Triggers
Vercel Cron and similar schedulers hit a URL on a schedule. Cleanup jobs, daily digest emails, sitemap regeneration, and external sync tasks all need an addressable endpoint. Define them as API routes, secure with a shared bearer token in the CRON_SECRET environment variable, and call from the cron config.
Decision Flow: Action or Route?
The Hybrid Pattern: Use Both, Share the Logic
Real apps use both. The trick is to keep the actual business logic in a shared service module so the action and the route are thin wrappers that handle their respective transports.
Shared service module — single source of truth:
// lib/services/leads.ts
import { z } from 'zod'
import { db } from '@/lib/db'
export const LeadSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
source: z.string().optional(),
})
export async function createLead(input: unknown) {
const data = LeadSchema.parse(input)
return db.lead.create({ data })
}Server action wrapper:
'use server'
import { createLead } from '@/lib/services/leads'
import { revalidatePath } from 'next/cache'
export async function submitLead(formData: FormData) {
await createLead({
name: formData.get('name'),
email: formData.get('email'),
source: 'website-form',
})
revalidatePath('/admin/leads')
return { success: true }
}API route wrapper for partner integrations:
import { NextResponse } from 'next/server'
import { createLead } from '@/lib/services/leads'
import { verifyApiKey } from '@/lib/auth'
export async function POST(request: Request) {
const apiKey = request.headers.get('x-api-key')
if (!verifyApiKey(apiKey)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const lead = await createLead({ ...body, source: 'partner-api' })
return NextResponse.json(lead, { status: 201 })
} catch (err) {
return NextResponse.json({ error: 'Invalid input' }, { status: 400 })
}
}One service, two transports, zero duplicated business logic. Adding a third caller—a CLI, a cron task, a Lambda—is another thin wrapper over the same function. This is the architecture pattern detailed in API development best practices: the transport is a thin shell over a service layer.
Caching, Revalidation, and Mutations
A common confusion: should server actions cache? They're mutations—no. Their job is to write, not to read. After the write, they trigger cache invalidation so the next render shows the new state.
The toolkit: revalidatePath(path) invalidates a specific route, revalidateTag(tag) invalidates every cache entry tagged with that label, and updateTag(tag) is the newer Cache Components-aware version. Use them at the end of the action body, after the mutation succeeds.
If you're still mapping out caching strategy, the deeper dive sits in Next.js performance optimization—Cache Components, Partial Prerendering, and the broader rendering model. Server actions are the write-side primitive; cached server components are the read-side primitive. They're complementary.
After every action that mutates data:
- Run the database write or external API call
- Call
revalidatePathorrevalidateTagfor affected pages - Return a result the client UI can use (success flag, validation errors, or the new resource)
- Optionally redirect with
redirect()if the workflow ends elsewhere
Common Gotchas and How to Avoid Them
Importing Server Code Into Client Components
Server actions can be imported by client components, but module-level side effects in those files run on both sides. Keep server-only secrets out of files that export actions—or use the server-only package to fail loud at build time when a server module gets imported by client code.
FormData Type Coercion
formData.get('count') returns a string or null—even for number inputs. Validate and coerce inside Zod (z.coerce.number()) rather than reaching for parseInt at the call site. Same goes for booleans (checkboxes are present-or-absent, not true-or-false).
Redirect After Mutation
Calling redirect() inside a try/catch swallows the redirect because Next.js implements it by throwing. Either avoid the try/catch, or rethrow when the caught error is a redirect (isRedirectError from next/dist/client/components/redirect).
Rate Limiting Public Actions
A public newsletter signup action is reachable by anyone visiting the site—same as a public API route. Add rate limiting (Upstash Redis, in-memory token bucket on the edge, or Vercel's built-in middleware) to both. The framework's CSRF protection blocks cross-origin abuse but not high-volume same-origin attacks.
Error Handling and User Feedback
Throwing inside an action sends an opaque error to the client. Return structured results ({ success: false, error: '...' }) for expected failures and let the framework error boundary handle truly exceptional cases. useActionState works hand-in-hand with the structured-result pattern.
Should You Rewrite Existing API Routes as Server Actions?
Probably not. Working API routes don't become broken because actions exist. Rewrite when one of these applies:
- The route is exclusively called from your own UI and the form code is heavy (loading state, error handling, manual fetch wrapper).
- You want progressive enhancement—the form should work without JavaScript.
- The endpoint is causing CSRF or origin headaches that the action defaults would eliminate.
- You're actively reducing client-side JavaScript and the fetch wrapper code is on the chopping block.
Don't rewrite if the route is also serving non-Next.js callers, if it depends on streaming, or if it's already stable and well-tested. Migration is mechanical but introduces risk—pick the rewrites that actually pay off.
Implementation Checklist
For every server action:
- □Authenticate the caller (auth() check or session validation)
- □Authorize the operation (does this user own the resource?)
- □Validate input with Zod (z.coerce for FormData primitives)
- □Rate limit if the action is public-facing
- □Return a structured result, not a thrown error, for expected failures
- □Call revalidatePath or revalidateTag after mutation
For every API route:
- □Verify origin or signature (for webhooks: provider-specific signature check)
- □Authenticate (session, API key, or bearer token depending on caller)
- □Validate the request body with Zod
- □Apply rate limiting per IP or per identity
- □Return correct HTTP status codes (201 for create, 400 for validation, 401/403 for auth, 429 for rate limit)
- □Set CORS headers explicitly if cross-origin access is intended
If you're hiring out the build instead of writing it yourself, the questions to ask candidates show up in how to hire a Next.js developer—familiarity with both primitives and the architectural sense to pick the right one is a real differentiator in 2026.
Frequently Asked Questions
What is the difference between server actions and API routes in Next.js?
Server actions are async functions marked with the "use server" directive that run on the server and can be called directly from client or server components, with Next.js handling the network call, serialization, and CSRF protection automatically. API routes are dedicated HTTP endpoints defined in app/api/[name]/route.ts that respond to standard HTTP requests (GET, POST, PUT, DELETE) and are addressable by URL. Use server actions for first-party form submissions and mutations from your own UI; use API routes when you need a public URL, third-party webhooks, non-browser clients, or full HTTP control.
Are server actions more secure than API routes?
Server actions ship with built-in CSRF protection, encrypted action IDs, and automatic same-origin checks—security features you would have to implement yourself on a raw API route. However, server actions are not safer than API routes in absolute terms: both run on the server and require the same authorization, input validation, and rate limiting. The advantage is that the secure-by-default surface for first-party form submissions removes a class of common mistakes. For public-facing endpoints, API routes give you the explicit control needed for token validation and origin verification.
Do server actions work with non-React clients?
No. Server actions are invoked through a Next.js-specific RPC protocol that depends on the React Server Components runtime. Mobile apps, third-party services, and any non-browser client cannot call a server action—they need an addressable HTTP endpoint, which means an API route. If you need a single backend that serves both your Next.js UI and a mobile app, expose the logic through an API route and have your server actions call that route internally, or extract the logic into a shared service module that both call.
When should I use API routes instead of server actions?
Use API routes when you need a stable public URL (webhooks, OAuth callbacks, third-party integrations), when non-Next.js clients must consume the endpoint (mobile apps, partner systems), when you need full HTTP semantics (custom status codes, streaming responses, CORS preflight handling), or when the operation is read-only and benefits from HTTP caching. Server actions are optimized for mutations triggered from your own UI; API routes are the right tool whenever you need a real HTTP API surface.
Can server actions be cached?
Server actions themselves are mutations and are not cached—every invocation runs server-side. They do, however, integrate with the Next.js cache: an action can call revalidatePath, revalidateTag, or updateTag to invalidate cached data after a write. The pattern is to mutate the database in the action, then trigger cache invalidation so the next render shows the updated state. Read-only data fetching belongs in cached server components or "use cache" functions, not in server actions.
How do I handle file uploads with server actions vs API routes?
Server actions accept FormData natively—pass an action to a form's action prop and the form data, including files, arrives as the function argument. This is the cleanest path for first-party uploads under the platform body size limit (Vercel's default is 4.5 MB on Hobby and Pro plans). For larger files, streaming uploads, or signed direct-to-storage uploads, use an API route or a presigned URL flow because you need explicit control over the request lifecycle and headers that server actions abstract away.
Building a Next.js App and Need a Senior Hand?
Verlua ships Next.js 16 marketing sites and product surfaces with the right mix of server actions and API routes—shared service modules, hardened auth, and tested cache invalidation. We work with founders who want to skip the architectural detours and ship the thing.
Talk to a Next.js EngineerFounder & Technical Director
Mark Shvaya runs Verlua, a web design and development studio in Sacramento. He builds conversion-focused websites for service businesses, e-commerce brands, and SaaS companies.
California real estate broker, property manager, and founder of Verlua.
Stay Updated
Get the latest insights on web development, AI, and digital strategy delivered to your inbox.
No spam, unsubscribe anytime. We respect your privacy.
Comments
Comments section coming soon. Have questions? Contact us directly!
Related Articles
React vs Next.js: Which Should You Choose?
When to reach for React alone and when Next.js earns its weight—routing, rendering, performance, and SEO trade-offs.
Read MoreNext.js Performance Optimization: Best Practices
Server Components, streaming, image optimization, and advanced caching patterns for fast Next.js apps.
Read MoreAPI Development Best Practices
REST design, versioning, auth, rate limiting, and the conventions that keep APIs maintainable.
Read More