Skip to content

Routing Patterns

How the authMachine uses meta.route, play.route events, formatPlayRouteTransitions, and always guards to implement actor-authoritative URL routing.

Overview

XMachines inverts the usual routing model. The actor owns navigation — its guards decide which states are valid. The router is passive infrastructure that observes actor.currentRoute and keeps the browser URL in sync. This is Actor Authority (INV-01).

Every routing interaction starts with a play.route event sent to the actor. If the machine’s guards allow the transition, the state changes and actor.currentRoute updates. The router bridge sees the new signal value and pushes a history entry. The URL never changes unless the actor approves.

Key Concepts

meta.route — Marking States as Routable

Add a meta.route URL template to any state to make it routable:

states: {
home: {
id: "home", // required: used as the play.route target ("#home")
meta: {
route: "/", // absolute URL path
},
},
dashboard: {
id: "dashboard",
meta: {
route: "/dashboard", // absolute URL path
},
states: {
overview: {
id: "dashboard-overview",
meta: {
route: "overview", // RELATIVE — resolves to /dashboard/overview
},
},
},
},
profile: {
id: "profile",
meta: {
route: "/profile/:username", // required parameter
},
},
settings: {
id: "settings",
meta: {
route: "/settings/:section?", // optional parameter
},
},
}

Rules:

  • Absolute paths start with /.
  • Relative paths (no leading /) are resolved against the parent state’s route.
  • Parameter segments use :param (required) and :param? (optional).
  • Every routable state must have an idformatPlayRouteTransitions throws MissingStateIdError otherwise.

formatPlayRouteTransitions — Auto-Generating Route Handlers

Instead of hand-writing play.route event handlers for every routable state, wrap your machine config with formatPlayRouteTransitions:

import { setup } from "xstate";
import { definePlayer, formatPlayRouteTransitions } from "@xmachines/play-xstate";
import type { PlayRouteEvent } from "@xmachines/play-router";
interface AuthContext {
isAuthenticated: boolean;
username: string | null;
params: Record<string, string>; // required for formatPlayRouteTransitions
query: Record<string, string>; // required for formatPlayRouteTransitions
}
const authSetup = setup({
types: {
context: {} as AuthContext,
events: {} as
| PlayRouteEvent
| { type: "auth.login"; username: string }
| { type: "auth.logout" },
input: {} as Partial<AuthContext> | undefined,
},
});
const authMachine = authSetup.createMachine(
formatPlayRouteTransitions({
id: "auth",
initial: "home",
context: ({ input }) => ({
isAuthenticated: input?.isAuthenticated ?? false,
username: input?.username ?? null,
params: input?.params ?? {},
query: input?.query ?? {},
}),
states: {
home: { id: "home", meta: { route: "/" } },
about: { id: "about", meta: { route: "/about" } },
login: { id: "login", meta: { route: "/login" } },
profile: { id: "profile", meta: { route: "/profile/:username" } },
},
}),
);

formatPlayRouteTransitions generates root-level handlers equivalent to:

on: {
"play.route": [
{ target: ".home", guard: ({ event }) => event.to === "#home", reenter: true, actions: assign({ params, query }) },
{ target: ".about", guard: ({ event }) => event.to === "#about", reenter: true, actions: assign({ params, query }) },
{ target: ".login", guard: ({ event }) => event.to === "#login", reenter: true, actions: assign({ params, query }) },
{ target: ".profile", guard: ({ event }) => event.to === "#profile", reenter: true, actions: assign({ params, query }) },
],
}

play.route Events — Navigation

To navigate, send a play.route event with to: "#stateId":

// Navigate to home
actor.send({ type: "play.route", to: "#home" });
// Navigate to profile — params are stored in context and used to resolve the URL
actor.send({
type: "play.route",
to: "#profile",
params: { username: "alice" },
});
// actor.currentRoute.get() → "/profile/alice"
// Navigate with query string
actor.send({
type: "play.route",
to: "#settings",
params: { section: "privacy" },
query: { tab: "advanced" },
});

to always uses "#stateId" format — the state’s id field prefixed with #. Do not pass URL paths here.

