Skip to content

SSR

What's new in 1.14.0

SSR gained flushBoundary, createSSRCache, createSSRMetrics, createEdgeHandler, cache-aware renderToResponse, and multi-chunk renderToStream in 1.14.0. Vary merging now preserves wildcard semantics (Vary: * survives merge). See the 1.14.0 release notes.

The SSR module renders bQuery templates to HTML strings, streams, or full Response objects on the server, serializes store state for transfer, and hydrates the client-side DOM back into a live reactive application.

ts
import {
  configureSSR,
  createAssetManager,
  createBunHandler,
  createDenoHandler,
  createEdgeHandler,
  createHeadManager,
  createNodeHandler,
  createResumableState,
  createSSRCache,
  createSSRContext,
  createSSRHandler,
  createSSRMetrics,
  createSSRRouterContext,
  createWebHandler,
  defer,
  defineLoader,
  deserializeStoreState,
  detectRuntime,
  flushBoundary,
  getSSRConfig,
  getSSRRuntimeFeatures,
  hydrateIsland,
  hydrateMount,
  hydrateOnIdle,
  hydrateOnInteraction,
  hydrateOnMedia,
  hydrateOnVisible,
  hydrateStore,
  hydrateStoreSnapshot,
  hydrateStores,
  HYDRATION_HASH_ATTR,
  isBrowserRuntime,
  isServerRuntime,
  readStoreSnapshot,
  renderToResponse,
  renderToStream,
  renderToStreamSuspense,
  renderToString,
  renderToStringAsync,
  resolveSSRRoute,
  resumeState,
  runRouteLoaders,
  serializeStoreSnapshot,
  serializeStoreState,
  verifyHydration,
} from '@bquery/bquery/ssr';
import type { HydrationHandle, HydrationMismatch, SSRStoreSnapshot } from '@bquery/bquery/ssr';

Server-Side Rendering

Recent additions

  • flushBoundary() lets renderToStream() split output into multiple chunks on explicit boundaries
  • createSSRCache() adds a small in-memory response cache for renderToResponse()
  • createSSRMetrics() collects render counts, durations, and hydration mismatch counters
  • createEdgeHandler() wraps fetch-style edge handlers with optional custom error handling

renderToString()

Renders a bQuery template with reactive context into a static HTML string. Signals and computed values in the context are automatically unwrapped.

ts
function renderToString(template: string, data: BindingContext, options?: RenderOptions): SSRResult;
ParameterTypeDescription
templatestringHTML template with bq-* directives
dataBindingContextData object (signals are unwrapped automatically)
optionsRenderOptionsOptional rendering configuration

RenderOptions

ts
type RenderOptions = {
  /** Directive prefix. Default: `'bq'` */
  prefix?: string;
  /** Remove directives from output HTML. Default: `false` */
  stripDirectives?: boolean;
  /** Include serialized store state. Accepts `true` (all stores) or an array of store IDs. Default: `false` */
  includeStoreState?: boolean | string[];
};

SSRResult

ts
type SSRResult = {
  /** The rendered HTML string */
  html: string;
  /** Serialized store state (only when `includeStoreState` is enabled) */
  storeState?: string;
};

Supported SSR Directives

DirectiveDescription
bq-textSets text content from a context value
bq-htmlSets innerHTML from a context value
bq-ifConditionally includes the element
bq-showToggles display: none based on a condition
bq-forRepeats the element for each array item
bq-classApplies CSS classes from an object or string
bq-styleApplies inline styles from an object or string
bq-bind:*Binds any attribute (e.g., bq-bind:href)

Examples

Basic rendering:

ts
const { html } = renderToString('<div id="app"><h1 bq-text="title"></h1></div>', {
  title: 'Hello SSR',
});

console.log(html);
// <div id="app"><h1>Hello SSR</h1></div>

With conditional and loop:

ts
const { html } = renderToString(
  `<ul>
    <li bq-for="item in items" bq-text="item"></li>
  </ul>
  <p bq-if="showFooter">Footer</p>`,
  { items: ['A', 'B', 'C'], showFooter: true }
);

Strip directives from output:

ts
const { html } = renderToString(
  '<h1 bq-text="title"></h1>',
  { title: 'Clean Output' },
  { stripDirectives: true }
);
// <h1>Clean Output</h1>  (no bq-text attribute in output)

With store state serialization:

ts
import { createStore } from '@bquery/bquery/store';

