Skip to main content

Routing

The moon-spa router is configured entirely in spa.config.json — no code needed. It supports nested routes, dynamic parameters, index routes, wildcard fallbacks, and route guards.


Basic setup

Add a "router" key to spa.config.json:

{
"page_title": "My App",
"server": { "host": "127.0.0.1", "port": 8000 },
"router": {
"routes": [
{ "path": "/", "component": "Home" },
{ "path": "/about", "component": "About" },
{ "path": "/blog", "component": "Blog" }
]
}
}

When router is enabled, route rendering is driven by router.routes.

Router contract (standard)

  • router.routes: defines which components are matched and rendered.
  • with "initial_path": "/", rendering starts from the route that matches "/".
  • if you want a layout shell, declare it as a route component and nest children under it.

Recommended route shape for layout apps:

{
"router": {
"routes": [
{
"path": "/",
"component": "Layout",
"children": [
{ "index": true, "component": "Home" },
{ "path": "about", "component": "About" }
]
}
]
}
}

Route parameters

Prefix a path segment with : to capture it as a prop:

{ "path": "/users/:id", "component": "UserDetail" },
{ "path": "/posts/:year/:month", "component": "PostArchive" }

The captured values are passed to the component as props and available in the template:

<python>
class UserDetail(Component):
def setup(self, props):
return {
"props": props,
"state": {},
"data": {"user_id": props.get("id", "unknown")},
"actions": {},
"lifecycle": {},
}
</python>

<template>
<h1>User {{ py.user_id }}</h1>
</template>

Multiple params in one path:

<python>
class PostArchive(Component):
def setup(self, props):
data = {
"year": props.get("year"),
"month": props.get("month"),
}

return {
"props": props,
"state": {},
"data": data,
"actions": {},
"lifecycle": {},
}
</python>

<template>
<h2>Posts from {{ py.month }}/{{ py.year }}</h2>
</template>

Nested routes

Use "children" to nest routes inside path groups:

{
"router": {
"routes": [
{
"path": "/",
"children": [
{ "index": true, "component": "Home" },
{ "path": "about", "component": "About" },
{ "path": "users", "component": "UserList" },
{ "path": "users/:id", "component": "UserDetail" }
]
}
]
}
}

The router resolves the full stack — parent first, then child — and renders them as nested component tags for the routed outlet:

<!-- resolved for /users/42 -->
<UserLayout>
<UserDetail __props="...id=42..." />
</UserLayout>

Resolution flow

How children is implemented in moon-spa

Internally, nested routing happens in three stages:

  1. Route tree normalization: The router reads children recursively from router.routes and builds a tree of route nodes.
  2. Recursive match: Router.resolve(path) matches the parent route first, then keeps matching against children with remaining path segments.
  3. Cascaded render: Router.render(match) creates nested tags from child to parent, so parent layouts wrap child pages.

For /users/42, the routed stack is equivalent to:

<UserLayout __props="...routeParams...">
<UserDetail __props="...routeParams.id=42..." />
</UserLayout>

The final output is the rendered route stack. If the parent route is a layout component, child content is passed to that layout as children.

Practical example (router + .lspa)

spa.config.json:

{
"page_title": "Nested Users",
"router": {
"initial_path": "/users/42",
"routes": [
{
"path": "/",
"component": "AppLayout",
"children": [
{ "index": true, "component": "HomePage" },
{ "path": "users", "component": "UserListPage" },
{ "path": "users/:id", "component": "UserDetailPage" }
]
}
]
}
}

AppLayout.lspa:

@import Nav from './Nav/Nav.lspa'

<python>
class AppLayout(Component):
pass
</python>

<template>
<div class="layout">
<Nav />
<main class="layout__content">
{{ children }}
</main>
</div>
</template>

UserDetailPage.lspa:

<python>
class UserDetailPage(Component):
def setup(self, props):
params = props.get("routeParams", {})
return {
"props": props,
"state": {},
"data": {"user_id": params.get("id", "unknown")},
"actions": {},
"lifecycle": {},
}
</python>

<template>
<article>
<h1>User {{ py.user_id }}</h1>
</article>
</template>

With "initial_path": "/users/42", the rendered structure is:

<AppLayout>
<HomePage /><!-- for "/" -->
<!-- or -->
<UserDetailPage /><!-- for "/users/42" -->
</AppLayout>

Index routes

An "index": true route matches when the parent path is matched exactly (no extra segments):

{
"path": "/dashboard",
"component": "Dashboard",
"children": [
{ "index": true, "component": "DashboardHome" },
{ "path": "settings", "component": "Settings" },
{ "path": "analytics", "component": "Analytics" }
]
}
URLRenders
/dashboardDashboard + DashboardHome
/dashboard/settingsDashboard + Settings
/dashboard/analyticsDashboard + Analytics

Wildcard / 404 fallback

Use "path": "*" as the last route to catch unmatched paths:

{
"router": {
"routes": [
{ "path": "/", "component": "Home" },
{ "path": "/about","component": "About" },
{ "path": "*", "component": "NotFound" }
]
}
}

Wildcard also works inside children to catch unknown sub-paths:

{
"path": "/app",
"component": "App",
"children": [
{ "index": true, "component": "Dashboard" },
{ "path": "*", "component": "NotFound" }
]
}

Static props on routes

Pass static props to a component directly from the route config:

{
"path": "/help",
"component": "Page",
"props": { "slug": "help", "title": "Help Center" }
}

These are merged with any dynamic params before being passed to the component.


Route metadata

Attach arbitrary metadata to a route with "meta":

{
"path": "/admin",
"component": "Admin",
"meta": { "requiresAuth": true, "role": "admin" }
}

meta is available in the component as props.routeMeta:

<python>
class Admin(Component):
def setup(self, props):
meta = props.get("routeMeta", {})
return {
"props": props,
"state": {},
"data": {"requires_auth": meta.get("requiresAuth", False)},
"actions": {},
"lifecycle": {},
}
</python>

Route guards

The "guard" field names a JavaScript guard function registered on window.SpaGuards. Guards run client-side before navigation:

{
"path": "/settings",
"component": "Settings",
"guard": "requireLogin"
}
// In a <script> tag or separate JS file loaded before the SPA
window.SpaGuards = {
requireLogin: function (route) {
if (!localStorage.getItem("token")) {
return "/login"; // redirect
}
return true; // allow
}
};

initial_path

Control which route is rendered for the first server-side HTML response:

{
"router": {
"initial_path": "/dashboard",
"routes": [ ... ]
}
}

Defaults to "/" if omitted.


Full example: layout + sections

{
"page_title": "Docs",
"server": { "host": "127.0.0.1", "port": 8000 },
"router": {
"initial_path": "/",
"routes": [
{
"path": "/",
"component": "Layout",
"children": [
{ "index": true, "component": "Home" },
{ "path": "guide", "component": "Guide" },
{ "path": "guide/:slug", "component": "GuidePage" },
{ "path": "api", "component": "ApiRef" },
{ "path": "*", "component": "NotFound" }
]
}
]
}
}

Layout.lspa:

@import Nav from './Nav/Nav.lspa'

<python>
class Layout(Component):
pass
</python>

<template>
<div class="layout">
<Nav />
<main class="layout__content">
{{ children }}
</main>
</div>
</template>

GuidePage.lspa:

<python>
class GuidePage(Component):
def setup(self, props):
return {
"props": props,
"state": {},
"data": {"slug": props.get("slug", "")},
"actions": {},
"lifecycle": {},
}
</python>

<template>
<article>
<h1>{{ py.slug }}</h1>
</article>
</template>