Skip to content

Server-Side Routing ​

With pushState-style URLs, a deep link like /contacts/3/edit reaches your server first, and the usual answer is a blanket SPA fallback: serve index.html for every path. That works, but the server now answers 200 with the app shell for garbage URLs too. The in-router 404 state renders, so users see the right page — while HTTP tells crawlers and monitoring that everything is fine.

The fix is to let the server share the client's routing: the same URL patterns, matched with the same semantics, deciding between the shell, a redirect, and a real 404. ui-router-server packages that decision as a routing verdict — a pathname in, a plain object out; no fetch, no Response, no runtime assumptions — so the server itself stays a thin consumer (the package page covers the tiers, adapters, and status). This site runs the pattern in production, and not just the destination: the Cloudflare Worker behind lit-ui-router.dev serves the whole spectrum below, live and side by side, from the platform-default fallback to a full headless router per request.

The server-support spectrum ​

How much server does a single-page app need? It is a spectrum, not a yes-or-no — running from location strategies that need no server at all to a full-stack system where the same router executes on both sides. Each level is defined by what the app asks of its server; find yours, and what moving up would buy.

The server-support spectrum= live mount on lit-ui-router.devwhat the app asks of its server5Path · full-stack shared routera real headless router replays every URL server-side/simulated-routing4Path · route-aware serverthe server knows the routes: shells, redirects, real 404s★ flagship/app/app-mobx/not-found-spa3Path · static error ruleshonest 404s, but only for asset misses — can't judge deep linksper-mount 404.html2Path · platform-default fallbackevery path answers 200 with the shell — HTTP lies (soft-404s)/not-found-naive200 for everything1Hash locationthe fragment never leaves the browser — any static host sufficesout of scope — by design0Memory locationno URL, no address bar — tests, embedded widgets, headless toolingno server story to need

Level 0 — memory location: no URL. memoryLocationPlugin runs the router with no address bar at all — tests, embedded widgets, headless tooling. Nothing can be deep-linked, so there is no server story to need.

Level 1 — hash location: any static host. The URL lives in the fragment, which never leaves the browser — by design. Zero server requirements: any static file host suffices, and none of this guide's machinery applies. Hash-mode apps are deliberately out of scope, not at risk.

