跳到正文
DEV Community· Waseem Anjum·· 4 小時前AI 評分59

Next.js Server Actions 與 Route Handlers(API Routes):各自適用場景

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

AI 導讀

Next.js 指南建議自家介面修改資料使用 Server Actions,外部用戶端使用 Route Handlers(API Routes),頁面讀取則由 Server Component 直接存取資料層。

正文

Cross-posted from my blog. The original, with any updates, lives on fabwaseem.com.

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

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

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

You need to... Use Why
Submit a form or mutate data from your own UI Server Action Progressive enhancement, typed arguments, fresh UI in one roundtrip
Show pending, error and success states for a mutation Server Action + useActionState The hook gives you state, formAction and pending
Read data for a page Server Component Fetch straight from the data source; no extra HTTP hop
Receive a webhook (Stripe, GitHub, a CMS) Route Handler Fixed URL, raw body for signature checks, custom status codes
Serve a mobile app, CLI or third-party client Route Handler A stable, documented HTTP contract that does not change per build
Return non-HTML content (RSS, CSV, images, files) Route Handler Return any Response with any content type
Handle an OAuth or payment callback URL Route Handler Third parties redirect to a known URL with query params
Poll or fetch client-side (SWR, TanStack Query) Route Handler Server Actions run one at a time and are not meant for reads
Expose a cacheable GET JSON endpoint Route Handler GET 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 Action Route Handler
Defined in 'use server' file or inline function app/**/route.ts
HTTP methods POST only GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS
URL No stable public URL; called by encrypted action ID Fixed URL you choose
Typical caller Your own React components Any HTTP client
Input Serialized function arguments or FormData Request (JSON, form data, raw text, streams)
Output Serializable return value plus fresh RSC Payload Any Response and content type
Caching Never cached GET can opt in; others never cached
Concurrency from one client Dispatched one at a time Fully parallel
Form integration Built in; <form action> works before JavaScript loads None; you wire up fetch or a form post yourself
Built-in CSRF check Yes, Origin vs Host comparison No; add your own for cookie-auth mutations
Default body size limit 1 MB (configurable) No Server Action limit applies
Immediate cache update updateTag (Server Actions only) revalidateTag(tag, 'max'), revalidatePath
Survives a new deploy IDs can change between builds URL 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:

// 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:

// 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:

// 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:

// 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:

// 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

// 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

Mistake What happens Fix
No auth check inside the action Anyone who can send the POST can run it Authenticate and authorize inside every action
Promise.all over several actions They still run one after another Do parallel work inside one action or a Route Handler
Using an action to load page data Sequential, uncached reads Fetch in a Server Component
Server Component fetches /api/... Extra round trip; build fails when prerendered Call the data layer directly
Uploading a large file through an action Rejected above the 1 MB default Raise bodySizeLimit or upload straight to storage
updateTag in a Route Handler Throws at runtime Use revalidateTag(tag, 'max') or revalidatePath
Mobile app calls a Server Action Breaks when action IDs change on deploy Expose a Route Handler with a stable URL
Several replicas, no shared key Action references fail on other instances Set 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.

Related: auth libraries hit this split directly. Better Auth signs users in from a server action only with its nextCookies() plugin, and serves everything else from a route handler. See Better Auth with Next.js 16.

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.

FAQ

Should I use Server Actions or Route Handlers in Next.js?

Use Server Actions for data mutations triggered by your own Next.js UI, such as form submissions, toggles and deletes. Use Route Handlers when an external client needs a stable URL: webhooks, mobile apps, third-party integrations, OAuth callbacks, file downloads or cacheable GET endpoints. For reading page data, fetch directly in a Server Component.

Can I use Server Actions to fetch data?

You can, but you should not. The React docs say Server Functions are designed for mutations and are not recommended for data fetching, and Next.js dispatches actions one at a time per client, so reads become sequential and uncached. Fetch in Server Components, or use a GET Route Handler with SWR or TanStack Query on the client.

Are Next.js Server Actions secure?

They are as secure as the code inside them. Next.js adds a CSRF check comparing Origin and Host, a 1 MB default body limit, encrypted action IDs and removal of unused actions, but every action is still reachable by a direct POST. Authenticate, authorize and validate input inside each action, and return only the fields the UI needs.

Are Route Handlers the same as API routes?

Route Handlers are the App Router equivalent of Pages Router API routes. They live in route.ts files inside the app directory and use the Web Request and Response APIs instead of Node-style req and res objects. The Next.js docs note you do not need to use API routes and Route Handlers together.

Can a mobile app call a Next.js Server Action?

It should not. Server Actions have no stable public URL; they are called by an encrypted action ID that can change between deployments, which causes a Failed to find Server Action error for out-of-date clients. Expose a Route Handler with a fixed URL, bearer authentication and documented JSON for mobile and third-party clients.

What is the Server Actions body size limit?

By default, Next.js caps the request body sent to a Server Action at 1 MB to prevent excessive parsing and potential DDoS attacks. You can raise it with serverActions.bodySizeLimit in next.config, for example '2mb', but for large files it is usually better to upload directly to object storage.


Originally published on fabwaseem.com.

來源:DEV Community · dev.to