Server rendering
The registry exists for this. A contribution is data resolved before render, so it is in the HTML the server sends — no effects on the render path, nothing to wait for.
The contract, in one sentence
Give the resolver the same inputs, in the same order, on the server and on the client.
That is all the library asks. Deep-equal resolutions produce identical markup; nothing depends on object identity across the seam.
If a tenant, a user or a flag controls the enabled set, that set is data. Send it with the HTML and resolve from it on both sides — or resolve once on the server and send the Resolution itself (see React Server Components below). If you compute the set again on the client from something the server did not see, hydration breaks. The library's own test suite proves both directions.
// catalog.ts — one declaration of the order the server and the client agree on.
export const = ["pipeline", "email"] as
const : <string, > = { , }
export function (: readonly string[]): [] {
return .(() => [] ?? [])
}Declaring the order once, as a list of ids, is the cheapest way to keep both sides honest.
Rendering it, without a framework
Nothing about the library is framework-specific. renderToString on the server
and hydrateRoot on the client is the whole integration, and the only thing
that has to cross between them is the list of ids:
// server.tsx — no framework required. Resolve from the ids, render, and send
// the same ids with the HTML. Deep-equal resolutions produce identical
// markup; nothing depends on object identity across the seam.
export function (: readonly string[], : string) {
const = (())
const = (
< ={}>
< ={} ={{ }} />
</>,
)
// The ids travel with the HTML. Recomputing them on the client is how
// hydration breaks.
return `<div id="root">${}</div>
<script>window.__PLUGINS__ = ${.()}</script>`
}The client reads the same ids back and resolves the same graph:
// client.tsx — the same ids, read back off the page, resolved again.
export function () {
const = ( as unknown as { : string[] }).
(
.("root") as HTMLElement,
< ={(())}>
< ={} ={{ : "deal-1" }} />
</>,
)
}Serialise ids, never plugins or resolutions: both hold components, which do not
survive JSON.stringify. The catalog that maps an id back to a plugin is
ordinary code, imported by both entries.
React Server Components
Two tiers, both without codegen. The core entry is what makes them possible:
create-slot/core never imports React at runtime, so a server component can
import manifests and call resolvePlugins — only the adapter
(SlotProvider, SlotHost, the hooks) is "use client".
examples/nextjs-app ships the second tier.
Tier 1 — the client boundary
Manifests and SlotProvider live behind one "use client" module; a server
component sends ids, and the client resolves. This is the v3 shape, and it
remains correct and simple.
// providers.tsx — tier 1's client boundary: one "use client" module holds the
// provider, receives ids, and resolves behind the seam.
export function ({
,
,
}: {
/** Ids, not components: the simplest data that crosses the boundary. */
: readonly string[]
:
}) {
const = (() => (()), [])
return < ={}>{}</>
}// page.tsx — a server component. It sends ids across the boundary, and the
// contributions are in the HTML it streams.
export async function ({ }: { : string }) {
const = await ()
return (
< ={}>
< ={} ={{ }} />
</>
)
}Tier 2 — the two-module discipline
Write each manifest as a plain module that imports its components from
"use client" files. Then the manifest — and resolvePlugins over it — is
importable from a server component, and the Resolution it returns is
serializable: metadata plus client references. The server resolves once and
passes the whole graph across the boundary as a prop.
// plugins/notes.ts — the two-module discipline, tier 2's whole trick. The
// manifest is a PLAIN module (no directive) that imports its component from a
// "use client" file. A server component may then import the manifest itself:
// `resolvePlugins` reads ids and contributions here, and the component
// crosses the RSC boundary as a client reference.
import from "./panel"
export const = ({
: "notes",
: [
.("panel", { : 10, : }),
],
})// app/layout.tsx — a SERVER component. `resolvePlugins` comes from
// "create-slot/core", the React-free entry, so it runs right here — and under
// the two-module discipline the Resolution it returns is serializable:
// metadata plus client references. The whole graph crosses as one prop.
declare function (: {
:
:
}):
export async function ({ }: { : }) {
const = await ()
const = (())
return < ={}>{}</>
}A server host in userland
Under tier 2 a fully server-rendered host is a few lines: entriesOf plus the
exported ContributionBoundary, which ships as "use client" so the failure
semantics stay the host's. The components are client references; they hydrate
under the shell's providers like any other contribution.
// app/server-panels.tsx — a host with no client half. `entriesOf` comes from
// "create-slot/core" (callable in a server component); `ContributionBoundary`
// ships as "use client", so the failure semantics stay the host's.
export function ({ }: { : }) {
const = (, )
return .(() => {
const = . // typed by the slot — no cast
return (
<
={.}
={.}
={.}
={.}
>
< ="deal-1" />
</>
)
})
}The hard walls
Three things do not cross an RSC boundary, and the design leans into each:
useSlotPropsis context, and therefore client-only. A server host passes serializable props directly, asServerPanelsdoes above.- Functions never cross — reducers,
setup, loaders. The module that carries what a server must own regardless of the graph (the server seam,crm-core/serverin the examples) survives both tiers unchanged. - A component defined inside the manifest module itself is not a client reference and will not cross. The two-module discipline is exactly the rule that prevents this.
Streaming
renderToPipeableStream needs nothing extra from the library, and the
per-contribution Suspense boundary is what makes it worthwhile: a slow
contribution holds up its own line and nothing else. The shell — every other
contribution, plus that one's fallback — goes out immediately, and the resolved
contribution arrives in a later chunk with React's own swap script.
// A contribution may read a promise the layout never awaited. The host wraps
// every contribution in `Suspense`, so this card arrives in a later chunk while
// the rest of the page goes out immediately.
export function ({ }: { : string }) {
const = (())
return <>Target: {}</>
}One thing to know before you try it
A state update during hydration destroys streamed HTML. An application's
setup loop typically registers commands in an effect, which fires while the
page is still hydrating. An urgent update reaching a boundary that has not
hydrated yet makes React discard the streamed markup and re-render it on the
client — "This Suspense boundary received an update before it finished
hydrating."
Wrap the registration in startTransition. See
Plugin state.
A deferred contribution
A deferred contribution loads its component in a later bundle chunk. What the server produces for it depends on the render path, and the two paths behave differently.
renderToPipeableStream handles it. The shell carries the contribution's
fallback — the provider's Pending, or your own inner Suspense — and the
body arrives in a later chunk. That is the mechanism
streaming already describes. Set Pending knowing the trade: the
skeleton ships in the streamed shell and React swaps it out — HTML size and
layout shift for earlier paint. Unset, the fallback is null and nothing extra
ships.
renderToString does not support Suspense at all. React reports this
itself. A React.lazy component therefore never reaches the HTML, and awaiting
the loader first does not change it: lazy resolves in a microtask, and this
render is synchronous.
| What you wrote | What renderToString produces |
|---|---|
lazy, plus the contribution's own Suspense | React marks the boundary for a client render and emits your fallback as placeholder markup. The content arrives after hydration. |
lazy on its own | React marks the boundary for a client render and emits the Pending fallback, or nothing. |
A render that does not stream therefore has two answers. Either do not defer that contribution, or resolve its module before you build the plugin list:
// `renderToString` does not support `Suspense`, so a `React.lazy` component
// never reaches the HTML there. It reaches the HTML only if the module is
// already resolved when the plugin list is built. Resolve the import first,
// then declare the contribution from what it returned.
const = () => import("./panel")
export async function (): <[]> {
const { : } = await ()
return [
({
: "notes-eager",
: [
.("panel", { : 10, : }),
],
}),
]
}The client has to build the list the same way. The contract is unchanged: the same inputs, in the same order, on both sides. A list the server awaited and the client did not is a different list.
The pages router does not stream, so this is the shape it needs. The app router streams, and can defer.
React.lazy splits the chunk, but it does not tell a framework which chunk a
page needs. Next.js uses next/dynamic for that, and component accepts its
result.
See splitting a declared contribution for the client half.
What is missing from the HTML
Two things, both by construction:
- Façade fills. The runtime channel's server and first hydration snapshots are intentionally empty. Even if a server prepass collected fills, the client could not reproduce entries from subtrees it has not reached yet without a mismatch. Registration therefore happens in an effect; server markup shows the façade host's placeholder, and the fill appears after hydration.
- Anything a
setupfunction registers. Same reason.
Both are visible in the pages-router example: view source and the plugins' nav items, deal actions and panels are already there, while the status bar still shows its placeholder.
Failure, on the server
getDerivedStateFromError never runs on the server, but the Suspense boundary
around every contribution still does its job: React marks just that boundary
for a client render (<!--$!--> in the HTML), keeps the rest of the markup,
and the client re-renders that one contribution — where the class boundary
finally catches it. One broken contribution costs its own line, not the page.
See Failure isolation.
A checklist before you ship
- The resolver's inputs are built from data sent with the HTML, not recomputed.
- The Resolution is held in a module or a
useMemo— one line, and it spares the hosts too. - Anything a
setuploop registers is wrapped instartTransition. - Every contribution that has to be in the markup is declared, not a façade fill.
- A contribution that reads a promise expects its own
Suspensefallback — which the host already places — rather than the layout's. - Every deferred contribution is loaded before a render that does not stream.
Next
- Failure isolation — what a throw costs here
- Plugin state — preloading a slice with the HTML
- Errors — the two React errors this page can cause
- Examples — the same features under three shells