encodeFederationPayload(...), and the consumer either renders a fetched Response with RenderFederatedPayload or decodes it imperatively with decodeFederationPayload.12345678910111213141516171819202122import { Suspense } from 'react' import { RenderFederatedPayload } from 'spiceflow/react' const response = await fetch('https://my-remote.com/api/chart?props=' + encodeURIComponent(JSON.stringify({ dataSource: 'revenue', }))) <Suspense fallback={<div>Loading...</div>}> <RenderFederatedPayload response={response} /> </Suspense> const esmResponse = await fetch('https://esm.sh/some-chart-component') <Suspense fallback={<div>Loading...</div>}> <RenderFederatedPayload response={esmResponse} /> </Suspense> const framerResponse = await fetch('https://framer.com/m/IOKnob-DT0M.js@eZsKjfnRtnN8np5uwoAx') <Suspense fallback={<div>Loading...</div>}> <RenderFederatedPayload response={framerResponse} /> </Suspense>
RenderFederatedPayload must be wrapped in <Suspense> — the fallback shows while the server responds (federation) or while the module loads (ESM).encodeFederationPayload:123456789101112// remote/vite.config.ts import spiceflow from 'spiceflow/vite' export default defineConfig({ base: process.env.REMOTE_ORIGIN || 'http://localhost:3001', plugins: [ spiceflow({ entry: './src/main.tsx', federation: 'remote', }), ], })
12345678910111213141516171819202122// remote/src/main.tsx import { Spiceflow } from 'spiceflow' import { cors } from 'spiceflow/cors' import { encodeFederationPayload } from 'spiceflow/federation' import { Chart } from './chart' import { Table } from './table' import { db } from './db' export const app = new Spiceflow() .use(cors({ origin: '*' })) // Dynamic: fetch data at request time, render the component, return the SSE response .get('/api/chart', async ({ request }) => { const props = JSON.parse(request.parsedUrl.searchParams.get('props') || '{}') const rows = await db.query('SELECT month, revenue FROM sales WHERE year = 2025') return await encodeFederationPayload(<Chart data={rows} {...props} />) }) // Static: pre-rendered at build time and written to disk. // Serve it from S3, a CDN, or any static host — no server needed at runtime. .staticGet('/api/table', async () => { const rows = await db.query('SELECT name, role, department FROM employees') return await encodeFederationPayload(<Table rows={rows} />) })
.staticGet route runs at build time and writes the response to disk. You can upload the output to S3 or any static host — the host app fetches it like any other URL, and RenderFederatedPayload renders it with full SSR and hydration. No server running for the remote at runtime.1234567891011121314151617181920212223// host/src/main.tsx import { Suspense } from 'react' import { Spiceflow } from 'spiceflow' import { RenderFederatedPayload } from 'spiceflow/react' const REMOTE = process.env.REMOTE_ORIGIN || 'http://localhost:3001' export const app = new Spiceflow() .page('/', async () => { const chart = await fetch(`${REMOTE}/api/chart`) const table = await fetch(`${REMOTE}/api/table`) return ( <div> <Suspense fallback={<div>Loading chart...</div>}> <RenderFederatedPayload response={chart} /> </Suspense> <Suspense fallback={<div>Loading table...</div>}> <RenderFederatedPayload response={table} /> </Suspense> </div> ) })
renderToStaticMarkup(...) from spiceflow/federation when you need an HTML string from JSX inside the RSC environment. This is useful for email HTML, static snippets, and other server-only markup that should not be hydrated.1234567891011121314import { renderToStaticMarkup } from 'spiceflow/federation' app.get('/api/email-preview', async () => { const html = await renderToStaticMarkup( <section> <h1>Welcome, Ada</h1> <p>Your invite code is 1234.</p> </section>, ) return new Response(html, { headers: { 'content-type': 'text/html;charset=utf-8' }, }) })
renderToStaticMarkup from react-dom/server does not work inside the React Server Components environment. RSC first renders JSX to a Flight stream, then the SSR environment decodes that stream into HTML. This helper uses the same Flight-to-HTML bridge that federation uses internally.decodeFederationPayload(response) when you want to fetch a route manually in a client event handler and use the decoded value yourself. This works for plain objects, JSX, or objects containing JSX. Async iterables are supported when they are fields on an object payload, for example { stream }.12345678910111213141516171819202122232425'use client' import { useState } from 'react' import { decodeFederationPayload } from 'spiceflow/react' export function ChatButton() { const [parts, setParts] = useState<React.ReactNode[]>([]) async function handleClick() { const response = await fetch('/api/chat', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ prompt: 'hello' }), }) const decoded = await decodeFederationPayload<{ message: string content: React.ReactNode }>(response) setParts((prev) => [...prev, <div key={prev.length}>{decoded.content}</div>]) } return <button onClick={handleClick}>Load</button> }
encodeFederationPayload returns a Response in SSE (text/event-stream) format with these events:dangerouslySetInnerHTML, then hydrates using hydrateRoot to patch the existing DOM in-place (no flash).<script type="importmap"> into the HTML with entries for shared modules:1react, react-dom, react-dom/client, react/jsx-runtime, spiceflow/react
import React from 'react', the browser resolves it through the import map to the host's React chunk — not a separate copy. This is how federation avoids duplicate React instances (which would break hooks and context). The same deduplication works for any module you add via the importMap plugin option: if a Framer component does import { motion } from 'framer-motion', and you've mapped framer-motion to a local re-export file, the browser loads the host's bundled copy.useRouterState from the host and read host-provided React contexts (via useContextBridge from its-fine). External ESM components from esm.sh or Framer also benefit — as long as they externalize react (e.g. https://esm.sh/some-lib?external=react), the import map resolves the bare specifier to the host's instance and everything just works.encodeFederationPayload handles React elements and plain objects differently. When the top-level value is a React element, the entire flight stream is buffered before any SSE events are sent. This gives the consumer SSR HTML for instant display, but it means Suspense boundaries inside the element do not stream incrementally. The client waits until every promise resolves before seeing anything.<Suspense> boundary), wrap your JSX in an object and render it on the consumer side:12345678910111213141516171819// ❌ Top-level JSX — entire payload is buffered, no Suspense streaming app.get('/api/dashboard', async () => { return await encodeFederationPayload( <Suspense fallback={<div>Loading...</div>}> <SlowChart /> </Suspense> ) }) // ✅ Object with JSX field — flight rows stream incrementally app.get('/api/dashboard', async () => { return await encodeFederationPayload({ chart: ( <Suspense fallback={<div>Loading...</div>}> <SlowChart /> </Suspense> ), }) })
12345678910111213141516'use client' import { useEffect, useState } from 'react' import { decodeFederationPayload } from 'spiceflow/react' export function Dashboard() { const [chart, setChart] = useState<React.ReactNode>(null) useEffect(() => { fetch('/api/dashboard') .then((res) => decodeFederationPayload<{ chart: React.ReactNode }>(res)) .then((decoded) => setChart(decoded.chart)) }, []) return <div>{chart}</div> }
12345678app.get('/api/feed', async () => { async function* items() { yield { id: '1', text: 'first' } await sleep(100) yield { id: '2', text: 'second' } } return await encodeFederationPayload({ stream: items() }) })
RenderFederatedPayload also works with plain JavaScript modules — any URL that returns content-type: text/javascript. The module is dynamically imported in the browser, and its default export (or first function export) is rendered as a React component.null during SSR and load after hydration.framer and framer-motion. These need to be in the browser's import map so the dynamic import() can resolve them. Use the importMap option in your Vite config to point these specifiers to local re-export files — this way the browser uses the same bundled instance as your host app (deduplication):1234567891011121314// vite.config.ts import spiceflow from 'spiceflow/vite' export default defineConfig({ plugins: [ spiceflow({ entry: './src/main.tsx', importMap: { 'framer-motion': './src/shared/framer-motion.ts', 'framer': './src/shared/framer.ts', }, }), ], })
12// src/shared/framer-motion.ts export * from 'framer-motion'
12// src/shared/framer.ts export * from 'framer'
spiceflow/react. If you prefer loading from a CDN instead, pass a URL:1234importMap: { 'framer-motion': 'https://esm.sh/framer-motion?external=react', 'framer': 'https://esm.sh/unframer@latest/esm/framer.js?external=react', }
react, react-dom, react/jsx-runtime, and spiceflow/react.