Routing
Pyxle uses file-based routing: the path of a .pyxl file inside
your pages/ directory is its URL. There is no @app.route("/")
decorator. There is no router config file. There is no list of routes
maintained by hand. The directory structure is the routing table.
This doc explains how that works, exhaustively. By the end you'll know exactly which URL each file maps to, what the dynamic segment syntax means, how layouts compose, and how a request gets dispatched to the right handler.
Files:
routing/paths.py(~135 lines) — file path → URL conversion (zero dependencies, importable from anywhere)devserver/routes.py(~280 lines) —PageRoute,ApiRoute,ActionRoute,RouteTabledataclassesdevserver/registry.py(~380 lines) — loads metadata, builds the route table at startup
The basic mapping
The simplest case is one file per URL:
pages/
├── index.pyxl → /
├── about.pyxl → /about
└── contact.pyxl → /contactThree rules turn the file path into the URL:
- Strip the
.pyxlextension. - Drop
indexsegments —index.pyxlcollapses to its parent. - Prefix with
/— every route starts at the root.
A nested directory becomes a path segment:
pages/
├── posts/
│ ├── index.pyxl → /posts
│ ├── popular.pyxl → /posts/popular
│ └── archive/
│ └── 2025.pyxl → /posts/archive/2025
└── settings/
└── index.pyxl → /settingsThat's the entire convention for static routes. There's nothing
clever happening — routing/paths.py:24-65 is the function that
does it, and it's about 40 lines of straightforward string handling.
Pyxle in Next.js terms: This is exactly how Next.js's
pages/directory works. The conventions are deliberately compatible because file-based routing is one of those design choices that's hard to improve on.
Dynamic segments — [id]
Real apps need URLs that contain parameters. Pyxle uses square bracket syntax in filenames to declare them:
pages/
└── posts/
└── [id].pyxl → /posts/{id}The brackets become a Starlette path parameter. Inside the loader,
you read it from request.path_params:
@server
async def load_post(request):
post_id = request.path_params["id"]
post = await db.fetch_post(post_id)
return {"post": post}The parameter name is whatever you put inside the brackets:
pages/
├── users/[username].pyxl → /users/{username}
├── docs/[section].pyxl → /docs/{section}
└── shop/[productSlug].pyxl → /shop/{productSlug}You can have multiple dynamic segments at different levels:
pages/
└── orgs/
└── [org]/
└── repos/
└── [repo]/
└── settings.pyxl → /orgs/{org}/repos/{repo}/settingsDynamic segments can sit alongside static ones:
pages/
└── blog/
├── [year]/
│ ├── index.pyxl → /blog/{year}
│ └── [slug].pyxl → /blog/{year}/{slug}
└── archive.pyxl → /blog/archiveRouting is most-specific-first, so /blog/archive will match
archive.pyxl rather than the dynamic [year]/index.pyxl. (This is
Starlette's standard behaviour — Pyxle doesn't override it.)
Parameter name sanitization
The parameter name inside brackets is sanitized for Starlette:
hyphens, dots, and special characters get replaced with underscores.
A name starting with a digit is prefixed with _. Empty brackets fall
back to param (or slug for catch-alls).
def _sanitize_param_name(name):
cleaned = name.replace("-", "_").replace(".", "_")...
if cleaned[0].isdigit():
cleaned = f"_{cleaned}"
if not cleaned:
cleaned = "param"
return cleanedSource: routing/paths.py:107.
In practice this is invisible — name your parameters with regular identifiers and you'll never notice. The sanitizer exists to make sure that even unusual filenames don't break route registration.
Catch-all segments — [...slug]
Sometimes a single route should match an arbitrary number of path
segments. For example, a docs site where the URL /docs/getting- started/installation should be served by one component that reads
the path as a list.
Pyxle uses [...name] for this:
pages/
└── docs/
└── [...slug].pyxl → /docs/{slug:path}The path converter tells Starlette to match everything (including
slashes). Inside the loader, request.path_params["slug"] is a
single string like "getting-started/installation". You can split it
on / to get the segments.
Catch-all routes only match URLs with at least one segment after
the prefix. So [...slug].pyxl matches /docs/foo and
/docs/foo/bar but not /docs itself.
If you want to match /docs as well as /docs/foo, you need the
optional catch-all (next section), or you can add an index.pyxl
sibling:
pages/
└── docs/
├── index.pyxl → /docs (matches "/docs")
└── [...slug].pyxl → /docs/{slug:path} (matches "/docs/anything/else")Optional catch-all — [[...slug]]
The double-bracket form matches everything including the empty case:
pages/
└── shop/
└── [[...path]].pyxl → /shop/{path:path} (primary)
/shop (alias)This is the same as a catch-all plus an index.pyxl sibling, but in
one file. It's useful when you have a single component that should
render for the entire URL space under a prefix — for example a
storefront that handles /shop, /shop/electronics, and
/shop/electronics/laptops/macbook-pro all with the same loader and
component.
Under the hood, Pyxle creates two Starlette routes for an
optional catch-all: a primary route and an alias. They're both
served by the same handler. The RoutePathSpec returned by
route_path_variants_from_relative() has both:
RoutePathSpec(
primary="/shop/{path:path}",
aliases=("/shop",),
)Source: routing/paths.py:24-65.
Route groups — (name)
Sometimes you want to organize files in a folder without the folder becoming part of the URL. Pyxle uses parentheses for this:
pages/
├── (marketing)/
│ ├── index.pyxl → /
│ ├── about.pyxl → /about
│ └── pricing.pyxl → /pricing
└── (app)/
├── dashboard.pyxl → /dashboard
└── settings.pyxl → /settingsThe (marketing) and (app) segments are invisible to routing
but visible to layouts. This lets you apply different layouts to
different parts of the app — say, a marketing layout for the public
pages and an app shell for the authenticated section — without
forcing the routes themselves to be prefixed with /marketing/ or
/app/.
You can nest route groups inside dynamic segments and vice versa:
pages/
├── (auth)/
│ ├── login.pyxl → /login
│ └── signup.pyxl → /signup
└── (dashboard)/
└── orgs/
└── [org]/
└── settings.pyxl → /orgs/{org}/settingsSource: routing/paths.py:75 (just _is_route_group — six lines).
A complete reference table
Here's every routing feature in one table:
| Source filename | Route | Notes |
|---|---|---|
pages/index.pyxl |
/ |
Index of root |
pages/about.pyxl |
/about |
Static route |
pages/posts/index.pyxl |
/posts |
Index collapses |
pages/posts/[id].pyxl |
/posts/{id} |
Dynamic segment |
pages/posts/[id]/comments.pyxl |
/posts/{id}/comments |
Nested under dynamic |
pages/docs/[...slug].pyxl |
/docs/{slug:path} |
Catch-all (>=1 segment) |
pages/shop/[[...path]].pyxl |
/shop/{path:path}/shop |
Optional catch-all (primary + alias) |
pages/(marketing)/about.pyxl |
/about |
Group is invisible |
pages/(auth)/login.pyxl |
/login |
Group is invisible |
pages/api/health.py |
/api/health |
API route (Python only) |
pages/layout.pyxl |
(not a route) | Layout — wraps siblings |
pages/error.pyxl |
(not a route) | Error boundary |
pages/not-found.pyxl |
(not a route) | 404 boundary |
The last three are special filenames — they're not routes themselves; they affect how other routes render. We'll cover them in the next sections.
Layouts
A layout.pyxl file at any level of the tree wraps every route below
it. For example:
pages/
├── layout.pyxl ← root layout, wraps everything
├── index.pyxl → / (wrapped by root)
├── about.pyxl → /about (wrapped by root)
└── dashboard/
├── layout.pyxl ← dashboard layout
├── index.pyxl → /dashboard (root → dashboard → page)
└── settings.pyxl → /dashboard/settingsThe dashboard's /dashboard/settings route is rendered as:
RootLayout(
DashboardLayout(
SettingsPage({data})
)
)Layouts compose from outermost to innermost. The compiler generates a
route module in .pyxle-build/client/routes/ that does the
nesting:
// .pyxle-build/client/routes/dashboard/settings.jsx
import Page from '../../pages/dashboard/settings.jsx';
import DashboardLayout from '../../pages/dashboard/layout.jsx';
import RootLayout from '../../pages/layout.jsx';
export default function PyxleWrappedPage(props) {
return (
<RootLayout>
<DashboardLayout>
<Page {...props} />
</DashboardLayout>
</RootLayout>
);
}The page metadata's client_path field points at this composed
module instead of the raw page file, so the dev server and the
bundler load the wrapped version automatically.
The layout discovery + composition logic lives in
devserver/layouts.py:27-220. When you add or remove a layout.pyxl,
the file watcher detects the change and rebuilds every route under
that directory's subtree.
A layout.pyxl looks like a regular page, but its component receives
a children prop instead of data:
# pages/layout.pyxl
@server
async def load_root_layout(request):
return {"appName": "MyApp"}
import React from 'react';
export default function RootLayout({ data, children }) {
return (
<html lang="en">
<body>
<header>{data.appName}</header>
<main>{children}</main>
</body>
</html>
);
}The layout can have its own @server loader — it runs in addition
to the page's loader, and its data is available to the layout
component via the data prop. (The page component still gets its own
data.)
Templates: layouts that don't persist
A template.pyxl is like a layout.pyxl but with one key difference:
templates remount on every navigation. A layout keeps its DOM
and React state across navigation; a template throws everything away
and rebuilds.
This matters for things like animations that should restart on
every page change, or analytics that should fire once per page view.
Use template.pyxl for "stateless wrapper around the page" and
layout.pyxl for "persistent shell that survives navigation."
Error boundaries
A error.pyxl file at any level catches errors raised by descendants.
When a loader raises LoaderError("not found", status_code=404), or
when a component throws during SSR, Pyxle walks up the tree from the
current route looking for the nearest error.pyxl and renders it.
pages/
├── error.pyxl ← root error boundary, catches everything
├── index.pyxl
└── orgs/
├── error.pyxl ← scoped to /orgs/* routes
└── [org]/
└── settings.pyxlIf /orgs/acme/settings's loader raises, Pyxle first tries
pages/orgs/error.pyxl. If that also fails (or doesn't exist), it
falls back to pages/error.pyxl. If neither exists, it falls back to
the framework's default error document.
The error boundary component receives the error context as a prop:
# pages/error.pyxl
import React from 'react';
export default function ErrorBoundary({ error }) {
return (
<main>
<h1>{error.message}</h1>
<pre>{error.statusCode}</pre>
</main>
);
}In dev mode, the error boundary also receives the Python traceback
in error.traceback for debugging. In production this is omitted —
production responses must not leak internal state.
The boundary discovery logic lives in
devserver/error_pages.py.
Not-found boundaries
A not-found.pyxl file is the same idea, scoped to 404 responses.
When a request hits Pyxle and no route matches, the framework walks
up from the request path looking for the nearest not-found.pyxl:
pages/
├── not-found.pyxl ← root 404 page
└── docs/
├── not-found.pyxl ← custom 404 for /docs/* routes
└── getting-started.pyxlA request for /docs/nope first tries /docs/not-found.pyxl. A
request for /random falls through to /not-found.pyxl. If neither
exists, the framework returns a plain 404 response.
API routes
Pages are JSX-rendered HTML. API routes are pure JSON endpoints
under pages/api/. They use plain .py files (not .pyxl) because
they have no client component:
pages/
└── api/
├── health.py → GET /api/health
├── users.py → /api/users (any HTTP method)
└── users/
└── [id].py → /api/users/{id}An API route module exports an async function with a name matching the HTTP method:
# pages/api/health.py
async def get(request):
return {"status": "ok"}
async def post(request):
body = await request.json()
return {"received": body}The dev server inspects the module at registration time, finds the
get, post, etc. functions, and registers a Starlette endpoint
for each. There's no decorator — the function name is the contract.
If a single function should handle multiple methods, name it
handle instead and Pyxle routes all methods to it:
async def handle(request):
if request.method == "GET":
return {"data": "..."}
elif request.method == "POST":
...API routes can use the same dynamic segment syntax as pages
([id].py, [...slug].py, etc.).
Action routes
@action-decorated functions inside a .pyxl page get their own
endpoint at /api/__actions/{action_name}. This is invisible — you
never write the URL — but it's how the client-side useAction() hook
talks to the server.
For example, given:
# pages/dashboard.pyxl
@server
async def load(request):
return {"count": 0}
@action
async def increment(request):
body = await request.json()
return {"value": body["current"] + 1}
@action
async def reset(request):
return {"value": 0}
# ... JSX ...…the dev server registers:
/dashboard→ page handler (loader + component render)/api/__actions/increment→ action handler (POST only)/api/__actions/reset→ action handler (POST only)
The client-side useAction('increment') hook returns a function that
POSTs to the /api/__actions/increment URL with whatever payload you
pass it. The action returns its result as JSON.
Action names must be unique within a page (the parser enforces
this). Across pages, names can collide — but each .pyxl file
registers its actions independently, so the routing is per-page.
Action routing lives in devserver/starlette_app.py:363-484.
How a request finds its handler
When Starlette receives a request, here's what happens (simplified):
Static asset middleware checks if the path starts with
/client/,/dist/, or matches a file inpublic/. If so, serve the file directly without invoking any Pyxle handlers.CORS / CSRF middleware runs (if configured).
Vite proxy middleware (dev only) checks if the path matches a Vite-served asset (
.js,.css,/@vite/*,/@react-refresh). If so, proxy to Vite's port.Page router looks up the path in the route table. If found, call the page handler:
- If
x-pyxle-navigation: 1header is set, return a JSON nav response (loader data + head markup). - Otherwise, return a streaming HTML response (full SSR).
- If
API router dispatches
/api/*requests (excluding action routes) to the matchingpages/api/*.pyhandler.Action router dispatches
/api/__actions/*requests to the matching@actionfunction in the relevant page.Catch-all 404 handler walks up the request path looking for the nearest
not-found.pyxl. Renders it if found, otherwise returns a plain 404.
The order matters: static files are served before any dynamic
handler runs, and action routes are dispatched before the generic
API router so an @action named users doesn't collide with a
pages/api/users.py file.
Source: devserver/starlette_app.py:506-735.
The route table
At startup (and after every rebuild), the dev server builds a single
in-memory RouteTable object containing every page, API, and
action route discovered in the project:
@dataclass(frozen=True)
class RouteTable:
pages: tuple[PageRoute, ...]
apis: tuple[ApiRoute, ...]
actions: tuple[ActionRoute, ...]Each PageRoute has all the paths the SSR pipeline needs to render
it (server module path, client module path, metadata path, route
path, loader name, head metadata, etc.). The dev server builds the
Starlette router by iterating these tuples and registering one
Starlette Route per entry.
The route table is frozen and rebuilt on changes — it's never mutated in place. When the file watcher detects a change, the incremental builder produces a new metadata file, the registry rebuilds the route table, and the dev server swaps it in atomically.
Where to read next
The dev server — How the route table gets built, how the Starlette router uses it, how the file watcher triggers rebuilds, and how the WebSocket overlay reports errors.
Server-side rendering — What happens inside a page handler: loader execution, head merging, component rendering, and document assembly.
The compiler — Where the metadata that powers the route table comes from.