Back to Blog
August 19, 20269 min read1 views

Server Actions vs Route Handlers: When Each Is an Anti-Pattern

Next.jsTypeScriptWeb Development
JL
Written by Julius Legaspi
LinkedInGitHub
Share

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:

  1. Server Actions are for form submissions and UI-coupled mutations
  2. Route Handlers are for external integrations, public APIs, and webhooks
  3. Don't mix concerns — use each for its intended purpose
  4. Handle errors properly — both approaches need error handling
  5. 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...