Next.js & React

Next.js Server Actions vs Route Handlers (API Routes): When to Use Each

Server Actions vs Route Handlers in Next.js 16: use Server Actions for mutations from your own UI, Route Handlers for webhooks, mobile apps and public APIs, and Server Components for reads. Tested code, comparison tables and security rules.

Server Actions vs Route Handlers cover: a 'use server' action file and a route.ts POST handler side by side on a dark grid
On this page
  1. What is the difference between Server Actions and Route Handlers?
  2. Quick definitions
  3. TL;DR: when to use Server Actions vs Route Handlers
  4. What are Server Actions in Next.js?
  5. What are Route Handlers in Next.js?
  6. Server Actions vs Route Handlers: side-by-side comparison
  7. When should you use Server Actions?
  8. Should you use revalidatePath, updateTag or revalidateTag in a Server Action?
  9. When should you use Route Handlers?
  10. Can you use Server Actions and Route Handlers together?
  11. Should you use Server Actions to fetch data?
  12. Should a Server Component fetch from your own Route Handler?
  13. Are Server Actions secure?
  14. Server Actions vs API routes: what changed from the Pages Router?
  15. Do Server Actions replace tRPC or TanStack Query?
  16. Common Server Action and Route Handler mistakes
  17. Key takeaways

Server Actions vs Route Handlers comes down to one question: who is calling your server code? Use a Server Action when your own Next.js UI mutates data (forms, buttons, optimistic updates). Use a Route Handler when anything else needs a real HTTP endpoint: webhooks, mobile apps, public APIs, OAuth callbacks, file downloads, or cached GET JSON. For reads, use neither; fetch directly in a Server Component.

This guide is written against Next.js 16.4 and React 19.3, the current stable releases. Every code sample below was built with a fresh create-next-app project (which now enables Cache Components by default), type-checked, linted, and run with next start, and the Route Handler responses shown are the real ones I got back. Where I state a framework behavior, it links to the official Next.js or React docs.

If you are building an API for AI agents, the same rules apply: the MCP endpoint in my MCP server in Next.js guide is a Route Handler, because an external client calls it.

What is the difference between Server Actions and Route Handlers?#

A Server Action is an async function marked with 'use server' that your React components call like a normal function; Next.js turns the call into a POST request and can return fresh UI in the same response. A Route Handler is a route.ts file that exports GET, POST and other HTTP methods and returns a standard Web Response to any client.

In other words, a Server Action is an internal RPC call for your own frontend, and a Route Handler is a public URL with a contract. Both run only on the server, both can read cookies and talk to your database, and both are reachable by anyone who sends the right request, so both need authentication.

Quick definitions#

TermWhat it means
Server FunctionAny async function marked with 'use server' that the client can call over the network (React docs).
Server ActionA Server Function used for a mutation, through <form action>, formAction or startTransition (Next.js docs).
Route HandlerA route.ts file in app/ that handles HTTP requests with the Web Request and Response APIs (Next.js docs).
API RouteThe Pages Router equivalent of a Route Handler, in pages/api.
RSC PayloadThe serialized Server Component tree the client uses to update the page.

TL;DR: when to use Server Actions vs Route Handlers#

You need to...UseWhy
Submit a form or mutate data from your own UIServer ActionProgressive enhancement, typed arguments, fresh UI in one roundtrip
Show pending, error and success states for a mutationServer Action + useActionStateThe hook gives you state, formAction and pending
Read data for a pageServer ComponentFetch straight from the data source; no extra HTTP hop
Receive a webhook (Stripe, GitHub, a CMS)Route HandlerFixed URL, raw body for signature checks, custom status codes
Serve a mobile app, CLI or third-party clientRoute HandlerA stable, documented HTTP contract that does not change per build
Return non-HTML content (RSS, CSV, images, files)Route HandlerReturn any Response with any content type
Handle an OAuth or payment callback URLRoute HandlerThird parties redirect to a known URL with query params
Poll or fetch client-side (SWR, TanStack Query)Route HandlerServer Actions run one at a time and are not meant for reads
Expose a cacheable GET JSON endpointRoute HandlerGET handlers can opt into caching; actions are always POST

