Next.js Server Actions vs API Routes: When to Use Each
Understand the differences between Server Actions and API Routes in Next.js 15. Learn when to use each approach with real-world examples and performance comparisons.
Next.js Server Actions vs API Routes: When to Use Each#
Next.js 15 gives you two ways to handle server-side logic: Server Actions and API Routes. Both work, but they're designed for different use cases. Choosing the wrong one leads to unnecessary complexity or missing features.
This guide teaches you when to use each approach.
Understanding the Differences#
Server Actions#
Server Actions are async functions that run on the server and are called directly from components.
// app/actions.ts
'use server'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
const content = formData.get('content') as string
const { data, error } = await supabase
.from('posts')
.insert({ title, content, user_id: userId })
if (error) throw error
return data
}
// app/posts/new/page.tsx
'use client'
import { createPost } from '@/app/actions'
export default function NewPostPage() {
async function handleSubmit(formData: FormData) {
const post = await createPost(formData)
console.log('Post created:', post)
}
return (
<form action={handleSubmit}>
<input name="title" placeholder="Title" required />
<textarea name="content" placeholder="Content" required />
<button type="submit">Create Post</button>
</form>
)
}
API Routes#
API Routes are HTTP endpoints that handle requests and responses.
// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
const { title, content } = await request.json()
const { data, error } = await supabase
.from('posts')
.insert({ title, content, user_id: userId })
if (error) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
return NextResponse.json(data)
}
// app/posts/new/page.tsx
'use client'
export default function NewPostPage() {
async function handleSubmit(formData: FormData) {
const response = await fetch('/api/posts', {
method: 'POST',
body: JSON.stringify({
title: formData.get('title'),
content: formData.get('content')
})
})
const post = await response.json()
console.log('Post created:', post)
}
return (
<form onSubmit={(e) => {
e.preventDefault()
handleSubmit(new FormData(e.currentTarget))
}}>
<input name="title" placeholder="Title" required />
<textarea name="content" placeholder="Content" required />
<button type="submit">Create Post</button>
</form>
)
}
Key Differences#
| Feature | Server Actions | API Routes | |---------|---|---| | Call Method | Direct function call | HTTP request | | CSRF Protection | Built-in | Manual | | Type Safety | Excellent | Manual | | Caching | Next.js cache functions | Manual | | External Calls | Not possible | Possible | | Middleware | No | Yes | | Complexity | Simple | More verbose | | Flexibility | Limited | High |
When to Use Server Actions#
Use Server Actions for:
Form Submissions#
// ✅ Perfect for form submissions
'use server'
export async function updateProfile(formData: FormData) {
const name = formData.get('name') as string
const email = formData.get('email') as string
const { error } = await supabase
.from('profiles')
.update({ name, email })
.eq('id', userId)
if (error) throw error
revalidatePath('/profile')
}
Simple Mutations#
// ✅ Good for simple mutations
'use server'
export async function deletePost(postId: string) {
const { error } = await supabase
.from('posts')
.delete()
.eq('id', postId)
if (error) throw error
revalidatePath('/posts')
}
Keeping Secrets Secure#
// ✅ API keys stay on server
'use server'
export async function sendEmail(email: string) {
// STRIPE_SECRET_KEY never exposed to client
const response = await fetch('https://api.stripe.com/v1/charges', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.STRIPE_SECRET_KEY}`
}
})
return response.json()
}
Database Operations#
// ✅ Direct database access
'use server'
export async function getPostsWithComments() {
const { data } = await supabase
.from('posts')
.select(`
*,
comments(*)
`)
return data
}
When to Use API Routes#
Use API Routes for:
Webhooks#
// ✅ External services need HTTP endpoints
export async function POST(request: NextRequest) {
const event = await request.json()
if (event.type === 'charge.succeeded') {
// Handle Stripe webhook
await supabase
.from('payments')
.insert({ stripe_id: event.data.id, status: 'completed' })
}
return NextResponse.json({ received: true })
}
External Integrations#
// ✅ Third-party services call your API
export async function POST(request: NextRequest) {
const { apiKey } = request.headers
// Verify API key
if (apiKey !== process.env.EXTERNAL_API_KEY) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const data = await request.json()
// Process data
return NextResponse.json({ success: true })
}
Complex Business Logic#
// ✅ Complex logic benefits from middleware and error handling
export async function POST(request: NextRequest) {
try {
const { userId, amount } = await request.json()
// Validate
if (amount < 0) {
return NextResponse.json({ error: 'Invalid amount' }, { status: 400 })
}
// Check user balance
const { data: user } = await supabase
.from('users')
.select('balance')
.eq('id', userId)
.single()
if (user.balance < amount) {
return NextResponse.json({ error: 'Insufficient balance' }, { status: 400 })
}
// Process transaction
const { data: transaction } = await supabase
.from('transactions')
.insert({ user_id: userId, amount })
return NextResponse.json(transaction)
} catch (error) {
return NextResponse.json({ error: 'Server error' }, { status: 500 })
}
}
Rate Limiting#
// ✅ Middleware for rate limiting
import { Ratelimit } from '@upstash/ratelimit'
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '1 h')
})
export async function POST(request: NextRequest) {
const ip = request.headers.get('x-forwarded-for') || 'unknown'
const { success } = await ratelimit.limit(ip)
if (!success) {
return NextResponse.json({ error: 'Rate limited' }, { status: 429 })
}
// Handle request
}
RESTful APIs#
// ✅ Full REST API with multiple methods
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const id = searchParams.get('id')
const { data } = await supabase
.from('posts')
.select('*')
.eq('id', id)
.single()
return NextResponse.json(data)
}
export async function POST(request: NextRequest) {
const body = await request.json()
const { data } = await supabase.from('posts').insert(body)
return NextResponse.json(data)
}
export async function PUT(request: NextRequest) {
const { id, ...updates } = await request.json()
const { data } = await supabase
.from('posts')
.update(updates)
.eq('id', id)
return NextResponse.json(data)
}
export async function DELETE(request: NextRequest) {
const { searchParams } = new URL(request.url)
const id = searchParams.get('id')
const { data } = await supabase.from('posts').delete().eq('id', id)
return NextResponse.json(data)
}
Real-World Decision Tree#
Is it a form submission or simple mutation?
├─ YES → Use Server Actions
└─ NO → Continue
Does an external service need to call your app?
├─ YES → Use API Routes
└─ NO → Continue
Do you need middleware or rate limiting?
├─ YES → Use API Routes
└─ NO → Continue
Do you need fine-grained HTTP control?
├─ YES → Use API Routes
└─ NO → Use Server Actions
Performance Comparison#
Server Actions:
- Lower latency (no HTTP overhead)
- Direct function call
- Automatic serialization
- Built-in CSRF protection
API Routes:
- Slightly higher latency (HTTP overhead)
- More flexible
- Manual serialization
- Manual CSRF protection
For most applications, the performance difference is negligible (less than 10ms). Choose based on functionality, not performance.
Error Handling#
Server Actions#
'use client'
async function handleSubmit(formData: FormData) {
try {
const result = await createPost(formData)
console.log('Success:', result)
} catch (error) {
console.error('Error:', error.message)
}
}
API Routes#
'use client'
async function handleSubmit(formData: FormData) {
try {
const response = await fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(Object.fromEntries(formData))
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.message)
}
const result = await response.json()
console.log('Success:', result)
} catch (error) {
console.error('Error:', error.message)
}
}
Best Practices#
Server Actions:
- ✅ Use for form submissions
- ✅ Keep them simple and focused
- ✅ Use revalidatePath for cache invalidation
- ✅ Throw errors for error handling
- ✅ Use FormData for file uploads
API Routes:
- ✅ Use for webhooks and external integrations
- ✅ Implement proper error handling
- ✅ Add rate limiting for public endpoints
- ✅ Validate all input
- ✅ Return appropriate HTTP status codes
Related Articles#
- 10 Common Mistakes Building with Next.js and Supabase
- Building SaaS with Next.js and Supabase
- Advanced Authentication Patterns
- GraphQL vs REST: The Definitive API Design Guide for 2026
- Next.js Server Actions vs API Routes in Production: Decision Guide
Conclusion#
Server Actions and API Routes both have their place. Use Server Actions for simple mutations and form submissions—they're simpler and have better type safety. Use API Routes for webhooks, external integrations, and complex business logic.
The key is understanding the trade-offs. Server Actions are simpler but less flexible. API Routes are more complex but more powerful. Choose the right tool for the job.
Production Notes#
- Root cause to verify: measure the route with production rendering mode, real cache headers, and realistic data volume.
- Production fix pattern: choose caching boundaries deliberately and verify invalidation after every mutation path.
- Verification step: compare p50 and p95 latency before and after the change, not just local dev behavior.
Frequently Asked Questions
One email a month — no fluff
RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.
Continue Reading
10 Common Mistakes Building with Next.js and Supabase (And How to Fix Them)
Avoid these critical mistakes when building with Next.js and Supabase. Learn from real-world errors that cost developers hours of debugging and discover proven solutions.
Next.js Turbopack Stuck Compiling: 9 Fixes for Dev and Production Builds
Turbopack stuck on compiling in Next.js 15? Learn the exact causes and 5 proven fixes to get your dev server running in minutes.
Fix Next.js revalidatePath Not Working in Server Actions
Your Server Action mutates data but the page shows stale values until you hard-refresh. `revalidatePath` is one of those APIs that "succeeds" while doing nothing. Here are the six reasons it no-ops, with the exact fix for each — including the one nobody tells you about: `dynamic = 'force-static'`.
Browse by Topic
Find stories that matter to you.
