Rendering & streaming
Hand the renderer an HTML template, let it own the whole response — head, shell, state, async content — and stream it into a real HTTP server.
The document render APIs
The renderDocument* family takes a full HTML template containing an outlet
marker and assembles the complete document: collected useHead() tags injected
before </head>, the app shell at the outlet, the serialized state blob, any
streamed async chunks, and the template tail. You no longer hand-splice
template.replace('<!--ssr-outlet-->', html) in your server.
| API | Returns | Default mode | Best for |
|---|---|---|---|
renderDocument | Promise<string> | 'blocking' | Buffer the full document, then send it once — crawlers, AI agents |
renderDocumentToNodeStream | { stream: Readable; shell: Promise<void> } | 'stream' | Node servers — Express, Fastify, H3 |
renderDocumentToWebStream | ReadableStream<Uint8Array> | 'stream' | Web-standard runtimes — Workers, Deno, edge |
All three accept a raw JSX element or an App from defineApp() (the App
form preserves AppContext for inject() and plugins such as a router), plus a
DocumentOptions object.
The template and outlet
const template = `<!DOCTYPE html>
<html>
<head><meta charset="utf-8" /></head>
<body>
<div id="root"><!--ssr-outlet--></div>
<script type="module" src="/entry-client.js"></script>
</body>
</html>`;
The outlet marker defaults to <!--ssr-outlet-->; override it with the outlet
option. The renderer also splits the tail at </body>: everything up to it —
including your entry <script> — flushes with the shell so the browser starts
downloading the client bundle immediately, while only the closing tags wait for
the stream to finish.
Blocking vs streaming
The mode option controls how keyed useData() / useStream() data is
delivered:
'blocking'— every keyed async value resolves inline: complete content, no placeholders, no replacement scripts. This is the default forrenderDocument. Ideal for crawlers and AI agents that read the first response without executing JavaScript — pick it perUser-Agent.'stream'— the synchronous shell flushes first with placeholders, then out-of-order replacement chunks swap in real content as each async value resolves. This is the default for the two streaming variants.
Both modes emit the trailing completion script (__SIGX_STREAMING_COMPLETE__ +
the sigx:ready event) so the client can gate hydration on a complete document,
and both serialize state unless you opt out with serializeState: false.
Express (Node)
renderDocumentToNodeStream returns the stream and a shell promise that
settles before the first byte. Await it to choose a real HTTP status code, then
pipe:
import express from 'express';
import { renderDocumentToNodeStream } from '@sigx/server-renderer/server';
import { App } from './App';
const template = await readTemplate(); // your index.html with <!--ssr-outlet-->
const server = express();
server.use(express.static('dist/client'));
server.get('*', async (req, res) => {
const { stream, shell } = renderDocumentToNodeStream(<App />, {
template,
onError: (err, phase) => console.error(`[ssr:${phase}]`, err),
});
try {
await shell; // shell ready → status is safe to set
} catch {
return res.status(500).send('<!doctype html><h1>Server error</h1>');
}
res.status(200).setHeader('Content-Type', 'text/html');
stream.pipe(res);
});
server.listen(3000);
Because shell settles before any byte goes out, a shell-phase failure still
lets you send a clean 500 page — the stream has not started yet.
Fastify / H3
The same { stream, shell } shape works with any Node HTTP framework. Await the
shell, set the status, then hand the Readable to reply.send():
import Fastify from 'fastify';
import { renderDocumentToNodeStream } from '@sigx/server-renderer/server';
import { App } from './App';
const fastify = Fastify();
fastify.get('*', async (req, reply) => {
const { stream, shell } = renderDocumentToNodeStream(<App />, { template });
await shell;
reply.type('text/html');
return reply.send(stream);
});
await fastify.listen({ port: 3000 });
Web ReadableStream (edge / Workers)
renderDocumentToWebStream returns a ReadableStream<Uint8Array> of UTF-8 bytes,
ready to use as a Response body:
import { renderDocumentToWebStream } from '@sigx/server-renderer/server';
import { App } from './App';
export default {
fetch(request: Request) {
const stream = renderDocumentToWebStream(<App />, { template });
return new Response(stream, {
headers: { 'Content-Type': 'text/html' },
});
},
};
Serving complete content to crawlers
Because renderDocument defaults to 'blocking', a string render delivers full,
inline content with no client round-trip — exactly what search crawlers and AI
agents want. A single server can branch on the request:
import { renderDocument, renderDocumentToNodeStream } from '@sigx/server-renderer/server';
const isBot = /bot|crawler|spider|gptbot|claudebot/i.test(req.headers['user-agent'] ?? '');
if (isBot) {
const html = await renderDocument(<App />, { template, mode: 'blocking' });
res.status(200).type('text/html').send(html);
} else {
const { stream, shell } = renderDocumentToNodeStream(<App />, { template });
await shell;
res.status(200).type('text/html');
stream.pipe(res);
}
Aborting & error handling
DocumentOptions carries two controls for the request lifecycle:
signal— anAbortSignal(client disconnect, timeout). A stream ends early without the document tail; arenderDocumentstring render rejects so a truncated render never looks successful.onError(error, phase)—phaseis'shell'when the failure happens before any byte is produced (you can still send a 500 page), or'stream'when it happens mid-stream after the shell flushed. Per-component errors stream their own fallbacks and do not reachonError— only stream-level failures do.
const ac = new AbortController();
req.on('close', () => ac.abort());
const { stream, shell } = renderDocumentToNodeStream(<App />, {
template,
signal: ac.signal,
onError(err, phase) {
if (phase === 'shell') metrics.shellFailures.inc();
else metrics.streamFailures.inc();
},
});
Server data with useData and useStream
Data loading lives in the useData / useStream composables from sigx —
not in this package. Every read is keyed, and keyed = server-transferable:
import { component, useData } from 'sigx';
export const UserCard = component(({ props }) => {
// Keyed: runs on the server, serialized under the key, restored on hydration.
const user = useData(`user:${props.id}`, async (key, { signal }) => {
const res = await fetch(`https://api.example.com/users/${props.id}`, { signal });
return res.json();
});
return () => user.match({
pending: () => <p>Loading…</p>,
error: () => <p>Failed to load.</p>,
ready: (u) => <div>{u.name}</div>,
});
});
useData(key, fetcher)runs the fetcher on the server, awaits it (blocking mode) or streams its placeholder then result (streaming mode), serializes the resolved value underkeyinto the page'swindow.__SIGX_ASYNC__blob, and restores it on hydration — so the fetch does not run again on the client. The fetcher receives the resolved key as its first argument and anAbortSignalon the context. Pass{ server: false }for a client-only keyed read: SSR renders the pending arm and the fetch runs after hydration. There is no unkeyed form — a read always has a key.useStream(key, source)is for progressive text (LLM-token-style). On the server in streaming mode the tokens append into the page as they arrive and the final text is serialized underkey; in blocking mode it is drained fully and inlined. On hydration the final text is restored and the source is not re-run, so there is no duplicate LLM call.
renderDocument* serializes this state automatically (serializeState: false
opts out). The ssr helper on the component context is now just two flags —
ctx.ssr.isServer and ctx.ssr.isHydrating — for the rare branch that must know
which environment it is in.
Defer & lazy on the server
lazy() components and <Defer> boundaries render correctly on the server:
lazy()resolves inline — the renderer awaits the component's module (preload()) during render, then renders the resolved component. No empty output, no fallback-forever.<Defer>in streaming mode emits itsfallbackimmediately, then swaps in one replacement once everything pending beneath it — lazy chunks and keyeduseDatareads — has resolved.<Defer>in blocking / string mode needs no special handling: the pending work is awaited inline and the children render directly.
To hydrate a server-resolved <Defer> subtree, preload the lazy chunk before
calling hydrate() so the component is available synchronously during the
hydration walk — see
Hydration & head.
Lower-level render APIs
When you assemble the document yourself (or only need a fragment), the renderer
also exposes the building blocks renderDocument* are built on:
| API | Returns | Notes |
|---|---|---|
renderToString | Promise<string> | The app shell only — no template, head injection, or state serialization |
renderToStream | web ReadableStream<string> | Pull-based, 4 KB batching, natural backpressure |
renderToNodeStream | Node Readable | Object-mode stream of HTML chunks |
renderToStreamWithCallbacks | Promise<void> | onShellReady / onAsyncChunk / onComplete / onError |
These stay opt-in for state serialization and head injection — reach for them
only when you have a reason to own the document assembly. For everything else,
renderDocument* is the zero-config entry point.
Plugin-driven rendering
renderDocument and the lower-level functions all read the SSR plugins installed
on the app, so you register a pack (islands, a custom strategy) with app.use(pack())
and render as usual:
import { createSSR } from '@sigx/server-renderer';
import { islandsPlugin } from '@sigx/ssr-islands';
import { defineApp } from 'sigx';
const app = defineApp(<App />).use(islandsPlugin());
const html = await createSSR().renderDocument(app, { template });