const settings = createStore({
  id: 'settings',
  state: () => ({ theme: 'dark', locale: 'en' }),
});

const { html, storeState } = renderToString(
  '<div bq-text="title"></div>',
  { title: 'Dashboard' },
  { includeStoreState: true }
);

console.log(storeState);
// JSON string with all store state

Store State Serialization

serializeStoreState()

Serializes all or selected stores to a JSON string and a <script> tag ready for embedding in server-rendered HTML. The output is sanitized to prevent XSS.

ts
function serializeStoreState(options?: SerializeOptions): SerializeResult;

SerializeOptions

ts
type SerializeOptions = {
  /** ID of the script tag embedded in the page. Default: `'__BQUERY_STORE_STATE__'` */
  scriptId?: string;
  /** Global variable name used to pass state to the client. Default: `'__BQUERY_INITIAL_STATE__'` */
  globalKey?: string;
  /** Only serialize specific stores. If omitted, all stores are serialized. */
  storeIds?: string[];
  /** Custom serialize function. Defaults to `JSON.stringify`. */
  serialize?: (data: unknown) => string;
};

SerializeResult

ts
type SerializeResult = {
  /** Raw JSON string of all serialized stores */
  stateJson: string;
  /** Ready-to-embed `<script>` tag */
  scriptTag: string;
};

Example: read embedded state

ts
const { scriptTag, stateJson } = serializeStoreState({
  scriptId: '__BQUERY_STORE_STATE__',
  globalKey: '__BQUERY_INITIAL_STATE__',
  storeIds: ['settings', 'user'],
});

// Embed in your server response
const serverHtml = `
  <html>
    <body>
      <div id="app">...</div>
      ${scriptTag}
    </body>
  </html>
`;

Client-Side Hydration

deserializeStoreState()

Reads store state from the global variable set by the SSR script tag. Automatically cleans up the global key and script element after reading.

ts
function deserializeStoreState(globalKey?: string, scriptId?: string): DeserializedStoreState;
ParameterTypeDefault
globalKeystring'__BQUERY_INITIAL_STATE__'
scriptIdstring'__BQUERY_STORE_STATE__'

DeserializedStoreState

ts
type DeserializedStoreState = Record<string, Record<string, unknown>>;

Example: hydrate the root

ts
// On the client, after the page loads:
const state = deserializeStoreState();
// { settings: { theme: 'dark', locale: 'en' }, user: { name: 'Ada' } }

hydrateStore()

Applies pre-serialized state to a single store using its $patch method.

ts
function hydrateStore(storeId: string, state: Record<string, unknown>): void;
ts
hydrateStore('settings', { theme: 'dark', locale: 'en' });

hydrateStores()

Convenience wrapper that calls hydrateStore() for each entry in the deserialized state map.

ts
function hydrateStores(stateMap: DeserializedStoreState): void;
ts
const state = deserializeStoreState();
hydrateStores(state);

hydrateMount()

Reuses server-rendered DOM and attaches reactive view bindings. Unlike mount(), it does not re-render the HTML — it reuses the existing markup and wires up directives.

ts
function hydrateMount(
  selector: string | Element,
  context: BindingContext,
  options?: HydrateMountOptions
): View;

HydrateMountOptions

ts
type HydrateMountOptions = MountOptions & {
  /** Enables hydration mode. Default: `true` */
  hydrate?: true;
};

Example

ts
import { hydrateMount } from '@bquery/bquery/ssr';

// Hydrate the server-rendered DOM
const view = hydrateMount(
  '#app',
  {
    title: 'Dashboard',
    items: ['A', 'B', 'C'],
  },
  { hydrate: true }
);

Full SSR Workflow

1. Server: Render and serialize

ts
import { renderToString, serializeStoreState } from '@bquery/bquery/ssr';
import { createStore } from '@bquery/bquery/store';

// Define stores
const settings = createStore({
  id: 'settings',
  state: () => ({ theme: 'dark' }),
});

// Render template
const { html } = renderToString(
  '<div id="app"><h1 bq-text="title"></h1></div>',
  { title: 'Welcome' },
  { stripDirectives: true }
);

// Serialize stores
const { scriptTag } = serializeStoreState();

// Compose the full page
const page = `
<!DOCTYPE html>
<html>
  <body>
    ${html}
    ${scriptTag}
    <script type="module" src="/client.js"></script>
  </body>
</html>
`;

2. Client: Hydrate

