Skip to content
LogoLogo

Server rendering

The registry exists for this. A contribution is data resolved before render, so it is in the HTML the server sends — no effects on the render path, nothing to wait for.

The contract, in one sentence

Give the resolver the same inputs, in the same order, on the server and on the client.

That is all the library asks. Deep-equal resolutions produce identical markup; nothing depends on object identity across the seam.

If a tenant, a user or a flag controls the enabled set, that set is data. Send it with the HTML and resolve from it on both sides — or resolve once on the server and send the Resolution itself (see React Server Components below). If you compute the set again on the client from something the server did not see, hydration breaks. The library's own test suite proves both directions.

// catalog.ts — one declaration of the order the server and the client agree on.
export const  = ["pipeline", "email"] as 
 
const : <string, > = { ,  }
 
export function (: readonly string[]): [] {
  return .(() => [] ?? [])
}

Declaring the order once, as a list of ids, is the cheapest way to keep both sides honest.

Rendering it, without a framework

Nothing about the library is framework-specific. renderToString on the server and hydrateRoot on the client is the whole integration, and the only thing that has to cross between them is the list of ids:

// server.tsx — no framework required. Resolve from the ids, render, and send
// the same ids with the HTML. Deep-equal resolutions produce identical
// markup; nothing depends on object identity across the seam.
export function (: readonly string[], : string) {
  const  = (())
 
  const  = (
    < ={}>
      < ={} ={{  }} />
    </>,
  )
 
  // The ids travel with the HTML. Recomputing them on the client is how
  // hydration breaks.
  return `<div id="root">${}</div>
<script>window.__PLUGINS__ = ${.()}</script>`
}

The client reads the same ids back and resolves the same graph:

// client.tsx — the same ids, read back off the page, resolved again.
export function () {
  const  = ( as unknown as { : string[] }).
 
  (
    .("root") as HTMLElement,
    < ={(())}>
      < ={} ={{ : "deal-1" }} />
    </>,
  )
}

Serialise ids, never plugins or resolutions: both hold components, which do not survive JSON.stringify. The catalog that maps an id back to a plugin is ordinary code, imported by both entries.

React Server Components

Two tiers, both without codegen. The core entry is what makes them possible: create-slot/core never imports React at runtime, so a server component can import manifests and call resolvePlugins — only the adapter (SlotProvider, SlotHost, the hooks) is "use client". examples/nextjs-app ships the second tier.

Tier 1 — the client boundary

Manifests and SlotProvider live behind one "use client" module; a server component sends ids, and the client resolves. This is the v3 shape, and it remains correct and simple.

// providers.tsx — tier 1's client boundary: one "use client" module holds the
// provider, receives ids, and resolves behind the seam.
export function ({
  ,
  ,
}: {
  /** Ids, not components: the simplest data that crosses the boundary. */
  : readonly string[]
  : 
}) {
  const  = (() => (()), [])
 
  return < ={}>{}</>
}
// page.tsx — a server component. It sends ids across the boundary, and the
// contributions are in the HTML it streams.
export async function ({  }: { : string }) {
  const  = await ()
 
  return (
    < ={}>
      < ={} ={{  }} />
    </>
  )
}

Tier 2 — the two-module discipline

Write each manifest as a plain module that imports its components from "use client" files. Then the manifest — and resolvePlugins over it — is importable from a server component, and the Resolution it returns is serializable: metadata plus client references. The server resolves once and passes the whole graph across the boundary as a prop.

// plugins/notes.ts — the two-module discipline, tier 2's whole trick. The
// manifest is a PLAIN module (no directive) that imports its component from a
// "use client" file. A server component may then import the manifest itself:
// `resolvePlugins` reads ids and contributions here, and the component
// crosses the RSC boundary as a client reference.
import  from "./panel"
 
export const  = ({
  : "notes",
  : [
    .("panel", { : 10, :  }),
  ],
})
// app/layout.tsx — a SERVER component. `resolvePlugins` comes from
// "create-slot/core", the React-free entry, so it runs right here — and under
// the two-module discipline the Resolution it returns is serializable:
// metadata plus client references. The whole graph crosses as one prop.
declare function (: {
  : 
  : 
}): 
 
export async function ({  }: { :  }) {
  const  = await ()
  const  = (())
 
  return < ={}>{}</>
}

A server host in userland

Under tier 2 a fully server-rendered host is a few lines: entriesOf plus the exported ContributionBoundary, which ships as "use client" so the failure semantics stay the host's. The components are client references; they hydrate under the shell's providers like any other contribution.

// app/server-panels.tsx — a host with no client half. `entriesOf` comes from
// "create-slot/core" (callable in a server component); `ContributionBoundary`
// ships as "use client", so the failure semantics stay the host's.
export function ({  }: { :  }) {
  const  = (, )
 
  return .(() => {
    const  = . // typed by the slot — no cast
 
    return (
      <
        ={.}
        ={.}
        ={.}
        ={.}
      >
        < ="deal-1" />
      </>
    )
  })
}

