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:
| History | Environment | Purpose |
|---|---|---|
createWebHistory() | Browser | Uses 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 / Tests | Keeps 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:
// 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.
// 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):
// 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:
// 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:
// 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.
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.
// 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:
// 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
- Getting Started — Basic router setup
- Navigation — Programmatic navigation
- Route Guards — Protect routes with guards
