Plugin registry
The registry is create-slot's declarative channel — since 4.0, its only one.
Plugins declare their contributions as data; resolvePlugins turns the plugin
list into a Resolution — one pure, synchronous, deterministic function; hosts
render what it resolved. A contribution is not an element that an effect moves
at runtime. It is data plus a component, under a required id — so the whole
graph is known before render, which is exactly what a server render needs.
The whole surface
// create-slot/core — React-free, importable from server components
defineSlot<Props>(name): Slot<Props> // { name, contribute, override }
definePlugin(definition) // { id, contributes? } plus your own fields
resolvePlugins(plugins, options?): Resolution // { slots, diagnostics }
entriesOf(resolution, slot): ResolvedEntry<Props>[]
// create-slot — the React adapter, plus everything above re-exported
<SlotProvider resolution onError? Failed? Pending? />
<SlotHost slot props? renderEntries?>placeholder</SlotHost>
useSlotProps(slot): Props | null
useContribution(): { slot, pluginId, contributionId }
<ContributionBoundary pluginId contributionId slot />create-slot/core never imports React at runtime, so a server component, a
Node script or a test can import it under any condition. The root entry
re-exports the core, so a client module needs one import.
1. Name the slots
// slots.ts — pure data, importable from server modules too
import { } from "create-slot/core"
export const = <{ : string }>("nav-menu")
export const = ("status-bar")A slot is a descriptor — a name plus a props type, never a component. Since the
core is React-free, slots.ts is importable from server modules too.
2. Declare the contributions
A plugin is an object with an id and a list of contributions. A contribution
is an ordinary component that receives the host's props — declared under a
required id.
// plugins/pricing.tsx
import { } from "create-slot/core"
export const = ({
: "pricing",
: [
// "nav-item" is the contribution's id: required, unique inside the plugin.
// The full id is "pricing/nav-item" — the React key and override target.
.("nav-item", { : 10, : }),
],
})
function ({ }: { : string }) {
// An ordinary component: hooks, context, data fetching, all of it.
if ( === "/checkout") {
return null
}
return <>Pricing</>
}A contribution's id is local to its plugin, never contains /, and must be
unique inside it. The full id ${pluginId}/${contributionId} is the React key
every host uses, the address disable and override target, and the name
diagnostics use. Because the key is the id, inserting or removing a
neighbouring contribution never remounts the others.
3. Resolve, and mount the provider
resolvePlugins is where everything that used to be render-time work happens:
grouping, disable, override patches, sorting, key minting. The application
owns the Resolution — and the memo boundary with it.
// app.tsx
import { , , } from "create-slot"
// One pure function turns the plugin list into a Resolution. Resolve at
// module scope or in a `useMemo` — the provider never rebuilds anything.
const = ([])
export function ({ }: { : string }) {
return (
< ={}>
<>
< ={} ={{ : }}>
<>No plugins installed</>
</>
</>
</>
)
}4. Configure without forking
disable drops a plugin or a single contribution by id. override patches
one contribution's order or component. Typed overrides come from the slot,
not from a string: a string id cannot carry Props, so NavMenu.override is
where a replacement component is type-checked.
// The options bag: drop contributions by full id, patch them by full id.
const = ({
: "billing",
: [.("beta-banner", { : })],
})
function () {
return <>Try the beta</>
}
const = ([, ], {
: { : ["billing/beta-banner"] },
: [.("pricing/nav-item", { : 5 })],
})
// Problems come back as data — never thrown, never silently dropped.
export const = .Problems come back as diagnostics — never thrown, never silently dropped:
duplicate-plugin-id · duplicate-contribution-id · invalid-contribution-id
unknown-disable-target · unknown-override-target · override-slot-mismatch
In development the provider prints them once per content change; a production build pays nothing for the printing, and the data is still on the Resolution for a test to assert on:
expect(resolvePlugins(PLUGINS).diagnostics).toEqual([])That one line is the catalog validator. The Resolution is plain data — slot
name → sorted entries plus the diagnostics — so an inspector, a policy check or
a snapshot is a .map over it, not a library feature.
5. Decide which plugins are enabled
The enabled set is application data — only the application knows whether the answer has to survive a hydration.
// enabling.tsx — the enabled set is application data. Filter the array, or
// keep the list whole and `disable` by id; both produce a new Resolution.
import type { } from "create-slot/core"
import { } from "react"
declare const : readonly []
declare function (): <string, boolean>
declare function (): .
export function () {
const = ()
// One line. An inline `resolvePlugins()` per render is legal too — entries
// are compared by content — but the memo spares the hosts as well.
const = (
() =>
(.(() => [.] !== false)),
[],
)
return (
< ={}>
< />
</>
)
}One rule comes with those inputs, and it is cheap to keep: give the resolver the same inputs, in the same order, on the server and on the client. See the SSR contract.
A suggested file layout
Nothing enforces this, and the library imports none of it. It is just the shape that keeps features from importing each other:
src/
slots.ts # defineSlot calls — the only module both sides import
plugins/
pricing/
index.ts # definePlugin: the manifest, a plain module
nav-item.tsx # the contribution components ("use client" under RSC)
reporting/
...
catalog.ts # the array, in the order the server and client agree on
app.tsx # resolvePlugins, SlotProvider, and the hosts
A feature imports slots.ts and nothing else from the application. The
application imports catalog.ts. Neither direction crosses between features.
Under React Server Components this split is also
the two-module discipline.
See it work
Enable a plugin and both hosts pick up the new Resolution at once. One card reads its own identity. The crash-test plugin throws on purpose, so the isolation boundary around every contribution is visible rather than described.
panels host
Pipeline
ACME-4417 · rendered as “pipeline/card”
3 threads on ACME-4417
Per-contribution identity
useContribution is in the API because it is the one thing only the library
knows while a contribution renders: which contribution it is — slot, plugin id,
contribution id. Every per-plugin facility an application wants — a store, a
prefixed logger, a settings namespace, a telemetry tag — hangs off it.
// The one thing only the library knows while a contribution renders: which
// contribution it is. Per-plugin stores, loggers and settings namespaces all
// hang off `useContribution().pluginId`.
function () {
const { } = ()
const = ()
return <>{.}</>
}
export const = ({
: "reporting",
: [
.("card", { : 10, : }),
],
})What you give up
A plugin's contributions do not share a React subtree, so they do not share
ordinary useState. Two contributions of one plugin are two separate places in
the tree that happen to carry the same pluginId.
That is the real cost of the declarative channel, and it has a straightforward answer: a store, keyed per plugin. See Plugin state for redux and mobx, both assembled from the catalog before render.
What it does not do, on purpose
No when predicate. Visibility has exactly one mechanism: the contribution
returns null. The cost is that a host cannot know how many contributions
produced output — its children render when nothing is contributed, which is
not the same thing. See empty states.
No policies beyond disable. Limits, allowlists and caps over the resolved
graph are a .filter over Resolution.slots the application writes with its
own vocabulary.
No exclusive slots and no routing. "Exactly one owner" is a routing problem, not a slot problem. Keep the claim in the manifest as an application field and resolve it into one table before render, where your code knows the actual keys and can report the plugin it refused.
No inventory helper. The inventory exists because the manifest is data:
it is a .map.
No state, no lifecycle, no command registry. The library knows who contributes what, in what order, and what happens when it breaks. Everything else is yours, and the manifest is open data you can extend with your own fields.
Next
- Server rendering — the SSR contract, the two RSC tiers, streaming
- Failure isolation —
onError,Failed,Pending,reset - Plugin state — stores assembled from the catalog
- Performance — what a re-render costs, and what stays free
- Examples — one CRM, three shells