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.
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()letsrenderToStream()split output into multiple chunks on explicit boundariescreateSSRCache()adds a small in-memory response cache forrenderToResponse()createSSRMetrics()collects render counts, durations, and hydration mismatch counterscreateEdgeHandler()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.
function renderToString(template: string, data: BindingContext, options?: RenderOptions): SSRResult;| Parameter | Type | Description |
|---|---|---|
template | string | HTML template with bq-* directives |
data | BindingContext | Data object (signals are unwrapped automatically) |
options | RenderOptions | Optional rendering configuration |
RenderOptions
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
type SSRResult = {
/** The rendered HTML string */
html: string;
/** Serialized store state (only when `includeStoreState` is enabled) */
storeState?: string;
};Supported SSR Directives
| Directive | Description |
|---|---|
bq-text | Sets text content from a context value |
bq-html | Sets innerHTML from a context value |
bq-if | Conditionally includes the element |
bq-show | Toggles display: none based on a condition |
bq-for | Repeats the element for each array item |
bq-class | Applies CSS classes from an object or string |
bq-style | Applies inline styles from an object or string |
bq-bind:* | Binds any attribute (e.g., bq-bind:href) |
Examples
Basic rendering:
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:
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:
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:
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 stateStore 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.
function serializeStoreState(options?: SerializeOptions): SerializeResult;SerializeOptions
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
type SerializeResult = {
/** Raw JSON string of all serialized stores */
stateJson: string;
/** Ready-to-embed `<script>` tag */
scriptTag: string;
};Example: read embedded state
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.
function deserializeStoreState(globalKey?: string, scriptId?: string): DeserializedStoreState;| Parameter | Type | Default |
|---|---|---|
globalKey | string | '__BQUERY_INITIAL_STATE__' |
scriptId | string | '__BQUERY_STORE_STATE__' |
DeserializedStoreState
type DeserializedStoreState = Record<string, Record<string, unknown>>;Example: hydrate the root
// 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.
function hydrateStore(storeId: string, state: Record<string, unknown>): void;hydrateStore('settings', { theme: 'dark', locale: 'en' });hydrateStores()
Convenience wrapper that calls hydrateStore() for each entry in the deserialized state map.
function hydrateStores(stateMap: DeserializedStoreState): void;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.
function hydrateMount(
selector: string | Element,
context: BindingContext,
options?: HydrateMountOptions
): View;HydrateMountOptions
type HydrateMountOptions = MountOptions & {
/** Enables hydration mode. Default: `true` */
hydrate?: true;
};Example
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
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
// 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.
globalKeycan 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 noDOMParseris available; only explicitly forcing the DOM backend requires a custom DOM implementation such ashappy-domorlinkedom.- 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
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
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
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
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:
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:
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():
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:
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 });
},
}
);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
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
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:
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. WithrenderToStringAsync()/renderToResponse(), the generated<script>tag automatically receives thenoncefromSSRContext.nonce.bq-htmlinterpolation is sanitized through SSR's internalsanitizeHtmlForSSR()path on both backends, so it stays consistent and works in DOM-less runtimes without relying on globaldocument/DOMParser.- Inline event-handler attributes (
onclick=, …) andjavascript:URLs are stripped by both renderers. - Inline
<script>bodies added through the head manager have</script>/<!--sequences and\u2028/\u2029line 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.
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>.
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().
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.
// 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.
// 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/:
| Runtime | Folder | Run |
|---|---|---|
| Bun | ssr-bun/ | bun examples/ssr-bun/serve.ts |
| Deno | ssr-deno/ | deno run -A examples/ssr-deno/serve.ts |
| Node | ssr-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 sorenderToStream()can split the final HTML into multiple stream chunks. For true progressive/out-of-order streaming of async data, userenderToStreamSuspense()withdefer().createSSRCache({ ttlMs, maxEntries, getKey })— in-memory cache keyed by request URL +Varyheaders (or a customgetKey); pass torenderToResponsevia{ cache: { store, vary } }.createSSRMetrics()— imperative collector withrecordRender()/recordSlot()and asnapshot()returning{ renderCount, totalRenderMs, slotCount, totalSlotMs, hydrationMismatches }. Pass it viacreateSSRContext({ metrics })to gather render/slot timings.createEdgeHandler(handler, options?)— thin wrapper around a fetch-style(request, context?) => Responsehandler that adds optionalonErrormapping for edge runtimes.
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 touchwindowordocumentdirectly. UserenderToStringAsync()for awaiting async data.renderToStream()chunks atflushBoundary()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.varyheaders; passcreateSSRCache({ 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 withcreateSSRCache. - Set
maxEntrieson the cache to bound memory;ttlMscontrols freshness. - Use
renderToResponseoverrenderToStringAsync+ manualnew Responseto inherit cache + metrics wiring.
Testing this module
tests/ssr.test.ts/tests/ssr-runtime.test.tscover the DOM-free fallback path; pattern your tests after them.- Use
mockFetch()for async resolvers and assert the streamed chunks viafor await (const chunk of stream).
Related modules
- Server —
ctx.render*helpers wrap these primitives. - Reactive — async data composables resolve before render.
- Store —
serialize()/hydrate()for state transfer. - Security — server-rendered HTML still goes through the sanitizer for unsafe paths.
Version history
- 1.14.0 —
flushBoundary,createSSRCache,createSSRMetrics,createEdgeHandler, cache-awarerenderToResponse, multi-chunkrenderToStream. - 1.11.0 —
createServer,renderToStringAsync,renderToStream,renderToResponse, runtime-agnostic WebSocket sessions.