ts
// client.js
import { deserializeStoreState, hydrateStores, hydrateMount } from '@bquery/bquery/ssr';

// Restore store state from the embedded script tag
const state = deserializeStoreState();
hydrateStores(state);

// Hydrate the pre-rendered DOM
hydrateMount('#app', { title: 'Welcome' }, { hydrate: true });

Notes

  • Serialized script output escapes dangerous content to avoid XSS when embedding state into HTML.
  • globalKey can be customized when integrating with existing server frameworks (e.g., Express, Hono, Elysia).
  • Hydration reuses existing markup and attaches view bindings instead of replacing the DOM wholesale.
  • renderToString() automatically falls back to the DOM-free renderer when no DOMParser is available; only explicitly forcing the DOM backend requires a custom DOM implementation such as happy-dom or linkedom.
  • Prototype-pollution keys (__proto__, constructor, prototype) are filtered during serialization.

Runtime-Agnostic SSR (Bun, Deno, Node ≥ 24)

The SSR module ships a DOM-free renderer that activates automatically when no DOMParser is available in the runtime. That makes the same renderToString()/renderToStringAsync()/renderToStream()/renderToResponse() calls work seamlessly on Node.js ≥ 24, Deno and Bun ≥ 1.3.13 — without any external dependency, polyfill or build-time branching.

Backend selection

ts
import { configureSSR, getSSRConfig } from '@bquery/bquery/ssr';

// Force the DOM-free renderer everywhere (recommended for cross-runtime apps):
configureSSR({ backend: 'pure' });

// Or inject a custom DOMParser implementation:
import { DOMParser } from 'linkedom';
configureSSR({ backend: 'dom', documentImpl: { DOMParser } });

// Default ('auto') uses DOM if available, otherwise the pure renderer.

The pure renderer is CSP-safe: it never calls eval or new Function() and parses expressions through a tightly-scoped Pratt parser supporting property access (a.b, a?.b, a[0]), comparisons, ternary, &&/||/??, unary !/+/-/typeof, function calls on context-bound functions and basic arithmetic.

Runtime detection

ts
import { detectRuntime, isServerRuntime, getSSRRuntimeFeatures } from '@bquery/bquery/ssr';

detectRuntime(); // 'bun' | 'deno' | 'node' | 'browser' | 'workerd' | 'unknown'
isServerRuntime(); // true on Bun/Deno/Node/workerd
getSSRRuntimeFeatures(); // { fetchApi, webStreams, textEncoder, subtleCrypto, randomUuid, domParser }

Async render with SSR context

ts
import { createSSRContext, renderToStringAsync, defer } from '@bquery/bquery/ssr';

const ctx = createSSRContext({ request });

// Loader-style data — Promises in the context are awaited automatically.
const result = await renderToStringAsync(
  template,
  {
    user: defer(
      fetch('/api/user').then((r) => r.json()),
      { name: 'Guest' }
    ),
    posts: fetchPosts(),
  },
  { context: ctx }
);

console.log(result.html);
console.log(result.headHtml);
console.log(result.assetsHtml);

SSRContext exposes request, url, headers, cookies, locale, userAgent, signal (for cancellation), nonce (auto-generated CSP nonce), head, assets, responseHeaders and status.

Streaming render

ts
import { renderToStream } from '@bquery/bquery/ssr';

const stream = renderToStream(template, data, { context: ctx });
return new Response(stream, { headers: { 'content-type': 'text/html' } });

renderToStream() returns a Web ReadableStream<Uint8Array> and respects SSRContext.signal for graceful cancellation.

renderToResponse()

renderToResponse() now accepts cache metadata for Cache-Control / Vary shaping and optional in-memory caching:

ts
const cache = createSSRCache({ maxEntries: 100, ttlMs: 60_000 });

const response = await renderToResponse('<main bq-text="title"></main>', { title: 'Cached' }, {
  cache: {
    store: cache,
    sMaxAge: 60,
    staleWhileRevalidate: 30,
    vary: ['accept-language'],
  },
  etag: true,
});

renderToStream() + flushBoundary()

Use flushBoundary() to insert a temporary streaming marker that renderToStream() removes while splitting the rendered template into multiple chunks. Place the boundary only between complete HTML fragments—never inside a tag, attribute, or incomplete element pair—or streamed chunks may become invalid HTML on their own:

