Skip to content
Codeloom
React

React Server Components: Architecture Patterns That Scale

Practical architecture patterns for React Server Components including composition strategies, data flow boundaries, and migration approaches for production apps.

·7 min read · By Codeloom
Advanced 12 min read

What you'll learn

  • How to structure a project around the server-client boundary
  • Composition patterns that keep server components pure
  • Data flow strategies that avoid prop drilling across boundaries
  • Migration patterns for moving an existing app to RSC
  • Performance patterns for streaming and selective hydration

Prerequisites

None — this post is self-contained.

React Server Components change how you think about application architecture. The runtime split between server and client is not just a rendering detail. It reshapes how you organize files, pass data, and compose UI. This article covers the architecture patterns that emerge once you move past the basics.

The boundary as an architecture decision

The most important decision in an RSC app is where you draw the line between server and client. Every "use client" directive is an architecture choice. It determines what code ships to the browser, what data serialization happens, and what interactivity is available.

A useful mental model: server components are your data layer disguised as UI. They can query databases, read files, and call internal services directly. Client components are your interaction layer. They handle clicks, form state, animations, and anything that needs browser APIs.

// ServerProductPage.tsx — runs on the server
import { getProduct } from '@/db/products';
import { ClientAddToCart } from './ClientAddToCart';

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

  return (
    <article>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <ClientAddToCart productId={id} price={product.price} />
    </article>
  );
}

The server component fetches data and renders static markup. It passes only serializable props to the client component. The client component handles the cart interaction without knowing how the product was fetched.

The composition pattern: server wraps client

The most scalable pattern is server components composing client components, not the other way around. Server components sit at the top of your component tree and push data downward. Client components receive pre-fetched, pre-processed data as props.

This pattern avoids a common mistake: making a component a client component just because one small part of it needs interactivity. Instead, extract the interactive piece into its own client component and keep the parent on the server.

// Bad: entire page becomes a client component
'use client';
export function Dashboard() {
  const [filter, setFilter] = useState('all');
  // Now you cannot use async/await or direct DB access
  return <div>...</div>;
}

// Good: server component with a client island
// ServerDashboard.tsx
import { getDashboardData } from '@/db/dashboard';
import { ClientFilterBar } from './ClientFilterBar';
import { DashboardGrid } from './DashboardGrid';

export default async function ServerDashboard() {
  const data = await getDashboardData();
  return (
    <div>
      <ClientFilterBar categories={data.categories} />
      <DashboardGrid items={data.items} />
    </div>
  );
}

The children slot pattern

When a client component needs to render server content, use the children prop. React can serialize server component output and pass it as children to a client component. The client component does not need to know what it is rendering.

'use client';
import { useState } from 'react';

export function ClientTabs({ tabs }: { tabs: { label: string; content: React.ReactNode }[] }) {
  const [active, setActive] = useState(0);
  return (
    <div>
      <nav>
        {tabs.map((tab, i) => (
          <button key={i} onClick={() => setActive(i)}>{tab.label}</button>
        ))}
      </nav>
      <div>{tabs[active].content}</div>
    </div>
  );
}
// ServerPage.tsx
import { ClientTabs } from './ClientTabs';
import { ServerAnalytics } from './ServerAnalytics';
import { ServerSettings } from './ServerSettings';

export default async function Page() {
  return (
    <ClientTabs
      tabs={[
        { label: 'Analytics', content: <ServerAnalytics /> },
        { label: 'Settings', content: <ServerSettings /> },
      ]}
    />
  );
}

The server components inside each tab are rendered on the server. Their output is serialized into the RSC payload. The client tab switcher just shows and hides the pre-rendered content. No additional network requests, no hydration of hidden tabs.

Data flow across the boundary

Props crossing from server to client must be serializable. You cannot pass functions, class instances, or symbols. This constraint shapes your data flow.

Three strategies work well in practice:

Strategy 1: Pre-compute on the server. If the client needs derived data, compute it in the server component and pass the result. Do not pass raw data and a computation function.