Decision flow for choosing between a Next.js Server Action, Route Handler or Server Component

What are Server Actions in Next.js?#

Server Actions are async server functions that Next.js exposes to your components through the 'use server' directive. At build time the compiler replaces the function in client bundles with an encrypted action ID, so calling it sends a POST to the current page. Next.js can then return both the action's result and a re-rendered page in a single response.

Three details from the official docs matter for the decision:

  • Only POST can invoke them. The Mutating Data docs state that actions use POST behind the scenes and only that method can call them.
  • One response carries data and UI. If the action calls revalidatePath, updateTag, refresh, sets a cookie or redirects, Next.js re-renders the current route and streams it back in the same response, so you do not need a follow-up fetch (Server Actions guide).
  • They are dispatched one at a time per client. If a user triggers three actions quickly, the second waits for the first. The docs say not to rely on Promise.all to parallelize them.

Forms that call Server Actions from Server Components also work before JavaScript loads, which is progressive enhancement you do not get from a fetch call to an API.

What are Route Handlers in Next.js?#

Route Handlers are route.ts files inside the app directory that export functions named after HTTP methods. They support GET, POST, PUT, PATCH, DELETE, HEAD and OPTIONS, return a 405 for anything else, and work with the standard Web Request and Response APIs, plus Next.js helpers NextRequest and NextResponse.

A few rules from the Route Handlers docs shape how you use them:

  • A route.ts cannot sit in the same segment as a page.tsx (app/route.ts next to app/page.tsx is a conflict; app/api/route.ts is fine).
  • Route Handlers are not cached by default. You can opt GET into caching; other methods are never cached.
  • With Cache Components enabled, GET handlers follow the same model as pages: they are prerendered when they touch no runtime data, and you can cache a helper with 'use cache'.
  • They do not take part in layouts or client-side navigation. They are the lowest-level routing primitive.

The Next.js Backend for Frontend guide sums up their role: Route Handlers are public HTTP endpoints, and any client can access them.

Server Actions vs Route Handlers: side-by-side comparison#

Server ActionRoute Handler
Defined in'use server' file or inline functionapp/**/route.ts
HTTP methodsPOST onlyGET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS
URLNo stable public URL; called by encrypted action IDFixed URL you choose
Typical callerYour own React componentsAny HTTP client
InputSerialized function arguments or FormDataRequest (JSON, form data, raw text, streams)
OutputSerializable return value plus fresh RSC PayloadAny Response and content type
CachingNever cachedGET can opt in; others never cached
Concurrency from one clientDispatched one at a timeFully parallel
Form integrationBuilt in; <form action> works before JavaScript loadsNone; you wire up fetch or a form post yourself
Built-in CSRF checkYes, Origin vs Host comparisonNo; add your own for cookie-auth mutations
Default body size limit1 MB (configurable)No Server Action limit applies
Immediate cache updateupdateTag (Server Actions only)revalidateTag(tag, 'max'), revalidatePath
Survives a new deployIDs can change between buildsURL stays the same

The last row is easy to miss. Next.js docs note that new deployments typically generate new action IDs, rotated at most every 14 days even with unchanged code, so a tab still running an old build can hit a "Failed to find Server Action" error. That is fine for your own UI, which reloads, but it rules Server Actions out as an API for clients you do not ship with each build.

When should you use Server Actions?#

Use a Server Action when your own Next.js UI changes data and the user should see the result right away: creating, editing or deleting records, toggling settings, submitting forms, liking a post, or updating a cart. You get typed calls, progressive enhancement, built-in pending state and one-roundtrip revalidation without writing an API contract.

Here is a complete, working example. The action authenticates, validates with Zod, mutates, and revalidates:

ts
// app/todos/actions.ts
'use server'

