Two channels
A contribution reaches a host through one of two channels, and the two differ
in what a contribution is. Everything else — whether it appears in server
HTML, whether one feature's parts can share useState, whether an error
boundary wraps it — follows from that.
Since 4.0 the two channels are two APIs with two hosts. The registry is the
declarative channel, whole; the runtime channel lives entirely inside the
createSlot() façade. The registry never merges fills.
The short answer
The registry for anything that must be in the HTML. The façade for chrome that depends on live tree state.
If one question decides it, it is this: does the contribution have to exist before hydration? Navigation, above-the-fold content, anything a crawler reads, anything whose late arrival shifts the layout — the registry. A status entry that exists only while a dialog is open, a badge a row puts up about its own selection — the façade.
When neither applies, prefer the registry. It is the primary channel, it costs nothing extra, and it is the one that keeps its options open.
The rest of this page is why.
Declarative: data plus a component
slot.contribute(id, spec) produces a plain object: a slot name, a required
id, an order, and a component. A plugin carries a list of them, and
resolvePlugins turns the plugin list into a Resolution before render.
// Declarative: the contribution is data plus a component, under a required
// id. The resolver enumerates it before render — which is exactly what a
// server render needs.
export const = ({
: "export",
: [
.("export-button", { : 10, : }),
],
})
function ({ }: { : string }) {
return < ="button">Export {}</>
}Because the graph is resolved before render, a host enumerates its contributions synchronously — which is exactly what a server render needs, and what lets the whole Resolution cross an RSC boundary. This is the primary channel.
Runtime: an element from where it is mounted
A createSlot() factory's slot component renders nothing where it sits. It
registers its child element into the factory's private store for as long as it
is mounted, and every mounted host of that factory renders it.
// Runtime: the fill is an element, registered from wherever it is mounted for
// as long as it is mounted. It lives entirely inside the createSlot() façade
// and never reaches server markup.
import { } from "create-slot"
const = ()
export function ({ }: { : boolean }) {
if (!) {
return null
}
return (
< ={20}>
<>Unsaved changes</>
</>
)
}This channel cannot reach server markup. Its server and first hydration snapshots are both empty: the client cannot reproduce fills from subtrees it has not reached yet without a mismatch. Registration therefore happens in an effect, after hydration.
The comparison
| Registry (declarative) | Façade (runtime) | |
|---|---|---|
| A contribution is | data plus a component, under an id | an element |
| Registered | before render, by resolvePlugins | from an effect, while mounted |
| In server HTML | yes | no |
| Host | SlotHost, under SlotProvider | the factory's own Host, no provider |
| Addressable | by full id: disable, override, diagnostics | no identity |
| Isolation per contribution | identity + error boundary + Suspense | Suspense with a null fallback |
One feature's parts share useState | no — separate subtrees | yes — one subtree |
| Reads host props via | its own props | useProps() |
| Turning a feature off | resolve without it, or disable by id | stop rendering it |
The façade's store belongs to the factory, not to a module-level registry: two factories can never exchange fills, and two React roots using one factory always do.
What each choice costs
Reach for the registry when the contribution is navigation, above-the-fold
content, anything a crawler reads, or anything whose absence during hydration
would shift the layout. You get server rendering, streaming, addressable ids
and per-contribution failure isolation, and you pay for it by giving up the
shared React subtree: a plugin's contributions no longer share ordinary
useState, so shared state needs a store. In exchange you get
per-contribution memoisation — see Performance.
Reach for the façade when the contribution genuinely is not known up front — a status bar entry that exists only while a dialog is open, a badge a row puts up about its own selection, chrome that depends on where in the tree it happens to be mounted. A feature stays one subtree, so its parts share ordinary state, and installing it is mounting it. You pay for it with SSR, identity and isolation — it is the application's own code in the application's own tree.
Using both
An application uses both channels side by side — one registry for the content, a façade slot here and there for the live chrome. What no longer exists is a mixed host: a registry host renders declared contributions only, and a façade host renders its factory's fills only. A late client fill can therefore never displace markup the server already shipped — that class of hydration bug is unrepresentable by construction.
// The registry never merges runtime fills. Content that must be in the HTML
// is declared; live chrome gets a façade slot of its own, with its own host.
import { } from "create-slot"
const = ()
export function ({ }: { : string }) {
return (
< ={}>
<>
< ={} ={{ : }} />
</>
<>
<.>
<>Idle</>
</.>
</>
{/* Registered while mounted. Never part of the Resolution. */}
< ={10}>
<>Unsaved changes</>
</>
</>
)
}The pages-router example ships exactly this shape: every feature surface server-rendered from the Resolution, and one façade status bar that fills after hydration.
Next
- Plugin registry — the declarative channel end to end
- Server rendering — the one requirement it comes with
- Ordering — how each channel ranks its contributions
- Plugin state — what replaces the shared subtree