Skip to content

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/camping app → /camping/templates/index.html
  • /camping/productscamping app → /productscamping/templates/products.html
  • /hiking/hiking app → /hiking/templates/index.html
  • /hiking/gearhiking app → /gearhiking/templates/gear.html
  • /shared app → /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.html overrides shared/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 Route for specific endpoints with defined paths:

    Route("/api/users", users_endpoint)
    Route("/health", health_check)
    

  • Use Mount for 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:

  1. Route name (e.g., "camping")
  2. 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).