// Server component
const stats = {
  totalRevenue: orders.reduce((sum, o) => sum + o.total, 0),
  averageOrder: orders.reduce((sum, o) => sum + o.total, 0) / orders.length,
  topProduct: orders.sort((a, b) => b.quantity - a.quantity)[0]?.name,
};

return <ClientStatsDisplay stats={stats} />;

Strategy 2: Server Actions for mutations. When the client needs to write data, use Server Actions instead of API routes. The action runs on the server but is callable from the client.

// actions.ts
'use server';
import { db } from '@/db';

export async function addToCart(productId: string, quantity: number) {
  await db.cart.insert({ productId, quantity, userId: getCurrentUser() });
  revalidatePath('/cart');
}

Strategy 3: URL state for server-driven filtering. When the client needs to change what data the server fetches, use URL search params. The server component reads the URL on each request and fetches accordingly.

// ServerProductList.tsx
export default async function ProductList({
  searchParams,
}: {
  searchParams: { category?: string; sort?: string };
}) {
  const products = await getProducts({
    category: searchParams.category,
    sort: searchParams.sort ?? 'newest',
  });
  return <ProductGrid products={products} />;
}

Streaming and progressive rendering

Server components enable streaming by default when wrapped in Suspense boundaries. Each boundary acts as a chunk point: React sends the fallback immediately and streams the resolved content when it is ready.

The architecture implication is that you should design your component tree around loading priorities. Critical content renders first. Secondary content streams in.

import { Suspense } from 'react';
import { ProductInfo } from './ProductInfo';
import { ProductReviews } from './ProductReviews';
import { RecommendedProducts } from './RecommendedProducts';

export default function ProductPage({ id }: { id: string }) {
  return (
    <main>
      {/* Critical: renders first */}
      <Suspense fallback={<ProductSkeleton />}>
        <ProductInfo id={id} />
      </Suspense>

      {/* Secondary: streams in */}
      <Suspense fallback={<ReviewsSkeleton />}>
        <ProductReviews productId={id} />
      </Suspense>

      {/* Low priority: streams last */}
      <Suspense fallback={<RecommendationsSkeleton />}>
        <RecommendedProducts productId={id} />
      </Suspense>
    </main>
  );
}

Each async server component starts its data fetch when the server begins rendering it. Suspense boundaries let you control the order in which content appears on screen, even if fetches resolve out of order.

Migration patterns

Moving an existing client-rendered app to RSC is not an all-or-nothing operation. Two patterns work well:

Outside-in migration. Start with the outermost layout components. Convert them to server components. Leave the interactive parts as client components. Move inward over time, extracting more server components from what used to be monolithic client components.

Feature-by-feature migration. Pick one feature or page. Rebuild it with server components at the top. Leave other pages untouched. This limits blast radius and lets you learn the patterns incrementally.

In both cases, the key rule is: never fight the boundary. If a component needs useState, useEffect, or browser APIs, it is a client component. Trying to work around that leads to fragile code.

Common architecture mistakes

Over-serialization. Passing large objects across the boundary when only a few fields are needed. Extract the fields on the server side before passing them as props.

Boundary creep. Adding "use client" to a file because one child needs it, when extracting that child into its own file would keep the parent on the server.

Ignoring cache boundaries. Server components re-run on every request by default. Use caching (framework-level or manual) for expensive queries. Without caching, an RSC app can be slower than a client app that caches in memory.

Mixing concerns. A server component that also tries to manage client state through workarounds. Keep each side clean. Server components fetch and compute. Client components interact and animate.

When to choose RSC

Server components are a good fit when your app is data-heavy, your pages have a lot of static content mixed with small interactive islands, or you want to reduce your client JavaScript bundle. They are less useful for highly interactive apps where most of the page is forms, drag-and-drop, or real-time updates. In those cases, the majority of your components will be client components anyway, and the server boundary adds complexity without proportional benefit.

The best RSC architectures treat the server-client boundary as a first-class design decision, not something that happens by accident. Plan it, document it, and revisit it as your app evolves.