Filesystem Routing

Build routes from a pages/ directory — folders become URLs, page.py files become handlers, and _context.py shares data down the tree

Page actions AI-ready formats and sharing
Open LLM text
Share with AI
Ask Claude Ask ChatGPT Ask Gemini Ask Copilot

Overview

Filesystem routing builds your routes from apages/ directory instead of @app.route() calls. The folder structure is the URL structure: a page.py file becomes a GET handler for its directory's path, {braces} folders become path parameters, and _context.pyfiles share data down the tree.

Reach for it when your app is a content hierarchy — docs, a forum, a dashboard with nested sections — and you want handlers, templates, and shared data co-located with the URLs they serve. For API-style or action endpoints, keep using decorator routes. You can mix both in one app.

from chirp import App, AppConfig

app = App(AppConfig(template_dir="pages"))
app.mount_pages("pages")
app.run()

Directory conventions

The discovery system walks thepages/directory and treats specific files as route definitions:

File Purpose
page.py GET handler for the directory's URL (documents/page.pyGET /documents).
edit.py, create.py, … Handlers that append the filename to the path (edit.pyGET /documents/edit).
_context.py Context provider that cascades to child routes.
_layout.html Layout shell the page renders into.
_actions.py Mutation handlers declared with the@actiondecorator.
_meta.py Route metadata (title, section, breadcrumb label).
_viewmodel.py View assembly for complex context merging.

Directories whose names are wrapped in{braces} become path parameters ({doc_id}//documents/{doc_id}).

Example structure

pages/
  _layout.html          # Root layout
  _context.py           # Root context (e.g. site config)
  documents/
    page.py             # GET /documents
    create.py           # GET /documents/create
    {doc_id}/
      _layout.html      # Nested layout
      _context.py       # Loads the doc, provides it to children
      page.py           # GET /documents/{doc_id}
      page.html         # Template for page.py
      edit.py           # GET /documents/{doc_id}/edit
      edit.html         # Template for edit.py

A request resolves through the tree in a fixed order:

  1. 1

    Discovery maps the URL to a route file

    mount_pages() walks pages/ once at startup. The matching page.py (or other handler file) for the requested path is selected, along with its ancestor _context.py and _layout.htmlfiles.

  2. 2

    Context cascades root to leaf

    Each_context.pyprovider runs in order. Output merges into an accumulated dict; deeper providers override parent values.

  3. 3

    The handler runs with resolved kwargs

    Theget/post/… function receives path params, cascade context, and services as keyword arguments, then returns a Page (or any other return type).

  4. 4

    The layout chain wraps the result

    Chirp composes the ancestor layouts around the page HTML. See layout composition for how that wrapping works.

Route files

page.py

page.py maps to the directory URL. A get function handles GET, a post function handles POST, and so on:

# pages/documents/page.py
from chirp import Page

def get():
    return Page("documents/page.html", "content", items=load_items())

You can also return Suspensefor instant first paint with deferred blocks — see [Suspense for instant first paint]. The layout chain applies automatically.

Other .py files

Any other.py file (except names starting with _) appends its stem to the path:

  • edit.py in documents/{doc_id}/GET /documents/{doc_id}/edit
  • create.py in documents/GET /documents/create

Handler functions are named after HTTP methods:get, post, put, delete, patch, head, options. If no method-named function exists, a handlerfunction defaults to GET.

A file can mix methods — a syncget for the form and an async postfor the mutation:

from chirp import Page

def get(doc_id: str, doc):  # doc comes from _context.py
    return Page("documents/{doc_id}/edit.html", "content", doc=doc)
from chirp import Redirect

async def post(doc_id: str, doc, request):
    data = await request.form()
    update_doc(doc_id, data)
    return Redirect(f"/documents/{doc_id}")

Path parameters

Directory names wrapped in{param}become URL path parameters:

documents/{doc_id}/page.py             →  /documents/{doc_id}
users/{user_id}/posts/{slug}/page.py   →  /users/{user_id}/posts/{slug}

Handlers receive path parameters as keyword arguments by name. Directory names are bare {braces} — the type comes from the handler's parameter annotation. Annotate doc_id: int and the matched segment is coerced to intbefore the handler runs.

Context cascade

_context.py files export a contextfunction that provides shared data to handlers. Context cascades from root to leaf; child context overrides parent.

A provider receives arguments from two sources:

  1. Path parameters — from the URL match (e.g.doc_id from /documents/{doc_id}).
  2. Parent context — values from providers higher in the tree.
# pages/_context.py — root provider, no params
def context() -> dict:
    return {"store": get_store(), "data_dir": "..."}

# pages/documents/{doc_id}/_context.py — receives doc_id from the path, store from the parent
def context(doc_id: str, store) -> dict:
    doc = store.get(doc_id)
    return {"doc": doc}

For /documents/abc-123, the root provider runs first and adds store and data_dir. The child provider then receives doc_id="abc-123" from the path and storefrom the accumulated context. Providers can be sync or async.

Inject services by type

A provider can also request types registered viaapp.provide(). A parameter whose type annotation matches a registered factory is resolved from that factory:

# pages/documents/{doc_id}/_context.py
def context(doc_id: str, store: DocumentStore) -> dict:
    doc = store.get(doc_id)
    return {"doc": doc}

With app.provide(DocumentStore, get_store), the storeparameter is injected from the factory.

Abort the cascade with an HTTPError

A provider can raiseNotFound (or any other HTTPErrorsubclass) to stop the cascade. Chirp renders the matching error page automatically:

# pages/documents/{doc_id}/_context.py
from chirp import NotFound

def context(doc_id: str, store) -> dict:
    doc = store.get(doc_id)
    if doc is None:
        raise NotFound(f"Document '{doc_id}' not found")
    return {"doc": doc}

Templates

When a route file has a sibling.html file with the same stem, you render it by passing its path to Page(). Template paths are relative to the pages root:

  • page.py + page.htmlreturn Page("documents/page.html", "content", ...)
  • edit.py + edit.htmlreturn Page("documents/edit.html", "content", ...)

The page template owns the region inside the layout's{% block content %}. It does not {% extends %} its sibling _layout.html — Chirp composes them. That distinction (and why a page can't fill a layout block like page_scripts) is the subject of layout composition.

Handler argument resolution

Page handlers receive arguments from several sources, in priority order — first match wins:

def get(doc_id: str, doc, store: DocumentStore):
    # doc_id from path, doc from _context.py, store from app.provide()
    return Page("documents/page.html", "content", doc=doc)

Gotchas

Filesystem vs decorator routes

Use filesystem routing when… Use@app.route()when…
Routes map to a content hierarchy Routes are API-like or action-oriented
Layouts and context cascade naturally Each route is independent
You want co-located handlers and templates You prefer explicit route registration

You can mix both:app.mount_pages("pages") for the main app, and @app.route("/api/...")for API endpoints.

See also