Server-Side Rendering
Render the same Wompo components on the server, stream them out with Suspense, hydrate them on the client as islands, and call typed Server Actions.
Overview
Wompo ships a string-based SSR engine, a streaming renderer, an islands-first hydration runtime, and a typed Server Actions API. The exact same components written with defineWompo render on the server, hydrate selectively on the client, and stream their suspended content out of order.
Three subpaths are exposed:
wompo/ssr—renderToString,renderToStream,defineAction, and the boundary runtime script.wompo/hydrate— the client-sidehydratefunction that upgrades islands.wompo/devalue— the structured-clone-friendly serializer used for island props (cycles,Date,Map,Set,BigInt,undefined,NaN,Infinity).
renderToString
renderToString renders a component (and all the components it nests) to an HTML string. It awaits every
import { renderToString } from 'wompo/ssr';
import Page from './Page.js';
const { html, headTags, css, islands } = await renderToString(Page, { user });
// html — '<page-component ...>…</page-component>'
// headTags — inline <style> block with every component's CSS (dedup'd)
// css — Map<componentName, css> for extraction into separate files
// islands — per-component hydration metadata (name, mode, doc-order index)The second argument is the root props object. The third is an options object:
hydration—'islands'(default) emits the markers the client runtime needs;'none'produces a plain static HTML string.css—'inline'(default) returns a ready-to-injectheadTags;'extract'leaves the CSS map for you to write to a stylesheet;'none'skips CSS collection.nonce— CSP nonce applied to inline<script>tags.base— URL prefix for emitted chunks (default'/_/').signal— optionalAbortSignal.
renderToStream + Suspense
renderToStream returns a ReadableStream<Uint8Array> you can pipe to any standard HTTP response. The shell (page chrome + any Suspense fallback) flushes first; resolved boundaries are appended out-of-order as their useAsync work completes, so a slow island never blocks a fast one.
import { renderToStream, BOUNDARY_RUNTIME_SCRIPT } from 'wompo/ssr';
import Page from './Page.js';
const stream = renderToStream(Page, props);
// 1. <script>self.__wompoR=...</script> + inline <style> + shell with
// <wompo-boundary id="Bn">FALLBACK</wompo-boundary>
// 2. for every resolved boundary, in completion order:
// <template data-wompo-resolve="Bn">…real content…</template>
// <script>self.__wompoR("Bn")</script>The inline runtime (BOUNDARY_RUNTIME_SCRIPT, ~140 bytes) is prepended automatically when you call renderToStream. It swaps each <template data-wompo-resolve> into its <wompo-boundary> placeholder as chunks arrive. Wrap any slow tree in a
import { defineWompo, html, Suspense, useAsync } from 'wompo';
function SlowList() {
const items = useAsync(() => fetch('/api/items').then((r) => r.json()), []);
return html`<ul>
${items.map((i) => html`<li>${i.name}</li>`)}
</ul>`;
}
defineWompo(SlowList);
export default function Page() {
return html`
<main>
<h1>Catalog</h1>
<${Suspense} fallback=${html`<p>Loading…</p>`}>
<${SlowList} />
</${Suspense}>
</main>
`;
}
defineWompo(Page);Islands & hydration
A component becomes an island in two ways:
- Declare a default mode at definition time, by passing
island: 'load' | 'idle' | 'visible'as adefineWompo option. - Override per call-site with
client:load,client:idle,client:visible, or disable it withclient:none. The attribute always wins over the component default.
// Counter.js
import { defineWompo, html, useState } from 'wompo';
export default function Counter({ start = 0 }) {
const [count, setCount] = useState(start);
return html`<button @click=${() => setCount(count + 1)}>${count}</button>`;
}
defineWompo(Counter, { name: 'my-counter', island: 'visible' });
// Page.js (server)
import { defineWompo, html } from 'wompo';
import Counter from './Counter.js';
export default function Page() {
return html`
<main>
<${Counter} start=${5} /> <!-- island: visible (default) -->
<${Counter} start=${0} client:load /> <!-- override: hydrate immediately -->
</main>
`;
}
defineWompo(Page);On the server, every island emits data-wompo-island and a sibling <template data-wompo-props> carrying its initial props serialized via the devalue-style codec (cycles, Date, Map, Set, BigInt, undefined, NaN, Infinity are all preserved).
On the client, call hydrate once after the document is parsed:
// Page.client.js (loaded from the document shell)
import { hydrate } from 'wompo/hydrate';
import './Counter.js'; // makes sure customElements.define has run
hydrate(document);
// Each [data-wompo-island] is hydrated per its mode:
// load → immediately
// idle → requestIdleCallback (fallback setTimeout)
// visible → IntersectionObserver with rootMargin: 200pxLazy island chunks: if your framework exposes a global window.__WOMPRO_ISLANDS mapping tagName → moduleUrl, the hydration runtime dynamically imports the matching chunk on demand instead of warning.
If the SSR DOM doesn't structurally match what the component would clone (mismatched elements, missing markers), Wompo falls back to a destructive re-render and logs a console.warn. Fix the mismatch in your template rather than ignoring the warning.
Server Actions
defineAction wraps an async function so that the same reference is usable on both sides of the wire: on the server it's a plain function; when it ends up in an island's serialized props, the client receives a transparent fetch proxy instead of the function body.
// actions.js
import { defineAction } from 'wompo/ssr';
export const addItem = defineAction(async (name) => {
// …hit a DB, queue, etc.
return { id: crypto.randomUUID(), name };
});Pass the action through to an island in the usual way, then call it as if it were local:
// ItemForm.js (island)
import { defineWompo, html, useState } from 'wompo';
export default function ItemForm({ onAdd }) {
const [name, setName] = useState('');
const submit = async (e) => {
e.preventDefault();
const item = await onAdd(name); // → POST /_action/<id>
setName('');
};
return html`
<form @submit=${submit}>
<input value=${name} @input=${(e) => setName(e.target.value)} />
<button>Add</button>
</form>
`;
}
defineWompo(ItemForm, { name: 'item-form', island: 'load' });
// Page.js (server)
import { defineWompo, html } from 'wompo';
import ItemForm from './ItemForm.js';
import { addItem } from './actions.js';
export default function Page() {
return html`<${ItemForm} onAdd=${addItem} />`;
}
defineWompo(Page);The /_action/:id endpoint that the proxy hits is wired up by your server framework. The companion wompro framework registers it for you; in a custom stack, use getRegisteredAction(id) exposed by wompo/ssr to look up the function and invoke it with the deserialized arguments.
Context on the server
Typescript
Learn how to use Typescript and improve your development experience with Wompo.