Next.js

Route Handlers vs Server Actions

When to use a route handler (route.ts) and when to use a Server Action. The key differences are the caller, the response shape, and whether a browser fo...

Both route handlers and Server Actions run code on the server. Both can read from a database, call external APIs, or mutate data. The question is which one belongs in your project, and the answer comes down to one thing: who is calling it?

Route Handlers

A route handler lives in a route.ts file inside app/. It responds to standard HTTP methods and returns a Response object. Anything that can make an HTTP request can use it.

// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getPosts } from '@/lib/posts'

export async function GET(request: NextRequest) {
    const posts = await getPosts()
    return NextResponse.json(posts)
}

export async function POST(request: NextRequest) {
    const body = await request.json()
    const post = await createPost(body)
    return NextResponse.json(post, { status: 201 })
}

The response shape is entirely in your hands. You control the status code, the headers, and the body. That flexibility is what makes route handlers the right tool when something outside your app needs to reach in.

Common use cases:

  • Webhook endpoints (Stripe, GitHub, and similar services POST to a URL you own)
  • REST or JSON APIs consumed by a mobile app or a third-party client
  • Public data feeds where you want predictable URLs
  • Authentication callbacks (OAuth redirects)

Server Actions

A Server Action is a function, not an endpoint. You annotate it with 'use server' and call it directly from a Client Component or a form. Next.js handles the transport layer. There is no URL to manage and no Response to construct.

// app/actions.ts
'use server'

import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'

export async function createPost(formData: FormData) {
    const title = formData.get('title') as string
    const body = formData.get('body') as string

    await savePost({ title, body })
    revalidatePath('/posts')
    redirect('/posts')
}

Wire it to a form with no client-side JavaScript required:

// app/posts/new/page.tsx
import { createPost } from '@/app/actions'

export default function NewPostPage() {
    return (
        <form action={createPost}>
            <input name="title" type="text" required />
            <textarea name="body" required />
            <button type="submit">Publish</button>
        </form>
    )
}

Or call it from a Client Component with useActionState for progressive feedback:

'use client'

import { useActionState } from 'react'
import { createPost } from '@/app/actions'

export function PostForm() {
    const [state, action, pending] = useActionState(createPost, null)

    return (
        <form action={action}>
            <input name="title" type="text" required />
            <textarea name="body" required />
            <button type="submit" disabled={pending}>
                {pending ? 'Publishing...' : 'Publish'}
            </button>
            {state?.error && <p>{state.error}</p>}
        </form>
    )
}

Server Actions are the right tool when you are the only client. They integrate tightly with React's form model and let you call revalidatePath, revalidateTag, or redirect directly inside the action. No fetch, no JSON parsing, no status codes to interpret.

The Decision Table

FactorRoute HandlerServer Action
Who is the caller?External client, webhook, mobile appYour own UI
Is a URL required?YesNo
Form submission?Only with extra fetch logicNative support
Response shapeFull HTTP controlReturn value or throw
Cache revalidationManualrevalidatePath / revalidateTag
Redirect after mutationManual Response with Locationredirect() directly
Works without JavaScriptNot easilyYes (progressive enhancement)

A Pattern Worth Knowing

The two tools are not mutually exclusive. A common pattern is to write the core business logic as a plain async function, then call it from both a Server Action (for your own forms) and a route handler (for external integrations).

// lib/posts.ts
export async function createPost(data: { title: string; body: string }) {
    // database write, validation, side effects
}
// app/actions.ts
'use server'
import { createPost } from '@/lib/posts'

export async function createPostAction(formData: FormData) {
    await createPost({
        title: formData.get('title') as string,
        body: formData.get('body') as string,
    })
    revalidatePath('/posts')
    redirect('/posts')
}
// app/api/posts/route.ts
import { createPost } from '@/lib/posts'

export async function POST(request: NextRequest) {
    const body = await request.json()
    const post = await createPost(body)
    return NextResponse.json(post, { status: 201 })
}

The logic lives once. The transport mechanism depends on the caller.

The Short Version

Use a route handler when something outside your app needs a stable URL to call. Use a Server Action when your own UI is doing the mutating. If you are writing a form in your Next.js app, a Server Action is almost always the cleaner choice. If you are setting up a Stripe webhook or building an API for a mobile client, a route handler is what you want.

← Older
Server Actions in Next.js 15
Newer →
Middleware Patterns in Next.js

Newsletter

A weekly newsletter on React, Next.js, AI-assisted development, and engineering. No spam, unsubscribe any time.