Rendering & streaming
Pick a render API, wire it into a real HTTP server, and stream async data to the client.
Picking a render API
| API | Returns | Best for |
|---|---|---|
renderToString | Promise<string> | Buffer the full document, then send it once |
renderToStream | web ReadableStream<string> | Web-standard runtimes (Workers, Deno, edge) |
renderToNodeStream | Node Readable | Node servers — Express, Fastify, H3 — pipe directly to the response |
renderToStreamWithCallbacks | Promise<void> | Fine-grained control over shell / async chunks |
Streaming is the recommended default: the renderer sends the synchronous shell first, then interleaves replacement scripts for async components as they resolve, and finishes with a completion script.
Passing an App vs a raw element
All render APIs accept either a raw JSX element or an App from defineApp().
Prefer the App form on the server — it preserves the AppContext so dependency
injection (inject()) and plugins (like a router) work during render:
import { defineApp } from 'sigx';
import { renderToNodeStream } from '@sigx/server-renderer/server';
import { App } from './App';
// Raw element — simplest, no DI/plugins:
const a = renderToNodeStream(<App />);
// App form — preserves AppContext for inject() and plugins:
const app = defineApp(<App />);
const b = renderToNodeStream(app);
Express (Node)
On Node, renderToNodeStream avoids web-stream overhead. Pipe it straight to the
response:
import express from 'express';
import { renderToNodeStream } from '@sigx/server-renderer/server';
import { App } from './App';
const server = express();
server.get('*', (req, res) => {
res.setHeader('Content-Type', 'text/html');
res.write(`<!DOCTYPE html><html><head><meta charset="utf-8" /></head><body><div id="root">`);
const stream = renderToNodeStream(<App />);
stream.pipe(res, { end: false });
stream.on('end', () => {
res.end(`</div><script type="module" src="/entry-client.js"></script></body></html>`);
});
});
server.listen(3000);
Fastify / H3
The same Readable works with any Node HTTP framework. With Fastify, hand the
stream to reply.send():
import Fastify from 'fastify';
import { renderToNodeStream } from '@sigx/server-renderer/server';
import { App } from './App';
const fastify = Fastify();
fastify.get('*', async (req, reply) => {
reply.type('text/html');
return renderToNodeStream(<App />);
});
await fastify.listen({ port: 3000 });
Web ReadableStream (edge / Workers)
For web-standard runtimes, renderToStream returns a ReadableStream<string>
(pull-based, batched at 4KB with natural backpressure). Wrap it in a Response:
import { renderToStream } from '@sigx/server-renderer/server';
import { App } from './App';
export default {
fetch() {
const stream = renderToStream(<App />);
return new Response(stream, {
headers: { 'Content-Type': 'text/html' },
});
},
};
Async data with ssr.load()
To fetch data during SSR, call ssr.load() from inside setup. The callback runs
on the server; its resolved HTML is streamed to the client:
import { component } from 'sigx';
export const UserCard = component(({ signal, props, ssr }) => {
const user = signal<{ name: string } | null>(null, 'user');
ssr.load(async () => {
const res = await fetch(`https://api.example.com/users/${props.id}`);
user(await res.json());
});
return () => <div>{user()?.name ?? 'Loading…'}</div>;
});
Default behavior by render mode:
- String mode (
renderToString) — async components default toblock: the renderer awaitsssr.load()and emits the resolved HTML inline. - Streaming mode (
renderToStream/renderToNodeStream) — async components default tostream: a placeholder is emitted immediately, then a$SIGX_REPLACEscript swaps in the real HTML oncessr.load()resolves.
State transfer needs the islands plugin
The bare render APIs produce correct HTML, but they do not serialize signal
state. In the core render path ctx.signal is the plain, non-tracking signal, so
no server state is sent to the client. On hydration of a core-rendered async
component there is nothing to restore, and ssr.load() runs again on the client.
Automatic skip-on-hydration and state restoration — so ssr.load() does not
re-run and you avoid a duplicate fetch — require a tracking plugin like
@sigx/ssr-islands, which swaps in a
tracking signal during render and a restoring signal during hydration.
Naming signals for hydration parity
When a tracking plugin is in use, hydration matches server-captured state to
client signals by key. Name the context signal that holds server-loaded state —
signal(value, 'name'), using the signal destructured from the component
context — so the keys line up. Unnamed signals fall back to positional keys
($0, $1, …); the dev build warns once because declaration-order drift between
server and client builds can silently mismatch state.
The module-level signal exported from sigx takes only a value and ignores a
name; the name parameter exists on the context signal (typed SSRSignalFn).
Handling component errors
Pass onComponentError so a single failing component yields fallback HTML instead
of failing the whole render. Return a string of fallback HTML, or null for the
default error placeholder:
import { createSSRContext, renderToString } from '@sigx/server-renderer/server';
import { App } from './App';
const ctx = createSSRContext({
onComponentError: (error, name) => `<!-- ${name} failed: ${error.message} -->`,
});
const html = await renderToString(<App />, ctx);
Completion signal
The streaming render forms emit a trailing <script> that sets
window.__SIGX_STREAMING_COMPLETE__ = true and dispatches a sigx:ready event,
so client code can wait for the full document before running deferred logic.
renderToString does not emit this script — it returns the complete document
as a string, so there is nothing to wait for.
