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 result = await supabase
.from('posts')
.insert({ title, content, user_id: userId })
if (result.error) throw result.error
return result.data
}
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 body = await request.json()
const { title, content } = body
const result = await supabase
.from('posts')
.insert({ title, content, user_id: userId })
if (result.error) {
return NextResponse.json({ error: result.error.message }, { status: 400 })
}
return NextResponse.json(result.data)
}
Key Differences#
Server Actions:
- Direct function calls from components
- Built-in CSRF protection
- Better type safety
- Simpler for form submissions
- No middleware support
- Can only be called from your app
API Routes:
- HTTP endpoints
- Manual CSRF protection
- Manual type definitions
- Better for external integrations
- Full middleware support
- Can be called from anywhere
When to Use Server Actions#
Use Server Actions for:
1. Form Submissions#
Server Actions are perfect for handling form data directly from components without HTTP overhead.
'use server'
export async function updateProfile(formData: FormData) {
const name = formData.get('name') as string
const email = formData.get('email') as string
const result = await supabase
.from('profiles')
.update({ name, email })
.eq('id', userId)
if (result.error) throw result.error
return result.data
}
2. Simple Mutations#
For straightforward database operations, Server Actions are cleaner and faster.
'use server'
export async function deletePost(postId: string) {
const result = await supabase
.from('posts')
.delete()
.eq('id', postId)
if (result.error) throw result.error
return result.data
}
3. Keeping Secrets Secure#
API keys stay on the server and are never exposed to the client.
'use server'
export async function sendEmail(email: string) {
const response = await fetch('https://api.stripe.com/v1/charges', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.STRIPE_SECRET_KEY}`
}
})
return response.json()
}
When to Use API Routes#
Use API Routes for:
1. Webhooks#
External services need HTTP endpoints to call your app.
export async function POST(request: NextRequest) {
const event = await request.json()
if (event.type === 'charge.succeeded') {
await supabase
.from('payments')
.insert({ stripe_id: event.data.id, status: 'completed' })
}
return NextResponse.json({ received: true })
}
2. External Integrations#
Third-party services call your API with authentication.
export async function POST(request: NextRequest) {
const apiKey = request.headers.get('authorization')
if (apiKey !== process.env.EXTERNAL_API_KEY) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const data = await request.json()
return NextResponse.json({ success: true })
}
3. Complex Business Logic#
API Routes support middleware and fine-grained control.
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { userId, amount } = body
if (amount < 0) {
return NextResponse.json({ error: 'Invalid amount' }, { status: 400 })
}
const result = await supabase
.from('transactions')
.insert({ user_id: userId, amount })
return NextResponse.json(result.data)
} catch (error) {
return NextResponse.json({ error: 'Server error' }, { status: 500 })
}
}
4. Rate Limiting#
Middleware can enforce rate limits on API endpoints.
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 result = await ratelimit.limit(ip)
if (!result.success) {
return NextResponse.json({ error: 'Rate limited' }, { status: 429 })
}
return NextResponse.json({ success: true })
}
Decision Tree#
Ask yourself these questions:
- Is it a form submission? → Use Server Actions
- Does an external service need to call your app? → Use API Routes
- Do you need middleware or rate limiting? → Use API Routes
- Do you need fine-grained HTTP control? → Use API Routes
- Otherwise → Use Server Actions
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#
- Building SaaS with Next.js and Supabase
- 10 Common Mistakes Building with Next.js and Supabase
- Advanced Authentication Patterns
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.
Frequently Asked Questions
Continue Reading
Supabase Realtime Gotchas: 7 Issues and How to Fix Them
Avoid common Supabase Realtime pitfalls that cause memory leaks, missed updates, and performance issues. Learn real-world solutions from production applications.
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 + Supabase Performance Optimization: From Slow to Lightning Fast
Transform your slow Next.js and Supabase application into a speed demon. Real-world optimization techniques that reduced load times by 70% and improved Core Web Vitals scores.
Browse by Topic
Find stories that matter to you.