Skip to content
LogoLogo

create-slot

Features declare what they render. Layouts decide where it appears.

A feature contributes UI to a named slot. A layout mounts a host for that slot and gets every contribution, in order, with its own props. Neither side imports the other, and nothing is drilled through the tree to connect them.

npm install create-slot

See it work

Toggle a feature. It is mounted somewhere else entirely — the sidebar never imports it, and it never imports the sidebar. The host renders its own children until something is contributed, and every contributed item reads the host's live props.

Installed featuresRoute
import {  } from "create-slot"
 
export const  = {
  : <{ : string }>(),
}
 
export function ({  }: { : string }) {
  return (
    <>
      <>
        <>Home</>
        <.. ={}>
          <>Nothing installed yet</>
        </..>
      </>
    </>
  )
}
 
export function () {
  return (
    <. ={10}>
      < />
    </.>
  )
}
 
function () {
  const {  } = ..()
 
  return (
    < ={ === "/pricing" ? "page" : }>Pricing</>
  )
}

Two channels

A contribution can be registered in two ways, and the difference is what a contribution is.

DeclarativeRuntime
A contribution isdata plus a component, under an idan element, from where it is mounted
Registeredbefore render, by resolvePluginsfrom an effect, while mounted
In server HTMLyesno
Addressabledisable, override, diagnostics — by full idno identity
Shared state inside one featurea storeordinary useState
Per-contribution isolationidentity + error boundary + SuspenseSuspense with a null fallback
APIdefineSlot, definePlugin, resolvePlugins, SlotProvider, SlotHostcreateSlot

Each channel keeps its own hosts — the registry never merges fills — and a single application uses both: the registry for anything that must be in the HTML, the façade for chrome that depends on live tree state. Two channels is the whole comparison, with the decision written out.

The declarative half of the same idea — a named slot, a plugin that declares a contribution under an id, one resolvePlugins call, and a provider the application mounts once:

// slots.ts — pure data, importable from server modules too
import {  } from "create-slot/core"
 
export const  = <{ : string }>("nav-menu")
export const  = ("status-bar")
 
 
// 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</>
}
 
 
// 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</>
        </>
      </>
    </>
  )
}

The Resolution is plain data, and resolvePlugins is React-free — a server component can resolve the graph and hand it across the RSC boundary whole.

What it does not do

The library knows who contributes what, in what order, and what happens when a contribution breaks. Everything else is yours:

  • No when predicate. A contribution returns null.
  • No exclusive slots and no routing. "Exactly one owner" is a table your application resolves before render, where it can report the plugin it refused.
  • No inventory helper. The manifest is data, so an inventory is a .map.
  • No state, no lifecycle, no command registry. The manifest is open data you extend with your own fields, and they keep their types.

Zero dependencies, two entry points — one of them React-free — and any React with useSyncExternalStore: 18 and up.

Where to go next

  1. Get started — a first contribution in three steps.
  2. Slots, hosts & fills — the four words this library uses, and what each one does.
  3. Plugin registry — the declarative channel end to end, and what you need for server rendering.
  4. API — every export, with the types twoslash infers from the published package.