always Guards — Protected Routes

Use XState always transitions to protect states. If the guard fires, the machine redirects before the state is fully entered:

dashboard: {
id: "dashboard",
meta: { route: "/dashboard" },
always: {
// If not authenticated, redirect to login immediately
guard: ({ context }) => !context.isAuthenticated,
target: "login",
},
},
profile: {
id: "profile",
meta: { route: "/profile/:username" },
always: {
guard: ({ context }) => !context.isAuthenticated,
target: "login",
},
},

Why always and not event guards? Guards on events check “can I TAKE this transition?”. always guards check “can I BE in this state?” — the correct invariant for authentication. The actor enforces the guard even on direct URL access (browser back/forward or deep link), because the router sends a play.route event which triggers the always guard.

Root-Level Event Handlers

Domain events placed at the root on: level are handled from any state:

const authMachine = authSetup.createMachine(
formatPlayRouteTransitions({
// ...
on: {
"auth.login": {
target: ".dashboard",
guard: ({ context }) => !context.isAuthenticated,
actions: authSetup.assign({
isAuthenticated: true,
username: ({ event }) => (event.type === "auth.login" ? event.username : null),
}),
},
"auth.logout": {
target: ".home",
guard: ({ context }) => context.isAuthenticated,
actions: authSetup.assign({
isAuthenticated: false,
username: null,
}),
},
},
states: {
/* ... */
},
}),
);

Complete Actor Usage

const createPlayer = definePlayer({ machine: authMachine });
const actor = createPlayer();
actor.start();
// Initial state
console.log(actor.currentRoute.get()); // "/"
// Navigate to about
actor.send({ type: "play.route", to: "#about" });
console.log(actor.currentRoute.get()); // "/about"
// Attempt protected route (redirect fires because isAuthenticated=false)
actor.send({ type: "play.route", to: "#dashboard" });
console.log(actor.getSnapshot().value); // "login" — guard redirected
// Login first
actor.send({ type: "auth.login", username: "alice" });
// auth.login root handler transitions to dashboard
console.log(actor.getSnapshot().value); // "dashboard"
console.log(actor.getSnapshot().context.username); // "alice"
// Navigate to profile with params
actor.send({ type: "play.route", to: "#profile", params: { username: "alice" } });
console.log(actor.currentRoute.get()); // "/profile/alice"
actor.stop();

Vanilla Router Setup

To sync the browser URL with actor.currentRoute, use @xmachines/play-dom-router:

import {
createBrowserHistory,
createRouter,
connectRouter,
createRouteMap,
} from "@xmachines/play-dom-router";
// createRouteMap extracts meta.route declarations from the machine
const routeMap = createRouteMap(authMachine);
const history = createBrowserHistory({ window });
const router = createRouter({ routeTree: authMachine, history });
// connectRouter handles all bidirectional sync:
// - actor.currentRoute signal → browser URL (history.push)
// - browser URL changes → play.route event sent to actor
const disconnect = connectRouter({ actor, router, routeMap });
// Cleanup on unload
window.addEventListener("beforeunload", () => {
disconnect();
router.destroy();
});

React Router Setup

For React, use @xmachines/play-react-router or @xmachines/play-tanstack-react-router:

import { PlayRouterProvider, createRouteMapFromTree } from "@xmachines/play-tanstack-react-router";
import { extractMachineRoutes } from "@xmachines/play-router";
const routeTree = extractMachineRoutes(authMachine);
const routeMap = createRouteMapFromTree(routeTree);
function App() {
return <PlayRouterProvider actor={actor} router={router} routeMap={routeMap} />;
}

Architectural Invariants

InvariantDescription
Actor Authority (INV-01)Guards on the machine validate every navigation. The router cannot change state directly.
Passive Infrastructure (INV-04)The router observes actor.currentRoute — it never decides where to go.
State-Driven Reset (INV-03)Browser back/forward sends play.route events to the actor. History is driven by actor state.
Strict Separation (INV-02)The machine has zero framework imports. Guards, actions, and context are pure TypeScript.

Next Steps

Learn More