Skip to content
·7 min read

Next.js Server Actions Are Great (Until You Use Them Wrong)

Server Actions are not a replacement for API routes. They shine for form mutations but break down for webhooks, third-party callbacks, and public APIs. Here's when to use them.

Next.jsServer ActionsReactWeb Development

Server Actions in Next.js App Router are one of those features that everyone is excited about and nobody explains when to use. The result is a lot of codebases using them for everything — webhooks, public APIs, third-party callbacks — and discovering they do not fit.

Server Actions solve one problem well: form mutations from a page you control. Outside that, they are the wrong tool. Here is how I decide.

The one-sentence rule

If the request comes from your UI, use a Server Action. If it comes from anywhere else, use a Route Handler.

That covers 90 percent of decisions. The remaining 10 percent is nuance.

What Server Actions are good at

Form mutations on a page. Submit, mutate data, re-render the page or redirect.

// app/actions/createPost.ts
'use server'
 
import { z } from 'zod'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { db } from '@/lib/db'
import { getSession } from '@/lib/session'
 
const schema = z.object({
  title: z.string().min(1).max(200),
  body: z.string().min(1).max(20000),
})
 
export async function createPost(formData: FormData) {
  const session = await getSession()
  if (!session?.userId) throw new Error('Not authenticated')
 
  const parsed = schema.safeParse({
    title: formData.get('title'),
    body: formData.get('body'),
  })
 
  if (!parsed.success) {
    return { errors: parsed.error.flatten().fieldErrors }
  }
 
  const post = await db.post.create({
    data: { ...parsed.data, authorId: session.userId },
  })
 
  revalidatePath(`/blog`)
  redirect(`/blog/${post.id}`)
}

The form:

// app/blog/new/page.tsx
import { createPost } from '@/app/actions/createPost'
 
export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" />
      <textarea name="body" />
      <button type="submit">Publish</button>
    </form>
  )
}

That is the happy path. The form submits to a Server Action. The action validates, mutates, revalidates the cache, and redirects. No onSubmit, no fetch, no API route.

Handling pending state and errors

For richer UX, use useActionState (the renamed useFormState from React 19):

'use client'
 
import { useActionState } from 'react'
import { createPost } from '@/app/actions/createPost'
 
export function NewPostForm() {
  const [state, formAction, pending] = useActionState(createPost, {
    errors: undefined,
  })
 
  return (
    <form action={formAction}>
      <input name="title" />
      {state.errors?.title && <p>{state.errors.title[0]}</p>}
 
      <textarea name="body" />
      {state.errors?.body && <p>{state.errors.body[0]}</p>}
 
      <button disabled={pending} type="submit">
        {pending ? 'Publishing...' : 'Publish'}
      </button>
    </form>
  )
}

This gives you pending state, validation errors, and the form still works without JavaScript. That last point matters more than people realize — slow mobile networks, blocked JS, accessibility tools.

Optimistic updates

For instant feedback, use useOptimistic:

'use client'
 
import { useOptimistic, useRef } from 'react'
import { likePost } from '@/app/actions/likePost'
 
export function LikeButton({ postId, initialLikes, isLiked }) {
  const optimisticallyLiked = useRef(isLiked)
  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    initialLikes,
    (state, _) => state + (optimisticallyLiked.current ? -1 : 1)
  )
 
  return (
    <form action={async () => {
      optimisticallyLiked.current = !optimisticallyLiked.current
      addOptimisticLike(null)
      await likePost(postId)
    }}>
      <button type="submit">{optimisticLikes} likes</button>
    </form>
  )
}

The like count updates instantly. The Server Action runs in the background. If it fails, the optimistic update reverts. This is the modern equivalent of the classic "fire and forget" pattern, but with a rollback built in.

When NOT to use Server Actions

The request comes from outside your UI. Examples:

Webhooks

Stripe, GitHub, Resend — these POST to your app from their servers. They cannot call a Server Action. They have no session or CSRF token.

Use a Route Handler:

// app/api/stripe/webhook/route.ts
import Stripe from 'stripe'
import { db } from '@/lib/db'
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
 
export async function POST(req: Request) {
  const signature = req.headers.get('stripe-signature')!
  const payload = await req.text()
 
  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(
      payload,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch {
    return new Response('Invalid signature', { status: 400 })
  }
 
  switch (event.type) {
    case 'checkout.session.completed':
      await db.subscription.update({
        where: { customerId: event.data.object.customer as string },
        data: { status: 'active' },
      })
      break
  }
 
  return new Response(null, { status: 200 })
}

Public APIs

If you expose an API for third parties to consume, it is a Route Handler. Server Actions are not addressable by URL in a stable way.

Long-running background jobs

Server Actions run in the request lifecycle. If you need a 30-second job, use a queue (Inngest, QStash, or your own worker) and a Route Handler that enqueues.

Frequent polling

A Server Action can be called repeatedly from the client, but it is not the right tool for "poll this endpoint every 5 seconds." A Route Handler with proper cache headers is.

Validation is non-negotiable

The biggest mistake I see: developers skip validation because "the form already validates."

Wrong. The form is a UI hint. The server is the source of truth. Anyone can POST a crafted payload to your action endpoint.

Always validate with Zod:

const schema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
})
 
const parsed = schema.safeParse(Object.fromEntries(formData))
if (!parsed.success) {
  return { errors: parsed.error.flatten().fieldErrors }
}

Revalidation

After a mutation, refresh the affected cache slices:

import { revalidatePath, revalidateTag } from 'next/cache'
 
await db.post.create({ data: { ... } })
revalidatePath(`/blog`)           // refresh the blog list
revalidateTag('posts:feed')       // refresh any fetch tagged posts:feed

Be specific. revalidatePath('/') invalidates everything, which defeats the cache. Tag-based invalidation scales better for larger apps.

The decision tree

  1. Did the request originate from your UI? Yes → Server Action. No → Route Handler.
  2. Is it a webhook or third-party callback? Route Handler.
  3. Is it a public API endpoint? Route Handler.
  4. Is it a long-running job? Queue + Route Handler.
  5. Is it a form submission on a page? Server Action.

That is the whole decision.

Server Actions are progressive enhancement for forms, not a replacement for HTTP. Use them for what they are good at and reach for Route Handlers for everything else.

Want a Next.js app built on the right patterns?

I build Next.js apps with the App Router, Server Actions, and clean separation from Route Handlers. Let's talk.

Frequently Asked Questions

When should I use Server Actions vs Route Handlers in Next.js?

Use Server Actions for form mutations and any action that originates from a user interaction on a page. Use Route Handlers for webhooks, third-party API callbacks, public APIs, cron jobs, and any request that does not come from your app's UI.

Can I call a Server Action from an external service?

No. Server Actions are tied to a session and CSRF token from the current page. External services like Stripe webhooks or OAuth callbacks cannot call them. Use a Route Handler for those cases.

How do I handle validation errors in Server Actions?

Use useActionState (formerly useFormState) to capture the return value of your action. Return a structured object with errors keyed by field, then render them next to the inputs. Validate input with Zod before any business logic.

Do Server Actions work without JavaScript?

Yes. Next.js progressively enhances Server Actions to work without client JavaScript. The form submits as a regular POST. This is useful for slow networks and users with JavaScript disabled, but most apps still ship client JS for richer interactions.