← Blog

Stario 4 is here

Stario 4 is a major release, and the biggest shift is how you build routes and wire a larger app. Mounts, nested routers, and url_for are gone. In their place: full paths registered on App, UrlPath constants as shared module config, and one registration pattern per feature. Everything else in 4.0 — Datastar imports, bootstrap shape, telemetry schema, CLI env vars — follows from that cleanup.

How v3 encouraged a messy split

In Stario 3, a common shape looked like this: each feature module built a Router, returned it, and bootstrap mounted it under a prefix:

python
def build_billing_router(db) -> Router:
    router = Router()
    router.get("/invoices", list_invoices(db))
    return router
 
async def bootstrap(app: App, span: Span):
    app.mount("/billing", build_billing_router(db), name="billing")
    yield

That works, but it gets unpleasant quickly. One module returns a Router; another exports a single handler; a third mixes both. There was no single contract for what a feature module should export, so composition in main turned into guesswork.

Worse, mounting in bootstrap meant routes inside the module only knew their local segment (/invoices), not the full URL (/billing/invoices). Templates and handlers could not honestly link to themselves without app.url_for("billing:invoices")—dynamic reverse routing keyed off mount names and route name= arguments. If you step back from framework convenience, that is a strange default: the people who own the route are the last to see the full path.

UrlPath: URLs as module config

Stario 4 follows the direction discussed in the community: treat URLs as module configuration, not as something the framework reassembles at runtime.

Each feature defines UrlPath constants—full path (and optional host) included:

python
from stario.routing import UrlPath
 
INVOICES = UrlPath("/billing/invoices")
INVOICE = UrlPath("/billing/invoices/{id}")

Templates and views import those constants and call .href() when building links. They do not import handlers and do not care how the route is registered. Handlers import the same constants and attach to App in one place:

python
def register_billing(app: App, db) -> None:
    app.get(INVOICES, list_invoices(db))
    app.get(INVOICE, show_invoice(db))

bootstrap stays a thin composition root: create shared dependencies, register static assets, call each register_*. Every module knows the exact URL it lives on. url_for and route names are removed because they are no longer doing hidden work.

That leaves one sensible pattern for larger apps: a feature owns urls.py, handlers.py (with register_feature(app, …)), and views.py. Shell layouts and partials can bake in real links because paths are ordinary values at import time—not secrets held in the mount table.

Static assets: manifest vs serving

The same split applies to static files. AssetManifest runs fingerprinting when the module loads (effectively free at import). Templates call ASSETS.href("css/app.css") with the same mental model as UrlPath.href(). Serving those files is separate: StaticAssets(manifest).register(app) in bootstrap wires HTTP routes. The manifest answers “what URL is this file?”; registration answers “how does the server deliver it?”

Middleware and errors on the path, not inside Router

Nested routers also hid where middleware and error handlers applied. In v4 you scope them on App with explicit path patterns:

  • app.use("/billing", billing_middleware) before routes on that prefix

  • app.not_found("/billing/{path...}", billing_404)

  • app.method_not_allowed("/billing/{path...}", billing_405)

Inherited down the branch, visible in bootstrap, grep-friendly—same philosophy as full-path registration.

CLI: configure the running program with env vars

stario serve and stario watch now take only the app spec (module:bootstrap). Listen address, limits, compression, tracers, and the rest use STARIO_* environment variables—the same mechanism you use in production to parametrize a long-running process. Runtime flags like --host and --port are removed. Run stario serve --help for the full list.

Other breaking changes (summary)

  • Datastarfrom stario.datastar import data, at, SSE, read_signals. One SSE(w) per response; patch_signals takes a mapping with snake_case keys.

  • Bootstrapasync def bootstrap(app, span): with exactly one yield; App.on_error handlers are async def.

  • Lifecycle — long-lived handlers use c.alive() on Context, not Writer.alive().

  • ErrorsHttpException is 4xx/5xx bodies only; redirects use RedirectException or responses.redirect. Markup from stario.markup (for example from stario.markup import html as h).

  • Telemetry — SQLite schema documented in Telemetry; delete old trace files if you still have pre-v4 databases. Attributes live in attrs_json (request.method, request.path, response.status_code). Use tracer.stats() for sink health.

Where to go next

Thanks to torcadev for the inspiration behind this redesign and direction—the UrlPath-as-config pattern and the push toward one clear way to structure larger apps started in those discussions.