Skip to content

Multi-Router Integration

How to integrate XMachines Play with the 8 router adapters shipped in this monorepo.

Overview

XMachines provides router adapters for every major JavaScript framework. All adapters implement the same Actor Authority invariant — the state machine guards control navigation, and the router passively observes actor.currentRoute.

There are two integration patterns:

PatternUsed byKey API
Provider patternReact (TanStack, React Router), SolidJS (TanStack, SolidJS Router), Vue Router<PlayRouterProvider actor router routeMap renderer={...} />
connectRouter patternVanilla DOM, SvelteKit, Svelte SPA RouterconnectRouter({ actor, routeMap, ...adapter })

Either pattern can share its router with routes the host owns — see Sharing a Router with the Host.

Pattern 1: Provider Pattern

Used by framework adapters that have a React/Solid/Vue provider context. The PlayRouterProvider owns the bridge lifecycle and calls a renderer prop with the current actor and router.

React + TanStack Router (@xmachines/play-tanstack-react-router)

import { useEffect, useMemo } from "react";
import { createRouter, createRootRoute } from "@tanstack/react-router";
import { PlayRouterProvider, createRouteMapFromTree } from "@xmachines/play-tanstack-react-router";
import { extractMachineRoutes } from "@xmachines/play-router";
import { definePlayer } from "@xmachines/play-xstate";
import { defineRegistry } from "@xmachines/play-react";
import { authMachine, authCatalog } from "@xmachines/play-actor-shared";
const createPlayer = definePlayer({ machine: authMachine });
const { registry } = defineRegistry(authCatalog, {
components: { /* ...your components */ },
actions: { login: async () => {}, logout: async () => {}, /* ... */ },
});
function createAppRuntime() {
const actor = createPlayer();
actor.start();
const routeTree = extractMachineRoutes(authMachine);
const routeMap = createRouteMapFromTree(routeTree);
const router = createRouter({ routeTree: createRootRoute(), defaultPreload: "intent" });
return { actor, routeMap, router };
}
export function App() {
const { actor, routeMap, router } = useMemo(createAppRuntime, []);
useEffect(() => () => { actor.stop(); }, [actor]);
return (
<PlayRouterProvider
actor={actor}
router={router}
routeMap={routeMap}
renderer={(currentActor, currentRouter) => (
<Shell actor={currentActor} router={currentRouter} registry={registry} />
)}
/>
);
}

React + React Router v7 (@xmachines/play-react-router)

import { createBrowserRouter, RouterProvider } from "react-router";
import { PlayRouterProvider, createRouteMapFromTree } from "@xmachines/play-react-router";
import { extractMachineRoutes } from "@xmachines/play-router";
import { definePlayer } from "@xmachines/play-xstate";
const createPlayer = definePlayer({ machine: authMachine });
function createAppRuntime() {
const actor = createPlayer();
actor.start();
const routeTree = extractMachineRoutes(authMachine);
const routeMap = createRouteMapFromTree(routeTree);
// React Router requires route elements upfront — use a catch-all
let router!: ReturnType<typeof createBrowserRouter>;
function RoutedShell() {
return <Shell actor={actor} router={router} registry={registry} />;
}
router = createBrowserRouter([{ path: "*", element: <RoutedShell /> }]);
return { actor, routeMap, router };
}
export default function App() {
const { actor, routeMap, router } = useMemo(createAppRuntime, []);
useEffect(() => () => { actor.stop(); }, [actor]);
return (
<PlayRouterProvider
actor={actor}
router={router}
routeMap={routeMap}
renderer={(currentActor, currentRouter) => (
<RouterProvider router={currentRouter} />
)}
/>
);
}

SolidJS + SolidJS Router (@xmachines/play-solid-router)

