Post 17
The Data-Fetching Decision Tree for the App Router
Server Component fetch, Server Action, Route Handler, client fetch, or a library like React Query — the App Router gives you five ways to load data and very little guidance on which to pick. Here is the decision tree I actually use, with the code for each branch.

The App Router did not remove the hard part of data fetching. It moved it. Instead of “how do I fetch,” the question is now “where does this fetch run, and what triggers it.” There are five common answers and they are not interchangeable.
The five options
| Mechanism | Runs | Triggered by |
|---|---|---|
fetch in a Server Component | Server, at render | Navigation / revalidation |
| Server Action | Server, on demand | A form submit or an explicit call |
Route Handler (route.ts) | Server, per request | An HTTP request from anywhere |
Client fetch in useEffect | Browser | Component mount / deps change |
| A client cache (React Query, SWR) | Browser | Mount, focus, interval, mutation |
Default: fetch in a Server Component
If the data is needed to render the page and does not change in response to user interaction, fetch it in a Server Component. No loading spinner in the client bundle, no waterfall from the browser, no client-side state to manage. This should be the majority of your data loading.
1export default async function Dashboard() {2 // parallel — both requests go out at once3 const [stats, activity] = await Promise.all([4 getStats(),5 getRecentActivity(),6 ]);7 8 return <DashboardView stats={stats} activity={activity} />;9}Sequential awaits in a Server Component create a server-side waterfall that is just as slow as a client one — it is only invisible because it happens before the HTML is sent. Reach for Promise.all, or split the slow parts into their own components behind <Suspense>.
1import { Suspense } from "react";2 3export default function Dashboard() {4 return (5 <>6 <FastHeader /> {/* renders immediately */}7 <Suspense fallback={<TableSkeleton />}>8 <SlowActivityTable /> {/* streams in when ready */}9 </Suspense>10 </>11 );12}13 14async function SlowActivityTable() {15 const rows = await getActivity(); // the 800ms query lives here16 return <ActivityTable rows={rows} />;17}Deduping and typing the data layer
Put the actual fetching behind small typed functions, not inline in components. fetch is memoized per request automatically, so calling getUser() in three components in one render hits the network once. For non-fetch sources, wrap them in cache().
1import { cache } from "react";2import "server-only";3 4// dedupes within a single render pass5export const getUser = cache(async (id: string) => {6 const row = await db.user.findUnique({ where: { id } });7 if (!row) return null;8 return { id: row.id, name: row.name, role: row.role } as const;9});The server-only import turns a mistaken client import of this file into a build error instead of a leaked database credential. Add it to every module that touches secrets.
Mutations and interactive reads: Server Actions
When the user does something that changes data — submits a form, toggles a setting, deletes a row — that is a Server Action. It runs on the server, can talk to your database directly, and can call revalidatePath or revalidateTag to refresh the Server Component data afterward.
1"use server";2 3import { z } from "zod";4import { revalidateTag } from "next/cache";5import { getCurrentUserId } from "@/lib/auth";6 7const schema = z.object({ theme: z.enum(["light", "dark", "system"]) });8 9export async function updateTheme(_prev: unknown, formData: FormData) {10 const parsed = schema.safeParse(Object.fromEntries(formData));11 if (!parsed.success) {12 return { ok: false, error: "Pick a valid theme" };13 }14 15 const userId = await getCurrentUserId();16 await db.settings.update({ where: { userId }, data: parsed.data });17 revalidateTag(`settings:${userId}`);18 return { ok: true };19}1"use client";2 3import { useActionState } from "react";4import { useFormStatus } from "react-dom";5import { updateTheme } from "./actions";6 7export function ThemeForm({ current }: { current: string }) {8 const [state, action] = useActionState(updateTheme, { ok: true });9 return (10 <form action={action}>11 <select name="theme" defaultValue={current}>12 <option value="system">System</option>13 <option value="light">Light</option>14 <option value="dark">Dark</option>15 </select>16 <SaveButton />17 {!state.ok && <p role="alert">{state.error}</p>}18 </form>19 );20}21 22function SaveButton() {23 const { pending } = useFormStatus();24 return <button disabled={pending}>{pending ? "Saving…" : "Save"}</button>;25}Server Actions can also be used for reads that are triggered by interaction — a “load more” button, a search-as-you-type — without building a Route Handler for it. You call the action, it returns data, you set state. It is a private RPC, not a public endpoint.
Route Handlers: only when something external calls you
A route.ts file exists to serve HTTP requests from outside your Next.js app: a webhook from Stripe, a callback from an OAuth provider, an endpoint a mobile app consumes, a public API.
1import Stripe from "stripe";2import { headers } from "next/headers";3 4const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);5 6export async function POST(req: Request) {7 const body = await req.text();8 const sig = (await headers()).get("stripe-signature")!;9 10 let event: Stripe.Event;11 try {12 event = stripe.webhooks.constructEvent(13 body, sig, process.env.STRIPE_WEBHOOK_SECRET!,14 );15 } catch {16 return new Response("bad signature", { status: 400 });17 }18 19 if (event.type === "checkout.session.completed") {20 await fulfilOrder(event.data.object.id);21 }22 return Response.json({ received: true });23}If you find yourself writing a Route Handler and then immediately calling it with fetch from a Client Component in the same app, stop. That is a Server Action wearing a costume — you have added a public URL, hand-written serialization, and lost end-to-end types for nothing.
Client fetching: real-time, or user-specific and frequently changing
There are genuine cases for fetching in the browser: data that updates on an interval, data that depends on client-only state, anything with optimistic updates and background revalidation. For these, reach for a real client cache — React Query or SWR — not a raw useEffect with fetch.
1"use client";2 3import { useQuery } from "@tanstack/react-query";4import { getPrice } from "@/app/actions/price"; // a Server Action5 6export function LivePrice({ symbol }: { symbol: string }) {7 const { data, isError } = useQuery({8 queryKey: ["price", symbol],9 queryFn: () => getPrice(symbol),10 refetchInterval: 5_000,11 refetchOnWindowFocus: true,12 staleTime: 4_000,13 });14 15 if (isError) return <span>—</span>;16 return <span>{data ? formatUsd(data) : <Skeleton w={64} />}</span>;17}- A raw
useEffectfetch has no dedupe, no cache, no revalidation, no request cancellation, and re-runs on every mount. - A client cache gives you all of that plus stale-while-revalidate, focus refetching, and a
mutatefunction for optimistic UI. - The data it fetches should still come from a Server Action or a Route Handler you control — the client library manages the cache, not the auth.
The tree, condensed
- Needed for the initial render, not interactive? → Server Component fetch.
- Changes data, or is a read triggered by user interaction? → Server Action.
- Called by something outside this app? → Route Handler.
- Real-time, polled, or optimistic? → Client cache (React Query / SWR) over a Server Action.
- Reaching for
useEffect+fetch? → Re-read steps 1–4.
Found this useful? Pass it on.
Want a custom write-up for your team? Get in touch.
Building something like this?
If a post here maps to a problem on your roadmap, that's usually a good sign we should talk.