Server Actions vs Route Handlers: When Each Is an Anti-Pattern
Server Actions vs Route Handlers: When Each Is an Anti-Pattern
Introduction
Next.js provides two ways to handle server-side mutations: Server Actions and Route Handlers. Both can handle form submissions, API calls, and data mutations, but they serve different purposes.
Using the wrong one can lead to security issues, performance problems, or maintenance headaches. After building several production applications, I've identified clear guidelines for when to use each approach.
Understanding the Difference
Server Actions
Server Actions are functions that run on the server and can be called directly from Client Components. They're designed for mutations that are tightly coupled to the UI.
Key characteristics:
- Called directly from form submissions or event handlers
- Automatically handle CSRF protection
- Can revalidate cache and redirect
- Return typed responses
- Simplify form handling
// actions.ts
'use server'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
const content = formData.get('content') as string
await db.post.create({ data: { title, content } })
revalidatePath('/posts')
redirect('/posts')
}
Route Handlers
Route Handlers are API endpoints that handle HTTP requests. They're designed for external integrations, webhooks, and public APIs.
Key characteristics:
- Follow standard HTTP methods (GET, POST, PUT, DELETE)
- Can handle any HTTP request
- Support streaming responses
- Can be called from anywhere (not just your app)
- Require explicit CSRF protection for form submissions
// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
const body = await request.json()
const post = await db.post.create({ data: body })
return NextResponse.json(post, { status: 201 })
}
When to Use Server Actions
1. Form Submissions
Server Actions are ideal for form submissions:
// Component
import { createPost } from './actions'
export function PostForm() {
return (
<form action={createPost}>
<input name="title" placeholder="Title" />
<textarea name="content" placeholder="Content" />
<button type="submit">Create Post</button>
</form>
)
}
// actions.ts
'use server'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
const content = formData.get('content') as string
await db.post.create({ data: { title, content } })
revalidatePath('/posts')
redirect('/posts')
}
Why it's better:
- Automatic CSRF protection
- No need for API routes
- Direct cache revalidation
- Type-safe with TypeScript
2. UI-Coupled Mutations
When the mutation is tightly coupled to the UI:
// Server Action
'use server'
export async function toggleLike(postId: string) {
const session = await getSession()
if (!session) throw new Error('Unauthorized')
const existingLike = await db.like.findUnique({
where: { postId_userId: { postId, userId: session.userId } }
})
if (existingLike) {
await db.like.delete({ where: { id: existingLike.id } })
} else {
await db.like.create({ data: { postId, userId: session.userId } })
}
revalidatePath(`/posts/${postId}`)
}
// Component
'use client'
function LikeButton({ postId, initialLiked }) {
const [liked, setLiked] = useState(initialLiked)
return (
<button onClick={async () => {
await toggleLike(postId)
setLiked(!liked)
}}>
{liked ? '❤️' : '🤍'}
</button>
)
}
3. Mutations That Need Cache Revalidation
When you need to update the UI immediately:
// Server Action
'use server'
export async function updateProfile(formData: FormData) {
const session = await getSession()
if (!session) throw new Error('Unauthorized')
const name = formData.get('name') as string
await db.user.update({
where: { id: session.userId },
data: { name }
})
revalidatePath('/profile')
revalidatePath('/dashboard')
}
// Component
export function ProfileForm() {
return (
<form action={updateProfile}>
<input name="name" placeholder="Name" />
<button type="submit">Update Profile</button>
</form>
)
}
When to Use Route Handlers
1. External Integrations
When you need to integrate with external services:
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
const body = await request.text()
const signature = request.headers.get('stripe-signature')!
try {
const event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
)
// Handle the event
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutComplete(event.data.object)
break
}
return NextResponse.json({ received: true })
} catch (error) {
return NextResponse.json({ error: 'Webhook error' }, { status: 400 })
}
}
2. Public APIs
When you need to expose an API to external consumers:
// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '10')
const posts = await db.post.findMany({
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' }
})
return NextResponse.json(posts)
}
3. Streaming Responses
When you need to stream data:
// app/api/stream/route.ts
import { NextRequest } from 'next/server'
export async function GET(request: NextRequest) {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
for (let i = 0; i < 10; i++) {
controller.enqueue(encoder.encode(`Chunk ${i}\n`))
await new Promise(resolve => setTimeout(resolve, 1000))
}
controller.close()
}
})
return new Response(stream, {
headers: { 'Content-Type': 'text/plain' }
})
}
4. Webhooks
When receiving webhooks from external services:
// app/api/webhooks/github/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
const body = await request.json()
const event = request.headers.get('x-github-event')
// Verify webhook signature
const signature = request.headers.get('x-hub-signature-256')
if (!verifySignature(body, signature)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
}
// Process the event
switch (event) {
case 'push':
await handlePush(body)
break
case 'pull_request':
await handlePullRequest(body)
break
}
return NextResponse.json({ received: true })
}
Anti-Patterns to Avoid
Anti-Pattern 1: Using Route Handlers for Form Submissions
Bad:
// app/api/posts/route.ts
export async function POST(request: NextRequest) {
const formData = await request.formData()
const title = formData.get('title') as string
await db.post.create({ data: { title } })
return NextResponse.json({ success: true })
}
// Component
'use client'
function PostForm() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const formData = new FormData(e.target as HTMLFormElement)
await fetch('/api/posts', { method: 'POST', body: formData })
}
return (
<form onSubmit={handleSubmit}>
<input name="title" />
<button type="submit">Create</button>
</form>
)
}
Why it's bad:
- No automatic CSRF protection
- Manual cache revalidation
- More code
- Type-unsafe
Good:
// actions.ts
'use server'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
await db.post.create({ data: { title } })
revalidatePath('/posts')
}
// Component
function PostForm() {
return (
<form action={createPost}>
<input name="title" />
<button type="submit">Create</button>
</form>
)
}
Anti-Pattern 2: Using Server Actions for External APIs
Bad:
// actions.ts
'use server'
export async function fetchExternalData() {
const response = await fetch('https://api.external.com/data')
return response.json()
}
Why it's bad:
- Exposes internal logic
- No streaming support
- Can't handle webhooks
- Security risks
Good:
// app/api/external/route.ts
export async function GET() {
const response = await fetch('https://api.external.com/data')
return Response.json(await response.json())
}
Anti-Pattern 3: Mixing Concerns
Bad:
// Using Server Action for API-like operations
'use server'
export async function getData(query: string) {
const data = await db.post.findMany({
where: { title: { contains: query } }
})
return data
}
Why it's bad:
- Server Actions are for mutations, not queries
- Route Handlers are better for data fetching
- Confuses the purpose of each approach
Good:
// app/api/search/route.ts
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const query = searchParams.get('q') || ''
const data = await db.post.findMany({
where: { title: { contains: query } }
})
return Response.json(data)
}
Anti-Pattern 4: Not Handling Errors Properly
Bad:
// actions.ts
'use server'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
await db.post.create({ data: { title } })
// No error handling!
}
Good:
// actions.ts
'use server'
export async function createPost(formData: FormData) {
try {
const title = formData.get('title') as string
if (!title) {
return { error: 'Title is required' }
}
await db.post.create({ data: { title } })
revalidatePath('/posts')
redirect('/posts')
} catch (error) {
return { error: 'Failed to create post' }
}
}
Anti-Pattern 5: Ignoring Security
Bad:
// actions.ts
'use server'
export async function deleteUser(userId: string) {
// No authorization check!
await db.user.delete({ where: { id: userId } })
}
Good:
// actions.ts
'use server'
export async function deleteUser(userId: string) {
const session = await getSession()
if (!session) throw new Error('Unauthorized')
// Check if user is admin or deleting their own account
if (session.userId !== userId && session.role !== 'admin') {
throw new Error('Forbidden')
}
await db.user.delete({ where: { id: userId } })
revalidatePath('/users')
}
Performance Considerations
Server Actions
- Pros: Automatic CSRF protection, direct cache revalidation
- Cons: Can't stream responses, tied to UI
Route Handlers
- Pros: Streaming support, flexible HTTP methods
- Cons: Manual CSRF protection, more boilerplate
Choose Based on Use Case
Use Server Actions when:
- Form submissions
- UI-coupled mutations
- Need automatic cache revalidation
- Type safety is important
Use Route Handlers when:
- External integrations
- Public APIs
- Streaming responses
- Webhooks
- Need HTTP method flexibility
Migration Guide
From Route Handler to Server Action
// Before: Route Handler
// app/api/posts/route.ts
export async function POST(request: NextRequest) {
const { title } = await request.json()
await db.post.create({ data: { title } })
return NextResponse.json({ success: true })
}
// After: Server Action
// actions.ts
'use server'
export async function createPost(title: string) {
await db.post.create({ data: { title } })
revalidatePath('/posts')
}
From Server Action to Route Handler
// Before: Server Action
// actions.ts
'use server'
export async function getData() {
const data = await fetch('https://api.external.com/data')
return data.json()
}
// After: Route Handler
// app/api/data/route.ts
export async function GET() {
const data = await fetch('https://api.external.com/data')
return Response.json(await data.json())
}
Decision Framework
Use this flowchart to decide:
Start
↓
Is it a form submission or UI mutation?
↓ Yes → Server Action
↓ No ↓
Is it an external integration or webhook?
↓ Yes → Route Handler
↓ No ↓
Do you need streaming?
↓ Yes → Route Handler
↓ No ↓
Is it a public API?
↓ Yes → Route Handler
↓ No ↓
Is it a query (read-only)?
↓ Yes → Route Handler
↓ No ↓
Server Action ✓
Conclusion
Server Actions and Route Handlers serve different purposes, and using the right one for the right job is crucial for building maintainable, secure applications.
Key takeaways:
- Server Actions are for form submissions and UI-coupled mutations
- Route Handlers are for external integrations, public APIs, and webhooks
- Don't mix concerns — use each for its intended purpose
- Handle errors properly — both approaches need error handling
- Consider security — Server Actions have automatic CSRF protection
The key insight is that Server Actions are about simplifying mutations in your UI, while Route Handlers are about building APIs. Once you internalize this distinction, the decision becomes natural.
Need help implementing Server Actions or Route Handlers in your project? Contact me to discuss your architecture.
Comments (0)
Loading comments...