ts
const stream = renderToStream(
  `<header><h1 bq-text="title"></h1></header>${flushBoundary()}<main bq-text="content"></main>`,
  {
    content: 'Body',
    title: 'Shell',
  }
);

createSSRMetrics()

Metrics collectors can be shared across related render calls through createSSRContext():

ts
const metrics = createSSRMetrics();
const context = createSSRContext({ metrics });

await renderToStringAsync('<h1 bq-text="title"></h1>', { title: 'Metrics' }, { context });

console.log(metrics.snapshot());

createEdgeHandler()

createEdgeHandler() keeps the fetch-style runtime signature and adds a single place for edge-specific error mapping:

ts
const handler = createEdgeHandler(
  async (request) => renderToResponse('<h1 bq-text="url"></h1>', { url: request.url }),
  {
    onError(error, request) {
      return new Response(`edge error: ${request.url}`, { status: 500 });
    },
  }
);
ts
import { renderToResponse } from '@bquery/bquery/ssr';

return renderToResponse(template, data, {
  cacheControl: 'public, max-age=60',
  etag: true, // weak ETag from SHA-1; replies 304 when If-None-Match matches
});

Head & assets management

ts
import { createSSRContext } from '@bquery/bquery/ssr';

const ctx = createSSRContext();

ctx.head.add({
  title: 'Dashboard',
  titleTemplate: '%s | Acme',
  meta: [{ name: 'description', content: 'My app' }],
  link: [{ rel: 'icon', href: '/favicon.ico' }],
  script: [{ src: '/app.js', module: true }],
});

ctx.assets.module('/app.js');
ctx.assets.preload('/font.woff2', { as: 'font', type: 'font/woff2', crossorigin: 'anonymous' });
ctx.assets.style('/main.css');

When renderToStringAsync() / renderToResponse() see the <head> / </body> markers in the template, they automatically inject the head, asset and store-state HTML in the right places. CSP nonces from SSRContext.nonce are propagated to all generated <script> tags.

Progressive hydration & islands

ts
import {
  hydrateIsland,
  hydrateOnVisible,
  hydrateOnIdle,
  hydrateOnInteraction,
  hydrateOnMedia,
} from '@bquery/bquery/ssr';

// Eager island hydration:
hydrateIsland('#cart', { items });

// Defer until the island scrolls into view:
const handle = hydrateOnVisible('#newsletter', { email });
handle.cancel(); // optional — cancel before it triggers
await handle.ready; // resolves with the View

// Defer until the browser is idle / first interaction / a media query matches:
hydrateOnIdle('#chat', { messages });
hydrateOnInteraction('#search', { query }, { events: ['focusin', 'pointerdown'] });
hydrateOnMedia('#sidebar', { layout }, '(min-width: 960px)');

All four progressive helpers fall back to immediate hydration on runtimes lacking the underlying API (IntersectionObserver, requestIdleCallback, matchMedia).

Runtime adapters

Drop-in helpers for the major server runtimes share a single fetch-style handler signature:

ts
import {
  createNodeHandler,
  createBunHandler,
  createDenoHandler,
  createWebHandler,
  createSSRHandler,
  renderToResponse,
} from '@bquery/bquery/ssr';

const handler = (request: Request) =>
  renderToResponse('<div bq-text="msg"></div>', { msg: 'Hello' });

// Bun
Bun.serve({ fetch: createBunHandler(handler), port: 3000 });

// Deno
Deno.serve(createDenoHandler(handler));

// Node (node:http)
import { createServer } from 'node:http';
createServer(createNodeHandler(handler)).listen(3000);
createServer(createNodeHandler(handler, { maxBodyBytes: 1024 * 1024 })).listen(3001);

// Hono / Elysia / Cloudflare Workers / generic web hosts
export default { fetch: createWebHandler(handler) };

// Or let bQuery pick the right one based on runtime detection:
const wrapped = createSSRHandler(handler);

createNodeHandler() translates node:http IncomingMessage / ServerResponse into Web Request / Response automatically — fetch-style handlers stay portable across all four runtimes. When needed, pass { maxBodyBytes } to reject oversized buffered request bodies with HTTP 413; malformed Host headers fall back to localhost instead of crashing URL construction.

