Home / Articles / Server Actions or Route Handlers? A Decision Guide for Next.js 16

This article is published in English.

Server Actions or Route Handlers? A Decision Guide for Next.js 16

Learn when a Next.js 16 mutation belongs in a Server Action and when it needs a Route Handler, with corrected examples for forms, errors, optimism and webhooks.

1558 words

Server Actions began in Next.js 13.4 as a shortcut for form submissions and are now a core App Router primitive, yet every new feature raises the same question: action or API route? Here is a mental model for the choice, both patterns in code, and fixes for misconceptions that cause real bugs.

Two different contracts

API routes, called Route Handlers in the App Router (app/api/.../route.ts), are ordinary HTTP endpoints. They have stable URLs any client can call, full control over status codes and caching headers, and no link to your components.

Server Actions are 'use server' functions invoked from your React code. Next.js generates the endpoint, serializes arguments and results, and wires them into forms, transitions and optimistic UI, with types flowing to the call site.

Contrary to a common claim, Server Actions are not private: each is a POST endpoint keyed by an action ID, and anyone holding that ID can invoke it with arbitrary arguments.

The rule of thumb: external callers get a Route Handler; mutations triggered by your own UI usually fit a Server Action.

Server Actions in practice

A form-driven mutation

The file starts with the directive, which marks every export as a server function:

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

The action authenticates, validates the fields, writes the order, revalidates /orders and redirects. auth() reads the request's cookies, so the client passes no token.

import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'export async function createOrder(formData: FormData) {
  // Auth is automatic — no need to pass session
  const session = await auth()
  if (!session?.user) throw new Error('Unauthorized')  const item = formData.get('item') as string
  const quantity = Number(formData.get('quantity'))  // Validate
  if (!item || quantity < 1) {
    return { error: 'Invalid order data' }
  }  // Database write
  const order = await db.order.create({
    data: { item, quantity, userId: session.user.id },
  })  // Invalidate cached data
  revalidatePath('/orders')  // Redirect after success
  redirect(`/orders/${order.id}`)
}

Note that redirect works by throwing, so never call it inside a try block that swallows errors. The page imports the action:

// app/shop/page.tsx
import { createOrder } from '@/app/actions/order'

and passes it straight to a form's action prop:

export default function ShopPage() {
  return (
    <form action={createOrder}>
      <input name="item" type="text" placeholder="Item name" />
      <input name="quantity" type="number" defaultValue={1} />
      <button type="submit">Order</button>
    </form>
  )
}

No client state, effect or fetch, and the form submits even before JavaScript loads.

Returning errors with useActionState

useActionState (React 19) stores the action's last return value and a pending flag:

'use client'
import { useActionState } from 'react'
import { createOrder } from '@/app/actions/order'
export function OrderForm() {
  const [state, action, isPending] = useActionState(createOrder, null)  return (
    <form action={action}>
      {state?.error && (
        <p style={{ color: 'red' }}>{state.error}</p>
      )}
      <input name="item" />
      <input name="quantity" type="number" />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Ordering...' : 'Place Order'}
      </button>
    </form>
  )
}

One catch: when an action is wrapped by useActionState, it receives the previous state as its first argument and the FormData second. The createOrder signature above takes only formData, so for this form it must become createOrder(prevState, formData).

Optimistic feedback with useOptimistic

useOptimistic shows the expected result until the action settles:

'use client'
import { useOptimistic } from 'react'
import { toggleLike } from '@/app/actions/post'
export function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
  const [optimisticLikes, setOptimistic] = useOptimistic(initialLikes)  async function handleLike() {
    setOptimistic(prev => prev + 1) // UI updates instantly
    await toggleLike(postId)        // Server call happens in background
  }  return (
    <button onClick={handleLike}>
      ❤️ {optimisticLikes}
    </button>
  )
}

The optimistic setter must run inside a transition or action. From a plain onClick, wrap the body in startTransition, or React warns and the optimistic state does not behave as expected. Rollback pitfalls are covered in five failure modes of useOptimistic.

When a Route Handler is the right tool

This versioned endpoint shows their strengths: query parameters, CDN caching and API-key auth.

// app/api/v1/products/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url)
  const category = searchParams.get('category')  const products = await db.product.findMany({
    where: category ? { category } : undefined,
  })  return NextResponse.json({ products }, {
    headers: {
      'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300',
    }
  })
}export async function POST(request: NextRequest) {
  const apiKey = request.headers.get('x-api-key')
  if (apiKey !== process.env.API_KEY) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }  const body = await request.json()
  const product = await db.product.create({ data: body })
  return NextResponse.json({ product }, { status: 201 })
}

Choose a Route Handler when:

  • mobile apps consume the endpoint;
  • a third party delivers webhooks;
  • responses need their own caching headers;
  • it is a public, versioned API or API product.

In production, validate the POST body instead of passing it to the database unchecked.

How the two compare

  • Round trips: both take one request from the browser to your server; neither is server-to-server.
  • Client bundle: the logic stays on the server either way.
  • Auth: actions read the session from cookies automatically; handlers serving other clients check tokens or keys per request.
  • Types: shared end to end with actions; handlers need a typed client or schema.
  • Caching: actions invalidate with revalidatePath or tags; handlers set Cache-Control.
  • Optimistic UI: built into React's hooks for actions; manual for handlers.

At the time of writing, Next.js also dispatches Server Actions from a client one at a time, so they suit mutations rather than parallel data reads; check the current docs.

A decision checklist

Start with the question that decides most cases:

Does an external system call this?
  YES → API Route

Then work through the rest:

Is this triggered by a form submit or user action within your UI?
  YES → Server ActionDo you need explicit HTTP caching headers?
  YES → API RouteDo you want automatic auth context without passing tokens?
  YES → Server ActionDo you need to call this from a mobile app?
  YES → API RouteEverything else?
  → Server Action (less boilerplate)

Two mistakes to avoid

Pointing webhooks at a Server Action

Stripe posts to a registered URL and cannot target an action ID.

// ❌ Wrong — Server Actions can't receive arbitrary HTTP POSTs from Stripe
'use server'
export async function handleStripeWebhook() { ... }

Webhooks belong in a Route Handler, where you can read the raw body and verify the signature.

// ✅ Correct
// app/api/webhooks/stripe/route.ts
export async function POST(request: NextRequest) { ... }

Trusting action inputs

Because any action can be invoked directly, a delete action without checks lets anyone delete anything:

// ❌ Wrong — Server Actions are not inherently trusted
'use server'
export async function deletePost(postId: string) {
  await db.post.delete({ where: { id: postId } }) // Anyone can call this!
}

The fix verifies the session and ownership before writing, then revalidates:

// ✅ Correct — always validate auth + ownership
'use server'
export async function deletePost(postId: string) {
  const session = await auth()
  const post = await db.post.findUnique({ where: { id: postId } })
  if (post?.userId !== session?.user?.id) throw new Error('Forbidden')
  await db.post.delete({ where: { id: postId } })
  revalidatePath('/posts')
}

Also reject missing sessions explicitly. See our article on authorization inside every Server Action covers this in depth.

Key takeaways

  • Every Server Action is a reachable endpoint; authenticate, authorize and validate inside it.
  • With useActionState, the action receives the previous state first.
  • Run useOptimistic updates inside a transition.