import { Router, Route } from "@solidjs/router";
import { onCleanup } from "solid-js";
import { PlayRouterProvider, createRouteMap } from "@xmachines/play-solid-router";
import { extractMachineRoutes, getRoutableRoutes } from "@xmachines/play-router";
import { definePlayer } from "@xmachines/play-xstate";
const createPlayer = definePlayer({ machine: authMachine });
const actor = createPlayer();
actor.start();
const routeTree = extractMachineRoutes(authMachine);
const routes = getRoutableRoutes(routeTree);
const routeMap = createRouteMap(authMachine);
// Layout rendered inside Router — has access to navigate/location/params hooks
const Layout = () => {
const navigate = useNavigate();
const location = useLocation();
const params = useParams();
onCleanup(() => { actor.stop(); });
return (
<PlayRouterProvider
actor={actor}
routeMap={routeMap}
router={{ navigate, location, params }}
renderer={(currentActor, currentRouter) => (
<Shell actor={currentActor} router={currentRouter} registry={registry} />
)}
/>
);
};
export default function App() {
return (
<Router root={Layout}>
{routes.map((route) => (
<Route path={route.fullPath} component={() => <div />} />
))}
</Router>
);
}

SolidJS + TanStack Solid Router (@xmachines/play-tanstack-solid-router)

import { createRouter, createRootRoute, createRoute, RouterProvider } from "@tanstack/solid-router";
import { onCleanup } from "solid-js";
import { PlayRouterProvider, createRouteMap } from "@xmachines/play-tanstack-solid-router";
import { extractMachineRoutes, getRoutableRoutes } from "@xmachines/play-router";
import { definePlayer } from "@xmachines/play-xstate";
const createPlayer = definePlayer({ machine: authMachine });
const actor = createPlayer();
actor.start();
const routeTree = extractMachineRoutes(authMachine);
const routes = getRoutableRoutes(routeTree);
const routeMap = createRouteMap(authMachine);
const rootRoute = createRootRoute({ component: Layout });
// Convert XMachines :param to TanStack $param format
const tanstackRoutes = routes.map((route) => {
const tanstackPath = route.fullPath.replace(/:(\w+)/g, "$$$1");
return createRoute({ getParentRoute: () => rootRoute, path: tanstackPath, component: () => <div /> });
});
const router = createRouter({ routeTree: rootRoute.addChildren(tanstackRoutes) });
function Layout() {
onCleanup(() => { actor.stop(); });
return (
<PlayRouterProvider
actor={actor}
router={router}
routeMap={routeMap}
renderer={(currentActor, currentRouter) => (
<Shell actor={currentActor} router={currentRouter} registry={registry} />
)}
/>
);
}
export default function App() {
return <RouterProvider router={router} />;
}

Vue + Vue Router (@xmachines/play-vue-router)

App.vue
<template>
<PlayRouterProvider
:actor="actor"
:route-map="routeMap"
:router="router"
:renderer="renderShell"
/>
</template>
<script setup lang="ts">
import { h, inject } from "vue";
import { createRouter, createWebHistory } from "vue-router";
import { PlayRouterProvider, createRouteMap } from "@xmachines/play-vue-router";
import { defineRegistry } from "@xmachines/play-vue";
import { authMachine, authCatalog } from "@xmachines/play-actor-shared";
const actor = inject("actor");
const routeMap = createRouteMap(authMachine);
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: "/:pathMatch(.*)*",
name: "xmachines-play",
component: { render: () => h("div") },
},
],
});
const { registry } = defineRegistry(authCatalog, {
components: {/* ...your Vue SFC components */},
actions: { login: async () => {}, logout: async () => {} /* ... */ },
});
const renderShell = (currentActor, currentRouter) =>
h(Shell, { actor: currentActor, router: currentRouter, registry });
</script>

Pattern 2: connectRouter Pattern

Used by vanilla DOM and Svelte adapters. Call connectRouter directly after creating the actor — no provider component needed.

Vanilla DOM (@xmachines/play-dom-router)

