Building a full SSR app
The render and hydrate primitives are small on purpose. This guide shows how they fit together into a complete, routed SSR application — and where to reach for the router and islands packages.
The shape of an SSR app
Every SignalX SSR app has the same three-file backbone plus an HTTP server:
src/
App.tsx # the shared component tree (runs on server AND client)
entry-server.tsx # renders App → HTML for a request
entry-client.tsx # hydrates the server HTML in the browser
server.ts # the HTTP server that calls entry-server per request
App.tsx is isomorphic — the exact same components run on both sides. There
are no special "server components"; the only difference is the entry that drives
them.
1. The shared app
// src/App.tsx
import { component, useHead } from 'sigx';
export const App = component(() => {
useHead({ title: 'My SignalX app', htmlAttrs: { lang: 'en' } });
return () => (
<main class="app">
<h1>Hello from SSR</h1>
</main>
);
});
2. The server entry
Create a fresh app per request and render the complete document. renderDocument
takes your HTML template and owns the whole response — head injection, the app
shell at the outlet, and the serialized state blob:
// src/entry-server.tsx
import { defineApp } from 'sigx';
import { renderDocument } from '@sigx/server-renderer/server';
import { App } from './App';
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>`;
export function render() {
const app = defineApp(<App />); // App form preserves AppContext
return renderDocument(app, { template }); // Promise<string>
}
The App form (from defineApp()) preserves the AppContext, so inject() and
plugins — a router, islands — work during render. To add a pack, install it on the
app and render the same way: defineApp(<App />).use(islandsPlugin()), then
renderDocument(app, { template }).
3. The client entry
Hydrate instead of mounting. ssrClientPlugin adds .hydrate() to the app:
// src/entry-client.tsx
import { defineApp } from 'sigx';
import { ssrClientPlugin } from '@sigx/server-renderer/client';
import { App } from './App';
defineApp(<App />)
.use(ssrClientPlugin)
.hydrate('#root');
4. The HTTP server
render() already returns the complete document, so the server just sends it and
serves the built client bundle as static files:
// server.ts
import express from 'express';
import { render } from './entry-server';
const app = express();
app.use(express.static('dist/client'));
app.get('*', async (req, res) => {
const html = await render();
res.status(200).set('Content-Type', 'text/html').send(html);
});
app.listen(3000);
For streaming instead of buffering, swap renderDocument for
renderDocumentToNodeStream from @sigx/server-renderer/node — it returns
{ stream, shell }; await shell to pick the status code, then pipe. See
Rendering & streaming.
A shortcut: createRequestHandler
Wiring the template, a fresh app per request, streaming, and the bot/redirect
decisions by hand is repetitive. createRequestHandler from
@sigx/server-renderer/node packages it into a Node/Connect handler:
// server.mjs
import { createRequestHandler } from '@sigx/server-renderer/node';
import { collectAssets } from '@sigx/server-renderer';
import { App } from './dist/server/entry-server.js';
import manifest from './dist/client/.vite/manifest.json' with { type: 'json' };
const handler = createRequestHandler({
template,
app: (url) => defineApp(<App />).use(createServerRouter(url)), // fresh app per request
document: { assets: collectAssets(manifest, ['src/entry-client.tsx']) },
});
server.use(handler);
The handler owns template and the render mode: it streams shell-first for
normal visitors and switches to mode: 'blocking' for crawlers (override the
detection with isBot). The shell is the status/redirect decision point — a
useResponse().redirect(...)
sends the location and no body. It is deliberately not a meta-framework: no
file-system routing, no conventions beyond these seams.
document.assets (from collectAssets(manifest, entries)) injects
<link rel="modulepreload"> and stylesheet links before </head> on the first
flush; every lazy boundary chunk is preloaded on top of that, deduped.
Adding routing
Real apps render different pages per URL. @sigx/server-renderer does not
know about routes — routing is the router's job. The pattern is: build the router
per request on the server (memory history) and once on the client (web
history), then .use(router) on both apps.
// entry-server.tsx (sketch)
const app = defineApp(<App />).use(createServerRouter(req.url));
@sigx/router is isomorphic and integrates directly with createSSR(). Rather
than repeat it here, follow the complete, verified walkthrough — router factory,
per-request instances, data loading and SSR redirects:
Adding islands (selective hydration)
By default hydrate() re-attaches reactivity to the whole tree. For a mostly
static page with a few interactive spots, hydrate only those islands with
@sigx/ssr-islands: mark components client:visible / client:idle / … in your
JSX, register the plugin on the server, and call hydrateIslands() on the client.
Build & dev
@sigx/vite's SSR mode builds both bundles from one vite build and gives you a
dev handler that shares a module graph with your app:
- Dev —
createDevRequestHandlerfrom@sigx/vite/ssrrenders through Vite's module runner, soentry-serverandentry-clienthot-reload together. - Build —
sigx({ ssr: { entry } })emits the client bundle (served statically, with its manifest) and the server bundle in one build.
See Vite SSR mode for the full config.
Next steps
- The fetch handler — the same handler in WinterCG shape, for Workers, Deno, Bun and the edge.
- The boundary model — flush and hydrate axes.
- Request lifecycle —
useResponse, the error seam, the./nodeentry. - Vite SSR mode — the build and dev-server setup.
- SSR Routing — the full router integration.
- Islands —
client:*selective hydration.
