Multi-Application Sites
A multi-application setup allows you to have multiple independent Starlette applications that can share templates and middleware while maintaining separate routing and configuration. This is useful for large projects that need to be modular or have distinct sections (e.g., a blog, an e-commerce store, and an admin panel) where each section can be developed and maintained independently, even in separate packages, but still share common resources and served by a single main application.
Multi-app architecture leverages Starlette's ability to mount sub-applications using the Mount routing class, allowing each sub-application to have its own routes, middleware, and template loaders. This setup provides flexibility through shared templates and scalability through separation of concerns for complex web applications.
When to use multi-app architecture
Multi-app sites are useful when you want to:
- Split different parts of your application into distinct modules (e.g., blog, e-commerce, admin)
- Reuse common templates, components, and middleware across applications
- Potentially deploy sub-applications separately while maintaining consistency
- Allow different teams to work on separate applications with clear boundaries
- Incrementally add new sections without restructuring existing code
Project Structure
A multi-app project structure that uses shared templates but has separate sub-applications might look like this:
myproject/
├── __init__.py
├── app.py # Main application that mounts sub-apps
├── camping/ # Camping sub-application
│ ├── __init__.py
│ └── templates/
│ ├── base.html
│ ├── index.html
│ └── products.html
├── hiking/ # Hiking sub-application
│ ├── __init__.py
│ └── templates/
│ ├── base.html
│ ├── index.html
│ └── gear.html
└── shared/ # Shared templates across all apps
├── __init__.py
└── templates/
├── nav.html
├── footer.html
└── error.html
This shows a single package example, but you can also have each sub-application (shared, camping, hiking) in its own package with its own templates, dependencies, and codebase.
Basic Multi-App Setup
Here's a complete example of a multi-app setup:
from jinja2 import PackageLoader
from starlette.routing import Mount
from starlette.middleware import Middleware
from starlette.applications import Starlette
from starlette_templates.routing import TemplateRouter
from starlette_templates.middleware import JinjaMiddleware
# Create camping sub-application
camping = Starlette(
routes=[
# Use Mount (not Route) to match all sub-paths
# Give a name for URL generation, {{ url('camping', '/') }}
Mount("/", TemplateRouter(debug=True), name="camping")
],
middleware=[
Middleware(
JinjaMiddleware,
template_loaders=[
PackageLoader("myproject.camping", "templates"),
# Add shared templates
PackageLoader("myproject.shared", "templates"),
]
)
],
debug=True,
)
# Create hiking sub-application
hiking = Starlette(
routes=[
# Use Mount (not Route) to match all sub-paths
# Give a name for URL generation, {{ url('hiking', '/') }}
Mount("/", TemplateRouter(debug=True), name="hiking")
],
middleware=[
Middleware(
JinjaMiddleware,
template_loaders=[
PackageLoader("myproject.hiking", "templates"),
# Add shared templates
PackageLoader("myproject.shared", "templates"),
]
)
],
debug=True,
)
# Main application that mounts sub-applications
app = Starlette(
routes=[
# Mount sub-applications at desired paths, with names for URL generation
Mount("/camping", camping, name="camping"),
Mount("/hiking", hiking, name="hiking"),
# Serve shared templates at root
Mount("/", TemplateRouter(debug=True), name="shared")
],
middleware=[
Middleware(
JinjaMiddleware,
template_loaders=[
# Shared templates for root app
PackageLoader("myproject.shared", "templates"),
]
)
],
debug=True,
)
URL Routing in Multi-App Sites
When you mount sub-applications, the URL routing works hierarchically:
/camping/→campingapp →/→camping/templates/index.html/camping/products→campingapp →/products→camping/templates/products.html/hiking/→hikingapp →/→hiking/templates/index.html/hiking/gear→hikingapp →/gear→hiking/templates/gear.html/→sharedapp →/→shared/templates/index.html
The mount path (/camping, /hiking, /) is stripped before passing to the sub-application, so the sub-app sees only the remaining path.
Template Sharing
Sub-applications can share templates in two ways:
1. Shared Template Package
Add a shared templates package as a fallback loader:
Middleware(
JinjaEnvMiddleware,
template_loaders=[
PackageLoader("myproject.camping", "templates"), # App-specific first
PackageLoader("myproject.shared", "templates"), # Shared as fallback
]
)
This allows:
- App-specific templates override shared ones
- Common templates (nav, footer) from shared package are reused
- Each app can customize shared templates by creating local versions with the same name (e.g.,
camping/templates/nav.htmloverridesshared/templates/nav.html)
2. Template Inheritance
Shared templates can define base layouts that sub-apps extend:
shared/templates/base.html:
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}My Site{% endblock %}</title>
</head>
<body>
{% include 'nav.html' %}
{% block content %}{% endblock %}
{% include 'footer.html' %}
</body>
</html>
camping/templates/index.html:
{% extends 'base.html' %}
{% block title %}Camping{% endblock %}
{% block content %}
<h1>Welcome to Camping</h1>
<a href="{{ url('camping', '/products') }}">View Products</a>
{% endblock %}
Debug mode configuration
Setting debug mode
To enable detailed error pages with full tracebacks, code context, and local variables, you need to set debug=True on TemplateRouter, not just the Starlette app:
routes=[
Mount("/", TemplateRouter(debug=True), name="camping")
]
Common Mistake
Setting debug=True only on Starlette(debug=True) is snot enoughs. You must also set debug=True on each TemplateRouter instance to see detailed error pages.
Both are needed because:
Starlette(debug=True) enables Starlette's debug features
TemplateRouter(debug=True) enables detailed error pages for template rendering errors
TemplateRouter has its own internal error handler that catches exceptions before they reach the Starlette app level, so it needs its own debug flag.
Debug error page features
When TemplateRouter(debug=True) is set, error pages show:
- Exception type and message with HTTP status code
- Full traceback with expandable frames
- Code context (5 lines before and after each frame)
- Local variables for each frame
- Request information (method, URL, headers, query params, cookies)
- Validation errors with detailed field information (for Pydantic errors)
Critical distinction Mount vs Route
Important: Use Mount, Not Route
When using TemplateRouter as a catch-all handler, you must use Mount, not Route. This is a common source of 404 errors.
Using Route("/", TemplateRouter(...)) only matches the exact path /:
# WRONG Only matches exact path "/"
routes=[
Route("/", TemplateRouter(), name="camping")
]
This causes:
/camping/→ Works (matches exact path)/camping/products→ 404 Not Found (doesn't match)/camping/gear→ 404 Not Found (doesn't match)
Using Mount("/", TemplateRouter(...)) matches / and all sub-paths:
# CORRECT Matches all paths
routes=[
Mount("/", TemplateRouter(), name="camping")
]
This makes everything work:
/camping/→ Works/camping/products→ Works/camping/gear→ Works-
/camping/anything/else→ Works -
Use
Routefor specific endpoints with defined paths:Route("/api/users", users_endpoint) Route("/health", health_check) -
Use
Mountfor catch-all handlers like TemplateRouter:Mount("/", TemplateRouter()) Mount("/static", StaticFiles(directories=["static"]))
URL generation with url(mount_name, path)
In multi-app sites, you can generate URLs using the url(mount_name, path) function in templates. The route names you define in Mount() become available:
# Route names defined here
app = Starlette(
routes=[
Mount("/camping", camping, name="camping"),
Mount("/hiking", hiking, name="hiking"),
]
)
Use in templates:
<!-- Link to camping homepage -->
<a href="{{ url('camping', '/') }}">Camping</a>
<!-- Link to camping products -->
<a href="{{ url('camping', '/products') }}">Camping Products</a>
<!-- Link to hiking homepage -->
<a href="{{ url('hiking', '/') }}">Hiking</a>
<!-- Link to hiking gear -->
<a href="{{ url('hiking', '/gear') }}">Hiking Gear</a>
The url() function (alias for url_for()) takes:
- Route name (e.g.,
"camping") - Path within that route (e.g.,
"/products")
Custom exception handlers
For routes that don't use TemplateFiles, you can add custom exception handlers at the app level:
from starlette.exceptions import HTTPException
from starlette.requests import Request
from starlette.responses import Response
from starlette_templates.errors import httpexception_handler, exception_handler
async def handle_http_exception(request: Request, exc: HTTPException) -> Response:
jinja_env = request.state.jinja_env
return await httpexception_handler(request, exc, jinja_env, debug=True)
async def handle_exception(request: Request, exc: Exception) -> Response:
jinja_env = request.state.jinja_env
return await exception_handler(request, exc, jinja_env, debug=True)
app = Starlette(
routes=[
Mount("/api", api_routes), # Non-TemplateRouter routes
Mount("/", TemplateRouter(debug=True)),
],
exception_handlers={
HTTPException: handle_http_exception,
Exception: handle_exception,
},
debug=True,
)
Note
If all your routes use TemplateRouter, you don't need custom exception handlers. TemplateRouter has its own internal error handler that works with TemplateRouter(debug=True).