import { revalidatePath } from 'next/cache'
import { getUserId } from '@/lib/auth'
import { createTodo, TodoInput } from '@/lib/todos'

export type FormState = { ok: boolean; message: string }

export async function addTodo(_prev: FormState, formData: FormData): Promise<FormState> {
  // 1. Authenticate inside the action. It is a public POST endpoint.
  const userId = await getUserId()
  if (!userId) return { ok: false, message: 'Please sign in first.' }

  // 2. Validate untrusted input.
  const parsed = TodoInput.safeParse({ title: formData.get('title') })
  if (!parsed.success) {
    return { ok: false, message: parsed.error.issues[0]?.message ?? 'Invalid input' }
  }

  // 3. Mutate, then revalidate so the same response carries fresh UI.
  await createTodo(userId, parsed.data)
  revalidatePath('/todos')
  return { ok: true, message: 'Todo added.' }
}

The client form uses React's useActionState, which returns the last result, a wrapped action and a pending flag:

tsx
// app/todos/todo-form.tsx
'use client'

import { useActionState } from 'react'
import { addTodo, type FormState } from './actions'

const initialState: FormState = { ok: false, message: '' }

export function TodoForm() {
  const [state, formAction, pending] = useActionState(addTodo, initialState)

  return (
    <form action={formAction}>
      <input name="title" placeholder="New todo" required maxLength={120} />
      <button type="submit" disabled={pending}>
        {pending ? 'Adding...' : 'Add todo'}
      </button>
      {state.message && <p role="status">{state.message}</p>}
    </form>
  )
}

And the page reads data in a Server Component. Because new projects ship with Cache Components on, the cookie-dependent list sits inside <Suspense>; without it, next build fails with a "blocking prerender" error, which I hit on the first build:

tsx
// app/todos/page.tsx
import { Suspense } from 'react'
import { getUserId } from '@/lib/auth'
import { listTodos } from '@/lib/todos'
import { TodoForm } from './todo-form'

export default function TodosPage() {
  return (
    <main>
      <h1>Todos</h1>
      <TodoForm />
      <Suspense fallback={<p>Loading todos...</p>}>
        <TodoList />
      </Suspense>
    </main>
  )
}

// Reads come straight from the data layer in a Server Component.
async function TodoList() {
  const userId = await getUserId()
  const todos = userId ? await listTodos(userId) : []

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  )
}

Should you use revalidatePath, updateTag or revalidateTag in a Server Action?#

Use updateTag when the user must see their own write immediately; it expires the tag and the action's re-render waits for fresh data, and it only works inside Server Actions. Use revalidatePath when one route is affected. Use revalidateTag(tag, 'max') for background, stale-while-revalidate refreshes that should not slow the response.

The updateTag docs are explicit: calling it from a Route Handler throws, and you should use revalidateTag there instead.

When should you use Route Handlers?#

Use a Route Handler whenever the caller is not your own React tree, or the response is not a UI update: webhooks, mobile and partner APIs, OAuth and payment callbacks, RSS feeds, file and CSV downloads, streaming responses, health checks, and cacheable public GET JSON. If someone outside your codebase needs a URL, it should be a Route Handler.

A JSON API for a mobile app, with bearer auth and proper status codes:

ts
// app/api/todos/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import { ZodError } from 'zod'
import { getUserIdFromBearer } from '@/lib/auth'
import { createTodo, listTodos } from '@/lib/todos'

export async function GET(request: NextRequest) {
  const userId = await getUserIdFromBearer(request.headers.get('authorization'))
  if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

  return NextResponse.json({ todos: await listTodos(userId) })
}

export async function POST(request: NextRequest) {
  const userId = await getUserIdFromBearer(request.headers.get('authorization'))
  if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

  try {
    const todo = await createTodo(userId, await request.json())
    return NextResponse.json({ todo }, { status: 201 })
  } catch (error) {
    if (error instanceof ZodError) {
      return NextResponse.json({ error: error.issues }, { status: 400 })
    }
    if (error instanceof SyntaxError) {
      return NextResponse.json({ error: 'Body must be valid JSON' }, { status: 400 })
    }
    throw error
  }
}

