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. 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. Same tools, both ends of the spectrum.

what the app asks of its server →
React Router logoReact Router logoReact Router

Framework mode ships ssr: true by default — full server routing as the launch state — while the documented SPA mode opts out to level 2’s /* /index.html 200 rule: one tool, both ends of the spectrum.

Vue Router logoVue Router logoVue Router

Its history-mode guide is the canonical level-2 documentation: the fallback that, by its own description, means the server will no longer report 404 errors.

Angular logoAngular logoAngular

Documents the same fallback rule for path-location deploys; honest statuses arrive through its SSR package rather than a route-aware edge.

Next.js logoNext.js logoNext.js

SSR-default: the framework owns rendering, and full server routing is the default launch state — launching at the far end of the spectrum.

SvelteKit logoSvelteKit logoSvelteKit

The same SSR-default class; turn SSR off and its adapters document a 200.html fallback — the level-2 rule under another name.

Netlify logoNetlify logoNetlify

The /* /index.html 200 redirect: level 2 as a single line of platform config.

Cloudflare logoCloudflare logoCloudflare

Pages assumes an SPA by default; Workers static assets host this very site, with the worker running only where a routing decision is needed.

nginx logonginx logonginx

try_files $uri /index.html; is the level-2 rewrite; hand-maintained error_page and location blocks are the level-3/4 prior art whose maintenance this stack replaces with projected data.

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-mobxRoute-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 each exhibit mount ships its own build — the vanilla sample app rebuilt with VITE_SAMPLE_APP_BASE_URL set to the mount's prefix (/not-found-spa/, /simulated-routing/, /not-found-naive/), the same per-mount --mode build /app-hash uses — 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 is the same mechanism pointed at 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
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 peerui-router-server/simulate+27.4 KiB · lazy chunk — core, wholeloads only when a simulate mount resolves — a matcher-only configuration never fetches itmin+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.

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 workerdocs/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 project their url-bearing states into pure data (sample-app-routes):

ts
export const routes: RouteDeclaration[] = [
  { name: 'welcome', url: '/welcome' },
  { name: 'home', url: '/home' },
  { name: 'login', url: '/login' },
  { name: 'contacts', url: '/contacts' },
  { name: 'contacts.contact', url: '/:contactId' },
  { name: 'contacts.contact.edit', url: '/edit' },
  { name: 'contacts.new', url: '/new' },
  { name: 'mymessages', url: '/mymessages' },
  { name: 'mymessages.compose', url: '/compose' },
  { name: 'mymessages.messagelist', url: '/:folderId' },
  { name: 'mymessages.messagelist.message', url: '/:messageId' },
  { name: 'prefs', url: '/prefs' },
  // Url-less, as in main/states.ts: structural only — never matched, never a
  // redirect target — but declarable as an otherwise projection.
  { name: 'notFound' },
];

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' };

Three 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.
  • Param defaults the projection doesn't need. RouteDeclaration accepts params, but the client's folderId: 'inbox' default only matters once the app is running; /mymessages already matches its parent state's pattern. Audit yours.

The projection mirrors the client's states rather than importing them, so it can drift; the app pins it with contract tests that resolve every verdict the worker will serve — 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. 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".

The worker: verdicts → HTTP

The whole handler (docs/worker/index.ts):

ts
import { mounts } from 'sample-app-routes';
import { createServerRouter } from 'ui-router-server';
import { createFetchHandler } from 'ui-router-server/fetch';

// All routing intelligence lives in ui-router-server; the ./fetch adapter
// turns a verdict into an HTTP Response. Module scope: the mount tables
// compile once per isolate.
const router = createServerRouter({ mounts });

// The 404-pattern exhibit mounts have no shell asset of their own — they serve
// the vanilla app's (its asset urls are absolute, so the shell works under any
// prefix). Mounts without an alias serve the shell at their own base, which is
// the adapter's shellPath default.
const SHELL_PATHS: Record<string, string> = {
  '/not-found-spa': '/app',
  '/simulated-routing': '/app',
};

// Every exhibit response carries noindex: the naive rung deliberately serves
// soft-404s, and the site must not be penalized by its own teaching material.
// The generic adapter owns verdict -> HTTP; SEO policy stays the site's,
// layered on the adapter's OUTPUT — a redirect Response the adapter builds has
// no host hook, so noindex rides the request path, not a per-verdict callback.
const EXHIBITS = ['/not-found-naive', '/not-found-spa', '/simulated-routing'];
const isExhibit = (pathname: string): boolean =>
  EXHIBITS.some((m) => pathname === m || pathname.startsWith(`${m}/`));

const withNoindex = (response: Response): Response => {
  const headers = new Headers(response.headers);
  headers.set('X-Robots-Tag', 'noindex');
  return new Response(response.body, { status: response.status, headers });
};

// The not-found-naive exhibit: the classic SPA-host fallback — every path
// serves the shell at 200, no route matching at all (the soft-404 anti-pattern
// baseline, and what this site shipped before this stack). It has no mounts
// entry BY DESIGN: the rung demonstrates the ABSENCE of server routing, so it
// bypasses the router entirely rather than riding a verdict.
const NAIVE_MOUNT = '/not-found-naive';

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (
      url.pathname === NAIVE_MOUNT ||
      url.pathname.startsWith(`${NAIVE_MOUNT}/`)
    ) {
      // Construct the shell request from the original so its conditional
      // headers ride along and a repeat load can still 304.
      const shell = await env.ASSETS.fetch(
        new Request(new URL('/app', request.url), request),
      );
      return withNoindex(shell);
    }

    // The fetch adapter fronts every routed mount: it owns status mapping,
    // mergeSearch on redirect Locations, validator stripping + status relabel
    // on status'd shells, and the canonical Link header. The host supplies the
    // asset IO (env.ASSETS) and the two policies the adapter can't know: shell
    // aliasing (SHELL_PATHS) and the real 404 page.
    const handler = createFetchHandler(router, {
      shellPath: (mount) => SHELL_PATHS[mount] ?? mount,
      // The adapter hands over a shell Request already rewritten to shellPath
      // and (for a status'd shell) stripped of validators — the host's job is
      // the raw asset fetch; the adapter owns the relabel and Link on the way
      // out. Deep-link revalidations still 304: a non-status'd shell request
      // carries the conditional headers along from the original.
      serveShell: (_mount, shellRequest) => env.ASSETS.fetch(shellRequest),
      // Mount-owned miss: serve that app's 404 page re-wrapped at an honest
      // 404; anything but the page itself (or an asset with no 404.html) falls
      // through to the assets binding's own 404.html handling.
      serveNotFound: async (mount, req) => {
        const page = await env.ASSETS.fetch(
          new URL(`${mount}/404.html`, req.url),
        );
        return page.status === 200
          ? new Response(page.body, {
              status: 404,
              headers: new Headers(page.headers),
            })
          : env.ASSETS.fetch(request);
      },
      // The worker runs FIRST for the mount prefixes (wrangler
      // run_worker_first) and real assets live at /assets, never under a
      // mount — so it judges every request that reaches it, not just
      // navigations.
      shouldHandle: () => true,
    });

    const response = await handler(request);
    // null is the adapter's pass-through: a notFound without a mount (the path
    // isn't this router's), served however the assets binding would.
    if (response === null) return env.ASSETS.fetch(request);
    // Quarantine the teaching exhibits from crawlers, layered on the adapter's
    // output (the redirect Response it builds has no host hook of its own).
    return isExhibit(url.pathname) ? withNoindex(response) : response;
  },
} satisfies ExportedHandler<Env>;

The flagship path is now a single call: createFetchHandler turns the router into a (Request) => Promise<Response | null>, and the worker shrinks to supplying 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), SHELL_PATHS aliases the vanilla shell under the exhibit prefixes, 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):

jsonc
// https://developers.cloudflare.com/workers/static-assets/configuration/
{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "lit-ui-router",
  "compatibility_date": "2026-07-01",
  "main": "./docs/worker/index.ts",
  "assets": {
    "directory": "./docs/dist",
    "binding": "ASSETS",
    "not_found_handling": "404-page",
    // Sample-app deep links and the 404-pattern exhibits invoke the worker;
    // other misses serve 404.html. EVERY mount lists its bare path too, not
    // just the `/*` wildcard: a `/*` pattern matches `/app/foo` but NOT the
    // bare mount root `/app`, and the root is exactly where the verdict that
    // differs from the plain asset lives — the flagship's `/` -> welcome 302,
    // and /app-hash's shell-at-200 (its own `app-hash.html`, not the vanilla
    // shell html_handling would serve at `/app-hash`). Omit the bare path and
    // html_handling answers before the worker runs, shadowing that verdict.
    // The exhibits also need their bare paths because no asset exists there —
    // the whole prefix is the demo.
    "run_worker_first": [
      "/app",
      "/app/*",
      "/app-mobx",
      "/app-mobx/*",
      "/app-hash",
      "/app-hash/*",
      "/not-found-naive",
      "/not-found-naive/*",
      "/not-found-spa",
      "/not-found-spa/*",
      "/simulated-routing",
      "/simulated-routing/*",
    ],
  },
}

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.

The routing side stays light in production, too: the deployed worker — handler, fetch adapter, both mount tables, and the headless router behind the simulate mount — uploads at ~268 KiB (~56 KiB gzip), around 2% of even the free plan's Workers size limit. That figure is the dogfood measuring itself: the repo's own cached wrangler dry-run produces it, and CI uploads the same bundle to size tracking. The site itself ships as static assets through the binding, outside that budget entirely.

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 (routes.ts):

ts
// The not-found-spa exhibit: an honest-404 SPA. It is the flagship mount plus
// one difference — the miss. Real routes and the root redirect earn a shell-200
// (its own base-baked build, VITE_SAMPLE_APP_BASE_URL /not-found-spa/, lets the
// client match deep links at this prefix), while the `otherwise` projection
// serves the app shell at an honest 404 for genuine misses; the client boots at
// the retained url and renders the rich in-app notFound state. The flagship
// carries no `otherwise`, so its miss is a static 404 page instead (see the
// fetch adapter's verdict mapping: an `otherwise` state is a status'd shell).
const notFoundSpaDemo: MountConfig = {
  ...app,
  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 consumer, both visible in the worker above: 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, plus the mount table that puts every level side by side:

ts
// The simulated-routing exhibit: full router semantics server-side — the
// same tables, but every verdict computed by replaying the url through a
// headless @uirouter/core router (redirect rules, otherwise, and one day
// hooks/resolves all ride). Its own base-baked build (VITE_SAMPLE_APP_BASE_URL
// /simulated-routing/) lets the client render the very route the server
// computed; noindex (worker) still quarantines the exhibit from crawlers.
const simulatedRoutingDemo: MountConfig = {
  routes,
  redirects,
  strategy: 'simulate',
  otherwise: { state: 'notFound' },
};

// The hash-location demo: a hash client keeps the whole route in the fragment,
// so the server only ever sees the bare mount — and it MUST serve the shell at
// 200 there. A redirect at the root (the flagship's `/` -> welcome rule) would
// 302 the mount, and a 302 sends the browser to a new path, stripping the
// route the hash client entered with; that is exactly why hash mode is not
// first-class at the flagship mounts. A single url-less-prefix root route
// (`url: ''`) matches the empty subpath to a shell verdict with no redirect;
// `strict: false` extends it to the trailing-slash form (`/app-hash/`). Deep
// paths never occur under a hash client, so they stay honest 404s.
const hashDemo: MountConfig = {
  routes: [{ name: 'root', url: '' }],
  config: { strict: false },
};

/**
 * Both sample apps run the same route tree, each under its own mount
 * (not-found-static); the demo mounts exhibit the not-found-spa and
 * simulated-routing rungs (not-found-naive lives worker-side — it is the
 * absence of routing config), and /app-hash the hash-location shape (shell at
 * the root, no redirect, so the fragment survives entry).
 */
export const mounts: Record<string, MountConfig> = {
  '/app': app,
  '/app-mobx': app,
  '/app-hash': hashDemo,
  '/not-found-spa': notFoundSpaDemo,
  '/simulated-routing': simulatedRoutingDemo,
};

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.

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.

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. A crawler that loads a real route gets a correct 200, but what comes back is still the empty client shell. 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, and today this package sits at its first setting: client-rendered — the shell hydrates in the browser. Build-time pre-rendering and request-time server-rendering are on the roadmap — a per-route dial that would ride this same routing spine, which returns the identical verdict at every setting. The honest HTTP status is here today; content rendering is the next axis to build on top of it.