Routing
Routes live under src/app,
folders define URL segments, and layouts nest automatically. Each page is
an index.py
module that returns readable markup with html(...)
and owns its metadata, first-render data, RPC actions, auth, caching, and redirects.
Current Rules
-
Put application routes in
src/app/. -
Every page is an
index.pywith readablehtml(...)markup and any route-owned metadata,page(),@rpc()actions, auth checks, caching, redirects, or other server-side logic. -
A folder is either a page
(
index.py) or an HTTP endpoint (route.py), never both — having both is a startup error. See API Routes. -
layout.pyowns the shared wrapper, synchronous layout props, and metadata for a route subtree. -
A single top-level root element or one imported
x-*root is the default template shape, with any owned plain<script>kept inside the template. Sibling top-level nodes are also valid: a multi-root component becomes a fragment, and a multi-rootindex.pyorlayout.pyis wrapped in a layout-neutraldisplay: contentsboundary host.
Route Shapes
| Route Shape | Files | Typical Use |
|---|---|---|
| Page module | index.py | Markup, metadata, data, and route-owned actions |
| Composed page | index.py + components | Short route assembly with focused Python components |
| Backend only | index.py | Redirect-only or action-oriented route |
| API endpoint | route.py | get/post/put/patch/delete functions returning JSON for webhooks, mobile apps, and third parties |
| Files | URL |
|---|---|
| src/app/index.py | / |
| src/app/about/index.py | /about |
| src/app/dashboard/index.py | /dashboard |
| src/app/(auth)/signout/index.py | /signout |
Route Modules
Import reusable components with normal Python imports, return one
readable html(...)
root, and render imported components with HTML-first kebab-cased
x-* tags.
from casp.component_decorator import html from src.components.Button import Button def page(): return html(r""" <section class="space-y-4"> <h1>Dashboard</h1> <x-button>Create report</x-button> <script> const [filter, setFilter] = pp.state("all"); </script> </section> """)
Root shape
Prefer one root in the markup passed to
html(...) —
for a component it is the only shape that can receive props. Sibling
top-level nodes are supported: a page or layout gets a
display: contents
boundary host, and a component becomes a fragment. Keep any owned plain
<script>
inside the template, and never handwrite runtime-managed attributes such as
pp-component,
pp-owner, or
pp-ref-forward;
those are injected by the runtime.
The Server Workflow
The same index.py
owns metadata, first-render preparation, and the page markup. Pass
server-known values to html(...)
as keyword context.
from casp.component_decorator import html from casp.layout import Metadata metadata = Metadata( title="Dashboard | Caspian", description="Section overview for the dashboard.", ) async def page(params: dict, request=None): slug = params.get("slug") return html(r""" <main><h1>{{ slug }}</h1></main> """, slug=slug)
params
dict. Query params are injected by name, and
request
is injected by keyword when declared.
async def page(): page_html = html("<main>Reports</main>") return page_html, "dashboard_body_class": "dashboard-shell dashboard-shell--reports"
The 2-item tuple form lets a child route push layout props upward so
parent layouts can consume them as
{{ layout.* }}.
Dynamic Segments
Use square brackets to define dynamic URL segments.
Catch-All Segments
Use an ellipsis inside brackets to match multiple path segments.
Optional Catch-All Segments
Double brackets make the catch-all optional, so the route also answers its base URL.
On the base URL params["slug"] is
None; otherwise it is the remaining path as one string.
- It must be the last segment of the route, and works the same way for
route.py. - A separate page or endpoint on the same base URL (for example
docs/index.pybeside it) is a startup error, because both would answer/docs. - For static export, a
static_pathsentry withslugset toNonewrites the base URL.
Route Groups And Section Layouts
Use a normal folder like dashboard/
when the section name should appear in the URL. Use a parenthesized group
like (marketing)/
when the folder should organize code and own a layout without adding a
URL segment.
src/
app/
(marketing)/
layout.py
pricing/
index.py
about/
index.py
dashboard/
layout.py
index.py
settings/
index.py
Multiple Root Layouts
A layout.py that sets
ROOT_LAYOUT = True ends inheritance at that file:
no layout above it wraps the pages below it, while layouts nested under it still apply. Use it for a
marketing shell beside a dashboard shell with a different <head>.
A layout that declares it must render a complete <html>
document, including the runtime script.
src/app/
(marketing)/
layout.py # ROOT_LAYOUT = True, full <html> document
index.py
(app)/
layout.py # ROOT_LAYOUT = True, a different document
dashboard/
index.py
- Each root has its own identity, sent as the
X-PP-Root-Layoutheader and thepp-root-layoutmeta tag. - Navigating within the same root swaps the body; crossing into a different root is a full page load, so each document keeps its own head.
- Without
src/app/layout.py, each group's own document layout is its root.
Layouts
Keep shared wrapper markup, synchronous props, and metadata in
layout.py.
The returned shell stays native HTML plus Jinja and includes <slot />.
<html> <head> <title>{{ metadata.title }}</title> <meta name="description" content="{{ metadata.description }}" /> </head> <body class="{{ layout.theme_class }}"> <main pp-reset-scroll="true"> <slot /> </main> </body> </html>
from casp.layout import Metadata metadata = Metadata( title="Docs Section | Caspian", description="Shared docs layout metadata.", ) def layout(context): return r"""<div><slot /></div>""", "theme_class": "docs-shell"
Current layout runtime
layout()
may be a plain function or an
async def
— the engine awaits the result when it is awaitable. Prefer keeping
async I/O in page()
or in route-owned @rpc()
actions anyway: a layout runs for every page in its subtree, so work done
here is paid on every one of them.