Skip to content

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.

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 object
  • createMemoryNavigation("/") 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("/"),
});
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.

The examples load styles.css?raw into each component’s shadow root.

See Styling for local CSS and theming.