Level 2 — path location, platform-default fallback. The moment routes move into the path, every deep link reaches the server — and this is the industry-default deployment for path-location SPAs. Serving the shell at 200 for every unknown path is the one fallback every major router's deployment guide prescribes (Vue Router says outright that "your server will no longer report 404 errors"; React Router notes some hosts do it by default; Angular documents the same rule), the out-of-the-box behavior on platforms like Cloudflare Pages and Workers' single-page-application mode, and a documented one-liner everywhere else: nginx try_files $uri /index.html;, a Netlify /* /index.html 200 redirect. The pattern even ships as a package — connect-history-api-fallback, ~14 million weekly downloads — and it is what this site itself shipped before this stack.

Users get the right page; HTTP starts lying. This is the level where the costs arrive: pages classified as soft 404s drop out of the index, crawl budget burns on garbage URLs, and legitimate pages risk misclassification (HTTP status codes and Search, John Mueller on soft-404s).

Not every ecosystem starts here. SSR-default ecosystems launch at the far end instead: Next.js and React Router's framework mode ship ssr: true by default, making full server routing the default launch state, with SPA mode as the documented opt-out that lands on exactly level 2's /* /index.html 200 rule. TanStack Start has the same shape around TanStack Router's client-side matcher: server rendering by default, and an SPA mode that prerenders the root route to a _shell.html and rewrites every miss to it. Same tools, both ends of the spectrum.

what the app asks of its server →

Level 3 — path location, static error rules. The app needs honest errors: a real 404 status with a helpful page. Ordinary hosting has conventions for exactly that — nginx error_page, a 404.html at the site root — but a host that doesn't know the app's routes can only apply them to asset misses; it cannot tell a deep link from a typo. The static page itself stays valuable at every level above this one: it is a free structural boundary for segregated 404 tracking, and it serves scanners and typo probes a few bytes instead of an app.

Level 4 — path location, route-aware server. The missing piece: a server that can tell the app's deep links from garbage. That was always possible by hand — ops teams maintain nginx location and regex blocks, Apache rewrites, or CDN rules that mirror the app's routes, re-synced manually on every route change — so this is an old capability with a maintenance problem, not a new capability. The shared route table attacks the maintenance: it is a data projection of the app's own route definitions (the projection below), exercised by the app's contract tests on every CI run — versus config in a different language, in a different layer of the stack, verified by nobody. Drift-resistant through a test-pinned seam, not drift-impossible — and it expresses what static server rules cannot: parameterized matching with the client's exact semantics, and redirect targets computed by format(). Teach the server the routes — the package's dependency-free matcher tier, pure data — and every answer becomes exact: the shell for real routes, computed redirects, real 404s for everything else. This is this site's flagship tier.

Whether the 404 body is the static page or the app shell rendering its own 404 view is a free choice at this level, and it is not an SEO question: Google ignores the body content of 4xx responses outright. It is analytics and weight — a shell-at-404 boots the whole app for every garbage probe and mixes error views into entrance reports unless the error state opts out (real-world, a 404 page has ranked as a site's second-highest landing page). This site's flagships keep the static page.

Level 5 — full-stack: the shared router. The far end: the same router executing server-side. The simulate tier replays every URL through a real headless @uirouter/core router — redirect rules and otherwise() actually run as rules, not reimplementations — and it is where routing that data cannot express (hooks, resolves, auth-aware verdicts) will live.

Live on this site ​

Every level from 1 up runs behind lit-ui-router.dev — same worker, same wrangler.jsonc, the differences are configuration:

LevelMount(s)Behavior
1/app-hashHash routing done right: the mount root serves the shell at 200 with no redirect, so the fragment survives entry; deep paths stay 404
2/not-found-naiveEvery path serves the shell at 200, no route matching — the platform-default fallback, reproduced
3+4/app, /app-mobx, /app-effect, /app-signalsRoute-aware verdicts: shell, 302, or a real 404 with a per-mount static 404 page (the flagship)
4/not-found-spaRoute-aware, shell-at-404: real routes serve 200; a miss serves the shell at 404 and the client renders its in-app 404 view
5/simulated-routingEvery verdict computed by a real headless router replaying the URL

Try them on a URL that matches nothing: /not-found-naive/no-such-page answers 200 (the lie), /app/no-such-page is a real 404 page, /not-found-spa/no-such-page is a 404 whose body is the app itself rendering its 404 state, and /simulated-routing/no-such-page is a 404 decided by a real otherwise() rule settling inside a headless router — while /simulated-routing/ 302s to /simulated-routing/welcome because a real when() rule ran. And /app-hash/ does the opposite of a flagship root — it serves the shell at 200, un-redirected, because a hash client carries its route in the fragment the server never sees.

Two honesty notes about the exhibits. Every exhibit response (/not-found-naive, /not-found-spa, /simulated-routing) carries X-Robots-Tag: noindex: the level-2 exhibit deliberately manufactures soft-404s, and the site must not be penalized by its own teaching material. And every exhibit mount serves the vanilla sample app's one shell, which derives its base from location.pathname at boot against the prefix list in shellMounts.ts — so the client router strips that prefix and renders real routes under the mount, exactly like the flagship. What makes them exhibits is the server verdict each demonstrates (the soft-404 lie, shell-at-404, the headless replay), not any client limitation; noindex simply keeps the teaching material out of the search index. /app-hash keeps a build of its own for hash mode: its --mode hash build bakes <base href="/app-hash/"> and the hash location plugin, so it is a fully working, indexable hash client rather than an exhibit.

Path-location clients ​

This machinery is for path-location clients — apps whose routes live in the URL path and therefore reach the server on every deep link: pushStateLocationPlugin, or its most modern shape, this repo's own ui-router-navigation-location-plugin companion package, which produces the same clean paths through the Navigation API. Path-addressed routes are the ones a server is asked to vouch for; that is what earns them routing verdicts.

A hash-location app needs none of this — by design. The hash strategy exists to avoid server-side requirements: the fragment never leaves the browser, so /#/contacts/3 asks the server only for /, and a plain 200 shell is the correct, complete server story. Even the level-2 fallback above is a perfectly sound server for a hash-mode app — the soft-404 concern is about path-addressed content, and a hash app addresses nothing by path. If you route in the fragment, you are deliberately out of scope here, not at risk.

The package tiers ​

The package prices its capabilities as separate entry points, measured min+gzip by its own esbuild probe:

importneeds @uirouter/core?costwhat it answers
ui-router-server/matcherno~6.6 KiB min / ~2.9 KiB gzipdoes this pathname match this pattern, with which params — and the inverse, format()
ui-router-server/redirectsno~8.7 KiB min / ~3.6 KiB gzipgiven routes and a redirect table, where does this pathname go
ui-router-server (root)only when a simulate mount resolves~11.5 KiB min / ~4.7 KiB gzipmounts in, verdict out
ui-router-server/simulateyes (optional peer)+~92 KiB min / ~27.4 KiB gzip, lazywhat would the real router do
ui-router-server/locationyes (optional peer)~0.5 KiB min / ~0.3 KiB gzipa path-shaped in-memory location for a server-side router
ui-router-server/url-configno; it configures the router you pass~0.6 KiB min / ~0.4 KiB gzipone url config for the client router, the mounts, and a server render
The package tiers, to scaleno @uirouter/core neededa differential-tested port of core's matching subsetui-router-server/matcher2.9 KiB · does this path match, with which params — and format()ui-router-server/redirects3.6 KiB · + declarative redirect evaluation over a route tableui-router-server4.7 KiB · mounts in, verdicts out — the defaultneeds @uirouter/core — optional peera lazy chunk, or your own eager importui-router-server/simulate+27.4 KiB · lazy chunk — core, wholeloads only when a simulate mount resolves — a matcher-only configuration never fetches itui-router-server/location0.3 KiB own code · eager import, for a server that already runs a real routermin+gzip, measured by the package's own esbuild probe · linear scale

The tiers' shape is measured, not aesthetic. Deep-importing @uirouter/core internals carves a matching-only consumer from 43 KiB gzip down to 14 — UrlMatcher and the param machinery sit near the leaves of core's module graph — but it cannot carve a headless router below the ~43 KiB floor: everything substantive hangs off the UIRouter constructor, and dropping the barrel imports sheds only empty re-export shims (~0.3 KiB). So the dependency-free tiers are a standalone port of the matching subset (type-pinned to core's signatures, differential-tested against core's own output), while the simulate tier carries core whole — behind a dynamic import that bundles as a lazy chunk, so a matcher-only configuration never loads it. That shape gives the matcher tier its contract: dependency-free, and held to a borrowed size discipline — AWS CloudFront Functions' 10,240-byte ceiling, the tightest edge-runtime budget around, which the package's bundle checks log the matcher against and its ~6.6 KiB minified bundle still clears. A discipline, not a deployment target: a working CloudFront function already crowds that ceiling and grows with core fidelity, so the plausible path there is generating a route-specific function from the same route data, not shipping this bundle.

Picking a tier:

  • /matcher — you have server code already and one question: does this path match, with which params. No route table, no verdicts.
  • /redirects — pattern matching plus declarative redirect evaluation over a route table, without mount bookkeeping.
  • root — the default. Mounts in, verdicts out; costs matcher-tier bytes until a mount opts into simulation.
  • /simulate (or strategy: 'simulate' on a mount) — replay the URL through a real headless router: redirect rules and the otherwise projection register as real when()/otherwise() rules and a transition actually runs. Both strategies consume the same declaration subset and produce identical verdicts (the package tests assert parity), so strategy stays a pure cost knob until routing that data cannot express — hooks, resolves, redirectTo functions — arrives with a wider config. A simulate mount's prewarm count builds idle routers once core loads, so its first requests skip the cold build.
  • /location — you drive a real @uirouter/core router yourself, on the server, and want its href()s to be paths rather than # fragments, so a server render's links match what a pushState client writes. installServerLocation(router, { url, baseHref, urlConfig }).
  • /url-config — you want the client router and the mounts to compile urls alike. applyUrlConfig(router, config) applies a mount's config to any @uirouter/core router before its states register; it imports no server code, so the client bundle can take it.

A 'matcher' mount finds its route with a linear scan by default, trying every pattern. routeIndex: routeTrie swaps in a segment trie that tries only the routes whose segments fit the path, with the same verdicts, so a path whose first segment no route declares runs no pattern at all. The trie can't express a case-insensitive url config or a path param whose pattern may match a / (such as {path:.*}). A mount that opts in with either throws at construction, naming the route, and a 'simulate' mount that sets routeIndex throws too. A bundle only carries the trie if it imports routeTrie. Whether it pays depends on the table: the acceptance (bench) job summary times each of this site's mounts both ways, and the site stays on the scan.

The projection: routes as data ​

Routes as data: the projectionClient appsample-app · states.tsurl-bearing statesroot redirect — when(/^\/?$/)deliberately stays client-sidecomponents — register customelements at module scopeconditional routing — DSRdefaults, requiresAuth hookotherwise() — a level choiceprojectedas dataPure datasample-app-routesroutes: [{ name, url },…]redirects: [{ pattern, to },]no imports, no components —safe in any server runtimeimportedEdge workerwww/…/worker/index.tscreateServerRouter({ mounts })resolve(url)→ verdictits one job:verdict → HTTPcontract tests pin the seam — every CI runevery verdict the worker will serve, resolved through the real package APIdrift-resistant through atest-pinned seam

A server runtime cannot import the client's state definitions — they import components, which register custom elements at module scope. Instead, the sample apps keep their state tree's data in dependency-free tables, one per module (stateTable/). Each module binds its components and resolves to its own table by state name, so a lazy module's rows load with it. The server reads all of them, less the future states, as its route table (sample-app-routes):

ts
export const contactsTable = [
  { name: 'contacts', parent: 'app', url: '/contacts', resolves: ['contacts'] },
  { name: 'contacts.contact', url: '/:contactId', resolves: ['contact'] },
  // …
] as const;

export const stateTable = [
  ...appTable,
  ...contactsTable,
  ...mymessagesTable,
  ...prefsTable,
] as const;

export const routes: RouteDeclaration[] = stateTable.filter(
  (row): row is Extract<Row, { name: StateName }> => !row.name.endsWith('.**'),
);

Dotted names nest and urls append, exactly as states do in the router: mymessages.messagelist.message folds to /mymessages/:folderId/:messageId. What the flagship config leaves out is as deliberate as what it includes:

ts
// Mirrors router.config.ts: the app root has no state url; a when(/^\/?$/)
// rule routes it to welcome. Its otherwise() -> notFound rule is deliberately
// NOT projected for the flagship mounts: unknown paths stay notFound verdicts
// (the not-found-static pattern) — 404 views stay out of entrance analytics
// and scanners get a few bytes; shell-at-404 is the /not-found-spa exhibit.
export const redirects: RedirectRule[] = [{ pattern: /^\/?$/, to: 'welcome' }];

// 'matcher': the tables above are pure data, so the dependency-free tier
// suffices. Routing the client decides conditionally (mymessages' DSR
// default, requiresAuth) is deliberately absent — the server must not pick a
// winner. If a rule ever needs hooks or resolves, flipping a mount to
// 'simulate' is config, not code.
const app: MountConfig = { routes, redirects, strategy: 'matcher' };

Two omissions to copy:

  • The client's otherwise() rule is a level choice. The flagships don't project it, so unknown paths stay notFound verdicts and misses serve the static 404 page — the analytics case from level 4. Projecting it is one line of config, shown on the /not-found-spa exhibit below.
  • Conditional routing stays client-side. The client redirects /mymessages to a remembered folder (a DSR default) and bounces unauthenticated users to login (the requiresAuth hook) — both decided by client state the server cannot see. The server serves the shell and lets the client's full configuration run.

The matcher builds a state's url as the client does: a state's parent is its parent:, otherwise its name minus the last dotted segment, and its url appends to that parent's. contacts.contact appends /:contactId to contacts's /contacts, and contacts appends /contacts to the url-less app it names as its parent:. An unknown parent or a parent: cycle throws when the mount compiles.

The app pins every verdict the worker serves with contract tests that resolve shells, the root redirect, real 404s, and the shell-not-redirect verdicts for the client-conditional routes through the real package API.

The verdict ​

Request to verdict to HTTPverdict — a plain objectHTTPincoming request/app/contacts/3createServerRouter(…).resolve(url)longest mount base winsno fetch, no Response —mounts validate at constructionkind: 'shell'mount, status?kind: 'redirect'location, statuskind: 'notFound'mount?200app shell (304s ok);status'd shell: 404302Location: computedby format()404per-mount 404.html,a real status

createServerRouter compiles and validates every mount at construction — unknown redirect targets, cycles, and a bad otherwise target throw at startup, never per-request — and the longest matching mount base owns a pathname outright. A mount's bare base (/app) resolves the empty subpath, so it 404s unless a url: '' route or a rule such as { pattern: /^\/?$/, to: 'welcome' } claims it. Construction warns in development for each mount other than / that leaves it unclaimed. resolve() accepts a pathname, an absolute URL string, or anything with a pathname:

ts
type Verdict =
  | { kind: 'shell'; mount: string; status?: number }
  | { kind: 'redirect'; mount: string; location: string; status: number }
  | { kind: 'notFound'; mount?: string };

shell.status follows a documented precedence: absent means default shell handling, conditional 304s included; set — 404 from the otherwise projection today, 401/403-with-shell for a future auth tier — it wins outright, with consumer obligations covered below. redirect.location is the mount-joined target path, and notFound.mount distinguishes "a mount owned this path but nothing matched" from "no mount at all".

To catch an unclaimed bare base in CI rather than in a log, assert in a test that diagnoseMounts(router) returns an empty array for a router built from the table. It returns the findings the warning prints, in every build; apps/sample-app-routes runs that assertion over the tables above.

A mount that means its bare base to 404 turns the finding off with ignoreDiagnostics; the warning and diagnoseMounts both skip it for that mount.

The worker: verdicts → HTTP ​

The worker (www/lit-ui-router.dev/worker/index.ts) builds the router from the mounts table once per isolate and fronts every routed mount with a single call: createFetchHandler turns the router into a (Request) => Promise<Response | null>, and the worker supplies only what the generic adapter can't know. 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). The worker supplies the host IO (serveShell / serveNotFound over env.ASSETS) and the site policy the adapter has no seam for: the level-2 exhibit bypasses the adapter entirely (it demonstrates the absence of server routing), shellPath points the vanilla pushState mounts at the one base-agnostic vanilla shell while every other mount serves its own, and withNoindex quarantines the exhibits on the way out. A null return is the adapter's pass-through — a path this router doesn't own — served however the assets binding would. Env is generated by wrangler types from the config's bindings, so the handler needs no hand-written environment interface.

Cloudflare Workers static assets serve the built site; the worker script runs only where a routing decision is needed (wrangler.jsonc): it binds the built site as ASSETS with not_found_handling: "404-page" and lists every mount prefix under run_worker_first.

run_worker_first sends every request under the mounts through the worker — it has to see misses like /app/definitely-not to judge them — while not_found_handling: "404-page" gives unmatched paths 404.html with a real 404 status. Every mount also lists its bare path, not just the /* wildcard: /* matches /app/foo but not the bare /app, and the mount root is exactly where a verdict differs from the plain asset — the flagship's / → welcome 302, and /app-hash's shell-at-200. Omit the bare path and Cloudflare's html_handling answers before the worker runs, shadowing that verdict. The exhibits list their bare paths for the same reason plus one more: no asset exists there, so the whole prefix is the demo.

How this site runs maps the rest of lit-ui-router.dev around this worker: the static assets and the release-ledger worker.

The routing side stays light in production, too: the deployed worker — handler, fetch adapter, the mounts table, and the headless router behind the simulate mount — gzips to a small fraction of even the free plan's Workers size limit. The dogfood measures itself: the site package's bundle:worker task (a cached wrangler deploy --dry-run) prints the upload size, and codecov:bundle sends the same bundle to Codecov's size tracking as docs-worker-esm. The site itself ships as static assets through the binding, outside that budget entirely.

One request at a time ​

Each diagram follows one request through the four lanes in production.

Static page 200 ​

Not a run_worker_first path, and the file exists. The worker never runs.

Static page: 200BrowserCloudflareStatic assetsWorkerGET /guides/not worker-first200 page

Static miss 404 ​

Not a run_worker_first path, and no file: not_found_handling serves the site’s 404.html.

Static miss: 404BrowserCloudflareStatic assetsWorkerGET /no-such-pagenot worker-first404 404.html

A route in a mount: the verdict is shell, and the worker fetches the app shell through ASSETS. A revalidation that gets a 304 passes it on.

App deep link: 200BrowserCloudflareStatic assetsWorkerGET /app/contacts/42worker-firstresolve → shellfetch shell200 or 304200 shell, canonical Link

App redirect 302 ​

A redirect rule at the mount root: the verdict is redirect, and nothing is fetched from assets.

App redirect: 302BrowserCloudflareStatic assetsWorkerGET /appworker-firstresolve → redirect302 Location: /app/welcome

Unknown path, mount with otherwise 404 ​

/not-found-spa declares an otherwise, so the verdict is the shell with status 404. The shell request goes without validators, so assets answer with a full body, never a 304.

Unknown path, mount with otherwise: 404BrowserCloudflareStatic assetsWorkerGET /not-found-spa/nopeworker-firstresolve → shell, status 404fetch shell, no validators200404 shell

Unknown path, mount without otherwise 404 ​

The verdict is notFound with a mount, and the worker serves that mount’s 404 page.

Unknown path, mount without otherwise: 404BrowserCloudflareStatic assetsWorkerGET /app/nopeworker-firstresolve → notFound, mount /appfetch /app/404.html200404 page

No mount as served ​

The pass-through, for a worker-first path no mount owns: the verdict is notFound without a mount, the adapter returns null, and the worker hands the request to ASSETS unchanged.

No mount: as servedBrowserCloudflareStatic assetsWorkerGET /unownedworker-firstresolve → notFound, no mountfetch requestasset or 404.htmlas served

vitepress dev sends the sample-app navigations through the same verdicts with the ui-router-server/vite plugin, Vite standing in for Cloudflare and the assets: the plugin answers a redirect itself, and for a shell or a mount-owned 404 it rewrites the request to the HTML file and lets Vite serve it, as the site's .vitepress/vite.config.ts sets up.

Redirects: data until they need code ​

The redirect table takes two kinds of entry, both pure data:

  • when()-style rules — { pattern, to }, evaluated first, in declaration order. A string pattern compiles as a matcher pattern and its extracted params carry into the target; a RegExp contributes no params. The sample apps' root rule above is the canonical example.
  • Per-state redirectTo — a route entry like { name: 'people', url: '/people', redirectTo: 'contacts' } sends a URL landing on that state elsewhere, mirroring the router's redirectTo subset: a string target keeps the matched params, a { state, params } target replaces them. Chains are followed; cycles are rejected at compile time.

That is the boundary: redirects expressible as data belong in the table. Anything that needs code to decide — hooks, resolves, redirectTo functions, injected services — is where the simulate tier is headed (a planned MountConfig widening; not yet reachable in the verdict API), and the sample apps deliberately keep those decisions client-side today.

Contracts worth internalizing:

Queries merge; they never concatenate. A redirect target with declared search params can format to a location that already carries a query, so appending url.search would produce ?page=2?flag=1. The worker uses the package's mergeSearch: target params win, remaining request params append. /app/?flag=1 302s to /app/welcome?flag=1. The matcher tier resolves pathnames only — incoming search values never flow into redirect params.

302, not 301. The redirect table is configuration that changes with the next deploy, and browsers cache 301s so aggressively that a mis-shipped permanent redirect outlives the config that produced it. Verdicts fix status: 302; a per-rule 301 for genuinely legacy moves would be a table extension.

Mount-root rules are for path-mode entries. Server redirects rewrite the path, and a hash-location client keeps its route state where the server never sees it — so don't declare a mount-root redirect rule for a mount that serves hash-mode entries; the shell at the mount root already is hash mode's whole server story. That is exactly why hash mode gets its own /app-hash mount — a url: '' root that serves shell-at-200 with no redirect — rather than riding the flagship's / → welcome rule: a 302 at the root would strip the fragment the hash client entered with.

Shell-at-404: the otherwise projection ​

The /not-found-spa exhibit is the flagship app config plus one field: MountConfig.otherwise projects the client's otherwise() rule, so its entry in routes.ts spreads the flagship config and adds otherwise: { state: 'notFound' }.

The semantics mirror the client rule they project. The target must be a declared, url-less state — enforced at construction — for the same reason the client's 404 state declares no url: the unmatched URL stays in the address bar, like a server-rendered 404 page. A url-full target would move the client's address bar, and the honest projection of that is a redirect, not a shell-404 at the retained path. Redirect rules and route matches take precedence, exactly as otherwise() only fires after every registered rule has failed; unknown paths then verdict as { kind: 'shell', status: 404 } — the shell IS the error page, never a 200 — and the client boots at the retained URL, where its own otherwise() rule renders the rich notFound view.

A status'd shell puts two obligations on the server, both handled by the fetch adapter: strip the request's validators (If-None-Match, If-Modified-Since) before the assets fetch so it returns a 200 body to relabel — never relabel a bare 304, which has no body (a 404 with a null body is malformed) and would let a probe read cache freshness for a path that doesn't exist — and emit no canonical Link.

A real router per request ​

The far end of the spectrum swaps the evaluation engine while keeping the same data: the /simulated-routing mount is the flagship route and redirect tables plus the otherwise projection, with strategy: 'simulate'.

The mounts table in routes.ts puts every level side by side: the flagship config mounted once per sample app, plus the demo mounts — /not-found-spa, /simulated-routing, and /app-hash. The hash demo is a single url-less root route (url: '') with strict: false: a hash client keeps its whole route in the fragment, so the server only ever sees the bare mount (with or without a trailing slash) and must serve the shell there at 200 with no redirect. Deep paths never occur under a hash client, so they stay honest 404s. /not-found-naive has no entry; it is the absence of routing config, served worker-side.

On a simulate mount nothing is approximated: the redirect table registers as real when() rules, otherwise as a real otherwise() rule, and a transition actually runs against a fresh in-memory router per request — /simulated-routing/ 302s because that transition redirected, and the 404 is a transition that settled on the notFound state. The cost is the full core chunk plus per-request router construction, and it is small: measured under wrangler dev (workerd), the first simulate request took 12.4 ms total, warm ones ~5–6 ms, against ~3 ms for matcher verdicts. The level earns its keep the day a redirect needs hooks, resolves, or a redirectTo function — until then the flagships stay on the matcher tier and the switch remains one word of config.

Real 404s, per mount ​

A notFound verdict falls through to the assets binding, and not_found_handling: "404-page" answers with the site's 404.html and a real 404 status. Try it: /app/contacts/3/edit is the shell, /app/contacts/3/edit/extra is this site's 404.

notFound.mount is the hook for going further: when it is set, a mount owned the path, so the worker can serve that app's own 404 page — ASSETS.fetch('<mount>/404.html') re-wrapped with status 404 — and give the user a way back into the app they were deep-linked into, instead of the site-wide page. Everything the worker needs is already in the verdict.

Parameterized routes and soft 404s ​

The projection carries url patterns, not the records behind them. A route like { name: 'sheet', url: '/sheet/:num' } accepts any segment, so /sheet/99 earns a shell verdict and a 200 whether or not sheet 99 exists. The client boots, finds nothing, and renders its in-router 404 state under a URL the server vouched for: the level-2 soft 404, reintroduced by the shape of the app's own url. Every :id route ships it by default.

When the ids are known at build time, narrow the param to them. An inline {name:regexp} placeholder replaces the default segment pattern:

ts
const ids = ['1', '2A', '12i']; // build-time data: the sheets that exist

const routes: RouteDeclaration[] = [
  { name: 'sheet', url: `/sheet/{num:(?:${ids.join('|')})}` },
];

Now /sheet/99 matches nothing and verdicts notFound, a real 404, while /sheet/2A stays a shell. Keep the group non-capturing: the matcher wraps each param's pattern in its own capture group. If ids can contain regex metacharacters, escape each one before joining.

Matching is case-sensitive by default, so with cased ids /sheet/2a is now a 404 too. When the lowercase form should reach the canonical url, add a case-insensitive redirect rule per id:

ts
const redirects: RedirectRule[] = ids.map((num) => ({
  pattern: new RegExp(`^/sheet/${num}$`, 'i'),
  to: { state: 'sheet', params: { num } },
}));

Rules run before route matching, so /sheet/2a 302s to /sheet/2A; the canonical url matches its own rule too, but a redirect that lands where it started is a no-op, and it stays a shell.

This only works for an id set the build can enumerate, and the narrowed pattern is as current as the build that produced it. Ids that live in a database or API stay :num: the shell verdict and the client's 404 state are the correct degrade for what the server can't know.

Everything above is the verdict for one URL. The links the page ships are the other half, and the two directive forms differ there. uiSref and uiSrefActive are element parts: they sit on the element and write to it from the outside, which only a live browser does. A server renderer runs render() and never update(), so what it emits is an <a> with no href — and a static analyser reading the template sees the same thing.

srefHref, srefAriaCurrent and srefActiveClass bind from inside the attribute they affect, so the href is a real attribute in the template. A whole nav:

ts
import { html, LitElement } from 'lit';
import { srefActiveClass, srefAriaCurrent, srefHref } from 'lit-ui-router';

class AppNav extends LitElement {
  render() {
    return html`<nav>
      ${['home', 'users', 'about'].map(
        (state) =>
          html`<a
            href=${srefHref(state)}
            class="nav-link ${srefActiveClass({ state, activeClasses: ['active'] })}"
            aria-current=${srefAriaCurrent({ state })}
            >${state}</a
          >`,
      )}
    </nav>`;
  }
}

While the router sits at users, that link is <a href="/users" class="nav-link active" aria-current="page">; at users.detail the active class stays and aria-current is gone, because the token is exact-match only. The three are covered in Attribute-part forms.

srefHref's render() is a function of the router and its arguments alone — it returns the href string, or nothing for a state with no url — and it touches no DOM. It finds the router from its element in a browser and from the render-scoped router described in The router on the server under @lit-labs/ssr. What is left in update() is what genuinely needs the element: the click handler and announcing the target to an enclosing container.

With no router in reach all three return noChange, which leaves the attribute exactly as authored rather than clearing it: an href a server wrote by another route survives hydration untouched, and the client takes over on the first update that finds a router. Container mode — srefActiveClass with no state — is the one shape that always lands there on the server, since the enclosed links announce themselves through element events a server render has no element to fire.

For the flip side — composing the active flag with classMap instead of letting a directive own the class attribute — see SrefStatusController.

The router on the server ​

A router is easy to build on the server; handing it to the code that renders is the hard part. In a browser every binding finds the router by asking its own element — <ui-router> answers a context-request on the way up the tree. A server render has no tree to walk: the renderer calls a directive's render() with no element behind it, so there is nothing to dispatch from and nothing to listen on.

lit-ui-router/context answers that with two tree-less providers, and ui-router-server/location supplies the location half — a memory plugin in html5 mode, so stateService.href() builds the same path the pushState client writes rather than the #/… form memoryLocationPlugin returns.

ts
import '@lit-labs/ssr/lib/install-global-dom-shim.js';
import { render } from '@lit-labs/ssr';
import { collectResultSync } from '@lit-labs/ssr/lib/render-result.js';
import { installServerLocation } from 'ui-router-server/location';

const { UIRouterLit } = await import('lit-ui-router/pure');
const { provideRouter, withRouterSync } = await import('lit-ui-router/context');

// one router per request, at the url the request asked for, query included
const { pathname, search } = new URL(request.url);
const router = new UIRouterLit();
installServerLocation(router, {
  url: pathname + search,
  urlConfig: { strict: false },
});
states.forEach((state) => router.stateRegistry.register(state));
router.start();

const uninstall = provideRouter(globalThis.litServerRoot, router);
let markup: string;
try {
  markup = withRouterSync(router, () => collectResultSync(render(page())));
} finally {
  uninstall();
}

withRouterSync(router, run) is a synchronous slot, not an async context: it sets a module-level variable, runs run, and restores the previous value in a finally. That is enough because render() from @lit-labs/ssr is a sync generator — consume it inside run with collectResultSync and every directive's render() has already happened by the time withRouterSync returns, getScopedRouter() reading the router each time. Return a promise from run instead and you get a TypeError rather than a router that silently went missing; the Sync suffix names the constraint the guard enforces. An async variant is a separate export.

provideRouter(root, router) is the other half: it answers the context-request protocol on any EventTarget, which on the server means globalThis.litServerRoot, the bottom of the renderer's event-target stack. Use it for elements that ask for their router by protocol — the same code path <ui-router> serves in a browser — and pair it with withRouterSync when the same render also contains attribute bindings. It returns its own uninstall function; call it in a finally once the render is done, or a throwing render leaves the listener behind for the next request.

serverLocationPlugin from the same ui-router-server/location entry is the plugin installServerLocation installs, for a render that wires the rest by hand.

lit-ui-router-ssr packages this hand-off: it owns the @lit-labs/ssr call, the provideRouter/withRouterSync pairing, and the finally — so a build that prerenders a whole mount table writes none of the above by hand. The Altitude Atlas prerenders every page this way, and on each render reads a lit-ui-router-effectRouterRefController under withRouterSync to check it sees the settled route.

The attribute directives read the slot. srefHref, srefActiveClass and srefAriaCurrent have no element to seek from, so they take the router withRouterSync scoped and nothing else: a provideRouter on the render root serves elements, not attribute parts. A nav rendered inside withRouterSync ships the real href, the active and exact classes, and aria-current for the state the request settled on — the markup the client would paint first, so hydration has nothing to correct.

What the server can't see ​

The fragment. The server never sees it — which is exactly why a hash-location app needs no verdicts at all. For a path-location app the fragment carries no routing state, so nothing is lost.

Client state. Auth flags in sessionStorage, remembered navigation targets, feature flags: none of it exists at the edge. Every routing decision 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 principle to itself: failed, timed-out, or throwing simulations degrade to the shell, never to a wrong redirect or a spurious 404.

Trailing slashes are strict on both sides. /app/welcome/ 404s just as the client would refuse to match it. If your client relaxes strictMode, pass the same relaxation as the mount's config: keep one config object and hand it to both the mount and applyUrlConfig on the client router, which throws if a state's url already compiled. A static host that serves <subpath>/index.html by redirecting onto the slashed url makes that relaxation mandatory — see static hosts add a trailing slash.

The 404 UX is asymmetric — by design. With the flagship pattern, client-side navigation to an unknown URL still renders the in-router 404 state (HTTP 200; no request is made), while a direct load of the same URL gets the server's 404 page instead of the shell. That is the goal: the app handles bad links gracefully once loaded, and the server stops vouching for URLs that don't exist. The shell-at-404 level trades that asymmetry away — both paths land in the in-app 404 view, and HTTP stays honest either way.

The rendered content. Everything above is the routing verdict — the status, redirect, and 404 a URL earns from the same route table the client runs — and deliberately not the page body. That is the line between HTTP-semantics SEO, which this guide delivers, and content SEO: a rendered body a crawler can read. Rendering is a second, orthogonal axis on the same routing spine, which returns the identical verdict at every setting. Left alone, ui-router-server sits at that axis's first setting — client-rendered, the shell hydrating in the browser, so a crawler loading a real route gets a correct 200 over an empty shell. lit-ui-router-ssr is the next setting: it consumes these same verdicts and renders each one to a static html file at build time.