CSP & security defaults

  • The DOM-free renderer is fully CSP-safe (no 'unsafe-eval' required).
  • serializeStoreState() keeps its </script>/Unicode-line-terminator escaping. With renderToStringAsync()/renderToResponse(), the generated <script> tag automatically receives the nonce from SSRContext.nonce.
  • bq-html interpolation is sanitized through SSR's internal sanitizeHtmlForSSR() path on both backends, so it stays consistent and works in DOM-less runtimes without relying on global document / DOMParser.
  • Inline event-handler attributes (onclick=, …) and javascript: URLs are stripped by both renderers.
  • Inline <script> bodies added through the head manager have </script>/<!-- sequences and \u2028/\u2029 line terminators escaped.

Backwards compatibility

renderToString(), hydrateMount(), serializeStoreState(), deserializeStoreState(), hydrateStore() and hydrateStores() keep their previous signatures and behaviour. The DOM-free renderer is only used as a fallback when the legacy DOMParser-based pipeline cannot run, or when configureSSR({ backend: 'pure' }) is set explicitly.

Hydration mismatch dev-warnings

Set annotateHydration: true on renderToString() / renderToStringAsync() / renderToResponse() to emit a small data-bq-h="<hash>" attribute on every element that carries a bq-* directive. On the client, call verifyHydration() after hydration to compare the recorded hash against a hash recomputed from the live DOM.

ts
import { renderToString, verifyHydration } from '@bquery/bquery/ssr';

// Server
const { html } = renderToString(template, data, { annotateHydration: true });

// Client (dev only)
import { hydrateMount } from '@bquery/bquery/ssr';
hydrateMount('#app', data);
if (process.env.NODE_ENV !== 'production') {
  verifyHydration(document.getElementById('app')!);
}

verifyHydration() returns the list of HydrationMismatch entries and emits a console.warn for each by default (gated by NODE_ENV). Pass { warn: false, onMismatch } for full control. The check is collision-tolerant — false positives are impossible, only false negatives.

Suspense out-of-order streaming

