Properly Splitting Server Actions and Route Handlers in Next.js App Router — Mutation Handling and Error Handling Strategies
This article is written with mid-level frontend developers in mind who have primarily worked with the Next.js Pages Router or traditional React SPAs and are transitioning to the App Router. If you've ever ended up with three files under app/api/ just to submit a single form, the confusion you're experiencing will be sorted out here. For a while, I went along thinking "can't I just use a Route Handler for form submissions?" and carried over the Pages Router pattern as-is — until I ran into quite a bit of trouble with unnecessary fetch URL management and cache invalidation issues.
In this article, I'll walk through when and how to use each approach — centered around the single decision rule: "If a human triggers it from the UI → Server Action; if a machine (external service) triggers it over HTTP → Route Handler" — with real code examples. I've also included the latest patterns for React 19 + Next.js 15, so I hope this serves as a useful reference whether you're starting a new project or cleaning up existing code.
If concepts like useActionState and Server Components are new to you, it's best to first look through the Getting Started section of the Next.js official documentation. This article is aimed at those who already have a general understanding of those concepts but are still unsure when to use each mechanism.
Core Concepts
How Are Server Actions and Route Handlers Different?
Both run on the server, but their design philosophies are completely different.
Server Actions are server functions called directly from React components. Declare them with a single "use server" directive, and Next.js handles the network layer for you. From a developer's perspective it feels like a plain function call, but it actually executes on the server, invalidates the cache via revalidatePath, and refreshes the UI — all in a single round trip.
Route Handlers are traditional HTTP endpoints located at app/api/... paths. They explicitly handle HTTP methods like GET, POST, PUT, and DELETE, and give you direct control over the HTTP layer including headers, status codes, and streaming responses. The key difference from Server Actions is that any HTTP client outside the React app — webhooks, mobile apps, external services — can call them.
The core decision rule: "Human triggers it from the UI → Server Action; machine (external service) triggers it over HTTP → Route Handler"
This single rule resolves most situations without any deliberation. The decision flow below makes it even clearer.
Who is the source of the request?
├── A user directly interacts with the UI (form submit, button click)
│ └── → Server Action
└── An external system calls via HTTP (webhook, mobile app, third-party)
└── → Route Handler
├── GET + public data that needs CDN caching → Route Handler
├── Streaming response required → Route Handler
└── OAuth callback handling → Route HandlerWhat Changed in React 19
With Next.js 15 and React 19 working in tandem, the APIs around Server Actions have been considerably refined.
useFormState has been renamed to useActionState, and it now manages form submission results, error states, and pending state all in a single hook. useFormStatus has a clearly defined dedicated role for submit button loading indicators, and useOptimistic now officially supports the optimistic UI update pattern.
An industry standard is also emerging on the error handling side: handle expected errors as return values instead of throw. Cases like validation failures or permission errors are returned as { success: false, error: "..." }, while throw is reserved only for truly unpredictable errors (DB crash, network failure).
Practical Application
Example 1: Post Creation Form — The Canonical Server Action
This is the case where a user fills out and submits a form. Registration, comment writing, and profile editing all fall into this category. Honestly, you might feel the urge to use a Route Handler here, but doing so suddenly makes fetch URL management and cache invalidation complicated.
One more thing — the recommended pattern in App Router is to keep page.tsx as a Server Component and extract only the interactive form part into a separate Client Component. It might feel cumbersome at first, but it enables a structure where the Server Component pre-fetches initial data and passes it down to the form, and also reduces bundle size.
// app/actions/post.ts
"use server";
import { z } from "zod";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
type ActionState = {
success: boolean;
errors?: {
title?: string[];
content?: string[];
};
} | null;
const schema = z.object({
title: z.string().min(1, "제목을 입력해 주세요"),
content: z.string().min(10, "내용은 10자 이상 입력해 주세요"),
});
export async function createPost(
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
const parsed = schema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
});
if (!parsed.success) {
return { success: false, errors: parsed.error.flatten().fieldErrors };
}
await db.post.create({ data: parsed.data });
revalidatePath("/posts");
return { success: true };
}// app/posts/new/_components/PostForm.tsx
"use client";
import { useActionState } from "react";
import { createPost } from "@/actions/post";
export function PostForm() {
const [state, action, isPending] = useActionState(createPost, null);
return (
<form action={action}>
<input name="title" placeholder="제목" />
{state?.errors?.title && (
<p className="text-red-500 text-sm">{state.errors.title[0]}</p>
)}
<textarea name="content" placeholder="내용" />
{state?.errors?.content && (
<p className="text-red-500 text-sm">{state.errors.content[0]}</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? "저장 중..." : "저장"}
</button>
</form>
);
}// app/posts/new/page.tsx — no "use client", kept as Server Component
import { PostForm } from "./_components/PostForm";
export default function NewPostPage() {
return (
<main>
<h1>새 게시글 작성</h1>
<PostForm />
</main>
);
}| Code Point | Description |
|---|---|
Explicit ActionState type |
Unifies prevState and the return type to prevent type mismatch errors |
schema.safeParse |
Handles validation failure as a return value instead of throw |
revalidatePath("/posts") |
Immediately invalidates the cache for the /posts path after saving to DB |
useActionState |
React 19's standard hook — manages pending state, errors, and results all at once |
Keeping page.tsx as Server Component |
Client Components must be extracted to preserve the server rendering benefits |
Example 2: Receiving Stripe Webhooks — When Route Handler Is Required
This is a case that absolutely cannot be handled with a Server Action. Stripe's server sends a POST request directly to our server — there's no room for a React component to get involved. Furthermore, verifying the webhook signature requires the raw body, which is only possible in a Route Handler. More often than you'd think, signature verification gets deferred as "we'll add it later" during early development and ends up shipping to production as-is. It's far better to include it from the start.
// app/api/webhooks/stripe/route.ts
import { headers } from "next/headers";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: Request) {
const body = await request.text(); // raw body required for signature verification
const signature = (await headers()).get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return new Response("Invalid signature", { status: 400 });
}
switch (event.type) {
case "checkout.session.completed":
await handlePaymentSuccess(event.data.object);
break;
case "customer.subscription.deleted":
await handleSubscriptionCanceled(event.data.object);
break;
}
return new Response("OK", { status: 200 });
}| Code Point | Description |
|---|---|
request.text() |
Preserves the raw body — parsing as JSON will cause signature verification to fail |
status: 400 |
Direct control over HTTP status codes — not possible with Server Actions |
stripe.webhooks.constructEvent |
Stripe signature verification — must be implemented to defend against tampering |
Returning Response directly |
A Route Handler characteristic — full control over the HTTP layer |
Example 3: Streaming AI Responses — Another Use Case for Route Handler
Route Handlers are also the right fit when you need to stream responses, such as for an AI chatbot. Server Actions cannot return HTTP chunked transfer (chunk-by-chunk streaming), so the client can only receive the response once it's fully complete. This is a separate topic from the streaming rendering of React Server Components. If you want to display text progressively like a typing effect, Route Handler is the only option.
// app/api/chat/route.ts
import OpenAI from "openai";
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
export async function POST(request: Request) {
const { messages } = await request.json();
const stream = new ReadableStream({
async start(controller) {
const response = await openai.chat.completions.create({
model: "gpt-4o",
messages,
stream: true,
});
for await (const chunk of response) {
const text = chunk.choices[0]?.delta?.content ?? "";
controller.enqueue(new TextEncoder().encode(text));
}
controller.close();
},
});
return new Response(stream, {
headers: { "Content-Type": "text/event-stream" },
});
}Pros and Cons Analysis
Advantages
Both mechanisms have their own distinct strengths.
| Item | Server Action | Route Handler |
|---|---|---|
| Type safety | Types shared directly like a function call between server and client | Requires separate type definitions or an OpenAPI spec |
| Round trips | Mutation + cache invalidation + UI update happen in one go | Request and response are handled separately by default |
| CSRF protection | Same-Origin verification handled automatically | Must be implemented manually or via middleware |
| Client compatibility | React components only | Callable from any HTTP client |
| HTTP control | Cannot directly control status codes or headers | Full HTTP layer control |
| Progressive Enhancement | Works as an HTML form even without JS | Requires client-side JS |
Drawbacks and Caveats
I've had late nights in production because of these. Especially with Server Action security — it's easy to brush off with "well, it runs on the server so it should be fine," but it needs careful attention.
| Item | Description | Mitigation |
|---|---|---|
bind arguments exposed to client |
Closure arguments bound via bind can be serialized and included in the client bundle |
Don't pass sensitive values (user ID, permission info) as bind arguments — look them up from the session directly on the server |
| Server Actions cannot be GET | Mutation-only, cannot be used for data fetching | Use direct fetch in Server Components or a Route Handler for reads |
| Double round trip with Route Handlers | Using them for internal mutations causes unnecessary round trips | Replace internal UI mutations with Server Actions |
| No streaming (Server Actions) | Cannot return HTTP chunked transfer — the full response is delivered only after completion | Use Route Handlers for AI streaming, SSE, etc. |
| Missing webhook signature verification | Accepting webhooks without verification leaves you vulnerable to tampering attacks | Mandatory implementation of signature verification logic for Stripe, GitHub, and other services |
Term note —
revalidatePath: A Next.js function that immediately invalidates the cache for a specific path. Can be called from Server Actions, Route Handlers, and Server Components alike — the next time the path is rendered after being called, it fetches fresh data.
Term note —
Progressive Enhancement: Even in environments where JavaScript is disabled, Server Actions execute via the native HTML form behavior. This is a major advantage of Server Actions in terms of accessibility and robustness.
The Most Common Mistakes in Practice
- Handling all mutations with Route Handlers — This often happens when old habits from the Pages Router era linger. In App Router, Server Actions are far more concise and type-safe for UI mutations.
throwing expected errors in Server Actions — Throwing errors for validation failures or "user already exists" scenarios causes the Error Boundary to catch them and replaces the entire UI with an error screen. The recommended pattern is to return them as{ success: false, errors: ... }.- Omitting signature verification on webhook endpoints — More often than you'd think, "let's just see if it works first" during early development gets shipped to production as-is. How to check: open any file under
app/api/webhooksin your IDE and search for a call toconstructEventor each service's signature verification function — you'll get the list immediately.
Closing Thoughts
Once you start using these two mechanisms distinctly, you'll notice your code changing at some point. Unnecessary fetch boilerplate decreases, type errors get caught earlier, and cache invalidation timing becomes predictable. Server Actions for "all state changes a user makes from the UI," Route Handlers for "HTTP contracts with the outside world" — this distinction aligns best with the design intent of the App Router architecture.
Three things you can start doing right now:
- Open the
app/apifolder of your current project and classify each Route Handler as either "receiving requests from an external service" or "only being called from your app's own UI." The latter are candidates for replacing with Server Actions. - Pick one of the Route Handlers you've identified for replacement, move it to the
app/actions/directory as a"use server"file, and connect it withuseActionStatein the client component — you'll immediately feel how much simpler the code becomes. - If there are places inside Server Actions where validation failures are handled with
throw, try converting them to the return pattern. Search forthrowwithin theapp/actionsdirectory in your IDE and you'll get the list right away. Switching to the{ success: false, errors: ... }return pattern eliminates the problem of Error Boundaries firing unnecessarily.
References
- Server Actions vs Route Handlers: When to Use Each in Next.js | MakerKit
- Next.js 15 Server Actions vs Route Handlers (I Got This Wrong for 3 Months) | DEV Community
- Route Handler vs Server Action in Production for Next.js | Wisp CMS
- Building APIs with Next.js | Next.js Official Blog
- Data Fetching: Server Actions and Mutations | Next.js Official Docs
- Getting Started: Error Handling | Next.js Official Docs
- Next.js Server Actions Error Handling: A Production-Ready Guide | Medium
- next-safe-action Official Docs
- Next.js Server Actions: The Complete Guide (2026) | MakerKit
- Getting Started: Revalidating | Next.js Official Docs