50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
next.jsMay 20, 2026·8 min read

Next.js API Routes vs Server Actions: The Honest Comparison

Both work. But picking the wrong one will haunt you. Here's when to use each, based on real projects — not the docs.

Robert Seghedi

Robert Seghedi

Co-founder, peal.dev

Next.js API Routes vs Server Actions: The Honest Comparison

When Server Actions landed in Next.js 13/14, a certain subset of developers declared API routes dead. They were wrong. Another subset said Server Actions were a gimmick and kept writing REST endpoints for everything. Also wrong. The truth, as usual, is more boring and more useful: they solve different problems, and conflating them is how you end up with a mess six months later.

We've used both extensively — in client projects, in our own SaaS products, and in the templates we build at peal.dev. Here's what we actually learned, including the parts that don't make it into the official docs.

What Each One Actually Is

API routes (or Route Handlers in the App Router world) are HTTP endpoints. They live in `app/api/` or `pages/api/`, they speak HTTP, and anything that can make an HTTP request can talk to them. A mobile app, a third-party webhook, a cron job hitting your server, your frontend — all the same interface.

Server Actions are something different. They're async functions that run on the server, but they're called directly from your React components — either in forms via the `action` prop or in event handlers via a regular async call. Under the hood Next.js does POST the data to the server, but that's an implementation detail. You never touch the URL. You never write request/response boilerplate. It's just a function.

// Server Action — in a separate file or inline in a Server Component
'use server'

export async function createProject(formData: FormData) {
  const name = formData.get('name') as string
  
  if (!name || name.length < 3) {
    return { error: 'Name must be at least 3 characters' }
  }

  const project = await db.project.create({
    data: { name, userId: await getCurrentUserId() }
  })

  revalidatePath('/projects')
  return { project }
}

// API Route Handler — always HTTP
export async function POST(request: Request) {
  const body = await request.json()
  
  const project = await db.project.create({
    data: { name: body.name, userId: body.userId }
  })

  return Response.json({ project })
}

Where Server Actions Genuinely Win

For standard CRUD operations in a Next.js app where the only consumer is your own frontend, Server Actions are genuinely better. You skip a ton of boilerplate. No `fetch`, no URL to maintain, no manual JSON serialization, no separate API client layer. The function just... runs on the server. The type safety is end-to-end without any extra tooling.

They also handle progressive enhancement well. If you wire them to a `<form action={createProject}>`, the form works even without JavaScript. That sounds like a 2010 concern but it's actually relevant for accessibility and for those moments when your JS bundle fails to load on a flaky connection.

// This form works with or without JS — because Server Actions
import { createProject } from './actions'

export default function NewProjectForm() {
  return (
    <form action={createProject}>
      <input name="name" placeholder="Project name" required />
      <button type="submit">Create</button>
    </form>
  )
}

// Want optimistic updates? Add useActionState
import { useActionState } from 'react'

export function NewProjectFormOptimistic() {
  const [state, action, isPending] = useActionState(createProject, null)

  return (
    <form action={action}>
      <input name="name" placeholder="Project name" required />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Creating...' : 'Create'}
      </button>
      {state?.error && <p className="text-red-500">{state.error}</p>}
    </form>
  )
}

Another thing people underestimate: `revalidatePath` and `revalidateTag` inside Server Actions. After a mutation, you call revalidatePath and Next.js refetches the relevant server components. No client-side state management, no cache invalidation logic in your frontend. This removes an entire category of bugs — the kind where you mutate something and the UI still shows stale data because you forgot to update the local state.

Where API Routes Are the Right Tool

The moment anything other than your Next.js frontend needs to call your endpoint, you need an API route. Full stop. Server Actions aren't a public API — they're tightly coupled to the Next.js request format, and that format is not stable or documented for external consumption.

  • Webhooks from Stripe, GitHub, Clerk, or any third-party service — always API routes
  • Mobile apps or other frontends hitting your backend — API routes
  • Public APIs that other developers integrate with — API routes
  • Cron jobs from services like Vercel Cron, Upstash, or GitHub Actions — API routes
  • OAuth callbacks — API routes
  • File uploads where you're streaming directly — API routes (better control over the request)

We've been burned by this once. We had a webhook handler for Stripe written as a Server Action — don't ask, it was late, we were tired. It worked in dev because we were calling it manually. The moment we pointed the actual Stripe webhook at it, it failed silently. The request format Stripe sends is not what Next.js expects for a Server Action invocation. Spent 45 minutes debugging at 2am before we just moved it to `/api/webhooks/stripe` and went to bed.

