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 2 up runs behind lit-ui-router.dev — same worker, same wrangler.jsonc, the differences are configuration:

LevelMount(s)Behavior
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: the otherwise projection; the client renders its in-app 404 at the retained URL
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.

Two honesty notes about the exhibits. Every demo-mount response 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 the demo mounts alias the vanilla sample app's shell (they have no build of their own), which works because the built shell's asset URLs are absolute — but the shell also bakes <base href="/app/">, so the client router cannot match deep links under a foreign prefix. Full client-side parity on a demo mount would need a per-prefix shell build; the exhibits instead teach server semantics and stay quarantined from crawlers.

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/matcherno2.7 KiBdoes this pathname match this pattern, with which params — and the inverse, format()
ui-router-server/redirectsno3.4 KiBgiven routes and a redirect table, where does this pathname go
ui-router-server (root)only when a simulate mount resolves4.1 KiBmounts in, verdict out
ui-router-server/simulateyes (optional peer)+27.3 KiB, 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.7 KiB · does this path match, with which params — and format()ui-router-server/redirects3.4 KiB · + declarative redirect evaluation over a route tableui-router-server4.1 KiB · mounts in, verdicts out — the defaultneeds @uirouter/core — optional peerui-router-server/simulate+27.3 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.

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, mergeSearch } from 'ui-router-server';

// All routing intelligence lives in ui-router-server; the worker's one job
// is verdict -> HTTP. 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 base.
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.
const EXHIBITS = new Set([
  '/not-found-naive',
  '/not-found-spa',
  '/simulated-routing',
]);
const noindexed = (mount: string, headers: Headers): Headers => {
  if (EXHIBITS.has(mount)) headers.set('X-Robots-Tag', 'noindex');
  return 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 itself shipped before this
// stack). It bypasses resolve() deliberately: the rung demonstrates the
// ABSENCE of server routing.
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}/`)
    ) {
      const shell = await env.ASSETS.fetch(
        new Request(new URL('/app', request.url), request),
      );
      return new Response(shell.body, {
        status: shell.status,
        headers: noindexed(NAIVE_MOUNT, new Headers(shell.headers)),
      });
    }
    const verdict = await router.resolve(url);
    if (verdict.kind === 'shell') {
      // Constructing the shell request from the original carries the
      // conditional headers along, so deep-link revalidations still 304.
      const shellRequest = new Request(
        new URL(SHELL_PATHS[verdict.mount] ?? verdict.mount, request.url),
        request,
      );
      // Status precedence per the Verdict contract: absent means default
      // shell handling, 304s included. An explicit status (404 via the
      // otherwise projection) wins outright, and the validators must go so
      // the fetch returns a 200 body to relabel — never a bare 304.
      if (verdict.status !== undefined) {
        shellRequest.headers.delete('If-None-Match');
        shellRequest.headers.delete('If-Modified-Since');
      }
      const shell = await env.ASSETS.fetch(shellRequest);
      const headers = noindexed(verdict.mount, new Headers(shell.headers));
      // No canonical Link on status'd shells: a 404 is not an alternate
      // representation of the mount root.
      if (verdict.status === undefined)
        headers.set('Link', `<${verdict.mount}>; rel="canonical"`);
      return new Response(shell.body, {
        status: verdict.status ?? shell.status,
        headers,
      });
    }
    if (verdict.kind === 'redirect') {
      const headers = new Headers({
        Location: mergeSearch(verdict.location, url.search),
      });
      return new Response(null, {
        status: verdict.status,
        headers: noindexed(verdict.mount, headers),
      });
    }
    // notFound with a mount: the mount owned the path but nothing matched,
    // so serve that app's 404 page re-wrapped with a real 404 status — never
    // a redirect, and no canonical Link header (it's not a route). The fresh
    // GET drops conditional headers, so the asset can't 304 into an empty
    // body; anything but the page itself falls through to default handling.
    if (verdict.mount) {
      const page = await env.ASSETS.fetch(
        new URL(`${verdict.mount}/404.html`, request.url),
      );
      if (page.status === 200) {
        return new Response(page.body, {
          status: 404,
          headers: noindexed(verdict.mount, new Headers(page.headers)),
        });
      }
    }
    // notFound: fall through to the assets binding's 404.html handling.
    return env.ASSETS.fetch(request);
  },
} satisfies ExportedHandler<Env>;

The flagship path through the handler is still just the verdict switch: resolve, then shell / redirect / notFound become HTTP. Everything else is the spectrum living in one worker — the level-2 exhibit bypasses resolve() on purpose (it demonstrates the absence of server routing), SHELL_PATHS aliases the vanilla shell under the exhibit prefixes, and noindexed quarantines the exhibits. The worker's own HTTP contributions: the canonical Link header keeps crawlers from indexing every deep link as a duplicate of the shell (status-less shells only — a 404 is not an alternate representation of anything), and forwarding the original request preserves conditional headers so revalidations still 304. 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. Exhibits include their bare paths — no
    // asset exists there, and the whole prefix is the demo.
    "run_worker_first": [
      "/app/*",
      "/app-mobx/*",
      "/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. The flagship patterns deliberately exclude the bare /app: every docs link targets it, and routing it through the worker would make each one pay the root rule's 302. The exhibits include their bare paths because no asset exists there — the whole prefix is the demo.

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 simulate-tier territory, and the sample apps deliberately keep those decisions client-side instead.

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. The sample apps switch location strategies at runtime, so their hash-mode entry point is the bare /app — outside run_worker_first — and the root rule only ever sees path-mode entries.

Shell-at-404: the otherwise projection

The /not-found-spa exhibit is one config line: MountConfig.otherwise projects the client's otherwise() rule (routes.ts):

ts
// The not-found-spa exhibit: the otherwise projection (mirroring the client's
// otherwise() -> notFound rule) makes every path under this mount serve the
// app shell at an honest 404 — the client boots at the retained url and
// renders the rich in-app notFound state. It deliberately carries NO
// url-bearing routes: the shell bakes <base href="/app/">, so the client
// router cannot match deep links under this prefix — a shell-200 here would
// be exactly the soft-404 shape the flagship mounts avoid.
const notFoundSpaDemo: MountConfig = {
  routes: [{ name: 'notFound' }],
  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). Deep links serve shell-200 here, but the shell's
// baked <base href="/app/"> means the client renders its in-app notFound
// under this prefix — the exhibit teaches SERVER semantics; noindex (worker)
// quarantines it from crawlers.
const simulatedRoutingDemo: MountConfig = {
  routes,
  redirects,
  strategy: 'simulate',
  otherwise: { state: 'notFound' },
};

/**
 * 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).
 */
export const mounts: Record<string, MountConfig> = {
  '/app': app,
  '/app-mobx': app,
  '/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.