Server/Packages/Server Renderer/Building a full SSR app
@sigx/server-renderer · Stable

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#

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

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

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

TypeScript
// 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:

TypeScript
// 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.

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

SSR Routing with @sigx/router

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.

Islands & selective hydration

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:

  • DevcreateDevRequestHandler from @sigx/vite/ssr renders through Vite's module runner, so entry-server and entry-client hot-reload together.
  • Buildsigx({ 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#