// app/api/webhooks/stripe/route.ts
// This MUST be an API route. Not a Server Action.
import Stripe from 'stripe'
import { headers } from 'next/headers'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function POST(request: Request) {
  const body = await request.text()
  const headersList = await headers()
  const signature = headersList.get('stripe-signature')!

  let event: Stripe.Event

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch (err) {
    return new Response('Webhook signature verification failed', { status: 400 })
  }

  switch (event.type) {
    case 'checkout.session.completed':
      await handleCheckoutCompleted(event.data.object)
      break
    case 'customer.subscription.deleted':
      await handleSubscriptionCanceled(event.data.object)
      break
  }

  return new Response('OK', { status: 200 })
}

The Security Question People Forget to Ask

Server Actions have a security model people don't think about enough. They are publicly invocable — anyone can POST to the action's endpoint if they can figure out the URL (and they can). Next.js doesn't add any magic protection. This means authorization logic inside a Server Action is not optional. It's the same situation as an API route: you have to check that the current user is allowed to do the thing.

Server Actions are not private just because they're not in your `api/` folder. They're HTTP endpoints with obfuscated URLs. Treat them like public API routes — authenticate and authorize every sensitive operation.

The nice thing is that with Server Actions you have direct access to your auth session without any token passing. You call `getServerSession()` or whatever your auth library provides and you're done. With API routes you have the same access, but there's a false sense that having a URL makes it more obviously a security boundary. Both need the same level of care.

'use server'

import { auth } from '@/lib/auth'
import { db } from '@/lib/db'

export async function deleteProject(projectId: string) {
  // Never skip this — Server Actions are still callable by anyone
  const session = await auth()
  if (!session?.user?.id) {
    return { error: 'Unauthorized' }
  }

  // Also check ownership — auth alone isn't enough
  const project = await db.project.findUnique({
    where: { id: projectId }
  })

  if (!project || project.userId !== session.user.id) {
    return { error: 'Not found or access denied' }
  }

  await db.project.delete({ where: { id: projectId } })
  
  revalidatePath('/projects')
  return { success: true }
}

The Practical Decision Framework

Here's the mental model we use and it has not failed us yet. Ask one question: does anything outside of this Next.js app need to call this? If yes, API route. If no, Server Action. That's 90% of the decision.

The remaining 10% is edge cases. You might reach for an API route even for internal-only operations when you need fine-grained control over the HTTP response — specific status codes, custom headers, streaming responses. Server Actions always return 200 unless they throw, and you can't set arbitrary response headers. For most mutations that doesn't matter. For some it does.

You might also reach for API routes when you're building something like a tRPC or REST layer that multiple frontends share — a Next.js web app and a React Native app hitting the same backend. In that case, unify behind API routes with proper auth middleware rather than trying to share Server Actions (you can't — they're tied to the Next.js server runtime).

What About Performance?

Server Actions avoid an extra network round trip compared to fetching from a client component to an API route, because the action runs during the same server render pass when called from a server component... except when they don't. When called from a client component (which is most interactive UI), they're still a network request. The difference is negligible in practice — we're talking about the overhead of an HTTP request vs. not, and that's milliseconds.

The bigger performance win with Server Actions is the cache invalidation story. Calling `revalidatePath` inside an action after a mutation means the next render will have fresh data from your database without you managing any client-side cache. That's real — it reduces the complexity of your data fetching layer, which indirectly reduces bugs, which makes your app feel faster because it actually shows correct data.

The Verdict (and What We Actually Do)

In any project we build now — including the templates on peal.dev — the split looks something like this: Server Actions for all the standard CRUD that only the web app touches (creating records, updating settings, deleting stuff), API routes for everything that touches the outside world (webhooks, OAuth callbacks, anything a third party calls).

We don't use Server Actions as a replacement for API routes across the board, and we don't use API routes out of habit when a Server Action would be cleaner. Both have their place. The mistake is picking one and applying it everywhere without thinking.

The Next.js docs are getting better about this but they still lean toward "use Server Actions for mutations" without clearly explaining the boundary. Read the docs, then ignore the ideology, then use what fits the actual requirement. That's the whole approach.

Default to Server Actions for internal mutations. Default to API routes the moment anything outside your Next.js app needs to call you. When in doubt, ask: would a Stripe webhook break if I used a Server Action here? If yes, use a route handler.
Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders