Client-side fetch vs Server Actions: when to use which
Status: đźź© COMPLETE Last updated: 2026-06-21 Plain-English tagline: In Next.js App Router, you can mutate data either by calling an API from the browser or by passing a Server Action to a form. Server Actions are the modern default; client fetch fits specific cases.
What this decides
When the user does something that mutates data (submit a form, click a button, drag-and-drop), where does the code that actually writes to the database run?
- Server Action — a function marked
"use server"that’s called via<form action={action}>or invoked from a Client Component. Next.js handles the network round-trip. - Client-side fetch — your Client Component calls
fetch("/api/x", { method: "POST", body: ... })and you handle the network manually. The server side is a Route Handler (route.ts).
For background: Server Actions 🟩, Next.js 🟩 🟦.
The short answer
Default: Server Actions. They’re simpler, integrate naturally with forms, work without JavaScript (progressive enhancement), and Next.js handles the plumbing.
Reach for client-side fetch when the mutation is part of a flow that doesn’t fit “user submits a form”:
- Real-time / event-driven updates
- Polling for status
- Optimistic mutations with manual rollback
- Multi-step interactive flows where each step depends on the previous
Bible Quest uses Server Actions for nearly everything (passage create/update, kid create, quiz answer submit, badge award). It uses client-side fetch only for the Like-style optimistic counter pattern.
The factors that matter
- Is this a form submission? Yes → Server Action is the natural fit. No → either could work; consider next factors.
- Do you need optimistic UI? Yes → both can do it.
useTransition+ Server Action is the modern pattern. - Do you need progressive enhancement (works without JS)? Yes → Server Action with
<form action={action}>works even when JS hasn’t loaded. - Are you doing multiple chained mutations? Yes → client-side fetch may be clearer (you control the sequence).
- Are you mutating in response to non-user events (timer, websocket)? Yes → fetch, since Server Actions are user-driven.
When to pick SERVER ACTIONS
- Forms — the canonical use case.
<form action={action}>is the simplest, works with progressive enhancement, getsuseFormStatusanduseFormStatefor free. - One-shot button mutations — “delete this passage,” “send this kid a high-five.” Server Actions wrap cleanly:
<form action={() => deleteAction(id)}>. - You want simplicity. Server Actions skip the API endpoint layer — no
route.ts, no manual fetch, no JSON-shape decisions. - You’re working with cookies / auth sessions. Server Actions automatically include the request’s cookies; the server-side code can
await getCurrentUser()directly. - You want type-safety end-to-end. The action’s signature IS the contract — no JSON parsing, no schema validation needed (if you trust your input).
Server Action pattern from Bible Quest:
// /admin/passages/actions.ts
"use server";
export async function deletePassageAction(formData: FormData) {
await requireAdmin();
const id = formData.get("id") as string;
await db.from("passages").delete().eq("id", id);
revalidatePath("/admin/passages");
}// /admin/passages/page.tsx
<form action={deletePassageAction}>
<input type="hidden" name="id" value={passage.id} />
<button type="submit">Delete</button>
</form>No client state, no fetch, no JSON. The form just works.
When to pick CLIENT-SIDE FETCH
- Optimistic counters / reactions — increment a like count visually, then send the request. Roll back if it fails. Easier with client-side state.
- Real-time updates — websocket / SSE driven mutations that arrive outside the user’s click.
- Polling — checking status every N seconds (job progress, build status).
- Multi-step flows — wizard forms where each step’s response shapes the next.
- Chained mutations with rollback — when failure of step 3 requires undoing steps 1 and 2.
- Cross-application API calls — when the API is meant to be consumable by other clients (mobile app, third-party). Route Handlers are public APIs.
Client-side fetch pattern (Bible Quest’s high-five button):
"use client";
export function HighFiveButton({ recipientId }) {
const [sent, setSent] = useState(false);
return (
<button
disabled={sent}
onClick={async () => {
setSent(true); // optimistic
const res = await fetch("/api/high-five", {
method: "POST",
body: JSON.stringify({ recipientId }),
});
if (!res.ok) setSent(false); // roll back on failure
}}
>
{sent ? "🙌 sent" : "👋"}
</button>
);
}The optimistic feel + manual rollback is easier with client-side fetch than with a Server Action.
The hybrid pattern
The most common modern shape: Server Actions for mutations + Server Components for reads.
- Server Component fetches data:
const posts = await db.from("posts").select(); - Server Action handles mutations:
<form action={createPostAction}>
No fetch from the client at all. The page is built from server-side data; mutations go through Server Actions. The simplest, most maintainable shape.
Use client-side fetch only when you’ve identified one of the specific reasons above.
What about useTransition for optimistic UI?
useTransition works with Server Actions:
"use client";
import { useTransition } from "react";
export function LikeButton({ initialCount, postId }) {
const [count, setCount] = useState(initialCount);
const [isPending, startTransition] = useTransition();
return (
<button
onClick={() => {
setCount(c => c + 1); // optimistic
startTransition(async () => {
const result = await likeAction(postId);
if (!result.ok) setCount(c => c - 1); // roll back
});
}}
>
❤️ {count} {isPending && "..."}
</button>
);
}This gets you most of the “optimistic UI” benefit of client-side fetch while still using a Server Action.
What if I’ve already chosen?
“I have a Route Handler I want to convert to a Server Action”: the conversion is usually mechanical. Move the function from route.ts to an actions file, add "use server", change clients from fetch to direct call. The hard part is identifying clients that depended on the URL.
“I have Server Actions but I need to call them from a mobile app”: Server Actions aren’t designed for that. Add a Route Handler (/api/x/route.ts) that exposes the same logic. The mobile app hits the route handler.
“My Server Action is getting too big”: factor out the business logic into a lib/ function that both the Server Action and any Route Handler can call. Keep the Server Action thin (auth + input handling + delegate).
“I’m using fetch from a Server Component to call my own API”: stop. Call the underlying logic directly. There’s no reason for a Server Component to make an HTTP call to itself.
See also
- Server Actions 🟩 — the textbook
- Next.js 🟩 🟦
- Server Component vs Client Component 🟩 — closely related decision
- REST APIs 🟩 — when you do need an API
- Forms and validation đźź©
- React đźź©
- Decision frameworks — index 🟩