renderToStreamSuspense() flushes the synchronous shell first (with each defer(...) value's fallback), then streams <template id="bq-r-N">…</template> + a tiny CSP-nonce-aware patch script per resolved promise. Add bq-defer="key" on the wrapping element to mark where each placeholder should be installed; without a marker the patches append at the end of <body>.

ts
import { defer, renderToStreamSuspense } from '@bquery/bquery/ssr';

const stream = renderToStreamSuspense(template, {
  user: defer(loadUser(), { loading: true }), // fallback while pending
});

return new Response(stream, { headers: { 'content-type': 'text/html' } });

Honours SSRContext.signal for cancellation, escapes the resolved value, and reports loader errors via SSRContext.onError without aborting the stream.

Router ↔ SSR bridge

Match URLs against your route table without instantiating a full router. Loaders attached as meta.loader run automatically when you use createSSRRouterContext().

ts
import { createSSRContext, createSSRRouterContext, renderToResponse } from '@bquery/bquery/ssr';

const ctx = createSSRContext({ request });
const router = await createSSRRouterContext({ url: ctx.url, routes, ctx });

if (router.isRedirect) return Response.redirect(router.redirectTo!, 302);
if (!router.matched) return new Response('Not Found', { status: 404 });

return renderToResponse(template, { ...router.bindings }, { context: ctx });

resolveSSRRoute() and runRouteLoaders() are also exported for finer-grained control. Loaders receive { route, ctx } and may return any JSON-serialisable value.

Versioned store snapshots

serializeStoreSnapshot({ version, storeIds?, nonce? }) produces { snapshot, json, scriptTag } — a versioned snapshot of every (or a subset of) registered stores, ready to embed via the scriptTag. On the client, hydrateStoreSnapshot(snapshot, { expectedVersion, strict }) skips the apply step on version mismatch and warns on unknown store IDs in strict mode. The simple serializeStoreState() / hydrateStore() pair stays available for cases that don't need versioning.

ts
// Server
const { scriptTag } = serializeStoreSnapshot({ version: '1.0.0', nonce: ctx.nonce });

// Client
import { hydrateStoreSnapshot, readStoreSnapshot } from '@bquery/bquery/ssr';
const snapshot = readStoreSnapshot();
if (snapshot) hydrateStoreSnapshot(snapshot, { expectedVersion: '1.0.0', strict: true });

Resumability hooks

createResumableState() is a tiny key/value collector for JSON-serialisable values that the server wants the client to read back without re-running the producer. The output is a CSP-nonce-aware <script> tag that publishes the snapshot on window.__BQUERY_RESUME__. resumeState() reads it back and cleans up.

ts
// Server
import { createResumableState } from '@bquery/bquery/ssr';
const resume = createResumableState();
resume.set('user', user);
resume.set('preferences', prefs);
const tag = resume.render({ nonce: ctx.nonce });

// Client
import { resumeState } from '@bquery/bquery/ssr';
const { get, hasSnapshot } = resumeState();
if (hasSnapshot) {
  const user = get<User>('user');
}

Cross-runtime examples

Three minimal SSR servers — one per runtime — live in examples/. All three share examples/shared/app.ts and serve http://localhost:3000/:

RuntimeFolderRun
Bunssr-bun/bun examples/ssr-bun/serve.ts
Denossr-deno/deno run -A examples/ssr-deno/serve.ts
Nodessr-node/node --experimental-strip-types examples/ssr-node/serve.ts

The cross-runtime CI matrix (.github/workflows/ssr-cross-runtime.yml) builds the library once with Bun and then runs tests/cross-runtime/run.mjs against Node 24, Bun 1.3 and Deno 2 to guard the runtime-agnostic surface.

Streaming and caching (1.14.0)

The SSR module ships dedicated helpers for streaming and cache-aware rendering:

  • flushBoundary() — returns a marker string. Embed it in a template so renderToStream() can split the final HTML into multiple stream chunks. For true progressive/out-of-order streaming of async data, use renderToStreamSuspense() with defer().
  • createSSRCache({ ttlMs, maxEntries, getKey }) — in-memory cache keyed by request URL + Vary headers (or a custom getKey); pass to renderToResponse via { cache: { store, vary } }.
  • createSSRMetrics() — imperative collector with recordRender() / recordSlot() and a snapshot() returning { renderCount, totalRenderMs, slotCount, totalSlotMs, hydrationMismatches }. Pass it via createSSRContext({ metrics }) to gather render/slot timings.
  • createEdgeHandler(handler, options?) — thin wrapper around a fetch-style (request, context?) => Response handler that adds optional onError mapping for edge runtimes.
ts
import {
  createSSRCache,
  createEdgeHandler,
  createSSRContext,
  createSSRMetrics,
  flushBoundary,
  renderToResponse,
} from '@bquery/bquery/ssr';

const cache = createSSRCache({ ttlMs: 30_000, maxEntries: 1024 });
const metrics = createSSRMetrics();

const template = `<main>
   <h1 bq-text="title"></h1>
   ${flushBoundary()}
   <article bq-html-safe="body"></article>
 </main>`;

export default createEdgeHandler(async (request) => {
  const context = createSSRContext({ request, metrics });
  return renderToResponse(template, { title: 'Hello', body: '<p>…</p>' }, {
    context,
    cache: { store: cache, vary: ['accept-language'] },
  });
});

Pitfalls and gotchas

  • renderToString() is DOM-free — it must not touch window or document directly. Use renderToStringAsync() for awaiting async data.
  • renderToStream() chunks at flushBoundary() markers; without them the stream emits as one piece.
  • Hydration keys must match between server and client — mismatched markup falls back to a full client render and logs a warning.
  • Cache keys default to the request URL plus cache.vary headers; pass createSSRCache({ getKey }) to add user/session variance.
  • Resumability (when enabled) requires you not to mutate signals during renderToString — do all setup in async resolvers.

Performance notes

  • Use Suspense streaming when you need earlier TTFB for async sections, and use flushBoundary() to control chunk boundaries around already-rendered HTML while caching stable sections with createSSRCache.
  • Set maxEntries on the cache to bound memory; ttlMs controls freshness.
  • Use renderToResponse over renderToStringAsync + manual new Response to inherit cache + metrics wiring.

Testing this module

  • tests/ssr.test.ts / tests/ssr-runtime.test.ts cover the DOM-free fallback path; pattern your tests after them.
  • Use mockFetch() for async resolvers and assert the streamed chunks via for await (const chunk of stream).
  • Serverctx.render* helpers wrap these primitives.
  • Reactive — async data composables resolve before render.
  • Storeserialize() / hydrate() for state transfer.
  • Security — server-rendered HTML still goes through the sanitizer for unsafe paths.

Version history

  • 1.14.0flushBoundary, createSSRCache, createSSRMetrics, createEdgeHandler, cache-aware renderToResponse, multi-chunk renderToStream.
  • 1.11.0createServer, renderToStringAsync, renderToStream, renderToResponse, runtime-agnostic WebSocket sessions.

Released under the MIT License.