The hard walls

Three things do not cross an RSC boundary, and the design leans into each:

  • useSlotProps is context, and therefore client-only. A server host passes serializable props directly, as ServerPanels does above.
  • Functions never cross — reducers, setup, loaders. The module that carries what a server must own regardless of the graph (the server seam, crm-core/server in the examples) survives both tiers unchanged.
  • A component defined inside the manifest module itself is not a client reference and will not cross. The two-module discipline is exactly the rule that prevents this.

Streaming

renderToPipeableStream needs nothing extra from the library, and the per-contribution Suspense boundary is what makes it worthwhile: a slow contribution holds up its own line and nothing else. The shell — every other contribution, plus that one's fallback — goes out immediately, and the resolved contribution arrives in a later chunk with React's own swap script.

// A contribution may read a promise the layout never awaited. The host wraps
// every contribution in `Suspense`, so this card arrives in a later chunk while
// the rest of the page goes out immediately.
export function ({  }: { : string }) {
  const  = (())
 
  return <>Target: {}</>
}

One thing to know before you try it

A state update during hydration destroys streamed HTML. An application's setup loop typically registers commands in an effect, which fires while the page is still hydrating. An urgent update reaching a boundary that has not hydrated yet makes React discard the streamed markup and re-render it on the client — "This Suspense boundary received an update before it finished hydrating."

Wrap the registration in startTransition. See Plugin state.

A deferred contribution

A deferred contribution loads its component in a later bundle chunk. What the server produces for it depends on the render path, and the two paths behave differently.

renderToPipeableStream handles it. The shell carries the contribution's fallback — the provider's Pending, or your own inner Suspense — and the body arrives in a later chunk. That is the mechanism streaming already describes. Set Pending knowing the trade: the skeleton ships in the streamed shell and React swaps it out — HTML size and layout shift for earlier paint. Unset, the fallback is null and nothing extra ships.

renderToString does not support Suspense at all. React reports this itself. A React.lazy component therefore never reaches the HTML, and awaiting the loader first does not change it: lazy resolves in a microtask, and this render is synchronous.

What you wroteWhat renderToString produces
lazy, plus the contribution's own SuspenseReact marks the boundary for a client render and emits your fallback as placeholder markup. The content arrives after hydration.
lazy on its ownReact marks the boundary for a client render and emits the Pending fallback, or nothing.

A render that does not stream therefore has two answers. Either do not defer that contribution, or resolve its module before you build the plugin list:

// `renderToString` does not support `Suspense`, so a `React.lazy` component
// never reaches the HTML there. It reaches the HTML only if the module is
// already resolved when the plugin list is built. Resolve the import first,
// then declare the contribution from what it returned.
const  = () => import("./panel")
 
export async function (): <[]> {
  const { :  } = await ()
 
  return [
    ({
      : "notes-eager",
      : [
        .("panel", { : 10, :  }),
      ],
    }),
  ]
}

The client has to build the list the same way. The contract is unchanged: the same inputs, in the same order, on both sides. A list the server awaited and the client did not is a different list.

The pages router does not stream, so this is the shape it needs. The app router streams, and can defer.

React.lazy splits the chunk, but it does not tell a framework which chunk a page needs. Next.js uses next/dynamic for that, and component accepts its result.

See splitting a declared contribution for the client half.

What is missing from the HTML

Two things, both by construction:

  • Façade fills. The runtime channel's server and first hydration snapshots are intentionally empty. Even if a server prepass collected fills, the client could not reproduce entries from subtrees it has not reached yet without a mismatch. Registration therefore happens in an effect; server markup shows the façade host's placeholder, and the fill appears after hydration.
  • Anything a setup function registers. Same reason.

Both are visible in the pages-router example: view source and the plugins' nav items, deal actions and panels are already there, while the status bar still shows its placeholder.

Failure, on the server

getDerivedStateFromError never runs on the server, but the Suspense boundary around every contribution still does its job: React marks just that boundary for a client render (<!--$!--> in the HTML), keeps the rest of the markup, and the client re-renders that one contribution — where the class boundary finally catches it. One broken contribution costs its own line, not the page. See Failure isolation.

A checklist before you ship

  1. The resolver's inputs are built from data sent with the HTML, not recomputed.
  2. The Resolution is held in a module or a useMemo — one line, and it spares the hosts too.
  3. Anything a setup loop registers is wrapped in startTransition.
  4. Every contribution that has to be in the markup is declared, not a façade fill.
  5. A contribution that reads a promise expects its own Suspense fallback — which the host already places — rather than the layout's.
  6. Every deferred contribution is loaded before a render that does not stream.

Next