The Hole in the Donut: RSC Composition Patterns

Mastering the art of Interleaving. How to use the "Children Prop" pattern to render Server Components inside Client Components without breaking the architecture.

By Satish Kumar December 13, 2025

In Article 4, we achieved the "Great Split." We have Server Components (The Architect) and Client Components (The Builder).

But now we have a practical problem. Real applications aren't just one or the other. They are a mix. You have a static Header, an interactive SearchBar, a static Sidebar, and an interactive Feed.

How do you combine them?

The naive attempt usually leads to the "Infection" Problem, where your entire app accidentally turns into Client Components. To fix this, we need to master the Composition Pattern, often called the "Hole in the Donut."

The "Infection" Rule

To understand Composition, you must understand how the Bundler (Webpack/Turbopack) thinks.

The boundary between Server and Client is defined by the 'use client' directive. This directive acts like a One-Way Door.

  1. Server Components can import Client Components. (✅ Safe)
  2. Client Components can import Server Components. (❌ The Trap)

The Trap Explained

If you write this code:

// Modal.tsx ('use client')
import ServerComments from './ServerComments'; // ❌ MISTAKE

export default function Modal() {
  return (
    <div className='modal'>
      <ServerComments />
    </div>
  );
}

You might expect ServerComments to run on the server. It will not.

Because Modal is a Client Component, everything it imports must be bundled and sent to the browser.

  • Next.js treats ServerComments as if it were a Client Component.
  • If ServerComments tries to access the DB, it will crash (because the DB doesn't exist in the browser).
  • If it imports a heavy library, that library is added to the Client Bundle.

This is the "Infection." One 'use client' at the top of your tree can cascade down and turn everything below it into Client Components.

The Solution: The "Hole" Pattern

We need a way to put the ServerComments inside the Modal without the Modal needing to import it.

We use the oldest feature in React: children.

Ryan Florence and Michael Jackson (creators of React Router) championed this pattern for years to avoid "Prop Drilling." In the RSC era, it has become an architectural necessity.

The Refactor

Step 1: The Client Wrapper (The Frame) The Client Component should look like a "Picture Frame." It handles the interactivity (open/close), but it has an empty hole (children) for the content.

// Modal.tsx ('use client')
'use client';
import { useState } from 'react';

export default function Modal({ children }: { children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <>
      <button onClick={() => setIsOpen(true)}>Open Comments</button>
      {isOpen && (
        <div className='modal-content'>
          {/* 
             The Modal doesn't know WHAT this is. 
             It just knows it has a slot to render.
          */}
          {children}
        </div>
      )}
    </>
  );
}

Step 2: The Server Parent (The Assembler) A Server Page (like page.tsx) acts as the coordinator. It imports both the Frame and the Photo.

// page.tsx (Server Component)
import Modal from './Modal'; // Client Component
import ServerComments from './ServerComments'; // Server Component

export default async function Page() {
  const comments = await db.comments.findMany(); // Run on Server!

  return (
    <main>
      <h1>My Blog Post</h1>

      {/* 
         Magical Interleaving:
         1. <Modal> renders on Client.
         2. <ServerComments> renders on Server.
         3. We pass the RESULT of (2) into the HOLE of (1).
      */}
      <Modal>
        <ServerComments initialData={comments} />
      </Modal>
    </main>
  );
}

Why This Works (Physics Check)

Recall Article 2 (Serialization).

When the Server renders page.tsx, it executes <ServerComments /> immediately on the server. It produces a serialized UI tree (JSON).

Then, it constructs the props for <Modal>. The children prop isn't a function or a component reference. It is the serialized JSON of the comments.

The Modal on the client simply receives:

props = {
  children: { type: "div", props: { children: "Great post!" } ... }
}

The Client Component never sees the code for ServerComments. It never sees the DB call. It just sees the output.

The "Context Provider" Scenario

This pattern is critical for Global Providers (Theme, Auth, Redux).

A common mistake is putting 'use client' in layout.tsx because you need a ThemeProvider. Do not do this. It marks your entire application body as Client Side.

The Fix:

// providers/ThemeProvider.tsx
'use client';

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  return <Context.Provider>{children}</Context.Provider>;
}
// app/layout.tsx (Server Component)
import { ThemeProvider } from './providers/ThemeProvider';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html>
      <body>
        {/* 
           The 'children' here is your Server Page.
           It flows THROUGH the client provider without turning into client code.
        */}
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  );
}

Summary

  1. The Infection: Importing a Server Component into a Client file forces it to run on the Client. Avoid this.
  2. The Hole Pattern: Use children (or other named props like sidebarSlot) to pass Server content into Client wrappers.
  3. The Assembler: Let your Server Pages (page.tsx, layout.tsx) be the place where Client and Server components meet.

We have mastered the structure. But there is still a timing problem. If <ServerComments> takes 3 seconds to fetch data, the entire page (including the Modal) will wait 3 seconds to show up.

In the next article, we break the "Waterfall" using Streaming.


Challenge: The Prop Drill Refactor

You have a DashboardLayout (Client Component) that creates a complex sidebar with collapsible menus. It needs to display the UserProfile (Server Data).

Current Code (Bad):

// layout.tsx (Server)
export default async function Layout() {
  const user = await db.user.get();
  // Passing data down...
  return <DashboardLayout user={user} />;
}

// DashboardLayout.tsx (Client)
import UserProfileDisplay from './UserProfileDisplay'; // Imported here = Client Component!
export default function DashboardLayout({ user }) {
  // Logic for sidebar...
  return (
    <div>
      <Sidebar />
      <UserProfileDisplay user={user} /> {/* This is now Client Code */}
    </div>
  );
}

Task: Refactor this so UserProfileDisplay remains a Server Component and doesn't bloat the Client bundle.

Click to Reveal Solution

Refactor: Pass the component itself, not the data.

// layout.tsx (Server)
import DashboardLayout from './DashboardLayout';
import UserProfileDisplay from './UserProfileDisplay'; // Stays Server!

export default async function Layout() {
  const user = await db.user.get();

  return (
    <DashboardLayout
      // Pass the rendered component as a prop (a "Slot")
      userSlot={<UserProfileDisplay user={user} />}
    />
  );
}

// DashboardLayout.tsx (Client)
export default function DashboardLayout({ userSlot }) {
  return (
    <div>
      <Sidebar />
      {/* Just render the slot */}
      {userSlot}
    </div>
  );
}

Subscribe to my free Newsletter

Join my weekly newsletter to get the latest updates on design engineering, new projects, and articles I've written. No spam, unsubscribe at any time.