Server Components Changed What 'Component' Means

By Odilon13 min read

Server Components Changed What "Component" Means#

Everything I thought I knew about React components... turns out I only knew half the story.

I've been sitting with this for weeks now, running the Next.js 13 beta, reading Dan Abramov's posts on the topic (his explanations are the only ones that actually clarified the model for me), and trying to reconcile what I thought "React component" meant with what it means now.

When hooks landed in React 16.8, the shift was syntactic. Class components became function components with hooks. But the execution model — render on client, hydrate from server HTML — stayed the same. Your mental model survived intact.

React Server Components change the execution model itself. Some components run exclusively on the server. Some run only in the browser. Some run on both. And the rules for which is which are explicit, enforced, and — once you internalize them — surprisingly elegant.

This isn't an optimization you bolt on. It's a different answer to the question "what is a component?"

I'm going to focus on the conceptual model here, not the Next.js implementation details. Those come next post. You need to understand the model first, or the implementation will feel like arbitrary rules.

The Problem That's Been Hiding in Plain Sight#

Every React data-fetching pattern before RSC has the same fundamental flaw: data lives on the server, components render on the client, and you pay a network round-trip to bridge the gap.

We've been living with this so long we stopped seeing it:

tsx
// This is what we've been writing for years
function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);
  const [posts, setPosts] = useState<Post[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // Round trip #1
    fetchUser(userId).then((u) => {
      setUser(u);
      // Round trip #2 (serial, because we need the user first)
      fetchUserPosts(u.id).then((p) => {
        setPosts(p);
        setLoading(false);
      });
    });
  }, [userId]);

  if (loading) return <ProfileSkeleton />;
  if (!user) return null;

  return (
    <div>
      <h1>{user.name}</h1>
      <PostList posts={posts} />
    </div>
  );
}

Even if you fix the waterfall with Promise.all, there are deeper problems. The browser downloads all the component code, the fetch code, the data processing code. All that JavaScript runs client-side before the user sees anything. And the server — which has direct database access — sits idle while the browser makes network requests back to it.

This is not a new observation. Dan Abramov and Lauren Tan have been talking about it for two years. But RSC is the first concrete solution that ships as part of React itself.

Two Kinds of Components (For Real This Time)#

Server Components#

A Server Component runs only on the server. It:

  • Ships zero JavaScript to the browser
  • Can be async — you can await database queries, file reads, API calls directly in the component body
  • Cannot use useState, useEffect, or any browser API
  • Cannot have event handlers (onClick, onChange)
  • Renders once — no re-renders, no reconciliation
tsx
// Server Component (the default in Next.js 13 App Router)
// No "use client" directive = runs on server only

async function UserProfile({ userId }: { userId: string }) {
  // direct database access — the component IS the data layer
  const user = await db.users.findById(userId);
  const posts = await db.posts.findByUserId(userId);

  if (!user) notFound();

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.bio}</p>
      <PostList posts={posts} />
    </div>
  );
}

No loading state. No effect. No useState. No JavaScript bundle cost.

Read that again: the code for this component never reaches the browser. The user and posts data is fetched on the server and the rendered HTML is streamed to the client. The browser never sees db.users.findById. It never downloads it. It doesn't exist in the bundle.

After years of building data-fetching layers, API routes, and client-side caching, seeing an await inside a component body felt wrong. Like breaking a fundamental rule. But it's not a hack — it's the new model.

Client Components#

A Client Component is what you've been writing since React 16. It runs in the browser (and on the server during SSR, same as always). You opt in with a directive:

tsx
"use client";

import { useState } from "react";

interface LikeButtonProps {
  postId: string;
  initialLikeCount: number;
}

export function LikeButton({ postId, initialLikeCount }: LikeButtonProps) {
  const [count, setCount] = useState(initialLikeCount);
  const [liked, setLiked] = useState(false);

  async function handleLike() {
    setCount((c) => (liked ? c - 1 : c + 1));
    setLiked((l) => !l);
    await toggleLike(postId); // optimistic update
  }

  return (
    <button onClick={handleLike} aria-pressed={liked}>
      {liked ? "Unlike" : "Like"} ({count})
    </button>
  );
}

This needs state and event handlers, so it's a Client Component. The "use client" directive is required. Without it, React tries to run this on the server and fails because useState doesn't exist server-side.

The Serialization Boundary#

Here's where the mental model needs adjustment. The boundary between Server and Client Components is a serialization point. Server Components can render Client Components, but the props they pass must be serializable — they cross the network as JSON.

tsx
// Server Component
async function PostPage({ postId }: { postId: string }) {
  const post = await db.posts.findById(postId);
  const likeCount = await db.likes.countByPost(postId);

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.htmlContent }} />

      {/* LikeButton is a Client Component */}
      {/* These props get serialized to JSON and sent to the browser */}
      <LikeButton
        postId={post.id}       // string: fine
        initialLikeCount={likeCount}  // number: fine
      />
    </article>
  );
}

What can't cross: functions, class instances, Date objects (use ISO strings), Map, Set. TypeScript catches most of these at compile time, which is a relief.