import {
createBrowserHistory,
createRouter,
connectRouter,
createRouteMap,
} from "@xmachines/play-dom-router";
import { extractMachineRoutes } from "@xmachines/play-router";
import { definePlayer } from "@xmachines/play-xstate";
import { createPlayUI, defineRegistry } from "@xmachines/play-dom";
import { authMachine, authCatalog } from "@xmachines/play-actor-shared";
const createPlayer = definePlayer({ machine: authMachine });
const actor = createPlayer();
actor.start();
// Router setup
const routeTree = extractMachineRoutes(authMachine);
const routeMap = createRouteMap(authMachine);
const history = createBrowserHistory({ window });
const router = createRouter({ routeTree, history });
// connectRouter handles bidirectional sync:
// - actor.currentRoute changes → browser URL updated
// - browser URL changes → play.route event sent to actor
const disconnect = connectRouter({ actor, router, routeMap });
// Renderer setup
const registryResult = defineRegistry(authCatalog, {
components: {/* ...your DOM components */},
});
const mount = createPlayUI(registryResult);
const disconnectRenderer = mount(actor, document.getElementById("app")!);
// Cleanup
window.addEventListener("beforeunload", () => {
disconnect();
router.destroy();
disconnectRenderer();
actor.stop();
});

SvelteKit (@xmachines/play-sveltekit-router)

lib/router.ts
import { defineRegistry } from "@xmachines/play-svelte";
import { authCatalog, authMachine } from "@xmachines/play-actor-shared";
import { definePlayer } from "@xmachines/play-xstate";
import { connectRouter, createRouteMap } from "@xmachines/play-sveltekit-router";
const createDemoPlayer = definePlayer({ machine: authMachine });
const { registry } = defineRegistry(authCatalog, {
components: {/* ...your Svelte components */},
actions: { login: async () => {}, logout: async () => {} /* ... */ },
});
export const actor = createDemoPlayer();
actor.start();
export const routeMap = createRouteMap(authMachine);
export const disconnectRouter = connectRouter({ actor, routeMap });
App.svelte
<script lang="ts">
import Shell from "./Shell.svelte";
import { actor, registry } from "./lib/router.js";
</script>
<Shell {actor} {registry} />

Svelte SPA Router (@xmachines/play-svelte-spa-router)

// lib/router.ts — identical pattern to SvelteKit, different import
import { connectRouter, createRouteMap } from "@xmachines/play-svelte-spa-router";
import { definePlayer } from "@xmachines/play-xstate";
import { authMachine } from "@xmachines/play-actor-shared";
const createDemoPlayer = definePlayer({ machine: authMachine });
export const actor = createDemoPlayer();
actor.start();
export const routeMap = createRouteMap(authMachine);
export const disconnectRouter = connectRouter({ actor, routeMap });

Sharing a Router with the Host (basePath)

Both patterns give the machine the complete URL space of its router by default. Pass a basePath and the machine owns a prefix only — the host keeps every other route in the same router.

The shape that motivates this is a URL such as /:machineId/play/dashboard. The host resolves :machineId — a TanStack loader, a useParams() call, a SvelteKit load function — and owns that prefix. The meta.route tree of the machine owns everything below it.

Under the Provider Pattern

basePath and basePathParams are reactive props. They need no stable reference, unlike actor, router, and routeMap, and a change moves the live bridge instead of rebuilding it:

function PlayRoute() {
const { machineId } = useLoaderData({ from: "/$machineId/play/$" });
return (
<PlayRouterProvider
actor={actor}
router={router}
routeMap={routeMap} // unchanged — the route map stays machine-relative
basePath="/:machineId/play"
basePathParams={{ machineId }} // the loader resolved this already
renderer={(currentActor) => <Shell actor={currentActor} registry={registry} />}
/>
);
}
// The "/dashboard" of the machine is now the URL "/abc123/play/dashboard".
// The "/" of the machine is now the URL "/abc123/play".

Under connectRouter

basePath and basePathParams join the options that the adapter needs already. The vanilla DOM adapter needs router, as it does in the example above:

