March 27, 202610 min read

React Server Components: What Changed and Why It Matters

Understand React Server Components -- server vs client components, data fetching, streaming, the 'use client' directive, and practical Next.js patterns.

react server-components nextjs frontend tutorial
Ad 336x280

React Server Components (RSC) represent the biggest architectural shift in React since hooks. And unlike hooks, which were mostly about developer ergonomics, RSC changes how your application fundamentally works -- where code runs, how data flows, and what gets shipped to the browser.

The confusion around RSC is real. The mental model is different from everything React developers have known for a decade. But once it clicks, you'll wonder why we ever shipped database-querying logic to the browser wrapped in useEffect and loading spinners.

The Problem RSC Solves

Here's the traditional React data fetching flow:

  1. Browser requests the page
  2. Server sends an empty HTML shell with a JavaScript bundle
  3. Browser downloads and parses the JavaScript
  4. React renders a loading spinner
  5. Component mounts, triggers useEffect
  6. useEffect makes a fetch() call to your API
  7. API route receives the request, queries the database
  8. Response comes back, component re-renders with data
That's a waterfall of sequential steps. The user stares at a loading spinner while the browser downloads JavaScript just to discover that it needs to fetch data. The data was on the server the whole time -- the browser had to make a round trip to get back to where it started.

RSC flips this. Server Components render on the server, where the data already is. They fetch data directly (no API round trip), render to HTML, and stream the result to the browser. The JavaScript for those components never ships to the client.

Server Components vs Client Components

In the Next.js App Router (the primary RSC implementation), every component is a Server Component by default. You opt into Client Components explicitly.

Server Components:
// app/users/page.tsx
// This is a Server Component (default)
import { db } from "@/lib/database";

export default async function UsersPage() {
// This runs on the server. Direct database access. No API needed.
const users = await db.query("SELECT * FROM users ORDER BY created_at DESC");

return (
<div>
<h1>Users</h1>
<ul>
{users.map((user) => (
<li key={user.id}>
{user.name} - {user.email}
</li>
))}
</ul>
</div>
);
}

Notice: the component is async. It awaits a database query directly. No useState, no useEffect, no loading state management. This code never runs in the browser, so the database client, the query, and the user data are never exposed to the client.

Client Components:
// components/counter.tsx
"use client";

import { useState } from "react";

export default function Counter() {
const [count, setCount] = useState(0);

return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}

The "use client" directive at the top marks this as a Client Component. It gets bundled into JavaScript and shipped to the browser. You need Client Components for:

  • Event handlers (onClick, onChange, etc.)
  • State (useState, useReducer)
  • Effects (useEffect)
  • Browser APIs (localStorage, window, etc.)
  • Interactive third-party libraries

The Mental Model: Where to Draw the Line

Think of your component tree as having a "client boundary." Everything above the boundary is server-rendered. Once you hit a "use client" component, that component and everything it imports runs on the client.

Layout (Server)
  +-- Header (Server)
  |     +-- Logo (Server)
  |     +-- NavMenu (Client) -- needs onClick for mobile menu
  |           +-- NavItem (Client) -- inside client boundary
  +-- MainContent (Server)
  |     +-- ArticleBody (Server)
  |     +-- CommentSection (Client) -- needs state for new comments
  |           +-- CommentForm (Client)
  |           +-- CommentList (Client)
  +-- Sidebar (Server)
        +-- RecentPosts (Server) -- fetches data directly

The key insight: push client boundaries as far down the tree as possible. Don't make an entire page a Client Component just because one button needs an onClick handler. Extract that button into its own "use client" component and keep the rest on the server.

Data Fetching Patterns

Direct database access in Server Components:
// app/products/page.tsx
import { prisma } from "@/lib/prisma";

export default async function ProductsPage() {
const products = await prisma.product.findMany({
where: { published: true },
include: { category: true },
orderBy: { createdAt: "desc" },
});

return (
<div className="grid grid-cols-3 gap-4">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}

Parallel data fetching:
// app/dashboard/page.tsx
export default async function Dashboard() {
  // These run in parallel, not sequentially
  const [user, orders, notifications] = await Promise.all([
    getUser(),
    getRecentOrders(),
    getNotifications(),
  ]);

return (
<div>
<UserProfile user={user} />
<OrderList orders={orders} />
<NotificationPanel notifications={notifications} />
</div>
);
}

Fetching at the component level (not the page level):
// components/recent-posts.tsx
// Server Component -- fetches its own data
async function RecentPosts() {
  const posts = await db.post.findMany({
    take: 5,
    orderBy: { createdAt: "desc" },
  });

return (
<aside>
<h3>Recent Posts</h3>
{posts.map((post) => (
<a key={post.id} href={/blog/${post.slug}}>
{post.title}
</a>
))}
</aside>
);
}

This is a major shift from the old model. In traditional React, data fetching happened at the page level, and you'd prop-drill data down. With RSC, each component can fetch exactly the data it needs. The framework handles deduplication and parallelization.

Streaming with Suspense

Server Components can be streamed to the client as they complete. If one component takes 3 seconds to fetch data, the rest of the page doesn't wait.

// app/dashboard/page.tsx
import { Suspense } from "react";

export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>

{/ These render as soon as their data is ready /}
<Suspense fallback={<p>Loading profile...</p>}>
<UserProfile />
</Suspense>

<Suspense fallback={<p>Loading orders...</p>}>
<RecentOrders />
</Suspense>

<Suspense fallback={<p>Loading analytics...</p>}>
<AnalyticsChart />
</Suspense>
</div>
);
}

