Next.js

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.

Mahdi Ben RhoumaMahdi Ben Rhouma
Updated
10 min read

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. Next.js enforces a framework-level CSRF check on every action — comparing the request's Origin to its Host — which is exactly the "Built-in" CSRF protection in the comparison table below.

typescript
// 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
}
typescript
// 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#

Route Handlers (what Next.js calls API Routes under the App Router) are HTTP endpoints that handle requests and responses, defined with a route.ts file exporting the HTTP method you want to support.

typescript
// 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)
}
typescript
// 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#

FeatureServer ActionsAPI Routes
Call MethodFunction-call syntax backed by HTTP POST from the clientExplicit HTTP request
CSRF ProtectionBuilt-inManual
Type SafetyExcellentManual
CachingNext.js cache functionsManual
External CallsDirect POST reachable; framework-specific protocolStable HTTP interface for external clients
MiddlewareApplies when the action request matches the route matcherApplies when the request matches the route matcher
ComplexitySimpleMore verbose
FlexibilityLimitedHigh

When to Use Server Actions#

Use Server Actions for:

Form Submissions#

typescript
// ✅ 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#

typescript
// ✅ 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#

typescript
// ✅ 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#

typescript
// ✅ 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#

typescript
// ✅ 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#

typescript
// ✅ 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#

typescript
// ✅ 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#

typescript
// ✅ Middleware for rate limiting
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
 
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#

typescript
// ✅ 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#

plaintext
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 fine-grained HTTP control?
├─ YES → Use API Routes
└─ NO → Use Server Actions

Transport and performance#

Server Actions:

  • Function-call syntax; client invocations still send an HTTP POST
  • Automatic serialization
  • Built-in CSRF protection

API Routes:

  • Explicit HTTP request and response contract
  • More flexible
  • Manual serialization
  • Manual CSRF protection

The Next.js mutation guide documents the POST transport for Server Actions. Neither approach removes network latency; measure your own workload before claiming one is faster. Choose a Route Handler when you need a stable public HTTP interface. A Server Action is also directly reachable: the security guide requires authentication and authorisation inside each action. Middleware matching a route does not replace those checks.

Error Handling#

Server Actions#

typescript
'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#

typescript
'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

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.

Further reading: Next.js Server Actions with Supabase: Complete Guide.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.