Server Components as Children

Passing Server Components as Children

The import rule has a loophole that turns out to be the most useful pattern in the App Router. A Client Component cannot import a Server Component, but it can receive one, because rendered JSX is serializable data.

A client shell with a slot, and the server content that fills itTSX
// app/panel/collapsible.js
"use client";
import { useState } from "react";
export default function Collapsible({ title, children }) {
  const [open, setOpen] = useState(false);
  return <section>
    <button onClick={() => setOpen(!open)}>{open ? "Hide" : "Show"} {title}</button>
    {open && children}
  </section>;
}
// app/panel/page.js — a Server Component
export default function PanelPage() {
  return <Collapsible title="changelog">
    <article dangerouslySetInnerHTML={{ __html: marked.parse(source) }} />
  </Collapsible>;
}

The payload shows what Collapsible receives — not a component, but its finished output:

The /panel payload: rendered children inside a client referenceTSX
["$","$L4",null,{"title":"changelog","children":["$","article",null,
  {"dangerouslySetInnerHTML":{"__html":"<h2>Changelog</h2>\n<ul>\n<li>Turbopack is
   the default bundler</li>\n</ul>\n"}}]}]

marked ran on the server, so its 42.7 kB is not in the bundle: the route-specific chunk for /panel is 0.4 kB, containing nothing but the compiled Collapsible. Over all scripts /panel loads 552.9 kB raw against /notes-client's 595.2 kB — for a page that is more interactive.

React 7,897 separates two roles to make this work. The owner is the component whose JSX contains the child; the parent is the one that contains it in the rendered tree. PanelPage owns <article>, so it renders on the server; Collapsible is merely its parent, so it gets the output to place but never the code to run.