This boundary is not optional or implicit. It's the core architectural constraint of RSC. Where you draw it determines your bundle size, your interactivity model, and your data flow.

The Component Tree, Rezoned#

The old mental model: a tree of components, all running on the client, with optional SSR that renders the same tree on the server first.

The new mental model: a tree with two zones.

Server zone: The root, layouts, data-fetching containers, static content. This is most of your tree by component count.

Client zone: Interactive leaves — forms, modals, dropdowns, tabs, anything with state or user events.

tsx
// Server Component (app/dashboard/page.tsx)
async function DashboardPage() {
  const session = await getServerSession();
  const metrics = await fetchDashboardMetrics(session.orgId);

  return (
    <DashboardLayout>                        {/* Server */}
      <MetricsHeader metrics={metrics.summary} />  {/* Server */}
      <div className="grid">
        <MetricsCard title="Revenue" value={metrics.revenue} />    {/* Server */}
        <MetricsCard title="Users" value={metrics.activeUsers} />  {/* Server */}
        <MetricsCard title="Churn" value={metrics.churnRate} />    {/* Server */}
      </div>
      {/* Client boundary — this is where interactivity lives */}
      <DateRangePicker defaultRange="last30days" />  {/* Client */}
      <MetricsChart data={metrics.timeSeries} />     {/* Client */}
    </DashboardLayout>
  );
}

The rule: push "use client" as far down the tree as possible. Layouts, data fetching, page structure — Server Components. The interactive leaves — Client Components.

No More API Layer for Reads#

This is the implication that took me the longest to accept. For data fetching within your own infrastructure, you don't need an API layer. The component and the database are on the same machine.

tsx
// Before RSC: build an API route, fetch from client, manage loading state
// GET /api/accounts/[id]/summary → fetchUser(id) in useEffect

// With RSC: just... query the database
async function AccountSummary({ accountId }: { accountId: string }) {
  const [account, transactions, balance] = await Promise.all([
    db.accounts.findById(accountId),
    db.transactions.findRecent(accountId, 10),
    db.accounts.calculateBalance(accountId),
  ]);

  return (
    <div>
      <AccountHeader account={account} balance={balance} />
      <TransactionPreview transactions={transactions} />
    </div>
  );
}

Promise.all runs on the server. Results render to HTML. Stream to browser. No API endpoint. No loading spinner. No useEffect.

You still need API routes for mutations — form submissions, button clicks, anything that changes data from the client. But the read path for server-rendered content can skip the API layer entirely.

Coming from Django (where the view queries the database and renders the template), this feels... familiar. Like React finally closed the loop that Python web frameworks figured out years ago, but with a component model and streaming.

What RSC Is Not#

I keep seeing these confused in blog posts and Twitter threads, so let me be explicit:

It's not SSR with a different name. Traditional SSR renders the entire tree to HTML once, then the client re-runs everything. RSC establishes a persistent boundary — Server Components never send their code to the browser. They only send rendered output. They can re-execute on the server independently without re-running client components.

It's not a replacement for client-side state. User interactions, form state, animations — still Client Components with hooks. RSC doesn't make React a server-only framework.

It's not a flag you flip. RSC changes how you structure your application. Moving from "fetch in effects" to "fetch in server components" is an architectural decision, not an optimization toggle.

My Take#

The hardest part of RSC is unlearning. For years, the model was "React renders on the client, with optional SSR." The new model is: "Some components produce serialized output on the server. Other components produce interactive DOM on the client." Those are fundamentally different execution contexts sharing a component syntax.

I spent a weekend just drawing component trees on paper, labeling each node "server" or "client," and tracing where the serialization boundaries fall. It sounds excessive but it's what made the model click. I'd recommend doing the same before touching Next.js 13.

One thing that caught me off guard: the "use client" directive propagates downward. If a Client Component imports another component, that imported component is also a Client Component — it's in the client bundle. This means one carelessly placed "use client" can pull your entire component tree into the browser bundle. The directive isn't just marking one file; it's drawing a boundary that affects everything below it.

The payoff is real though. Smaller bundles. No client-side loading spinners for initial data. Co-location of data access with the components that display it. After building with it for a few weeks, going back to useEffect + useState for data fetching feels like going back to callbacks after learning async/await.

But I don't think anyone — including the React team — fully understands the long-term implications yet. The patterns and conventions around RSC are still forming. The community is figuring this out in real time.

Which brings me to the question I can't answer yet: where do Server Components end and the framework begin? RSC is a React primitive, but you can't use it without a framework (Next.js, Remix, Waku). The line between "React feature" and "Next.js feature" is blurrier than it's ever been. And I'm not sure that's a good thing.

Is this the future of React? I think so. Is it ready for every production app today? I'm less certain. But the direction is clear, and the mental model — once you internalize it — is genuinely better.

Next post: Next.js 13 App Router. The first production implementation of RSC, and what it's actually like to migrate a real feature from Pages Router.

Server Components Changed What 'Component' Means | Blog