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:
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") yieldThat 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:
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:
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 prefixapp.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)
Datastar —
from stario.datastar import data, at, SSE, read_signals. OneSSE(w)per response;patch_signalstakes a mapping withsnake_casekeys.Bootstrap —
async def bootstrap(app, span):with exactly oneyield;App.on_errorhandlers areasync def.Lifecycle — long-lived handlers use
c.alive()onContext, notWriter.alive().Errors —
HttpExceptionis 4xx/5xx bodies only; redirects useRedirectExceptionorresponses.redirect. Markup fromstario.markup(for examplefrom 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). Usetracer.stats()for sink health.
Where to go next
Getting started — live Tiles demo and annotated source.
Multi-file layout — Structuring larger applications and the chat-room example.
Full API and removal list —
CHANGELOG.md(4.0.0).APIs and how-tos — Documentation.
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.