Skip to content
·5 min read

Server Components Click When You Stop Thinking in APIs

React Server Components are not about performance tricks. They change where your data lives. The mental model that made them obvious, with patterns I use daily.

ReactNext.jsServer ComponentsWeb Dev

React Server Components confused me for months, and I finally noticed why: I kept asking "how do I fetch data in a client component?" when the whole point is that most components do not need to.

Here is the mental model that fixed it.

Step 1: Stop building an API you call from your own browser

The old default architecture:

Browser → fetch("/api/projects") → API route → database → JSON → browser → render

With server components:

Server component → database → rendered HTML/RSC payload → browser
// app/projects/page.tsx
import { db } from "@/lib/db";
 
export default async function ProjectsPage() {
  const projects = await db.project.findMany({
    orderBy: { createdAt: "desc" },
    take: 20,
  });
 
  return (
    <ul>
      {projects.map((p) => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  );
}

An async component that awaits the database directly. No useEffect, no loading state, no API route, no JSON serialization layer you maintain. The component runs on the server, where the database already is.

The API routes you keep are for third parties, webhooks, and genuinely external consumers. Your own pages stop being API clients.

Step 2: Default to server, add client islands deliberately

Every component is a server component until you say otherwise. The switch is "use client":

// FilterBar.tsx
"use client";
import { useState } from "react";
 
export function FilterBar({ categories }: { categories: string[] }) {
  const [active, setActive] = useState(categories[0]);
  return (
    <div className="flex gap-2">
      {categories.map((c) => (
        <button key={c} onClick={() => setActive(c)}
          className={active === c ? "font-bold" : ""}>
          {c}
        </button>
      ))}
    </div>
  );
}

The question I ask for every component: does it need the browser? State, event handlers, browser APIs, or animation mean client. Everything else (data display, layout, lists, SEO content) stays on the server and ships zero JavaScript.

A typical page of mine is 90% server components with two or three client islands. Compare that to the classic SPA where the entire page hydrates.

Step 3: Pass serializable props across the boundary

Server components can render client components and hand them data:

// page.tsx (server)
export default async function Page() {
  const categories = await db.category.findMany();
  return <FilterBar categories={categories.map((c) => c.name)} />;
}

The boundary has one rule: props must be serializable. Strings, numbers, plain objects, arrays, dates cross fine. Functions, class instances with methods, and most Map/Set patterns do not.

The error you will hit when you get this wrong is unmistakable: "Functions cannot be passed directly to Client Components." The fix is almost always to move the function into the client component, or to pass an ID and let the client side look up the rest.

Step 4: Move loading states outward with Suspense

Without server components, one slow query holds the whole page hostage behind a spinner. With them, you scope slowness to the section that owns it:

export default function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Suspense fallback={<ChartSkeleton />}>
        <RevenueChart />   {/* async, takes 800ms */}
      </Suspense>
      <Suspense fallback={<ListSkeleton />}>
        <RecentOrders />   {/* async, takes 200ms */}
      </Suspense>
    </div>
  );
}

The shell renders instantly. RecentOrders streams in at 200ms without waiting for the chart. The user starts reading immediately, and each skeleton resolves independently. No global loading state, no waterfall of sequential client fetches either, because both sections start fetching on the server at the same moment.

Step 5: Keep mutations and state where they belong

Interactivity does not disappear; it gets honest about where it lives:

  • Forms and buttons stay client components (or use server actions for the actual mutation).
  • Global-ish state (a shopping cart, a theme toggle) lives in a client island near where it is used.
  • Everything data-shaped is re-fetched on the server, typically by revalidating instead of syncing client caches by hand.

The habit that makes this work: when a client component needs fresh data, do not fetch inside it. Trigger a server refresh (router.refresh() or a server action with revalidate) and let the server component tree re-render with new data. One source of truth, on the server, where the data is.

The one-sentence version

Server components move your components to where the data lives, and client components become small interactive islands instead of the whole architecture.

Once that clicked for me, the "weird rules" (no useEffect, no handlers, serializable props) stopped feeling like restrictions. They are the compiler telling you which side of the network boundary you are standing on.

Want a Next.js app built this way from the start instead of retrofitted later? I build these for clients every week. Tell me what you are planning and I will help you get the architecture right.