Structuring larger applications
This how-to is about where files go when a Stario app outgrows a single module. Stario does not enforce a layout; the goal here is a pragmatic default so mounts stay obvious, bootstrap stays the single place that wires the world, and you avoid a flat directory of twenty unrelated handlers.
It is not a guide to dependency injection or middleware. For passing shared clients into handlers, see Injecting dependencies. For sessions, cookies, and auth-shaped middleware, see Authentication with cookie sessions.
Before you lean on this page: you should already be comfortable with bootstrap, Router / mount, and the per-request handler shape from the getting started walkthrough. If those are still fuzzy, read those links first—this page stays opinionated about layout, not about Stario basics.
What stays stable as you grow
bootstrap— One composition root: create long-lived clients, callapp.mount, register routes, attach static assets. Feature modules should stay importable without side effects;bootstrapimports features, not the other way around, so you avoid circular imports. Treatbootstrapas the only place that wires the app: everything else either imports shared types from here or is imported intobootstrapfor registration—avoid a second hidden global registry.Mount by domain — Prefer
app.mount("/billing", billing_router)(or a prefix that matches the feature folder) over repeating the same path prefix on everyget. Alternatively,app.mount("/", feature_router)with absolute paths inside the child router is valid (thechat-roomexample does this)—choose URL layout and grep ergonomics, not a framework requirement.Handlers stay thin — Parse or delegate input, call something that returns data, pick a response helper. Heavy logic belongs in modules that do not need
Writer(easier tests).Views stay
data → html— Buildstario.htmltrees from plain values. If the tree needs URLs, resolve them before you call the view (see Static assets andurl_for).
Split by feature
Group routes, handlers, and templates by feature or bounded context so grepping stays predictable. Two common shapes:
Flat packages under
app/— e.g.app/billing/,app/about/beside shared modules (bootstrap.py,database.py,static/). Fine for many teams; paths stay short.app/features/…when the tree gets wide — e.g.app/features/billing/,app/features/about/, with the same files inside each feature folder. Keeps cross-cutting code obvious atapp/(bootstrap, DB client, static assets, sharedinputs.py) and feature-only code under one branch.
Pick the layout that matches how large the repo feels; the rules below are the same either way.
app/ __init__.py bootstrap.py # async def bootstrap(app, span) — imports features, mounts routers database.py # pool / engine — shared; used from bootstrap + handlers relay.py # example: outbound client — same idea inputs.py # shared parsing (see below) static/ css/ js/ billing/ # or: features/billing/ router.py # builds and returns a Router, or exposes register(app) (optional style) handlers.py views.py about/ # or: features/about/ handlers.py views.pyName modules after what they hold, not after a pattern name. If only the database needs a module, database.py (or db.py) beats a generic deps.py full of unrelated build_* functions. Same for a third-party API wrapper: weather_api.py, relay.py, etc. Keep bootstrap readable: import concrete modules and pass instances into factories or routers.
bootstrap.py wires features without owning their internals:
from collections.abc import AsyncIterator from stario import App, Span from app.billing.router import build_router as build_billing_routerfrom app.database import connect_pool async def bootstrap(app: App, span: Span) -> AsyncIterator[None]: pool = await connect_pool() app.mount("/billing", build_billing_router(pool)) yield await pool.close()(Shape of bootstrap follows Runtime — Application lifecycle; the point is one place that imports feature routers and passes shared objects in.)
Sub-routers and shared clients
Mounted routers need the same database or HTTP client the rest of the app uses. Two styles both work; use whichever fits that feature—closures for a small surface, a class when many routes and shared helpers pay for the type, or a mix across the codebase.
Closures / factories —
build_billing_router(pool)returns aRouterwhose handlers close overpool. Small, explicit, easy to follow.Class + methods — A type instantiated in
bootstrapwithpoolonself; passrouter.get(..., billing.list_invoices)etc. Good when the feature has many routes and shared private helpers.
Avoid reaching for a DI container until import wiring actually hurts. If it does, the patterns in Injecting dependencies still apply inside a larger tree.
inputs.py and shared parsing
Put reused request parsing in one module, e.g. inputs.py, and import it from handlers—generic helpers, not one-off copies per route. That keeps handlers short and answers “how do we read this app’s requests?” in one place.
Align with the patterns in Reading and writing Datastar signals: thin wrappers around read_signals, helpers that validate into Pydantic (or msgspec / attrs + cattrs) models you define, shared JSON-body readers, file-signal handling, and anything else you repeat across resources. Name functions after what they parse (read_order_form, signals_for_dashboard) rather than scattering ad hoc json.loads and field access in every handler. Resource-specific logic that only one route needs can stay in that feature’s package; cross-cutting wire formats belong in inputs.py (or a small package if it outgrows one file).
Static assets and url_for
Mount a directory with StaticAssets and a name= (for example name="static"). Files are fingerprinted and registered for app.url_for("static:path/under/dir.css") so HTML can point at stable logical names while clients load hashed URLs. Omitting name= still serves files but gives url_for nothing to resolve.
from pathlib import Pathfrom stario import StaticAssets # Path("static") is relative to the process cwd; in real apps prefer Path(__file__).parent / "static".app.mount("/static", StaticAssets(Path("static"), name="static"))app.url_for("static:css/app.css") # fingerprinted path for that fileResolve URLs early
Call url_for where you already have app — typically in the handler or in a view function invoked from the handler with plain strings for href / src / Datastar URLs:
import stario.html as hfrom stario import App def layout(app: App, *children): css_href = app.url_for("static:css/style.css") js_src = app.url_for("static:js/app.js") return h.HtmlDocument( {"lang": "en"}, h.Head( h.Link({"rel": "stylesheet", "href": css_href}), h.Script({"type": "module", "src": js_src}), ), h.Body(*children), )That is simpler than threading app, c.app, or a url_for callable through every template helper. If a nested helper needs a URL, pass the already-built string (or a small struct), not the whole app.
In handlers you can also use c.app.url_for(...) when you do not have app in scope (see Context).
Many template parameters
When a view needs a long list of pieces (title, breadcrumbs, flash message, pagination, etc.), bundle them in a @dataclass(frozen=True, slots=True) (or similar) and pass one object into the view. That shortens signatures, keeps HTML builders readable, and makes it obvious what each page needs.
Tests
Colocate tests/ next to the package or mirror app/ under tests/. Prefer TestClient against the same bootstrap as production whenever you can: identical wiring catches integration bugs and keeps “how the app starts” in one place.
When that is not practical—in-memory fakes, test databases, or stubbed HTTP clients instead of real ones—consider a bootstrap used only in tests that still reuses the real setup: shared connection helpers, the same mount graph with test doubles wired in place of production clients, and the same async-generator shape (yield, teardown) so behavior stays comparable. The point is to avoid maintaining two unrelated startup stories; extract what both paths need into small functions or modules, then compose production bootstrap and test bootstrap from those pieces. No second composition root should drift silently from the first.
Where to look in the real world
The chat-room example in the Stario repo (examples/chat-room) uses feature packages (app/chat, app/about), a shared static mount, url_for("static:…"), and tests against bootstrap.
Related
Injecting dependencies — closures,
Services, context managers inbootstrapAuthentication with cookie sessions — middleware and session state
Reading and writing Datastar signals — shared parsing and signal helpers
Realtime tiles — a single-file tutorial you can split using the layout above
Routing —
Router,mount, andnameRequest and context —
ContextandRequestRuntime —
bootstrap,StaticAssets,App