Skip to content
LogoLogo

Get started

Contribute your first piece of UI in three steps.

Pick the channel first

create-slot delivers a contribution to a host through two channels, and the choice is the first thing to make, not the last:

DeclarativeRuntime
APIdefineSlot, definePlugin, resolvePlugins, SlotProvider, SlotHostcreateSlot
In server-rendered HTMLyesno
Needs a provideryesno
Steps on this pagethe last sectionthe next three

This page teaches the runtime channel, because it is the shortest path and needs no provider. Two channels is the full comparison.

Install

npm install create-slot

The package has no dependencies and two entry points: create-slot, and the React-free create-slot/core for server modules. React is a peer dependency; anything with useSyncExternalStore works, so React 18 and up.

1. Define the slot

A slot is a name plus a props type. The props are the host's — whatever the place that renders contributions knows and the contributions might want.

import {  } from "create-slot"
 
export const  = {
  : <{ : string }>(),
}

Define slots in their own module. It is the only thing both sides import, which is what keeps the feature and the layout from importing each other.

2. Mount a host

The host is where contributions render. Its own children are the placeholder: they render only while nothing is contributed, so an empty state costs no extra API.

export function ({  }: { : string }) {
  return (
    <>
      <>
        <>Home</>
        <.. ={}>
          <>Nothing installed yet</>
        </..>
      </>
    </>
  )
}

You can mount as many hosts for one slot as you like — one per row of a table, for instance. Each renders the same contributions, with its own props.

3. Contribute from anywhere

The slot component itself is the contributor. It renders nothing where it sits; it registers its child into the slot for as long as it is mounted.

export function () {
  return (
    <. ={10}>
      < />
    </.>
  )
}

order is a priority, not an array index. Leave gaps — 10, 20, 30 — so a feature can be inserted later without renumbering anything. See Ordering.

Read the host's props

The contributed element is written next to the feature but rendered where the host is, so it reads the host's props through a hook rather than receiving them:

function () {
  const {  } = ..()
 
  return (
    < ={ === "/pricing" ? "page" : }>Pricing</>
  )
}

That is what makes one contribution adapt to many hosts: useProps reflects the host doing the rendering, not the place the element was written.

Install the feature

Installing a feature is mounting it. There is no registration call, no provider, and no array to keep in sync — switching a feature off is a && that stops rendering it.

export function () {
  return (
    <>
      < ="/pricing" />
      < />
    </>
  )
}

The same feature, declared

The declarative channel is the same three ideas — a slot, a host, a contribution — with one difference: the contribution is data plus a component, so the resolver enumerates it before render and the server can put it in the HTML.

Five changes to the code above:

  1. The slot gains a name: defineSlot<Props>("nav-menu").
  2. The fill becomes a contribution with a required id: NavMenu.contribute("nav-item", { order, component }).
  3. Contributions are grouped into a plugin with a unique id.
  4. The application calls resolvePlugins once, turning the plugin list into a Resolution.
  5. The application mounts SlotProvider with that Resolution, and the host becomes SlotHost.
// 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</>
}

The contribution receives the host's props as its own, so there is no useProps in this version. Resolve once, and mount the provider above every host:

// 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 two channels keep their own hosts — the registry never merges fills — but one application uses both freely. See Two channels.

Next

  • Slots, hosts & fills — placeholders, multiple hosts, useProps
  • Two channels — the full comparison, with the decision written out
  • Plugin registry — the declarative channel end to end
  • API — every export, with its inferred types
  • Errors — every message the library throws, and its fix