Tarek.
← All posts

React Server Components in Practice

· 3 min read · Tarek

After a year and a half of building production apps on React Server Components (RSC), I've stopped thinking of them as "components that run on the server." That framing misses the point. RSC changes where the boundaries of your application live — and once you internalize that, most of the confusion evaporates.

The mental model that finally stuck

A server component is not a component. It's a serialized description of UI that the client hydrates into. The component function runs on the server, its output becomes data, and interactivity is grafted on at explicit boundaries.

That means the question to ask about any piece of UI is not "is this fast?" but:

What does this subtree need to know about state?

If the answer is "nothing" — a blog post, a product card, a table — it can be a server component and cost your bundle nothing.

Where the seams go

The practical skill in RSC development is choosing where client boundaries sit. A few rules I've converged on:

1. Push "use client" as deep as possible

Don't mark a page interactive because one button inside it is. Split until only the truly stateful leaf is client-side:

app/dashboard/page.tsx
// Server component — no directive needed
import { FilterDropdown } from "./filter-dropdown";
import { StatsTable } from "./stats-table";
 
export default async function DashboardPage() {
  const stats = await getStats(); // direct DB access, no API layer
  return (
    <section>
      <FilterDropdown />   {/* the ONLY client island here */}
      <StatsTable stats={stats} />
    </section>
  );
}
app/dashboard/filter-dropdown.tsx
"use client";
 
export function FilterDropdown() {
  const [value, setValue] = useState("all");
  // ...the interactive part
}

The table stays on the server; the dropdown ships its own tiny JS island.

2. Props crossing a boundary must be serializable

Functions, class instances and Dates (historically) don't cross. When you find yourself passing callbacks upward, that's a signal the boundary is drawn in the wrong place — or that you want a Server Action instead.

3. Fetch where you render

RSC removes the waterfall between "load shell" and "fetch data":

export default async function Page() {
  const [user, projects] = await Promise.all([getUser(), getProjects()]);
  return (
    <>
      <Profile user={user} />
      <ProjectList projects={projects} />
    </>
  );
}

No useEffect, no loading spinner for first paint, no API endpoint to maintain for your own frontend. The database query is the API.

What surprised me

  • Bundles shrank more than expected. Markdown rendering, syntax highlighting, date formatting — all the utility-heavy code left the client bundle entirely.
  • Secrets stay secret by construction. API keys used in server components never reach the browser. No proxy layer needed.
  • Testing shifted. You test server components like functions (render to string / snapshot) and reserve E2E for the interactive islands.

The rough edges

Honesty section. Three things still bite:

  1. Context doesn't cross the boundary. Providers must re-wrap client subtrees; plan your composition early.
  2. Some libraries assume client rendering. Anything touching window at import time needs a wrapper or dynamic import.
  3. Streaming requires discipline. Suspense boundaries placed thoughtfully, or a slow query holds your whole page hostage.

None of these are dealbreakers — they're just design decisions that used to be implicit and now are yours to make explicitly.

Verdict

Eighteen months in, I wouldn't start a new content-heavy or data-heavy app any other way. The apps feel faster because they are doing less work in the browser — and the architecture pushes you toward it instead of against you.