DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Next.js 15 Server Actions vs Route Handlers: When to Use Each (I Got This Wrong for 3 Months)

For the first three months I used Next.js 13+ App Router, I defaulted to Route Handlers for everything. Mutations? Route Handler. Form submissions? Route Handler. Deleting a record? Route Handler. I was mentally porting my Express habits into a framework that had evolved past them.

Then I rewrote half the app using Server Actions and the difference was immediate — less code, better loading states, and no client-side fetch boilerplate that existed purely to call my own backend. I also introduced a few Server Action bugs that took me a day to track down. This is what I learned.

The Core Mental Model

The heuristic that finally clicked for me:

If a human triggers it from your UI → Server Action. If a machine triggers it → Route Handler.

Server Actions are a React primitive. They're designed to be called from <form action={...}> or onClick handlers in Server and Client Components. They run on the server, can mutate data, and can call revalidatePath/revalidateTag to bust the cache — all in one function, no round-trip HTTP boilerplate.

Route Handlers are HTTP endpoints. They exist to be called by things that speak HTTP: webhooks from Stripe, mobile apps, third-party integrations, your own scripts, other services. They return Response objects with explicit status codes.

Once that distinction is clear, most decisions make themselves.

Server Actions: The Right Use Cases

Server Actions shine when the mutation is tightly coupled to UI state. Here's a realistic example — updating a user's plan in a SaaS app:

// app/settings/actions.ts
'use server';

import { revalidatePath } from 'next/cache';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

export async function updateUserPlan(formData: FormData) {
  const session = await auth();
  if (!session?.user?.id) throw new Error('Unauthorized');

  const plan = formData.get('plan') as string;
  if (!['free', 'pro', 'enterprise'].includes(plan)) {
    throw new Error('Invalid plan');
  }

  await db.user.update({
    where: { id: session.user.id },
    data: { plan },
  });

  revalidatePath('/settings');
  revalidatePath('/dashboard'); // bust any cached dashboard data too
}
Enter fullscreen mode Exit fullscreen mode
// app/settings/page.tsx
import { updateUserPlan } from './actions';