Against next start, this returned 401 with no token, 201 with the created todo, 400 with Zod's issue list for an empty title, 400 for malformed JSON, and 405 for an unsupported PUT, all without extra code for the 405.

A webhook needs the raw body to verify its signature, which is exactly what a Route Handler gives you and a Server Action does not:

ts
// app/api/webhooks/payments/route.ts
import { createHmac, timingSafeEqual } from 'node:crypto'
import { revalidateTag } from 'next/cache'

export async function POST(request: Request) {
  const secret = process.env.WEBHOOK_SECRET
  if (!secret) return new Response('Webhook not configured', { status: 500 })

  // Read the raw body first: signatures are computed over the exact bytes sent.
  const body = await request.text()
  const signature = request.headers.get('x-signature') ?? ''
  const expected = createHmac('sha256', secret).update(body).digest('hex')

  const received = Buffer.from(signature, 'hex')
  const wanted = Buffer.from(expected, 'hex')
  if (received.length !== wanted.length || !timingSafeEqual(received, wanted)) {
    return new Response('Invalid signature', { status: 401 })
  }

  const event = JSON.parse(body) as { type: string }
  if (event.type === 'order.paid') {
    revalidateTag('orders', 'max') // stale-while-revalidate refresh
  }

  return Response.json({ received: true })
}

With a correctly signed body it returned 200 {"received":true}; with a forged signature, 401. Payment providers such as Stripe use their own signature headers and SDK helpers, but the shape is the same: read text, verify, then parse.

Can you use Server Actions and Route Handlers together?#

Yes, and most production apps should. Put your business logic in a server-only data layer, then make Server Actions and Route Handlers thin adapters over it: the action handles your web UI, the Route Handler serves mobile apps and webhooks. Validation, authorization rules and database access live in one place, so the two entry points cannot drift apart.

One data layer shared by a Server Action for the web UI and a Route Handler for external clients

ts
// lib/todos.ts
import 'server-only'
import { z } from 'zod'

export const TodoInput = z.object({
  title: z.string().trim().min(1, 'Title is required').max(120),
})

export type Todo = { id: string; title: string; ownerId: string; done: boolean }

// In-memory store so the example runs as-is. Swap in Prisma or Drizzle.
const todos: Todo[] = []

export async function listTodos(ownerId: string): Promise<Todo[]> {
  return todos.filter((t) => t.ownerId === ownerId)
}

export async function createTodo(ownerId: string, input: unknown): Promise<Todo> {
  const { title } = TodoInput.parse(input) // throws ZodError on bad input
  const todo: Todo = { id: crypto.randomUUID(), title, ownerId, done: false }
  todos.push(todo)
  return todo
}

The server-only import makes the build fail if a Client Component ever imports this file, which keeps database code and secrets out of the browser bundle. This is the same Data Access Layer idea the Next.js Data Security guide recommends. In a real app, swap the array for Prisma or Drizzle, as in my open-source Foliora (Prisma and PostgreSQL) and Forgekit (Drizzle and pgvector) projects.

Should you use Server Actions to fetch data?#

No. The React docs say Server Functions "are designed for mutations that update server-side state; they are not recommended for data fetching," and that frameworks typically process one action at a time and cannot cache the return value. Fetch in a Server Component instead, or use a GET Route Handler when the client must fetch.

The Next.js docs make the same point from the other side: Server Actions are queued, so using them for reads makes requests sequential. That is the opposite of what you want for a dashboard that loads several widgets. For client-side polling or data that depends on browser APIs, the Backend for Frontend guide suggests a Route Handler with SWR or TanStack Query.

Should a Server Component fetch from your own Route Handler?#

No. Call the data layer directly. The Next.js docs warn that fetching your own Route Handler from a Server Component adds an extra HTTP round trip, and for pages prerendered at build time it fails the build, because no server is listening yet. Route Handlers are for clients outside the React server render.