const disconnect = connectRouter({
actor,
router, // required: @xmachines/play-dom-router reads its history
routeMap, // unchanged — the route map stays machine-relative
basePath: "/:machineId/play",
basePathParams: { machineId }, // the loader resolved this already
});

The Svelte adapters take the same two prefix options. They need no router: @xmachines/play-svelte-spa-router reads an optional window, and @xmachines/play-sveltekit-router reads an optional location.

What the Prefix Changes

A location outside the prefix belongs to the host. The bridge sends no play.route event there, it runs no corrective navigation there, and it writes no route of its actor there. That silence is what lets the two halves share one router: without it, the unknown-path correction would drag the user off every page the host owns, and so would any route change of the actor that no URL event caused — an after timer, an async guard that settles, a restore of a snapshot. The bridge remembers such a move and writes it when the location comes back under the prefix, so the machine keeps its place.

Location of the routerbasePath = "/abc123/play"
/abc123/playthe / of the machine → play.route
/abc123/play/aboutthe /about of the machine → play.route
/abc123/play/nopeunknown inside the mount → the URL follows the actor
/account/billinga route of the host → the bridge does nothing
/abc123/playgroundanother segment → the host’s, the bridge does nothing

The prefix resolves in advance, because the bridge writes a real browser URL. It rejects every shape that resolves to one concrete path never — a * wildcard, a :param?, a $param (the TanStack spelling; write :param), a query string, and a hash. A base path is a pathname, so a URL scheme is rejected too: write /admin, not https://app.example.com/admin. Every segment that a browser rewrites is rejected on the same rule, the resolved value of a :param included — a dot segment, a backslash, whitespace, and a character that a URL percent-encodes, such as the é of /café — because each one makes the prefix that comes back differ from the prefix that went out, and the machine would go silent with no error. An unresolved :param throws MissingBasePathParamError at construction, rather than falling back to “no prefix yet”: a bridge with no prefix would claim the complete router and start correcting the URLs of the host.

The machine stays authoritative over its own params (INV-01). event.params carries what the pattern of the machine declares and nothing else. The params of the prefix belong to the host, so they travel in no play.route event, and a host param that shares a name with one of the machine can shadow it never. Read the resolved mount from the bridge instead, where it cannot go stale:

bridge.basePath; // "/abc123/play"
bridge.basePathParams; // { machineId: "abc123" }

A machine that needs the identity of its host takes it through the actor input, where that identity decides which machine runs.

Several Machines Alive at Once

Several machines can be alive together, each mounted at its own prefix, all sharing one router. Every bridge hears every location change, and a location under another machine’s prefix is foreign in exactly the way a route of the host is — so a play.route reaches the machine that owns the URL and no other, and a correction of an unknown path happens only inside the prefix that owns it.

Each bridge can stay connected, as long as each one has its own prefix. A bridge outside its mount keeps the silence in both directions: it sends no play.route, it corrects no URL, and it writes no route of its actor. The machine that the location belongs to is therefore the only one that writes, and an actor that nobody is looking at cannot take the URL from the one on screen. A route that the hidden machine moved to is not lost either: the bridge remembers it, and it writes it when the host navigates back under its prefix.

Two bridges contend when neither prefix separates their halves of the URL. That is the case for two bridges with no prefix, for two with the same prefix, and also for two whose prefixes NEST: a mount at /a and a mount at /a/b both claim /a/b/x, because /a is a prefix of it. Give sibling prefixes — /a/one and /a/two — and no location belongs to two machines. Mount the provider for the machine the host is showing, and let the others keep their state with no bridge attached.

Loading and Unloading Routes

setBasePath() moves where an actor is mounted, and never which actor is mounted. The actor, the route map, and its LRU cache all stay:

// The host moved this actor from one region of its URL space to another.
bridge.setBasePath("/:region/:machineId/play", { region: "us", machineId });

The call also brings the location in step. Nothing else moves the address bar, so a move to a prefix that the URL is not under writes the new mount and keeps the route of the actor: /eu/abc123/play/about becomes /us/abc123/play/about. A location that already lies under the new prefix drives the actor instead, exactly as it does on connect().