Here's what happens:

  1. Next.js sends the initial HTML with the

    , the three loading fallbacks

  2. As each Server Component finishes fetching, its HTML streams into the page
  3. The browser swaps the fallback with the real content -- no full-page reload
This is fundamentally better than the old waterfall. The user sees content immediately, and the page progressively fills in. The analytics chart taking 2 seconds doesn't block the user profile from appearing.

Passing Data Between Server and Client Components

Server Components can pass props to Client Components, but with restrictions. Props must be serializable -- no functions, no classes, no Dates (use strings or timestamps instead).

// app/products/[id]/page.tsx (Server Component)
import AddToCartButton from "@/components/add-to-cart-button";

export default async function ProductPage({ params }) {
const product = await getProduct(params.id);

return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<p>${product.price}</p>

{/ Pass serializable data to Client Component /}
<AddToCartButton
productId={product.id}
productName={product.name}
price={product.price}
/>
</div>
);
}

// components/add-to-cart-button.tsx
"use client";

import { useState } from "react";

export default function AddToCartButton({ productId, productName, price }) {
const [added, setAdded] = useState(false);

const handleAdd = async () => {
await fetch("/api/cart", {
method: "POST",
body: JSON.stringify({ productId, quantity: 1 }),
});
setAdded(true);
};

return (
<button onClick={handleAdd} disabled={added}>
{added ? "Added to Cart" : Add ${productName} - $${price}}
</button>
);
}

The product data is fetched on the server, the page renders server-side, and only the interactive button ships JavaScript to the client.

Server Actions: Mutations Without API Routes

Server Actions let Client Components call server-side functions directly:

// app/actions.ts
"use server";

import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";

export async function createComment(formData: FormData) {
const content = formData.get("content") as string;
const postId = formData.get("postId") as string;

await prisma.comment.create({
data: {
content,
postId: parseInt(postId),
},
});

revalidatePath(/posts/${postId});
}

// components/comment-form.tsx
"use client";

import { createComment } from "@/app/actions";

export default function CommentForm({ postId }: { postId: string }) {
return (
<form action={createComment}>
<input type="hidden" name="postId" value={postId} />
<textarea name="content" required />
<button type="submit">Post Comment</button>
</form>
);
}

The "use server" directive marks functions that run on the server. The framework generates an API endpoint behind the scenes. The client calls it automatically when the form submits. No manual fetch, no API route file, no serialization code.

Composition Pattern: Server Component Children in Client Components

Here's a pattern that trips people up. A Client Component can render Server Component children through the children prop:

// components/interactive-layout.tsx
"use client";

import { useState } from "react";

export default function InteractiveLayout({ children }) {
const [sidebarOpen, setSidebarOpen] = useState(true);

return (
<div className="flex">
<aside className={sidebarOpen ? "w-64" : "w-0"}>
<button onClick={() => setSidebarOpen(!sidebarOpen)}>
Toggle
</button>
</aside>
<main>{children}</main>
</div>
);
}

// app/dashboard/page.tsx (Server Component)
import InteractiveLayout from "@/components/interactive-layout";

export default async function DashboardPage() {
const data = await fetchDashboardData(); // Server-side fetch

return (
<InteractiveLayout>
{/ This Server Component content is passed as children /}
<DashboardContent data={data} />
</InteractiveLayout>
);
}

The InteractiveLayout is a Client Component (it uses useState), but its children are rendered on the server. This works because children is just a prop -- the Server Component renders first, and the serialized result is passed to the Client Component.

Common Mistakes

Making everything a Client Component. If you add "use client" to your top-level layout, you've essentially opted out of RSC. Only add it to components that genuinely need interactivity. Importing server-only code in Client Components. If a Client Component imports a module that uses Node.js APIs or database clients, the build fails (or worse, it bundles and crashes at runtime). Use the server-only package to enforce this:
npm install server-only
// lib/database.ts
import "server-only";
import { PrismaClient } from "@prisma/client";

export const prisma = new PrismaClient();

Now if a Client Component tries to import @/lib/database, you get a clear build error instead of a mysterious runtime crash.

Passing non-serializable props. Functions, Date objects, Map, Set, class instances -- none of these can cross the server/client boundary as props. Serialize them first. Fetching in useEffect when you could use a Server Component. Old habits die hard. If a component just displays data and doesn't need interactivity, make it a Server Component and fetch directly. No loading spinner needed. Not understanding the cache. Next.js aggressively caches Server Component renders and fetch results. During development, this can cause stale data. Use revalidatePath() or revalidateTag() to bust the cache when data changes.

Migration from Pages Router

If you're moving from the Next.js Pages Router:

Pages RouterApp Router (RSC)
getServerSidePropsAsync Server Component (fetch directly)
getStaticPropsAsync Server Component + generateStaticParams
useEffect for dataServer Component fetch
API routes for mutationsServer Actions
_app.tsx for providerslayout.tsx with Client Component wrappers
getStaticPathsgenerateStaticParams
You don't have to migrate all at once. The App Router and Pages Router can coexist in the same Next.js project during migration.

What's Next

React Server Components are still evolving. The patterns are getting refined, the tooling is improving, and the ecosystem is adapting. What's clear is that this is the direction React is heading -- not an experiment, but the foundation.

The core mental shift: stop thinking of React as a client-side library with server-side rendering bolted on. Think of it as a full-stack framework where you choose, per component, where each piece of your UI should run.

If you're building with React and Next.js and want more practical guides, check out CodeUp.

Ad 728x90