Extension slots

Extension slots let one pilet declare a named placeholder in its UI that any other pilet can fill — without either pilet knowing anything about the other. The app shell's extension registry connects them at runtime.

Pilet A · cart CartPage items · summary <ExtensionSlot name="cart-extra" /> Pilet B · promotions setup(api) registerExtension('cart-extra', Promo) never imports Pilet A registers App Shell · extension registry matches slot names to registered components renders into slot
Pilet B fills a slot that Pilet A declared — connected at runtime by the shell, with zero coupling between the two.

Declaring a slot

In any component, render <ExtensionSlot> from piral:

import { ExtensionSlot } from 'piral';

const CartPage = () => (
  <div>
    <CartItemList />
    <CartSummary />

    {/* Any pilet can render content here */}
    <ExtensionSlot name="cart-extra" />
  </div>
);

If no pilet has registered for this name, nothing renders. You can provide fallback content for the empty state:

<ExtensionSlot name="cart-extra">
  <p>No additional information.</p>
</ExtensionSlot>

Filling a slot

From any pilet's setup function:

export function setup(api: PiletApi) {
  api.registerExtension('cart-extra', PromoWidget);
}

Multiple pilets can register for the same slot — all contributions render in feed-response order.

Passing parameters

The slot can pass contextual data to all registered components:

<ExtensionSlot
  name="cart-extra"
  params={{ cartTotal: 89.99, itemCount: 3 }}
/>

Extension components receive params as a prop:

const PromoWidget: React.FC<{ params: { cartTotal: number } }> = ({ params }) => {
  if (params.cartTotal < 50) return null;
  return <div className="promo">You qualify for free shipping!</div>;
};

Conditionally wrapping a slot

There's no useExtensions hook — the ExtensionSlot decides what to render. To wrap a slot in a section only when something is registered, use its render prop. It receives the already-rendered extensions and lets you return your own markup around them (or nothing at all):

const CartPage = () => (
  <div>
    <CartSummary />
    <ExtensionSlot
      name="cart-extra"
      params={{ cartTotal: total }}
      render={(nodes) =>
        nodes.length ? (
          <section className="cart-extras">
            <h3>Special offers</h3>
            {nodes}
          </section>
        ) : null
      }
    />
  </div>
);

This works anywhere a slot does, including inside a pilet — it's just props on the component, not a shell-only hook.

For the simpler "show a fallback when the slot is empty" case, pass empty (and emptySkipsRender to render nothing instead) rather than checking yourself — see Declaring a slot.

Reading the registry directly (advanced)

If you genuinely need the raw registrations, a shell component can read them from global state: useGlobalState((s) => s.registry.extensions['cart-extra'] ?? []). This is shell-only (pilets can't use it) and couples you to internal state shape — prefer the render prop above.

Limiting extension count

{/* Render at most one extension */}
<ExtensionSlot name="featured-promo" limit={1} />

Extension slots in the app shell layout

Well-designed app shells use extension slots in their layout components. This lets pilets contribute navigation items, header actions, and sidebar widgets without the shell explicitly listing any pilet:

const AppHeader = () => (
  <header>
    <Logo />
    <nav>
      <ExtensionSlot name="nav-primary" />
    </nav>
    <div className="header-actions">
      <ExtensionSlot name="header-actions" />
      <UserMenu />
    </div>
  </header>
);

Slot naming conventions

Slot names are the coordination surface between pilets. They are effectively public API — treat them as such.

  • Kebab-case: cart-extra, not CartExtra
  • Namespace with context: checkout-cart-extra to avoid conflicts with other pilets' slots
  • Describe purpose, not location: product-quick-actions rather than product-page-right-col
  • Document your slots: pilet developers need to know what slots exist, what params shape they receive, and how many extensions render

Changing a slot name or its params shape is a breaking change for every pilet that fills it. Version accordingly.

Unregistering

You rarely need to do this manually. Everything a pilet registers is automatically torn down when the pilet is hot-reloaded during development, updated to a new version, or removed from the feed — so you don't have to clean up on the pilet's behalf.

Calling unregisterExtension yourself only makes sense in genuinely dynamic scenarios: an extension that should be available for a short time window, or only while some complex runtime condition holds. To unregister, keep a reference to the exact same component you registered:

// Hold a reference to the same function to unregister later
const component = () => <PromoWidget />;
api.registerExtension('cart-extra', component);

// Later, when the condition no longer applies:
api.unregisterExtension('cart-extra', component);