Server/Packages/Server Renderer/Rendering & streaming
@sigx/server-renderer · Stable

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.

APIReturnsDefault modeBest for
renderDocumentPromise<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
renderDocumentToWebStreamReadableStream<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#

TSX
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 for renderDocument. Ideal for crawlers and AI agents that read the first response without executing JavaScript — pick it per User-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:

TSX
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():

TSX
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:

TSX
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:

TSX
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 — an AbortSignal (client disconnect, timeout). A stream ends early without the document tail; a renderDocument string render rejects so a truncated render never looks successful.
  • onError(error, phase)phase is '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 reach onError — only stream-level failures do.
TSX
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:

TSX
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 under key into the page's window.__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 an AbortSignal on 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 under key; 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 its fallback immediately, then swaps in one replacement once everything pending beneath it — lazy chunks and keyed useData reads — 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:

APIReturnsNotes
renderToStringPromise<string>The app shell only — no template, head injection, or state serialization
renderToStreamweb ReadableStream<string>Pull-based, 4 KB batching, natural backpressure
renderToNodeStreamNode ReadableObject-mode stream of HTML chunks
renderToStreamWithCallbacksPromise<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:

TSX
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 });

Next steps#