Routing
Build routing with a source that owns navigation behavior.
Keep route matching in a pure machine.
The application connects that machine to browser navigation and passes the started source to Ignite.
The runnable examples live in:
Both examples require browser Navigation API support (Baseline 2026).
No History API fallback is provided.
1. Routes are plain data
Section titled “1. Routes are plain data”export type RouteName = "home" | "users" | "user" | "login" | "dashboard" | "not-found";
export const routes = [ { name: "home", path: "/" }, { name: "users", path: "/users" }, { name: "user", path: "/users/:id" }, { name: "login", path: "/login" }, { name: "dashboard", path: "/dashboard", requiresAuth: true },] as const;2. Keep matching and route resolution pure
Section titled “2. Keep matching and route resolution pure”The following modules are example-local, not Ignite package exports.
Place these snippets alongside the runnable SPA example’s src modules.
Import its real matcher and route table instead of leaving helper names undefined.
import { matchRoute } from './matchRoute';import { LOGIN_PATH, routes } from './routes';import type { RouteName } from './routes';
const requiresAuth = (name: RouteName): boolean => routes.find((route) => route.name === name)?.requiresAuth ?? false;
export const resolveNavigation = (toPath: string, authed: boolean) => { const match = matchRoute(toPath); if (requiresAuth(match.name) && !authed) { const login = matchRoute(LOGIN_PATH); return { path: login.path, route: login.name, params: login.params, redirected: true }; }
return { path: match.path, route: match.name, params: match.params, redirected: false, };};The machine should not import window, navigation, History, or Ignite.
3. Define a tiny example-local navigation boundary
Section titled “3. Define a tiny example-local navigation boundary”type NavigationPort = { currentPath(): string; observe(listener: (path: string) => void): () => void; commit(path: string, history: "push" | "replace"): Promise<void>;};The examples implement that port twice:
createBrowserNavigation(navigation)with the browser’s Navigation API objectcreateMemoryNavigation("/")for deterministic headless tests
The browser adapter filters non-interceptable, cross-origin, hash-only, and download navigations, suppresses self-originated commits, and uses navigation.navigate(...) for accepted writes.
4. Compose the exact source in application code
Section titled “4. Compose the exact source in application code”Pass the browser’s navigation object from the application bootstrap.
The example derives the parameter type from its navigation adapter.
This works even when the TypeScript DOM library has no window.navigation declaration.
It does not add runtime browser support.
The host must check Navigation API support before calling this function.
import { createBrowserNavigation } from './navigation';import { createRouterSource } from './routerSource';
export function startBrowserRouter( navigation: Parameters<typeof createBrowserNavigation>[0],) { return createRouterSource({ navigation: createBrowserNavigation(navigation) });}createRouterSource(...) is example-owned composition, not an Ignite router abstraction.
It binds:
- source-owned observation through
fromCallback(...) - accepted post-decision navigation commits
- explicit failure facts when a commit rejects
The same factory works headlessly:
import { createMemoryNavigation } from './navigation';import { createRouterSource } from './routerSource';
const testSource = createRouterSource({ navigation: createMemoryNavigation("/"),});5. Ignite only projects and sends intent
Section titled “5. Ignite only projects and sends intent”import { igniteCore } from 'ignite-element/xstate';import { createRouterSource } from './routerSource';
export function createOutlet(routerSource: ReturnType<typeof createRouterSource>) { const outlet = igniteCore({ source: routerSource, states: (snapshot) => ({ route: snapshot.context.route, path: snapshot.context.path, }), commands: ({ source: actor }) => ({ navigate: (to: string) => actor.send({ type: "NAVIGATE_REQUESTED", to }), }), }); return outlet;}The router component does not write browser state in commands or effects.
That work belongs to the application-owned source.
6. Headless tests use the same source factory
Section titled “6. Headless tests use the same source factory”import assert from 'node:assert/strict';import { igniteCore } from 'ignite-element/xstate';import { createMemoryNavigation } from './navigation';import { createRouterSource } from './routerSource';
const source = createRouterSource({ navigation: createMemoryNavigation('/') });const router = igniteCore({ source, states: (snapshot) => ({ id: snapshot.context.params.id ?? null, path: snapshot.context.path, }), commands: ({ source: actor }) => ({ navigate: (to: string) => actor.send({ type: 'NAVIGATE_REQUESTED', to }), }),});
try { await router.execute({ command: 'navigate', input: '/users/7' }); assert.equal(router.get('states').id, '7');} finally { try { router.dispose(); } finally { source.stop(); // The test owns the started source and its navigation observer. }}This keeps the routing proof aligned across:
- pure machine tests
- source-owned commit/cleanup tests
- Ignite headless runtime tests
7. Parent/child composition uses the same ownership rule
Section titled “7. Parent/child composition uses the same ownership rule”The nested-child example keeps one shared routerSource and exposes scoped commands from different elements:
- parent links send
NAVIGATE_REQUESTED - docs tabs send
OPEN_DOC_SECTION - settings tabs send
OPEN_SETTINGS_PANEL
Those commands still express intent only.
The source decides the accepted path and commits it afterward.
Styling
Section titled “Styling”The examples load styles.css?raw into each component’s shadow root.
See Styling for local CSS and theming.