Are Server Actions secure?#

Server Actions are as secure as the checks you write inside them. Next.js adds a CSRF check (comparing Origin with Host), a 1 MB default body limit, encrypted action IDs, removal of unused actions from client bundles, and encryption of closed-over variables. It does not authenticate users or validate input for you, and every action is reachable by a direct POST.

The official guidance boils down to three rules inside every action, which apply equally to Route Handlers:

  1. Authenticate and authorize. Hiding a form on a page is not a security boundary; requests can skip your UI.
  2. Validate inputs. Treat FormData, arguments, query params and headers as untrusted. Schema validation checks shape, not ownership, so look records up by both ID and owner.
  3. Constrain return values. Whatever an action returns is serialized to the client, so return only what the UI renders, never raw database rows.

Two configuration details matter when you self-host:

  • Behind a proxy or CDN, add the external domains to serverActions.allowedOrigins in next.config, or the CSRF check can reject legitimate requests (serverActions config).
  • With more than one instance, set NEXT_SERVER_ACTIONS_ENCRYPTION_KEY to the same value everywhere so action references decrypt on every replica. I cover replicas in the production Next.js Dockerfile guide and the Coolify self-hosting guide.

Route Handlers get none of the automatic protections, so for cookie-authenticated mutations you add CSRF protection, rate limiting and payload verification yourself. The Backend for Frontend guide's security section covers headers, rate limiting, payload verification and access to protected resources.

Server Actions vs API routes: what changed from the Pages Router?#

API Routes live in pages/api and use Node-style req and res objects. In the App Router, Route Handlers replace them and use the Web Request and Response APIs instead. The Next.js docs say you do not need to use both. Server Actions are new with the App Router; they became stable in Next.js 14.

So when people search "Server Actions vs API routes," they usually mean the same decision as this post. If you are migrating, map each API route to a Route Handler first, then move mutations that only your UI calls into Server Actions, and leave anything an external client depends on as a Route Handler.

Do Server Actions replace tRPC or TanStack Query?#

Partly. For mutations called only by your own Next.js UI, Server Actions give you typed calls without a separate API layer. They do not give you client-side caching, parallel reads, or a contract other clients can consume. Keep TanStack Query or SWR for client-side fetching against Route Handlers, and keep an API layer like tRPC or oRPC when you need versioned, reusable endpoints.

Common Server Action and Route Handler mistakes#

MistakeWhat happensFix
No auth check inside the actionAnyone who can send the POST can run itAuthenticate and authorize inside every action
Promise.all over several actionsThey still run one after anotherDo parallel work inside one action or a Route Handler
Using an action to load page dataSequential, uncached readsFetch in a Server Component
Server Component fetches /api/...Extra round trip; build fails when prerenderedCall the data layer directly
Uploading a large file through an actionRejected above the 1 MB defaultRaise bodySizeLimit or upload straight to storage
updateTag in a Route HandlerThrows at runtimeUse revalidateTag(tag, 'max') or revalidatePath
Mobile app calls a Server ActionBreaks when action IDs change on deployExpose a Route Handler with a stable URL
Several replicas, no shared keyAction references fail on other instancesSet one NEXT_SERVER_ACTIONS_ENCRYPTION_KEY

Related: Next.js 16.4.1 fixed a high-severity DoS that affects any App Router app with a Server Action, so check your version with the Next.js security update October 2026 guide. To refresh cached data after a Server Action, see updateTag in Next.js use cache explained.

Key takeaways#

  • Use Server Actions for mutations from your own UI, Route Handlers for any caller with a URL, and Server Components for reads.
  • Server Actions are POST-only, dispatched one at a time per client, and can return fresh UI in the same response.
  • Route Handlers are public, uncached by default, support every common HTTP method and can return any content type.
  • Both are public entry points: authenticate, validate and shape return values in every one.
  • Share one server-only data layer so actions and Route Handlers stay thin and consistent.

Questions