ui-router-server
ui-router-server runs the client's route table on the server, so a static single-page app can answer deep links honestly: the app shell for real routes, a computed 302 for redirects, and a real 404 for everything else.
It is a verdict engine, not a framework. A pathname goes in and a plain object comes out — no fetch, no Response, no runtime assumptions in the core — and turning that verdict into HTTP is the server's one job. Adapters for Connect/Express, Vite, WinterCG fetch, and Hono ship that last step for you.
Early days
This package is published and dogfooded — the Worker behind this site runs it in production, serving every level of the spectrum side by side, and the VitePress dev server runs the same mounts through the Vite plugin — but it is still 0.x. The APIs below are live and covered by tests; they can still move in a minor release before 1.0, so pin what you install.
When to use it
Use it when a server has to honour a ui-router state tree that already exists for another reason, usually the client app's own route tables. Reusing that tree is what makes the setup worthwhile: the server answers the same urls the client routes, from one declaration. Each use below adds one concept and leaves the ones before it unchanged:
- An honest SPA host.Real 404s, redirects, and the shell for the client's routes:
createServerRouter, an adapter, and aserveShell. - Server-rendered pages. The same states' resolves and views, rendered with
lit-ui-router-ssronsettleandprerender, whichserverRouterPluginruns aftervite build. - Response states. A state answers with its own
Response(a redirect, JSON, a badge) built from its resolves oncesettlelands it. - Commands whose url the tree defines.Delegating states hand a url to a host handler. Delegate when the tree is the authority for the url: a command that shares params with a page, or a path
prerendermust skip. - Outside HTTP. The verdict core has no HTTP dependency: a pathname goes in and a plain object comes out.
It is not for urls no state tree defines. Route those at the host, ahead of the router: a platform route, an earlier middleware, or in Hono a route registered before the router's catch-all.
const app = new Hono<{ Bindings: Env }>();
// No state defines /healthz, so the host answers it first.
app.get('/healthz', (c) => c.text('ok'));
app.use('*', (c, next) =>
serverRouterHono<typeof router, { Bindings: Env }>(router, {
serveShell: (_mount, request) => c.env.ASSETS.fetch(request),
})(c, next),
);Paths outside every mount already pass through: the fetch adapter returns null, and the Hono and Connect middleware call next(), so the host keeps its own fallthrough and 404 handling. A path a mount owns but no state matches is that mount's 404, which serveNotFound can answer with the app's own 404 page (real 404s, per mount). One request at a time traces each case, and the client's side of the same miss is the 404 state.
What problem it solves
With pushState URLs, every deep link reaches the server, and the universal SPA fallback answers 200 with the shell for every path — including garbage ones. Users see the right page because the in-router 404 state renders, while HTTP tells crawlers and monitoring that everything is fine (soft 404s).
This package closes the gap by giving the server the same route patterns the client matches, projected as pure data. That is HTTP-semantics SEO — the status, redirect, and 404 a URL earns — and deliberately not content SEO: the body is still the client-rendered shell. Rendering is a separate axis, and lit-ui-router-ssr is what turns these verdicts into prerendered html.
The Server-Side Routing guide is the full treatment: the six-level server-support spectrum, the routes-as-data projection, and every level running live on this site. This page is the package tour.
The tiers
Capabilities are priced as separate entry points, measured min+gzip by the package's own esbuild probe:
| Entry point | Needs @uirouter/core? | Cost | What it answers |
|---|---|---|---|
ui-router-server/matcher | no | ~2.9 KiB gzip | does this pathname match this pattern, with which params — and the inverse, format() |
ui-router-server/redirects | no | ~3.6 KiB gzip | given routes and a redirect table, where does this pathname go |
ui-router-server (root) | only for a simulate mount | ~4.7 KiB gzip | mounts in, verdicts out |
ui-router-server/simulate | yes (optional peer) | +~27.4 KiB, lazy | what would the real router do |
ui-router-server/location | yes (optional peer) | ~0.3 KiB gzip | a path-shaped in-memory location for a server-side router |
ui-router-server/url-config | no; it configures the router you pass | ~0.4 KiB gzip | one url config for the client router, the mounts, and a server render |
ui-router-server/prerender | only for a simulate mount | ~6.3 KiB gzip | which files and host rules does this mount table make, around your render |
ui-router-server/ssr | yes (optional peer) | ~1.1 KiB gzip | when has a server router landed, and which of its resolves can the client adopt |
ui-router-server/ssr/client | yes (optional peer) | ~0.7 KiB gzip | let the client's boot transition adopt the resolves a server render drew |
The dependency-free tiers are a standalone port of core's matching subset, type-pinned to core's signatures and differential-tested against its output. The simulate tier carries @uirouter/core whole, behind a dynamic import that bundles as a lazy chunk — a matcher-only configuration never loads it.
Picking one:
/matcher— you already have server code and one question: does this path match, with which params.urlMatcherFactory().compile(pattern), thenexec,format, andcompare./redirects— pattern matching plus declarative redirect evaluation over a route table (compileRoutes,matchRoute,compileRedirects,evaluateRedirects), without mount bookkeeping. Synchronous, dependency-free.- root — the default:
createServerRouter({ mounts }), verdicts out. /simulate(orstrategy: 'simulate'on a mount) — replay the URL through a real headless router, so redirect rules andotherwise()run as actual rules. Both strategies consume the same declaration data and produce identical verdicts today (the package tests assert parity), sostrategyis a pure cost knob. A simulate mount'sprewarmcount builds idle routers once core loads, so its first requests skip the cold build. The routerssimulatebuilds register copies of the mount's declarations, so inside a declaration's own functions (redirectTo, resolves, hooks)transition.to()andstate.selfare the copy, not the app's object; compare states byname, as UI-Router code does./location— you drive a real@uirouter/corerouter yourself, on the server, and want itshref()s to match what the client writes: paths by default, for a pushState client, or#fragments underhtml5Mode: false.installServerLocation(router, { url, baseHref, urlConfig, html5Mode })./url-config— the client router and the mounts should compile urls alike.applyUrlConfig(router, config)applies a mount'sconfigto any@uirouter/corerouter before its states register, and imports no server code, so the client bundle can take it./ssr— you render pages from a server router, with any renderer, and need each path's transition landed before you draw it, and its resolved values handed to the client's boot through/ssr/client.settle(router, path), thendehydrateandseed./prerender— you want the mount table written out as a static site: a page per shell verdict from your render, a_redirectsline per redirect, and the 404 document.prerender(options).
Installation
npm install ui-router-serverNothing else is required: @uirouter/core (only for simulate mounts and the /location, /ssr and /ssr/client entries) and hono are optional peer dependencies, and every other adapter targets a host you already have.
latest is the stable line (0.1.0 today), and the only dist-tag — the pre-release seed tags have been retired.
Quick start
Routes are data, not state declarations — a server runtime cannot import components:
import type { MountConfig, RouteDeclaration } from 'ui-router-server';
const routes: RouteDeclaration[] = [
{ name: 'welcome', url: '/welcome' },
{ name: 'contacts', url: '/contacts' },
{ name: 'contacts.contact', url: '/:contactId' },
// Url-less: structural only, but declarable as an `otherwise` projection.
{ name: 'notFound' },
];
const app: MountConfig = {
routes,
redirects: [{ pattern: /^\/?$/, to: 'welcome' }],
strategy: 'matcher',
};
export const mounts: Record<string, MountConfig> = { '/app': app };Urls append along each state's parent, exactly as states do in the router: the parent is the state's parent:, otherwise its name minus the last dotted segment. contacts.contact folds to /contacts/:contactId, and { name: 'contactHelp', parent: 'contacts', url: '/help' } to /contacts/help.
Then compile the mounts once and resolve pathnames against them:
import { createServerRouter } from 'ui-router-server';
// Mounts validate at construction: unknown redirect targets, cycles, and a
// bad `otherwise` target throw at startup, never per-request.
const router = createServerRouter({ mounts });
// Accepts a pathname, an absolute url string, or anything with a `pathname`.
const verdict = await router.resolve('/app/contacts/3');
// → { kind: 'shell', mount: '/app' }The longest matching mount base owns a pathname outright. A bare mount base (/app) resolves the empty subpath, which the /^\/?$/ rule above claims; construction warns in development for each mount other than / that leaves it to 404. To catch that in CI, assert in a test that diagnoseMounts(router) returns an empty array: it reports, as data and in every build, the findings the warning prints.
The verdict
type Verdict =
| { kind: 'shell'; mount: string; status?: number }
| { kind: 'redirect'; mount: string; location: string; status: number }
| {
kind: 'delegate';
mount: string;
subpath: string;
delegate: string;
state: string;
params: RawParams;
}
| {
kind: 'response';
mount: string;
subpath: string;
state: string;
params: RawParams;
}
| { kind: 'notFound'; mount?: string };shell— serve the app shell. An absentstatusmeans default shell handling, conditional 304s included; a setstatus(404 from theotherwiseprojection today) wins outright, and the consumer must strip the request's validators so the asset fetch returns a body to relabel.redirect—locationis the mount-joined target path and may already carry a query string, so preserve the request's search with the exportedmergeSearch(location, incoming)rather than concatenating.statusis302unless the matched state declaresredirectStatusCode: a redirect table is configuration that changes with the next deploy, and browsers cache a permanent redirect past it. A pattern rule's redirect is302.delegate— the path matched a state that names a host handler by key (delegating states); the handler answers the request.response— the path matched a response state; the handler bound to its name answers with aResponse, never the shell.notFound—mountset means a mount owned the path but nothing matched (serve that app's 404 page); absent means no mount claimed it at all (not this router's problem).
Adapters
Four entry points turn verdicts into HTTP. Each is a thin wrapper — all the routing intelligence stays in the verdict API.
| Entry point | Export | For |
|---|---|---|
ui-router-server/connect | createConnectMiddleware | Connect / Express middleware stacks |
ui-router-server/vite | serverRouterPlugin | vite dev and vite preview |
ui-router-server/fetch | createFetchHandler | WinterCG Request → Response (Workers, Deno, Bun) |
ui-router-server/hono | serverRouterHono | Hono middleware |
They share one contract: a verdict is either answered or passed on. The fetch adapter returns Response | null; the Connect adapter either writes or calls next(). Both default shouldHandle to the navigation heuristic connect-history-api-fallback established — GET/HEAD requests whose Accept includes text/html — so module and asset fetches pass through untouched. Override with () => true when the adapter sits behind the static layer and should judge everything.
Serving under a base path
Every adapter takes base, the url path the site is served at, such as Vite's base; it is / by default. The mount table stays at /: the adapter matches each request with the base taken off, passes a request outside the base on (null from fetch, next() from Connect and Hono), and puts the base back on what it writes: redirect Locations, the canonical Link, and the shell url it hands on.
createFetchHandler(router, {
base: '/solar/',
serveShell: (mount, request) => env.ASSETS.fetch(request),
});
// GET /solar/legacy → 302 Location: /solar/about
// GET /legacy → nullA delegate or response handler gets the original request, base included. A relative base (./) or a full-URL base throws at construction.
A table can name the base in its keys instead. createServerRouter takes base too: every mount key lies under it, the router compiles the keys with the base taken off, and it reports the base as router.base. The table below resolves exactly as { '/': appMount } does, and resolve() takes paths under the base, as the adapters hand them over:
const MOUNT = '/solar/';
const router = createServerRouter({
base: MOUNT,
mounts: { [MOUNT]: appMount },
});
router.base; // '/solar/'
await router.resolve('/legacy'); // { kind: 'redirect', location: '/about', ... }prerender()'s pathsIncludeBase says whether paths spell the base. It defaults to whether the router declares one: a resolver from createServerRouter({ base, mounts }), or a mounts table keyed by the base, lists real urls such as /solar/about, while a table at / stripped under a base lists base-free paths such as /about, so the same script deploys at any base. The default follows that declaration, never the shape of the paths, and an explicit value wins. When it is on, every path lies under the base, which is taken off once, and a path outside it throws; when it is off, a path that starts with the base is read as given. The output is the same either way, and result.pages, warnings and delegated report each path as listed. Under base / it changes nothing.
Each adapter's base defaults to router.base, and a different value throws. serverRouterPlugin takes no base option: it reads Vite's resolved base, and sets Vite's from router.base when the config leaves it unset. When both are set and differ, the config fails.
A table keyed by the base without declaring it would see the base twice. Under a base other than /, on a router with no base, the plugin's config, each adapter and prerender() resolve the base path, and fail when the mount that owns it already carries it: mount '/solar' already carries base '/solar/': declare createServerRouter({ base: '/solar/', mounts }), or key the mount '/'. The plugin fails at config time and prerender() before it writes anything; the adapters fail each request, through next(error) from Connect and a rejection from fetch and Hono. Prerender with Vite covers the build.
fetch — the edge
A minimal Cloudflare Worker, with each mount's shell at its base and a 404.html under it:
import { mounts } from './mounts';
import { createServerRouter } from 'ui-router-server';
import { createFetchHandler } from 'ui-router-server/fetch';
// Module scope: the mount tables compile once per isolate.
const router = createServerRouter({ mounts });
export default {
async fetch(request, env) {
const handler = createFetchHandler(router, {
// Asset IO is the one thing a runtime-neutral adapter can't default.
// The Request arrives already rewritten to the shell path (and, for a
// status'd shell, stripped of validators) — the host just fetches it.
serveShell: (_mount, shellRequest) => env.ASSETS.fetch(shellRequest),
// Mount-owned miss: that app's own 404 page, at an honest 404.
serveNotFound: async (mount, req) => {
// Built from the original request, so a HEAD stays a HEAD.
const page = await env.ASSETS.fetch(
new Request(new URL(`${mount}/404.html`, req.url), req),
);
return new Response(page.body, {
status: 404,
headers: new Headers(page.headers),
});
},
// The Worker runs after the assets layer, so judge everything.
shouldHandle: () => true,
});
// null is the pass-through: a path this router doesn't own.
return (await handler(request)) ?? env.ASSETS.fetch(request);
},
} satisfies ExportedHandler<Env>;The adapter owns the verdict → HTTP mechanics: status mapping, mergeSearch on redirect Locations, stripping validators and relabelling status'd shells, and the canonical Link header (status-less shells only — a 404 is not an alternate representation of anything).
This site's Worker (www/lit-ui-router.dev/worker/index.ts) builds on the same shape: a shellPath that points several pushState mounts at one shared shell, a pass-through when a mount has no 404.html, and noindex on its teaching exhibits.
vite — dev/prod parity
The honest-status upgrade of Vite's built-in SPA fallback: vite dev and vite preview answer the same 302s and mount-owned 404s the production server does, from the same mounts table. The plugin installs its middleware in the pre position — before Vite's always-200 HTML fallback — and its returned object is a structural subset of Vite's Plugin. Vite (7 or 8) is an optional peer the plugin imports only for its build step, which prerenders the built app.
In this example each mount's HTML entry is built to <mount>.html:
import { createServerRouter } from 'ui-router-server';
import { serverRouterPlugin } from 'ui-router-server/vite';
import { defineConfig } from 'vite';
const router = createServerRouter({ mounts });
export default defineConfig({
plugins: [
serverRouterPlugin(router, {
// Where each mount's HTML entry lives, when it isn't the base.
shellPath: (mount) => `${mount}.html`,
}),
],
});This site's dev server runs the plugin with its own shellPath table and a serveNotFound that serves each mount's 404 page (www/lit-ui-router.dev/.vitepress/vite.config.ts).
connect — Node servers
createConnectMiddleware(router, options) returns a plain (req, res, next) middleware. Mount it before the static layer: the default serveShell rewrites req.url to the mount base and next()s into whatever serves your HTML.
import express from 'express';
import { createServerRouter } from 'ui-router-server';
import { createConnectMiddleware } from 'ui-router-server/connect';
const app = express();
app.use(createConnectMiddleware(createServerRouter({ mounts })));
app.use(express.static('dist'));The request/response types are structural subsets of http.IncomingMessage and http.ServerResponse, so Node, Express, and Connect objects all fit without the package depending on Node types.
Connect can't call delegate handlers, so createConnectMiddleware throws at construction for a router whose states name delegate keys. Pass delegates: 'next' to pass navigations that match a delegating state to the downstream stack, and serve those states there. Response states work the same way: it throws for a router that declares them unless you pass responses: 'next', and answer them downstream.
hono
serverRouterHono wraps the fetch handler in Hono's continuation: a verdict Response is returned, anything else next()s. Mount it before the static layer. hono is a type-only optional peer — the import is erased at build, so the bundle never requires it.
import { Hono } from 'hono';
import { createServerRouter } from 'ui-router-server';
import { serverRouterHono } from 'ui-router-server/hono';
const router = createServerRouter({ mounts });
const app = new Hono<{ Bindings: Env }>();
app.use('*', (c, next) =>
serverRouterHono<typeof router, { Bindings: Env }>(router, {
serveShell: (_mount, request) => c.env.ASSETS.fetch(request),
})(c, next),
);Delegating states
A state with a delegate key hands its url to a host handler: a command endpoint, a form POST, a JSON feed. The tree carries only the key, so the client and the server can share it. The fetch and Hono adapters map keys to fetch-shaped handlers in their delegates option. A delegate receives exactly the host's handler arguments: (request, env, ctx) on Workers, (request, c.env, c.executionCtx) behind serverRouterHono, and Connect's own (req, res, next) downstream, so a Hono app's fetch or a Worker handler fits bare. Wrap or bind a handler whose extra parameters are optional, so a host argument such as ctx never lands in one:
import { env } from 'cloudflare:workers';
import { Hono } from 'hono';
import { createServerRouter } from 'ui-router-server';
import { createFetchHandler, delegateContext } from 'ui-router-server/fetch';
const routes = [
{ name: 'home', url: '' },
{ name: 'claims', url: '/claims/{id}', delegate: 'ledger' },
] as const;
const router = createServerRouter({ mounts: { '/app': { routes } } });
// The delegate gets the original request, '/app' prefix included.
const ledger = new Hono<{ Bindings: Env }>().basePath('/app');
ledger.post('/claims/:id', async (c) => {
const { params } = delegateContext(c.req.raw, 'ledger', router);
await c.env.CLAIMS.send({ id: params.id, claim: await c.req.json() });
return c.body(null, 202);
});
const handler = createFetchHandler(router, {
serveShell: (_mount, shellRequest) => env.ASSETS.fetch(shellRequest),
delegates: { ledger: ledger.fetch },
});
export default {
async fetch(request, env, ctx) {
// env and ctx reach ledger.fetch untouched.
return (await handler(request, env, ctx)) ?? env.ASSETS.fetch(request);
},
} satisfies ExportedHandler<Env>;serverRouterHono forwards c.env and c.executionCtx after the request, so delegates: { ledger: ledger.fetch } works behind the middleware too, and ui-router-server/hono re-exports delegateContext.
A request of any method and any Accept that matches a delegating state goes to its handler. The handler gets the original Request object, body unread and url unchanged, which is what keeps delegateContext working; its Response goes out untouched. Unlike Hono's app.mount(), nothing strips the mount base, so route on the context's subpath, give the delegate the mount base (basePath('/app') above), or put delegating states on the / mount. Requests that match no delegating state keep the navigation contract above.
delegateContext(request, key, router?) returns { mount, subpath, delegate, state, params } for the match. subpath is the pathname within the mount ('' at the bare base). params holds what the pathname alone resolves: search params keep their declared defaults, so read the query from the request. It throws when the request wasn't delegated, was delegated to a different key, or came through a router other than the one passed. The context is keyed by the Request object, so a request.clone() or a new Request(request) has none: read it from the request the handler received (c.req.raw in Hono).
- A delegating state needs a url and no
redirectTo, and its children don't inherit the key. AredirectToor redirect rule that targets a delegating state is rejected too, since redirects answer navigations only and a POST would never reach the handler.createServerRouterthrows for each. - A redirect rule whose pattern also matches a delegating url is ordered as the tier orders any rule against a state:
matchermounts evaluate rules first and redirect, whilesimulatemounts follow core's rule sort, where an equally specific state url wins. Keep rule patterns off delegating urls. createFetchHandlerthrows at construction when the router names a key thatdelegatesdoesn't map. Extra keys indelegatesare allowed.- A handler that throws rejects the adapter's promise, so the host's own error handling answers it.
- Requests outside
shouldHandleonly match urls: they never run a transition, even on asimulatemount. When the router names no delegate keys they pass asnullwithout matching. - A delegating state never runs a transition, so its resolves don't run either.
- The Connect adapter throws for delegate keys unless
delegates: 'next', as its section above describes.
A static build leaves delegated paths to the server: prerender() lists them in result.delegated, in the form paths listed them. With a table keyed by the base, paths are real urls by default (serving under a base path), so delegated goes straight into Cloudflare's assets.run_worker_first. With a table at / stripped under a base, paths and delegated are base-free, and the deploy step adds the base.
Typing delegates
Declare routes as const and the router's type carries its state names and delegate keys: delegates must then map every key the routes name, a missing key is a compile error, and delegateContext(request, key, router) narrows state to the states that name key. Each router is typed on its own, so two routers in one program can name different keys. Untyped routes keep string keys and states and core's RawParams.
The router's type is createFetchHandler's first type parameter and the host's arguments its second, so the key check holds however the arguments are given: inferred from the handlers, written out as createFetchHandler<typeof router, [env: Env]>, or annotated on the result as const handler: FetchHandler<[env: Env]> = createFetchHandler(…). serverRouterHono<typeof router, AppEnv> takes them in the same order.
params reads the DelegateSeam interface that ui-router-server/fetch exports empty. A typed state registry augments it with a params member indexed by state name, and DelegateParams<N> returns that entry, or RawParams for a state it doesn't name.
ui-router-server augments @uirouter/core's StateDeclaration with an optional delegate?: string, so a client tree typed with core's declarations can carry the key. The augmentation applies in programs that import ui-router-server; a client-only program adds import type {} from 'ui-router-server';.
Response states
A state marked response: true answers with a Response built from its resolves (a redirect, JSON, a badge) instead of a page. The tree carries only the marker; the host binds a handler to the state's name, so client bundles never import server code. Its verdict is response, decided by url matching alone with no transition, so a host can tell it from a page before rendering anything, for example to skip a trailing-slash redirect it gives pages.
import type { ResponseHandlers } from 'ui-router-server/ssr';
const routes = [
{ name: 'home', url: '' },
{ name: 'badge', url: '/{pkg}/badge.svg', response: true },
] as const;
const responses = {
badge: ({ resolves }) =>
new Response(resolves.svg, {
headers: { 'Content-Type': 'image/svg+xml' },
}),
} satisfies ResponseHandlers<
(typeof routes)[number],
{ badge: { svg: string } }
>;A handler receives { resolves, request }: the values the state's transition landed with, inherited ones included, and the request it answers. ResponseHandlers requires a handler for exactly the states the routes mark, and its second parameter types each handler's resolves by state name.
- A response state needs a url and no
redirectTo, can't also delegate, and its children don't inherit the marker.createServerRouterthrows for each, andas constroutes make a row with both markers a compile error. A redirect may target a response state. A url whose pattern is sometimes a page and sometimes a redirect needs aredirectTofunction instead: which decides when. - The fetch and Hono adapters pass a GET or HEAD of any
Acceptthat matches a response state toserveResponse(verdict, request, ...args), so an<img>reaches its badge, and answer other methods with a 405.lit-ui-router-ssr'srespondersettles the state there and runs its handler; theResponsegoes out untouched. A typed router that declares response states requiresserveResponseat compile time, and construction throws without it. - The Connect adapter throws for response states unless
responses: 'next'. ui-router-serveraugments core'sStateDeclarationwithresponse?: true, as it does withdelegateandredirectStatusCode.
Server-side hrefs
installServerLocation puts a real @uirouter/core router on an in-memory location whose url shape you pick, so the links a server render emits match the ones the client writes. It is path-shaped by default, for a pushState client, and builds /sheet/7B; core's own memoryLocationPlugin is fixed at html5Mode()false and builds #/sheet/7B.
import { servicesPlugin, UIRouter } from '@uirouter/core';
import { installServerLocation } from 'ui-router-server/location';
const { pathname, search } = new URL(request.url);
const router = new UIRouter();
router.plugin(servicesPlugin);
installServerLocation(router, {
url: pathname + search,
baseHref: '/app/',
urlConfig: { strict: false },
});
states.forEach((state) => router.stateRegistry.register({ ...state }));Register a copy of each declaration, or build them in a function that runs per request: core stamps the object it registers with a $$state pointer to that router's state, so a declaration shared by concurrent routers points at whichever registered last. createHeadlessRouter and simulate mounts copy their declarations already.
baseHref is the mount prefix: it is added to every href, and url is passed without it, exactly as a <base href> client matches. urlConfig takes the same object as a mount's config and goes through applyUrlConfig first, so it throws, leaving the router as it was, if a state's url already compiled. For a hash-location client, pass html5Mode: false and the same router builds #/sheet/7B. The plugin itself is exported as serverLocationPlugin — router.plugin(serverLocationPlugin, { html5Mode: false }) — and its config as ServerLocationConfig, for a router that installs its own plugins. Simulate mounts run on the same location.
Trailing slashes
A static host decides whether a page lives at /releases/lit/ or /releases/lit, and the pages a server renders should follow it. Name that spelling once, in the config the mount and the client router share, and leave the slash out of the route table:
import type { RouterUrlConfig } from 'ui-router-server/url-config';
export const urlConfig = {
strict: false,
trailingSlash: { pages: 'always' },
} satisfies RouterUrlConfig;| Kind of state | Key | Default |
|---|---|---|
| A page: neither of the others | pages | 'as-requested' |
| A response state | responses | 'as-requested' |
| A delegate | — | 'as-requested' |
Each key takes 'always', 'never' or 'as-requested'.
- Matching: a navigation that matches a state in the other spelling verdicts as a
308redirect to the canonical one, on both strategies. A dataredirectTolands in its target's canonical spelling in one hop. A non-navigation request to a response state or a delegate is answered at either spelling. - Formatting:
applyUrlConfiginstalls astateRegistryurl decorator, sohref(),ui-srefand the urls a transition pushes come out canonical, on the client and on a server router built withinstallServerLocation(router, { urlConfig }). - Composition: the slash is added when a url is formatted, never spelled in a pattern, so a child url appends to its parent's without a
//. - Static sites:
prerenderwrites each page or response state at the path listed and a308line from the other spelling, the redirect the server answers.
The policy needs strict: false, so both spellings match before it picks one; createServerRouter, compileRedirects and applyUrlConfig throw without it. On Cloudflare, match the static assets' html_handling: force-trailing-slash is { pages: 'always' }, drop-trailing-slash is { pages: 'never' }, and none is the default.
Settling a server router
A server render reads the router only once its transition has landed. settle(router, path) from ui-router-server/ssr sets the url, syncs the router to it, and resolves with the transition that landed, resolves included, so a synchronous render after it reads every value. A page loop calls it once per path on the same router, started or not.
import { installServerLocation } from 'ui-router-server/location';
import { settle } from 'ui-router-server/ssr';
installServerLocation(router, { urlConfig: { strict: false } });
for (const path of ['/', '/sheet/7B']) {
await settle(router, path);
pages.set(path, render(router));
}It rejects rather than hangs: on a url no rule matches, on a failed transition, and past its timeout. The entry imports @uirouter/core and no renderer, so it runs under any renderer. lit-ui-router-ssr builds its prerender on the same step.
A redirectTo chain settles on its final state. redirectedTo(router, transition) then returns { url, status } for a transition a redirect moved, a redirectTo function's included: the mount-relative url it landed on, query included, and the redirectStatusCode of the state the url matched, 302 by default. It returns undefined for any other transition, so a host answers a redirect found at settle as a 30x; see which decides when.
Handing resolves to the client
A client booting over a server-rendered page would run every resolve the server already ran. dehydrate(transition) draws the resolved values of a landed transition into a JSON payload, keyed by state and token, along with the state and url parameters it was drawn for. seed(router, payload) from ui-router-server/ssr/client, called on the client router before it starts, makes the boot transition adopt those values instead of calling their resolve functions.
// server, per path: ui-router-server/ssr
const { payload, unserializable } = dehydrate(await settle(router, path));
// client, before the router starts: ui-router-server/ssr/client
seed(router, payload);The boot is still a real transition, with every hook it fires. Every resolve the payload does not cover runs as usual:
- A value that is not plain JSON data. A
Date, aMap, a class instance orundefinedwould come back changed, sodehydratelists it inunserializableinstead, and the client runs its resolve. A transporter can carry more. - A
NOWAITresolve.dehydrateneither awaits nor draws it; it stays with the client. - A resolve the call keeps out.
shouldDehydratedecides per state and token, such as for data every page shares that the client loads once from a cacheable file. A resolve it declines is not listed inunserializable. - Another target. A boot to a state, or url parameters, the payload was not drawn for adopts nothing, and neither does a missing payload.
- Later transitions. The seed disarms once a transition succeeds.
The client entry imports @uirouter/core and nothing from the server entry, so a browser bundle takes it alone; it exports the payload types too. Where the payload goes in the document is the renderer's choice.
Values beyond plain JSON
dehydrate and seed take a transport: a record of transporters, each carrying one type of value plain JSON can't, keyed by a name both sides share.
import type { Transporter } from 'ui-router-server/ssr';
export const transport = {
Date: {
encode: (value) => value instanceof Date && value.toISOString(),
decode: (encoded) => new Date(encoded as string),
} satisfies Transporter<Date>,
};
// server: dehydrate(transition, { transport }); client: seed(router, payload, { transport })- Encoding. Each value that is not plain JSON data, at the top of a resolve or nested in its arrays and plain objects, is offered to each transporter in key order. The first
encodethat answers with anything butfalse,nullorundefinedcarries it, so an encoding offalseornullmust be wrapped, as in[false]. A value none carries, or one whoseencodethrows, lists its resolve inunserializable.shouldDehydrateruns first, so a resolve it declines is offered to no transporter. - The payload. Values stay where they are in
resolves, as their transporters encoded them. Atransportedmember lists each one's transporter name and path, apart from the values, so a value of the app's own never passes for a carried one. Without a carried value, the payload has notransportedmember. - Decoding.
seedrebuilds each carried value with the transporter of the same name. A resolve whose value it can't rebuild, for want of that transporter or becausedecodethrows, runs its function, andseedwarns in development; the other resolves are still adopted.
Emitting a static site
prerender(options) asks the mount table for a verdict per path and writes the site. A shell verdict becomes <subpath>/index.html, holding whatever render returns for it. A redirect becomes a _redirects line, paired with and without the trailing slash, and no page. Under a mount's trailing-slash policy, a page or response state spelled 'always' or 'never' also gets a 308 line from its other spelling onto the path listed, so list paths in their canonical spelling, as href() formats them. The otherwise projection becomes 404.html. A path that matches nothing is counted, listed in result.warnings, and warned about in development. A path that matches a delegating state gets no page and no rules line: it is counted in result.tally.delegate and listed in result.delegated, in enumeration order. A path that matches a response state is answered by the respond hook: a 2xx body, read as text, is written at the path its url spells (badge.svg), its headers dropped, and a 3xx becomes a _redirects line to its Location. Either is counted in result.tally.response; another status throws, as does a response state with no respond. A verdict kind the loop doesn't know throws instead of being counted.
base is the url path outDir is served at, / by default, as for the adapters. The mounts and the page files stay where they are; the base goes once onto each generated rules line's from and onto its to when that is a path. extraRules are written as given. base defaults to a resolver's router.base, and a mounts table keyed by the base compiles as createServerRouter({ base, mounts }) does. A table with keys both under and outside the base throws. paths spell the base for a router that declares one, and lie under it with it taken off otherwise; pathsIncludeBase overrides that (serving under a base path).
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
import { prerender } from 'ui-router-server/prerender';
import { settle } from 'ui-router-server/ssr';
const result = await prerender({
mounts,
outDir: 'dist',
paths: ['/', '/sheet/7B', '/legacy'],
render: async (verdict, { path }) => {
await settle(router, path);
return page(router);
},
write: async (file, body) => {
await mkdir(dirname(file), { recursive: true });
await writeFile(file, body);
},
});The loop never touches a filesystem itself: write receives each file with outDir joined on, so the same call emits through node:fs, a virtual fs, or a Map, under any runtime. lit-ui-router-ssr's prerender is this loop with a lit render, and writes through node:fs by default.
Hosts without redirect rules
rules decides what happens to the redirect lines: '_redirects' (the default) writes the file Cloudflare Pages and Netlify read, 'none' leaves them in result.rules, and a function receives them with the run's write, outDir, base and written pages. A host with no redirect rules, such as GitHub Pages or a plain bucket, takes redirectPages(): each line becomes a page at <from>/index.html that sends the browser to its to.
import { prerender, redirectPages } from 'ui-router-server/prerender';
await prerender({
mounts,
outDir: 'dist',
paths,
render,
write,
rules: redirectPages(),
});- What the page holds. A
location.replacethat carries the request's query and fragment into a target with none of its own, a meta refresh and a plain link for browsers without script, a canonical link to the target, andnoindex. The target is HTML-escaped withescape-html. - No status. The page answers 200, so the line's 301, 302 or 308 never reaches the host. The canonical link tells search engines the content moved.
- Collisions. A line whose page would replace one the run wrote throws. A line between a path's two spellings, as a trailing-slash policy adds, writes nothing: its page would be its target's own. The first line for a page wins, as in
_redirects, so a path's two spellings share one page. A line whosefromis a pattern (*,:param) throws. - Options.
baseis the url pathoutDiris served at,prerender()'sbaseby default.redirectsFile: truealso writes_redirects, for a site deployed to both kinds of host. - Your own page.
pagebuilds each page's HTML from its line (from,to,status), and may return a promise.redirectPageis the default, exported so a template can wrap it. A template getstounescaped: HTML-escape it in markup, withescape-htmlor the template's own escaper, and escape it as a script string too if it goes in a script.
import { redirectPage, redirectPages } from 'ui-router-server/prerender';
const rules = redirectPages({
// Wrap the default: add a tag to every page.
page: (line) => redirectPage(line).replace('</head>', `${analytics}</head>`),
});After vite build
serverRouterPlugin prerenders the built app after vite build, through a prerender script whose default export calls prerender() with a renderer of its own. The script runs in a worker thread, one per build, after the modules listed in preload, so whatever they install never reaches Vite's process:
serverRouterPlugin(router, {
prerender: { entry: './src/prerender.ts', preload: ['./src/dom-shim.ts'] },
});Prerender with Vite walks through the plugin option and the script.
A delegated path has no static file, so the host must send it to the server. On Cloudflare Workers, list result.delegated in assets.run_worker_first so the Worker sees those paths before static assets do.
What the server can't see
Client state — auth flags, remembered navigation targets, feature flags — does not exist at the edge, so routing that depends on it stays out of the projection and the shell verdict is the degrade path: the client router re-runs the URL with its full configuration. The simulate tier applies the same rule to itself, degrading failed or timed-out simulations to the shell rather than a wrong redirect or a spurious 404. Trailing slashes are strict on both sides; if your client relaxes strictMode, pass the same relaxation as the mount's config, or share one object through applyUrlConfig, along with any trailing-slash policy. A static host that serves <subpath>/index.html by redirecting onto the slashed url makes that relaxation mandatory (static hosts add a trailing slash). Nor does it see which ids exist: a :id param matches any value, so narrow it to the known ids when the build can list them (parameterized routes and soft 404s).
Releases
Every release, with its notes and git tag, is on the ui-router-server releases page. Compare two versions:
Further reading
- API reference — every entry point, one module per subpath import
- Server-Side Routing guide — the spectrum, the projection, and the live mounts
lit-ui-router-ssr— these verdicts prerendered into a static site- Unmatched URLs — the client-side 404 state this pairs with
- Location plugins — why path-location clients are the ones that need any of this
- On the Altitude Atlas — this package as a brick in sheet 2's model in the round, sized by its authored files and seated on what it peers