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.

Jun 2, 2026/12 min readArchitecture
ShareY
The Data-Fetching Decision Tree for the App Router

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

MechanismRunsTriggered by
fetch in a Server ComponentServer, at renderNavigation / revalidation
Server ActionServer, on demandA form submit or an explicit call
Route Handler (route.ts)Server, per requestAn HTTP request from anywhere
Client fetch in useEffectBrowserComponent mount / deps change
A client cache (React Query, SWR)BrowserMount, 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.

app/dashboard/page.tsx
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}
Watch out

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>.

app/dashboard/page.tsx — streaming the slow part
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().

lib/data/user.ts
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});
Tip

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.

app/settings/actions.ts
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}
app/settings/theme-form.tsx
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.

app/api/webhooks/stripe/route.ts
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}
Watch out

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.

components/live-price.tsx
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 useEffect fetch 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 mutate function 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

  1. Needed for the initial render, not interactive? → Server Component fetch.
  2. Changes data, or is a read triggered by user interaction? → Server Action.
  3. Called by something outside this app? → Route Handler.
  4. Real-time, polled, or optimistic? → Client cache (React Query / SWR) over a Server Action.
  5. Reaching for useEffect + fetch? → Re-read steps 1–4.

Found this useful? Pass it on.

All posts

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.