SSR Routing#

On the server there is no browser History API, so the router needs a different history backend. @sigx/router ships two history implementations:

HistoryEnvironmentPurpose
createWebHistory()BrowserUses the browser History API for SPA navigation
createHashHistory()Browser (static hosts)Uses /#/path URLs — no server-side SPA fallback needed (GitHub Pages, S3, Electron)
createMemoryHistory()Server / TestsKeeps navigation state in memory — no DOM required

The core idea is simple: create the router per-request on the server with createMemoryHistory, and once on the client with createWebHistory.

Setup — Router Factory#

Define your routes once and export two factory functions — one for each environment:

TypeScript
// src/router.ts
import {
    createRouter,
    createWebHistory,
    createMemoryHistory,
    type RouteRecordRaw,
} from '@sigx/router';
import { Home } from './pages/Home';
import { About } from './pages/About';
import { BlogIndex } from './pages/BlogIndex';
import { BlogPost } from './pages/BlogPost';

export const routes: RouteRecordRaw[] = [
    { path: '/', name: 'home', component: Home },
    { path: '/about', name: 'about', component: About },
    { path: '/blog', name: 'blog', component: BlogIndex },
    { path: '/blog/:slug', name: 'blog-post', component: BlogPost },
];

/** Browser — uses the History API */
export function createClientRouter() {
    return createRouter({
        history: createWebHistory(),
        routes,
    });
}

/** Server — receives the request URL and resolves the route in memory */
export function createServerRouter(url: string) {
    return createRouter({
        history: createMemoryHistory({ initialLocation: url }),
        routes,
    });
}

Why a factory? Each incoming HTTP request must get its own router instance. Sharing a single router across requests would leak state between users.

Server-Side Rendering#

The server entry creates a fresh app + router for every request and renders it to HTML with @sigx/server-renderer.

TSX
// src/entry-server.tsx
import { defineApp } from 'sigx';
import { createSSR } from '@sigx/server-renderer';
import { App } from './App';
import { createServerRouter } from './router';

const ssr = createSSR();

export async function render(url: string) {
    const router = createServerRouter(url);
    const app = defineApp(<App />).use(router);
    return await ssr.render(app);
}

Then wire it into your HTTP server (Express shown here, but any Node framework works):

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) => {
    try {
        const html = await render(req.url);
        res.status(200).set({ 'Content-Type': 'text/html' }).send(`
            <!DOCTYPE html>
            <html>
            <head><title>My App</title></head>
            <body>
                <div id="root">${html}</div>
                <script type="module" src="/entry-client.js"></script>
            </body>
            </html>
        `);
    } catch (err) {
        console.error(err);
        res.status(500).send('Internal Server Error');
    }
});

app.listen(3000);

Streaming#

For large pages you can stream HTML to the client instead of buffering the full string. createSSR() supports several streaming modes:

TypeScript
// ReadableStream (Web Streams API)
const stream = ssr.renderStream(app);

// Node.js Readable stream
const nodeStream = ssr.renderNodeStream(app);

// Callback-based streaming for fine-grained control
await ssr.renderStreamWithCallbacks(app, {
    onShellReady(html) { /* send the initial shell */ },
    onAsyncChunk(chunk) { /* stream async component updates */ },
    onComplete() { /* finalize the response */ },
    onError(err) { /* handle errors */ },
});

Client Hydration#

On the client, create the router with createWebHistory() and hydrate instead of mounting:

TSX
// src/entry-client.tsx
import { defineApp } from 'sigx';
import { ssrClientPlugin } from '@sigx/server-renderer/client';
import { App } from './App';
import { createClientRouter } from './router';

const router = createClientRouter();

function startHydration() {
    defineApp(<App />)
        .use(router)
        .use(ssrClientPlugin)
        .hydrate('#root');
}

// Wait for streaming to finish before hydrating
if (window.__SIGX_STREAMING_COMPLETE__) {
    startHydration();
} else {
    window.addEventListener('sigx:ready', startHydration, { once: true });
}

The client reuses the same routes array, so route matching is identical on both sides. Once hydrated, navigation is handled entirely in the browser via createWebHistory().

Common Patterns#

Route-Based Data Loading#

Load data on the server before rendering with a keyed useData read inside component setup. A keyed read runs during SSR, serializes its value into the page, and restores on hydration without refetching — key it off the route param so it re-fetches on client navigation.

TSX
import { component, useData } from 'sigx';
import { useParams } from '@sigx/router';

export const BlogPost = component(() => {
    const params = useParams();

    // Keyed on the slug: runs on the server, restores on hydration,
    // re-fetches whenever the route param changes.
    const post = useData(() => `post:${params.slug}`, async (key, { signal }) => {
        const res = await fetch(`/api/posts/${params.slug}`, { signal });
        return res.json() as Promise<Post>;
    });

    return () => (
        <article>
            {post.match({
                pending: () => <p>Loading…</p>,
                error: (e, retry) => <p>Failed: {e.message} <button onClick={retry}>Retry</button></p>,
                ready: (p) => <><h1>{p.title}</h1><div>{p.body}</div></>,
            })}
        </article>
    );
});

Redirects During SSR#

Guards and route-record redirects run on the initial route resolution, so a guarded or redirected URL is resolved in memory on the server before any HTML is produced. Wait for that to finish with await router.isReady(), then read the resolved route to emit a real 30x — don't just send the rendered HTML under the requested URL.

TypeScript
// router.ts — a route-record redirect, or any guard that returns a location
export const routes: RouteRecordRaw[] = [
    { path: '/old-page', redirect: '/new-page' },
    // ...other routes
];

When a guard or redirect sends the navigation elsewhere, the resolved route carries the original location on currentRoute.redirectedFrom. The server handler checks for it (and, as a fallback, compares the resolved path to the requested one) and replies with a 302:

TypeScript
// Imports and `const ssr = createSSR()` as in the entry-server example above.
export async function render(url: string) {
    const router = createServerRouter(url);
    const app = defineApp(<App />).use(router);

    // Let the initial navigation — including guards and redirects — settle.
    await router.isReady();

    const resolved = router.currentRoute;
    const redirect =
        resolved.redirectedFrom != null || resolved.fullPath !== url
            ? resolved.fullPath
            : null;

    if (redirect) return { html: null, redirect };

    const html = await ssr.render(app);
    return { html, redirect: null };
}

// server.ts
app.get('*', async (req, res) => {
    const { html, redirect } = await render(req.url);
    if (redirect) {
        return res.redirect(302, redirect);
    }
    res.status(200).send(/* ... */);
});

An initial redirect uses replace semantics, so the redirected-away URL never enters the client's history — Back won't return to it after hydration.

Next Steps#