When server actions landed in Next.js 13/14, the discourse went wild. Half the dev community was calling them a paradigm shift, the other half was calling them PHP. We shipped real apps with both, made real mistakes with both, and we're here to tell you the actual trade-offs — not the marketing version.
The short answer: both are useful, neither is universally better, and picking the wrong one for the wrong job will cost you debugging hours at 2am. Let's make sure that doesn't happen to you.
What We're Actually Comparing
API routes (both the old pages/api pattern and the newer route handlers in app/api) are HTTP endpoints. They're predictable, they're stateless, they work with any client that can make an HTTP request. Server actions are functions that run on the server but get called from the client like regular async functions — no explicit HTTP calls, no fetch, no JSON.stringify.
The conceptual difference sounds small until you actually build with each one. Then the ergonomic gap becomes very obvious, in both directions depending on what you're building.
The Case for Server Actions
Server actions genuinely are nicer for form submissions and data mutations that are tightly coupled to a single component. You write less code, the types flow through automatically, and you don't have to maintain a separate API contract. Here's what a simple form submission looks like:
// app/actions/create-project.ts
'use server'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
import { auth } from '@/lib/auth'
export async function createProject(formData: FormData) {
const session = await auth()
if (!session?.user) throw new Error('Unauthorized')
const name = formData.get('name') as string
if (!name || name.trim().length === 0) {
return { error: 'Project name is required' }
}
const project = await db.project.create({
data: {
name: name.trim(),
userId: session.user.id,
},
})
revalidatePath('/dashboard')
return { success: true, projectId: project.id }
}
// app/dashboard/new-project-form.tsx
'use client'
import { createProject } from '@/app/actions/create-project'
import { useActionState } from 'react'
export function NewProjectForm() {
const [state, action, isPending] = useActionState(createProject, null)
return (
<form action={action}>
<input name="name" placeholder="Project name" />
{state?.error && <p className="text-red-500">{state.error}</p>}
<button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create Project'}
</button>
</form>
)
}No API route to write. No fetch call. No error handling split across two files. The types just work because it's all TypeScript in the same codebase. And revalidatePath means the dashboard refreshes automatically after the mutation — without you wiring up any cache invalidation logic.
This is genuinely great for CRUD operations tied to a specific page. If you're building a dashboard with a bunch of forms, server actions will save you a meaningful amount of boilerplate.
The Case for API Routes
API routes have been around since Next.js 9. They're boring. They're also extremely useful in situations where server actions either can't work or make your life harder:
- External services calling your backend (Stripe webhooks, GitHub webhooks, any third-party integration)
- Mobile apps or other non-Next.js clients that need to consume your backend
- Complex authentication flows that need specific headers or status codes
- File uploads with streaming or multipart handling
- Public APIs that other developers might use
- WebSocket upgrades or SSE (Server-Sent Events)
Here's the webhook example that makes this concrete. When Stripe sends you a payment event, it's making an HTTP POST to your server. Server actions don't exist in that world — you need a real endpoint:
// app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers'
import { NextResponse } from 'next/server'
import Stripe from 'stripe'
import { db } from '@/lib/db'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!
export async function POST(request: Request) {
const body = await request.text()
const headersList = await headers()
const signature = headersList.get('stripe-signature')
if (!signature) {
return NextResponse.json({ error: 'No signature' }, { status: 400 })
}
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
} catch (err) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
}
switch (event.type) {
case 'customer.subscription.updated': {
const subscription = event.data.object as Stripe.Subscription
await db.user.update({
where: { stripeCustomerId: subscription.customer as string },
data: { subscriptionStatus: subscription.status },
})
break
}
case 'invoice.payment_failed': {
// Handle failed payment
break
}
}
return NextResponse.json({ received: true })
}You could not do this with a server action. Stripe doesn't know about React. It's sending you an HTTP request with a specific signature header, and you need to respond with a specific HTTP status code. API routes are the right tool here, full stop.
Where Server Actions Will Bite You
We learned some of these the hard way. Server actions have real footguns:
First, error handling is awkward. If you throw inside a server action, it either crashes the action or you have to remember to return an error object instead. There's no clean way to return different HTTP status codes because there are no HTTP status codes — it's all abstracted away. For most form submissions this is fine, but if you need granular error states (validation errors vs auth errors vs server errors), you end up with verbose return objects.
Second, server actions are always POST requests under the hood. Next.js generates a POST endpoint for each one. This means you can't use them for data fetching (use server components for that), and it means they don't play well with anything expecting GET semantics.
Third, testing is annoying. Unit testing a server action requires mocking the Next.js runtime. Testing an API route is just testing a function that takes a Request and returns a Response — much simpler.
// Testing an API route handler — straightforward
import { POST } from '@/app/api/projects/route'
describe('POST /api/projects', () => {
it('returns 401 when not authenticated', async () => {
const request = new Request('http://localhost/api/projects', {
method: 'POST',
body: JSON.stringify({ name: 'Test' }),
headers: { 'Content-Type': 'application/json' },
})
const response = await POST(request)
expect(response.status).toBe(401)
})
})
// Testing a server action — you need to mock 'next/headers', 'next/cache',
// and set up the Next.js server context. Not impossible, just annoying.The Progressive Enhancement Argument
One thing the server actions advocates get right: if you use server actions with HTML forms (not JavaScript-driven calls), your forms work without JavaScript. The form posts directly to the action, Next.js handles it server-side, you redirect. This is real progressive enhancement and it's not something you get for free with API routes + fetch.
In practice, most SaaS apps we build require JavaScript anyway (interactive dashboards, real-time stuff, complex UI). So this argument is mostly academic for our use cases. But if you're building a public-facing form that needs to work on slow connections or for users with JavaScript disabled, server actions with native forms are legitimately the better choice.
Progressive enhancement with server actions is real and valuable. It's just not relevant for most SaaS dashboards, which require JS anyway. Know your use case before treating it as a deciding factor.
The Pattern We Actually Use
After shipping several projects, we settled on a pretty clear mental model:
- Server actions for mutations triggered by UI interactions inside the app (create, update, delete operations on your own data)
- API route handlers for anything external (webhooks, third-party integrations, public APIs)
- API route handlers for any endpoint a mobile client might call
- Server components (not actions, not routes) for data fetching — just async components with direct DB calls
- API route handlers when you need proper HTTP semantics (status codes, cache headers, specific response formats)
This isn't "use server actions for everything" or "API routes are legacy". It's matching the tool to the job. The majority of your user-facing mutations will be server actions. A non-trivial slice of your backend will still be API routes.
One thing we do regardless of which approach we use: we keep the business logic in a separate service layer. Neither the server action nor the route handler should contain the actual logic — that goes in functions that either can call. This makes testing sane and means you can switch between the two without rewriting everything.
// lib/projects/service.ts — pure business logic, no Next.js dependencies
export async function createProjectForUser(
userId: string,
name: string
): Promise<{ id: string; name: string }> {
if (!name || name.trim().length === 0) {
throw new Error('Project name is required')
}
return db.project.create({
data: { name: name.trim(), userId },
select: { id: true, name: true },
})
}
// app/actions/create-project.ts — thin server action
'use server'
import { auth } from '@/lib/auth'
import { createProjectForUser } from '@/lib/projects/service'
import { revalidatePath } from 'next/cache'
export async function createProject(formData: FormData) {
const session = await auth()
if (!session?.user) return { error: 'Unauthorized' }
try {
const project = await createProjectForUser(
session.user.id,
formData.get('name') as string
)
revalidatePath('/dashboard')
return { success: true, projectId: project.id }
} catch (err) {
return { error: err instanceof Error ? err.message : 'Failed to create project' }
}
}
// app/api/projects/route.ts — same service, different transport
import { auth } from '@/lib/auth'
import { createProjectForUser } from '@/lib/projects/service'
import { NextResponse } from 'next/server'
export async function POST(request: Request) {
const session = await auth()
if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { name } = await request.json()
try {
const project = await createProjectForUser(session.user.id, name)
return NextResponse.json(project, { status: 201 })
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed' },
{ status: 400 }
)
}
}The service layer is the same. You can test it in isolation. The server action and the API route are just thin adapters that handle auth, call the service, and format the response for their respective transport. This pattern has saved us multiple times when we needed to expose something to an external client that we'd originally built as a server action.
The Practical Takeaway
Don't burn time debating which one to use in the abstract. Start with server actions for your UI mutations — they'll be faster to write and the DX is genuinely better. Add API route handlers when you need them: webhooks, external clients, complex HTTP semantics. Keep your business logic in neither — put it in a service layer that both can call.
Most of our templates at peal.dev ship with both patterns set up correctly from the start: server actions for the dashboard mutations, API route handlers for Stripe webhooks and auth callbacks, and a clean service layer in between. Not because it's fancy architecture, but because that's what real apps actually need.
The framework isn't asking you to choose a side. It's giving you two tools. Use the right one for the job, and don't let Twitter discourse make a simple decision complicated.