An actor never changes identity, so a segment of the prefix that identifies the actor never moves through this method. A new identity is a new actor, and a new actor takes a new bridge, because connect() permits one bridge for each actor. The segments that move here are the ones that locate: a region, a locale, a tenant, a workspace slug.

For a host router that declares real route objects, ask for the list — and drop it again when the machine unloads:

import { extractMachineRoutes, getRouteMappings } from "@xmachines/play-router";
const tree = extractMachineRoutes(authMachine);
// Concrete, for a route that the host adds after a loader resolved the mount
getRouteMappings(tree, { basePath: "/:machineId/play", basePathParams: { machineId } });
// [{ stateId: "home", path: "/abc123/play" },
// { stateId: "profile", path: "/abc123/play/profile/:userId" }, ...]
// The pattern, for a static route declaration of the host
getRouteMappings(tree, { basePath: "/:machineId/play" });
// [{ stateId: "home", path: "/:machineId/play" }, ...]

Polyfill note. Under a mount, @xmachines/play-vue-router and @xmachines/play-solid-router resolve each param from the stripped path with URLPattern, and not from the pre-parsed params of their framework: under a prefix the framework matched a route of the host, so those params describe the route of the machine never — and a collision of names carries the value of the host. Both adapters therefore need a URLPattern polyfill on an older runtime when they are mounted. With no prefix, both keep the parse of their framework whenever it reports at least one param the pattern declares, and every required one. A pattern whose params are ALL optional and a framework that reports none — /settings/:section? under a catch-all of the host — is settled by the PATH: /settings is the bare form of that pattern, so every optional segment is absent and the params are {}. A path that DOES carry a value, such as /settings/security, still reaches the extraction, because only the extraction reads that value.

These branches decide the CALLS of a navigation, and not whether the application needs the polyfill. RouteMap compiles each parameterized route in its constructor and throws there without URLPattern, so one :param in the route map makes the polyfill a startup requirement on an older runtime.

Adapter Summary

PackageFrameworkPatternKey Import
@xmachines/play-dom-routerVanilla DOMconnectRouterconnectRouter, createRouteMap, createBrowserHistory, createRouter
@xmachines/play-react-routerReact Router v7ProviderPlayRouterProvider, createRouteMapFromTree
@xmachines/play-tanstack-react-routerTanStack React RouterProviderPlayRouterProvider, createRouteMapFromTree
@xmachines/play-solid-routerSolidJS RouterProviderPlayRouterProvider, createRouteMap
@xmachines/play-tanstack-solid-routerTanStack Solid RouterProviderPlayRouterProvider, createRouteMap
@xmachines/play-vue-routerVue RouterProviderPlayRouterProvider, createRouteMap
@xmachines/play-sveltekit-routerSvelteKitconnectRouterconnectRouter, createRouteMap
@xmachines/play-svelte-spa-routerSvelte SPA RouterconnectRouterconnectRouter, createRouteMap

Renderer Packages

Each framework also has a companion renderer package for the view layer:

PackageFrameworkKey API
@xmachines/play-domVanilla DOMcreatePlayUI, createRenderer, defineRegistry
@xmachines/play-reactReactPlayRenderer, defineRegistry
@xmachines/play-solidSolidJSPlayRenderer, defineRegistry
@xmachines/play-vueVue 3PlayRenderer, defineRegistry
@xmachines/play-svelteSvelte 5PlayRenderer, defineRegistry

Architectural Invariants

All adapters preserve the 5 architectural invariants:

  1. Actor Authority (INV-01): Machine guards validate all navigation
  2. Strict Separation (INV-02): Business logic (machine) has zero framework imports
  3. State-Driven Reset (INV-03): Browser back/forward sends play.route events to actor
  4. Passive Infrastructure (INV-04): Router observes actor, never decides
  5. Signal-Only Reactivity (INV-05): TC39 Signals work identically in all adapters

Next Steps

Learn More