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/ssrrenderToString, renderToStream, defineAction, and the boundary runtime script.
  • wompo/hydrate— the client-side hydrate function 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 useAsync and lazy call inside the tree before resolving.

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-inject headTags; '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— optional AbortSignal.


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 Suspense boundary to opt in.

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:

  1. Declare a default mode at definition time, by passing island: 'load' | 'idle' | 'visible' as a defineWompo option.
  2. Override per call-site with client:load, client:idle, client:visible, or disable it with client: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: 200px

Lazy 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

Contexts work on the server the same way they do on the client: wrap a subtree in a Provider and consume the value with useContext. The server runtime maintains a per-context stack, so nested providers shadow outer values correctly.