export default function SettingsPage() {
  return (
    <form action={updateUserPlan}>
      <select name="plan">
        <option value="free">Free</option>
        <option value="pro">Pro</option>
        <option value="enterprise">Enterprise</option>
      </select>
      <button type="submit">Update Plan</button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

No useState, no fetch('/api/update-plan'), no loading spinner wired up manually. Progressive enhancement comes free — the form works without JavaScript. Add useFormStatus if you want the button to disable during submission.

The revalidatePath call is the killer feature here. After the mutation, Next.js automatically refetches the affected routes and updates the UI. With a Route Handler you'd have to manually router.refresh() on the client after the fetch resolves — one more thing to forget.

Route Handlers: The Right Use Cases

Route Handlers are for consumers that aren't your own React tree. Stripe webhooks are the canonical example:

// app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers';
import Stripe from 'stripe';
import { db } from '@/lib/db';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(request: Request) {
  const body = await request.text();
  const signature = (await headers()).get('stripe-signature')!;

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    return new Response('Invalid signature', { status: 400 });
  }

  switch (event.type) {
    case 'customer.subscription.created':
    case 'customer.subscription.updated': {
      const sub = event.data.object as Stripe.Subscription;
      await db.user.update({
        where: { stripeCustomerId: sub.customer as string },
        data: { plan: sub.status === 'active' ? 'pro' : 'free' },
      });
      break;
    }
    case 'customer.subscription.deleted': {
      const sub = event.data.object as Stripe.Subscription;
      await db.user.update({
        where: { stripeCustomerId: sub.customer as string },
        data: { plan: 'free' },
      });
      break;
    }
  }

  return new Response('ok', { status: 200 });
}
Enter fullscreen mode Exit fullscreen mode

This could never be a Server Action. Stripe is calling this endpoint — there's no React form, no useFormStatus, no UI revalidation needed. The explicit status: 400 on bad signatures is also critical — Stripe uses HTTP status codes to decide whether to retry. Server Actions don't give you that control.

Same logic applies to: mobile app API calls, public APIs you expose to customers, internal service-to-service calls, cron job triggers from Vercel/Railway.

The Gotchas That Will Bite You

Server Actions are POST-only. They have no concept of GET requests. If you need a query endpoint that returns data to a non-React consumer, that's a Route Handler. If you need to fetch data for your own UI, that's async Server Component data fetching — not a Server Action at all.

// WRONG — Server Actions are for mutations, not queries
export async function getUsers() {
  'use server';
  return db.user.findMany(); // don't do this
}

// RIGHT — fetch in Server Components directly
export default async function UsersPage() {
  const users = await db.user.findMany();
  return <UserList users={users} />;
}
Enter fullscreen mode Exit fullscreen mode

Error handling surfaces differently. In a Server Action, throwing an error will propagate to the nearest error.tsx boundary unless you handle it with useActionState. In a Route Handler, an unhandled error returns a 500 with no body to the caller. Make sure your error handling matches what the consumer expects.

// Server Action with useActionState for inline error display
'use client';
import { useActionState } from 'react';
import { updateUserPlan } from './actions';

export function PlanForm() {
  const [error, action, isPending] = useActionState(
    async (prev: string | null, formData: FormData) => {
      try {
        await updateUserPlan(formData);
        return null;
      } catch (e) {
        return (e as Error).message;
      }
    },
    null
  );

  return (
    <form action={action}>
      {error && <p className="text-red-500">{error}</p>}
      <select name="plan">
        <option value="pro">Pro</option>
      </select>
      <button disabled={isPending}>Update</button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Auth looks different in each. With Server Actions, auth() from next-auth or your session library works directly — you're already on the server. With Route Handlers, the pattern is the same, but Route Handlers are easier to lock down with API key authentication for external consumers:

// Route Handler with API key auth for external callers
export async function POST(request: Request) {
  const apiKey = request.headers.get('x-api-key');
  if (apiKey !== process.env.INTERNAL_API_KEY) {
    return new Response('Unauthorized', { status: 401 });
  }
  // ...
}
Enter fullscreen mode Exit fullscreen mode

You technically can check headers in a Server Action, but it's awkward — they're not designed as API endpoints.

Streaming: Different Models

Both support streaming but through different mechanisms.

Server Actions work with Suspense and StreamingText naturally because they're integrated with React's rendering model:

// Streaming with Server Actions + Suspense
import { Suspense } from 'react';

export default function Page() {
  return (
    <Suspense fallback={<Spinner />}>
      <SlowDataComponent />
    </Suspense>
  );
}

async function SlowDataComponent() {
  const data = await fetchSlowData(); // streams in
  return <DataView data={data} />;
}
Enter fullscreen mode Exit fullscreen mode

Route Handlers use ReadableStream or EventSource for SSE, which is what you want when the client is not a React component:

// Route Handler SSE for external clients or non-React consumers
export async function GET() {
  const stream = new ReadableStream({
    async start(controller) {
      const encoder = new TextEncoder();
      for await (const chunk of generateAgentStream()) {
        controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
      }
      controller.close();
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

I use this pattern at whoffagents.com for streaming agent responses to the dashboard. The agent runner is a Route Handler because the SSE consumer is a vanilla EventSource in a Client Component — not a Suspense boundary.

Testing in Isolation

Route Handlers are easier to unit test because they're just functions that take a Request and return a Response:

// __tests__/webhooks.test.ts
import { POST } from '@/app/api/webhooks/stripe/route';

test('rejects invalid signature', async () => {
  const request = new Request('http://localhost/api/webhooks/stripe', {
    method: 'POST',
    body: 'bad-body',
    headers: { 'stripe-signature': 'bad-sig' },
  });

  const response = await POST(request);
  expect(response.status).toBe(400);
});
Enter fullscreen mode Exit fullscreen mode

Server Actions are harder to test in isolation because they require Next.js internals (revalidatePath, cookies, headers). The practical approach is to extract the business logic into a plain async function and test that directly, keeping the Server Action as a thin wrapper.

Decision Matrix

Scenario Use
Form submission in your UI Server Action
Mutation triggered by a button click Server Action
Need revalidatePath after mutation Server Action
Stripe / GitHub / any webhook Route Handler
Mobile app or external API consumer Route Handler
SSE stream to non-React client Route Handler
Need explicit HTTP status codes Route Handler
Need API key authentication Route Handler
Public REST API Route Handler

The rewrite I did three months in removed about 400 lines of fetch('/api/...') boilerplate, eliminated a dozen router.refresh() calls that were easy to miss, and moved auth checks closer to the data. The Route Handlers that remained became cleaner too — they were now genuinely external-facing endpoints with explicit contracts, not internal plumbing.

If a human triggers it from your UI, use a Server Action. If a machine triggers it, use a Route Handler. When you're building AI agent infrastructure at whoffagents.com, that distinction maps to almost every decision in the data layer.

Top comments (0)