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.
// 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:
["$","$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.