# Starlette-Templates
Serve Starlette templates with file-based routing, Pydantic forms, and reusable components
# Getting Started
# Starlette-Templates
[Documentation](https://starlette-templates.tycho.engineering) | [PyPI](https://pypi.org/project/starlette-templates/)
This package extends [Starlette](https://starlette.dev/) with support for template-driven routing, form handling, and reusable UI components, built on [Jinja2](https://jinja.palletsprojects.com/en/stable/) and [Pydantic](https://docs.pydantic.dev/latest/).
**Why does this exist?** Starlette is a toolkit that offers building blocks for web apps. But common tasks like template routing, form validation, and UI reuse require significant boilerplate. This package streamlines those workflows by directly routing URLs to templates, validating form data with Pydantic, and enabling type-safe, reusable UI components built as Jinja templates. This makes applications easier to build, reason about, and scale.
## Features
- Serve HTML templates with file-based routing
- Pydantic forms with validation and rendering
- Reusable UI components using Jinja2 and Pydantic with type and validation safety
- Static files with gzip compression and multi-directory support
- JSON:API compliant error responses with custom error pages
- ETag and Last-Modified headers with 304 Not Modified support
## Installation
```
pip install starlette-templates
```
## Quick Start
```
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.routing import Mount
from jinja2 import PackageLoader
from starlette_templates.routing import TemplateRouter
from starlette_templates.middleware import JinjaMiddleware
app = Starlette(
routes=[
# File-based routing for the "templates" package directory
Mount("/", TemplateRouter(debug=True)),
],
middleware=[
# Configure Jinja2 environment for template autodiscovery
Middleware(
JinjaMiddleware,
template_loaders=[PackageLoader("mypackage", "templates")],
)
]
)
```
Create `templates/index.html`:
```
My App
Welcome to {{ request.url.hostname }}
```
Visit `http://localhost:8000/` and your template will be rendered automatically.
## Next Steps
- Learn about [Applications](https://starlette-templates.tycho.engineering/applications/index.md) for setup and configuration
- Learn about [Template Files](https://starlette-templates.tycho.engineering/templates/index.md) for file-based routing
- Explore [Static Files](https://starlette-templates.tycho.engineering/static-files/index.md) for serving assets with gzip compression
- Explore [Forms](https://starlette-templates.tycho.engineering/forms/index.md) for type-safe form handling
- Discover [Components](https://starlette-templates.tycho.engineering/components/index.md) for reusable UI elements
- Learn about [Error Handling](https://starlette-templates.tycho.engineering/errors/index.md) for custom exceptions and error pages
- Check the [API Reference](https://starlette-templates.tycho.engineering/api/index.md) for detailed documentation
## LLM context
This documentation is available as an LLM-friendly markdown file:
- [llms.txt](https://starlette-templates.tycho.engineering/llms.txt) - links to documentation pages
- [llms-full.txt](https://starlette-templates.tycho.engineering/llms-full.txt) - concatenated full docs in one file
# Core Concepts
# Applications
Use the JinjaMiddleware middleware and TemplateRouter ASGI app to add template support to Starlette applications.
## Package structure
A typical Starlette-Templates application is organized as a Python package:
```
myapp/
├── __init__.py
├── app.py # Main application file
├── templates/ # Template files
│ ├── base.html # Base layout
│ ├── index.html # Homepage
│ ├── about.html # About page
│ ├── 404.html # Custom 404 page
│ ├── 500.html # Custom 500 page
│ ├── partials/ # Reusable fragments
│ │ ├── navigation.html
│ │ └── footer.html
│ └── components/ # Component templates
│ ├── alert.html
│ └── card.html
└── static/ # Static files
├── css/
├── js/
└── images/
```
Your `app.py` would typically look like:
```
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.routing import Mount, Route
from starlette.responses import JSONResponse
from jinja2 import PackageLoader
from importlib.resources import files
from starlette_templates.routing import TemplateRouter
from starlette_templates.staticfiles import StaticFiles
from starlette_templates.middleware import JinjaMiddleware
PKG_DIR = files("myapp")
async def api(request):
return JSONResponse({"message": "API Root"})
app = Starlette(
routes=[
# Custom routes first
Mount("/api", api, name="api"),
# Static files
Mount("/static", StaticFiles(directories=[PKG_DIR / "static"]), name="static"),
# Template files for remaining routes
Mount("/", TemplateRouter()),
],
middleware=[
Middleware(
JinjaMiddleware,
template_loaders=[PackageLoader("myapp", "templates")],
)
]
)
```
## Basic setup
A minimal Starlette Templates application requires two components:
1. JinjaMiddleware - Middleware that configures the Jinja2 environment, making it available on request state
1. TemplateRouter - An ASGI app that serves HTML templates
```
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.routing import Mount
from jinja2 import PackageLoader
from starlette_templates.routing import TemplateRouter
from starlette_templates.middleware import JinjaMiddleware
app = Starlette(
routes=[
Mount("/", TemplateRouter()),
],
middleware=[
Middleware(
JinjaMiddleware,
template_loaders=[PackageLoader("mypackage", "templates")],
)
]
)
```
## Static files
Starlette Templates provides enhanced static file handling with gzip compression and multi-directory support using StaticFiles.
```
from starlette.routing import Mount
from starlette_templates.staticfiles import StaticFiles
app = Starlette(
routes=[
Mount("/static", StaticFiles(directories=["static"]), name="static"),
Mount("/", TemplateRouter()),
]
)
```
For detailed information about static file handling, including pre-compressed files, multi-directory support, and HTTP caching, see the [Static Files](https://starlette-templates.tycho.engineering/static-files/index.md) guide.
## Custom routes
Override the default template routing by defining custom routes. Routes defined before the TemplateRouter mount take precedence:
```
from starlette.routing import Route
from starlette.requests import Request
from starlette_templates.responses import TemplateResponse
async def homepage(request: Request) -> TemplateResponse:
return TemplateResponse("home.html", context={"title": "Welcome"})
app = Starlette(
routes=[
# Custom route for homepage
Route("/", homepage, name="home"),
# Template routing for all other routes
Mount("/", TemplateRouter())
],
middleware=[
Middleware(
JinjaMiddleware,
template_loaders=[PackageLoader("myapp", "templates")]
)
]
)
```
## Middleware configuration
### JinjaMiddleware
JinjaMiddleware configures and injects the Jinja2 environment into request state, making it available at `request.state.jinja_env`.
```
from jinja2 import PackageLoader, FileSystemLoader
from starlette_templates.middleware import JinjaMiddleware
app = Starlette(
middleware=[
Middleware(
JinjaMiddleware,
template_loaders=[
PackageLoader("myapp", "templates"),
FileSystemLoader("custom/templates"),
],
include_default_loader=True, # Include built-in templates
)
]
)
```
The middleware provides:
- Automatic template loader configuration
- Built-in Jinja2 globals: `url_for`, `url`, `absurl`, `jsonify`
- Request state injection for all templates
### Registering components
Register ComponentModel classes to make them available in all templates:
```
from starlette_templates.components.base import ComponentModel
class Alert(ComponentModel):
template: str = "components/alert.html"
message: str
variant: str = "info"
app = Starlette(
middleware=[
Middleware(
JinjaMiddleware,
template_loaders=[PackageLoader("myapp", "templates")],
extra_components=[Alert], # Register component
)
]
)
```
Then use directly in templates:
```
{{ Alert(message="System error", variant="danger") }}
```
## Context processors
Add custom context variables to all templates using context processors:
```
from starlette.requests import Request
async def add_user_context(request: Request) -> dict:
return {"user": await get_current_user(request)}
async def add_site_context(request: Request) -> dict:
return {
"site_name": "My Site",
"year": datetime.now().year,
}
templates = TemplateRouter(
context_processors=[add_user_context, add_site_context]
)
```
Context processors are async functions that receive the [Request](https://starlette.dev/requests/) object and return a dictionary of context variables.
### Route-specific context processors
Context processors can also be applied to specific routes using Route objects with path patterns:
```
from starlette.requests import Request
from starlette.routing import Route
async def add_product_context(request: Request) -> dict:
"""Context processor that only runs for product pages."""
product_id = request.path_params.get("product_id")
return {"product": await get_product(product_id)}
async def add_user_context(request: Request) -> dict:
"""Global context processor that runs for all templates."""
return {"user": await get_current_user(request)}
templates = TemplateRouter(
context_processors=[
add_user_context, # Runs for all templates
# Only runs when path matches /products/{product_id}
Route("/products/{product_id}", add_product_context),
]
)
```
Route-specific context processors only execute when the URL path matches the route pattern, allowing you to add context data conditionally based on the request path. Path parameters from the route (like `{product_id}`) are available via `request.path_params`.
## Error handling
Starlette Templates provides error handling with custom exceptions, error pages, and JSON:API error responses.
Create custom error pages by adding templates like `404.html`, `500.html`, or `error.html` to your template directory. The framework automatically serves these pages for browser requests and returns JSON:API compliant responses for API requests.
For detailed information about error handling, custom exceptions, error pages, and JSON:API responses, see the [Error Handling](https://starlette-templates.tycho.engineering/errors/index.md) guide.
## HTTP caching
Configure HTTP caching with ETag and Last-Modified headers on TemplateRouter:
```
templates = TemplateRouter(
cache_max_age=3600, # Cache for 1 hour
gzip_min_size=1024, # Compress files > 1KB
)
```
TemplateRouter automatically:
- Generates ETag headers based on file modification time
- Sets Last-Modified headers
- Returns 304 Not Modified responses when appropriate
- Compresses responses over the threshold with gzip
## Markdown content
Markdown files can be rendered as HTML inside templates using the `{{ markdown(file) }}` Jinja2 function.
The file path is relative to template loaders configured in JinjaMiddleware and converted to HTML using the [Markdown](https://pypi.org/project/Markdown/) Python package.
You can also access all template variables and components inside the Markdown content.
```
# Hiking Tips
Wear comfortable shoes, carry water, and check the weather.
{{
Alert(
heading='Need Help?',
variant='info',
content=markdown('message.md')
)
}}
```
Jinja template syntax is parsed before converting Markdown to HTML. Markdown files receive the same context as the parent template. The converted HTML is then injected back into the parent template.
Markdown and [PyMdown](https://facelessuser.github.io/pymdown-extensions/) extensions are enabled by default:
- [`markdown.extensions.extra`](https://python-markdown.github.io/extensions/extra/) - A collection of useful extensions
- [`markdown.extensions.codehilite`](https://python-markdown.github.io/extensions/code_hilite/) - Syntax highlighting for code blocks
- [`markdown.extensions.nl2br`](https://python-markdown.github.io/reference/markdown/extensions/nl2br/) - Convert newlines to ` ` tags
- [`markdown.extensions.sane_lists`](https://python-markdown.github.io/extensions/sane_lists/) - Better handling of lists
- [`markdown.extensions.smarty`](https://python-markdown.github.io/extensions/smarty/) - Punctuation substitutions with HTML entities
- [`markdown.extensions.toc`](https://python-markdown.github.io/extensions/toc/) - Table of contents generation
- [`pymdownx.arithmatex`](https://facelessuser.github.io/pymdown-extensions/extensions/arithmatex/) - LaTeX math support
- [`pymdownx.betterem`](https://facelessuser.github.io/pymdown-extensions/extensions/betterem/) - Improved emphasis handling
- [`pymdownx.blocks.admonition`](https://facelessuser.github.io/pymdown-extensions/extensions/blocks/plugins/admonition/) - Admonition blocks
- [`pymdownx.blocks.definition`](https://facelessuser.github.io/pymdown-extensions/extensions/blocks/plugins/definition/) - Definition lists
- [`pymdownx.blocks.details`](https://facelessuser.github.io/pymdown-extensions/extensions/blocks/plugins/details/) - Details/summary blocks
- [`pymdownx.blocks.html`](https://facelessuser.github.io/pymdown-extensions/extensions/blocks/plugins/html/) - Raw HTML block support
- [`pymdownx.blocks.tab`](https://facelessuser.github.io/pymdown-extensions/extensions/blocks/plugins/tab/) - Tabbed content blocks
- [`pymdownx.caret`](https://facelessuser.github.io/pymdown-extensions/extensions/caret/) - Caret (^) for superscript
- [`pymdownx.details`](https://facelessuser.github.io/pymdown-extensions/extensions/details/) - Collapsible details blocks
- [`pymdownx.extra`](https://facelessuser.github.io/pymdown-extensions/extensions/extra/) - Extra features from PyMdown
- [`pymdownx.highlight`](https://facelessuser.github.io/pymdown-extensions/extensions/highlight/) - Syntax highlighting
- [`pymdownx.inlinehilite`](https://facelessuser.github.io/pymdown-extensions/extensions/inlinehilite/) - Inline code highlighting
- [`pymdownx.magiclink`](https://facelessuser.github.io/pymdown-extensions/extensions/magiclink/) - Auto-linking URLs and emails
- [`pymdownx.mark`](https://facelessuser.github.io/pymdown-extensions/extensions/mark/) - Highlight markup
- [`pymdownx.pathconverter`](https://facelessuser.github.io/pymdown-extensions/extensions/pathconverter/) - Convert file paths to links
- [`pymdownx.progressbar`](https://facelessuser.github.io/pymdown-extensions/extensions/progressbar/) - Progress bars
- [`pymdownx.saneheaders`](https://facelessuser.github.io/pymdown-extensions/extensions/saneheaders/) - Better header handling
- [`pymdownx.superfences`](https://facelessuser.github.io/pymdown-extensions/extensions/superfences/) - Fenced code blocks
- [`pymdownx.tabbed`](https://facelessuser.github.io/pymdown-extensions/extensions/tabbed/) - Tabbed content
- [`pymdownx.tasklist`](https://facelessuser.github.io/pymdown-extensions/extensions/tasklist/) - Task lists with checkboxes
- [`pymdownx.tilde`](https://facelessuser.github.io/pymdown-extensions/extensions/tilde/) - Subscript and strikethrough
# 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](https://starlette.dev/routing/) 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
├── templates/ # Shared templates at package level
│ ├── nav.html
│ ├── footer.html
│ └── error.html
└── static/ # Shared static files
├── css/
└── js/
```
This shows a single package example, but you can also have each sub-application (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=[
# Load camping-specific templates first
PackageLoader("myproject.camping", "templates"),
# Add shared templates from package level
PackageLoader("myproject", "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=[
# Load hiking-specific templates first
PackageLoader("myproject.hiking", "templates"),
# Add shared templates from package level
PackageLoader("myproject", "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 from package level
PackageLoader("myproject", "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/products` → `camping` app → `/products` → `camping/templates/products.html`
- `/hiking/` → `hiking` app → `/` → `hiking/templates/index.html`
- `/hiking/gear` → `hiking` app → `/gear` → `hiking/templates/gear.html`
- `/` → root app → `/` → `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(
JinjaMiddleware,
template_loaders=[
PackageLoader("myproject.camping", "templates"), # App-specific first
PackageLoader("myproject", "templates"), # Shared as fallback
]
)
```
This allows:
- App-specific templates override shared ones
- Common templates (nav, footer) from package-level templates are reused
- Each app can customize shared templates by creating local versions with the same name (e.g., `camping/templates/nav.html` overrides `templates/nav.html`)
### 2. Template Inheritance
Shared templates can define base layouts that sub-apps extend:
**templates/base.html:**
```
{% block title %}My Site{% endblock %}
{% include 'nav.html' %}
{% block content %}{% endblock %}
{% include 'footer.html' %}
```
**camping/templates/index.html:**
```
{% extends 'base.html' %}
{% block title %}Camping{% endblock %}
{% block content %}
Welcome to Camping
View Products
{% 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:
```
CampingCamping ProductsHikingHiking Gear
```
The `url()` function (alias for `url_for()`) takes:
1. Route name (e.g., `"camping"`)
1. 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)`.
# Templates
TemplateRouter is an ASGI app that serves HTML templates with automatic routing and file resolution.
```
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.routing import Mount
from jinja2 import PackageLoader
from starlette_templates.routing import TemplateRouter
from starlette_templates.middleware import JinjaMiddleware
app = Starlette(
routes=[
Mount("/", TemplateRouter()),
],
middleware=[
# Required for template autodiscovery
Middleware(
JinjaMiddleware,
template_loaders=[PackageLoader("myapp", "templates")],
)
]
)
```
### Features
- Automatically tries `.html`, `.htm`, `.jinja`, `.jinja2` extensions
- Serves `index.html` or `index.htm` for directory requests
- HTTP caching with ETag and Last-Modified headers with 304 responses
- Automatic gzip compression for responses over 500 bytes
- Custom error pages with `404.html`, `500.html`, `error.html`
- Custom context variables with context processors
### File Resolution
When a request is made, `TemplateRouter` resolves templates in this order:
1. Exact match: `/about` → `about.html`
1. With extensions: `/about` → tries `about.html`, `about.htm`, `about.jinja`, `about.jinja2`
1. Index files: `/blog/` → tries `blog/index.html`, `blog/index.htm`
### Configuration
```
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.routing import Mount
from starlette.requests import Request
from jinja2 import PackageLoader
from starlette_templates.routing import TemplateRouter
from starlette_templates.middleware import JinjaMiddleware
async def add_context(request: Request) -> dict:
return {"user": await get_current_user(request)}
app = Starlette(
routes=[
Mount("/", TemplateRouter(
context_processors=[add_context], # Add custom context
cache_max_age=3600, # 1 hour cache
gzip_min_size=500, # Compress files > 500 bytes
)),
],
middleware=[
# Required for template autodiscovery
Middleware(
JinjaMiddleware,
template_loaders=[PackageLoader("myapp", "templates")],
)
]
)
```
### Route-Specific Context Processors
Add context processors that only run for specific URL patterns using Starlette's `Route`:
```
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.routing import Mount, Route
from starlette.requests import Request
from jinja2 import PackageLoader
from starlette_templates.routing import TemplateRouter
from starlette_templates.middleware import JinjaMiddleware
# Global context processor - runs for ALL templates
async def add_user_context(request: Request) -> dict:
return {"user": await get_current_user(request)}
# Route-specific context processors
async def add_country_context(request: Request) -> dict:
"""Only runs for /country/{code} paths."""
code = request.path_params["code"]
return {"country": await get_country(code)}
async def add_post_context(request: Request) -> dict:
"""Only runs for /blog/{post_id} paths."""
post_id = request.path_params["post_id"]
return {"post": await get_post(post_id)}
app = Starlette(
routes=[
Mount("/", TemplateRouter(
context_processors=[
add_user_context, # Global - runs for all templates
Route('/country/{code}', add_country_context), # Only /country/* paths
Route('/blog/{post_id}', add_post_context), # Only /blog/* paths
]
)),
],
middleware=[
Middleware(
JinjaMiddleware,
template_loaders=[PackageLoader("myapp", "templates")],
)
]
)
```
In your template at `/country/us`:
```
{{ country.name }}
User: {{ user.name }}
```
Route-specific processors:
- Have access to path parameters via `request.path_params`
- Only execute when their route pattern matches the current request
- Can be mixed with global processors in the same list
- Use Starlette's existing `Route` class - no new abstractions
## Template Response
Use TemplateResponse to render templates in route handlers:
```
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.routing import Route
from starlette.requests import Request
from jinja2 import PackageLoader
from starlette_templates.responses import TemplateResponse
from starlette_templates.middleware import JinjaEnvMiddleware
async def homepage(request: Request) -> TemplateResponse:
return TemplateResponse(
"home.html",
context={"title": "Welcome", "items": [1, 2, 3]}
)
app = Starlette(
routes=[
Route("/", homepage),
],
middleware=[
# Required for template autodiscovery
Middleware(
JinjaMiddleware,
template_loaders=[PackageLoader("myapp", "templates")],
)
]
)
```
### Context Variables
TemplateResponse automatically includes:
- `request` - The Starlette `Request` object
- All context variables from context processors
- Any additional context passed to `TemplateResponse`
## Template Context
All templates have access to:
### Request Object
```
Current path: {{ request.url.path }}
Host: {{ request.url.hostname }}
Method: {{ request.method }}
User agent: {{ request.headers.get('user-agent') }}
```
### URL Helpers
#### url_for
Generate URLs for named routes:
```
Profile
```
#### url
Generate URLs for mounted apps:
```
```
#### absurl
Generate absolute URLs:
```
```
### JSON Serialization
Safely embed Python data in templates with `jsonify`:
```
```
This properly escapes and serializes Python objects to JSON.
## Custom Filters and Globals
Add custom Jinja2 filters and globals through JinjaMiddleware:
```
from starlette.applications import Starlette
from starlette.middleware import Middleware
from jinja2 import Environment, PackageLoader
from starlette_templates.middleware import JinjaMiddleware
def setup_jinja_env(env: Environment):
# Add custom filter
env.filters['uppercase'] = lambda x: x.upper()
# Add custom global
env.globals['site_name'] = "My Site"
app = Starlette(
middleware=[
Middleware(
JinjaMiddleware,
template_loaders=[PackageLoader("myapp", "templates")],
# Custom setup function
environment_setup=setup_jinja_env,
)
]
)
```
Then use in templates:
```
{{ "hello" | uppercase }}
{{ site_name }}
```
## Template Loaders
Configure template loading with [Jinja2 loaders](https://jinja.palletsprojects.com/en/stable/api/#loaders):
### PackageLoader
Load templates from a Python package:
```
from starlette.applications import Starlette
from starlette.middleware import Middleware
from jinja2 import PackageLoader
from starlette_templates.middleware import JinjaMiddleware
app = Starlette(
middleware=[
Middleware(
JinjaMiddleware,
template_loaders=[
PackageLoader("myapp", "templates"),
]
)
]
)
```
### FileSystemLoader
Load templates from filesystem directories:
```
from starlette.applications import Starlette
from starlette.middleware import Middleware
from jinja2 import FileSystemLoader
from starlette_templates.middleware import JinjaMiddleware
app = Starlette(
middleware=[
Middleware(
JinjaMiddleware,
template_loaders=[
FileSystemLoader("custom/templates"),
]
)
]
)
```
### Multiple Loaders
Use multiple loaders with fallback priority:
```
from starlette.applications import Starlette
from starlette.middleware import Middleware
from jinja2 import PackageLoader, FileSystemLoader
from starlette_templates.middleware import JinjaMiddleware
app = Starlette(
middleware=[
Middleware(
JinjaMiddleware,
template_loaders=[
PackageLoader("myapp", "templates"), # Check here first
FileSystemLoader("custom/templates"), # Then here
],
include_default_loader=True, # Then built-in templates
)
]
)
```
## Error Pages
Create custom error pages by adding templates to your template directory:
### 404.html
```
404 Not Found
{% endblock %}
```
## Including Templates
Include reusable template fragments:
```
{% include "partials/navigation.html" %}
{% include "partials/alert.html" with context %}
```
## Macros
Define reusable template functions with macros:
```
{% macro render_field(field) %}
{% if field.errors %}
{{ field.errors }}
{% endif %}
{% endmacro %}
{{ render_field(form.name) }}
{{ render_field(form.email) }}
```
# Static Files
Starlette-Templates provides static file handling with
- Pre-compressed gzip file support
- Automatic HTTP caching headers
- Multi-directory fallback support
- ETag and Last-Modified headers
## StaticFiles
StaticFiles serves static files with support for pre-compressed `.gz` files that are automatically decompressed in browsers and multi-directory fallback support.
### Basic Usage
```
from starlette.applications import Starlette
from starlette.routing import Mount
from starlette_templates.staticfiles import StaticFiles
app = Starlette(
routes=[
Mount("/static", StaticFiles(directories=["static"]), name="static"),
]
)
```
### Pre-compressed Files
Create pre-compressed versions of your static files for better performance:
```
# Create pre-compressed CSS files
gzip -k static/css/bootstrap.css
gzip -k static/css/style.css
# Create pre-compressed JavaScript files
gzip -k static/js/app.js
gzip -k static/js/vendor.js
```
This creates `.gz` versions alongside the original files:
```
static/
├── css/
│ ├── bootstrap.css
│ ├── bootstrap.css.gz
│ ├── style.css
│ └── style.css.gz
└── js/
├── app.js
├── app.js.gz
├── vendor.js
└── vendor.js.gz
```
### Using Pre-compressed Files
Reference the `.gz` files in your templates:
```
```
The browser automatically decompresses the files and applies proper caching headers.
### How It Works
When serving a `.gz` file, StaticFiles:
1. Serves the pre-compressed content
1. Sets `Content-Encoding: gzip` header
1. Sets appropriate `Content-Type` based on the original file extension
1. Adds `ETag` and `Last-Modified` headers for caching
1. Returns `304 Not Modified` for cached requests
### Configuration
```
from pathlib import Path
static_files = StaticFiles(
directories=[Path("static")],
packages=None, # Optional: list of package names
html=False, # Don't serve HTML files
check_dir=True, # Verify directory exists
)
```
## Multi-Directory Support
StaticFiles serves static files from multiple directories with fallback priority, useful for:
- Theme overrides
- Plugin systems
- Framework + application static files
- Development vs production assets
### Basic Usage
```
from pathlib import Path
from starlette.applications import Starlette
from starlette.routing import Mount
from starlette_templates.staticfiles import StaticFiles
static_files = StaticFiles(
directories=[
Path("myapp/static"), # Check here first
Path("framework/static"), # Fallback to here
Path("vendor/static"), # Then here
]
)
app = Starlette(
routes=[
Mount("/static", static_files, name="static"),
]
)
```
### Fallback Priority
Files are resolved in order:
1. If `myapp/static/css/style.css` exists, serve it
1. Otherwise, if `framework/static/css/style.css` exists, serve it
1. Otherwise, if `vendor/static/css/style.css` exists, serve it
1. Otherwise, return 404
This allows you to override framework files with your application files.
### Example Directory Structure
```
myapp/
└── static/
└── css/
└── custom.css # Application-specific styles
framework/
└── static/
├── css/
│ ├── base.css # Framework base styles
│ └── components.css # Framework components
└── js/
└── framework.js # Framework JavaScript
vendor/
└── static/
└── js/
├── jquery.js # Third-party libraries
└── bootstrap.js
```
### Usage in Templates
```
```
### Combining Multi-Directory and Gzip Support
StaticFiles automatically supports both multi-directory fallback and pre-compressed files:
```
from pathlib import Path
from starlette_templates.staticfiles import StaticFiles
# StaticFiles automatically supports .gz files and multiple directories
static_files = StaticFiles(
directories=[
Path("myapp/static"),
Path("framework/static"),
]
)
```
Then create compressed versions in any directory:
```
gzip -k myapp/static/css/custom.css
gzip -k framework/static/css/base.css
```
## HTTP Caching
StaticFiles supports HTTP caching:
### ETag Headers
Automatically generated based on file modification time and size:
```
ETag: "abc123-1234567890"
```
### Last-Modified Headers
Set from file modification time:
```
Last-Modified: Wed, 20 Dec 2023 12:00:00 GMT
```
### 304 Not Modified
When client sends `If-None-Match` or `If-Modified-Since` headers that match:
```
HTTP/1.1 304 Not Modified
ETag: "abc123-1234567890"
```
### Cache-Control Headers
Set appropriate cache headers for static files:
```
from starlette.responses import Response
# In your static files configuration
static_files = StaticFiles(
directories=["static"],
# Files are served with cache headers
)
```
Response includes:
```
Cache-Control: public, max-age=3600
```
## Best Practices
### Pre-compression
Pre-compress large static files for production:
```
# Find and compress all CSS and JS files
find static -type f \( -name "*.css" -o -name "*.js" \) -exec gzip -k {} \;
# Or use a more selective approach
gzip -k static/css/bootstrap.css
gzip -k static/js/app.bundle.js
```
### Build Process Integration
Integrate compression into your build process:
```
# build.py
import gzip
import shutil
from pathlib import Path
def compress_static_files():
static_dir = Path("static")
for file in static_dir.rglob("*.css"):
with open(file, 'rb') as f_in:
with gzip.open(f"{file}.gz", 'wb') as f_out:
shutil.copyfileobj(f_in, f_out)
for file in static_dir.rglob("*.js"):
with open(file, 'rb') as f_in:
with gzip.open(f"{file}.gz", 'wb') as f_out:
shutil.copyfileobj(f_in, f_out)
if __name__ == "__main__":
compress_static_files()
```
### Directory Organization
Organize static files by type and priority:
```
static/
├── css/
│ ├── vendor/ # Third-party CSS
│ ├── base/ # Base styles
│ └── components/ # Component styles
├── js/
│ ├── vendor/ # Third-party JS
│ └── app/ # Application JS
├── img/
│ ├── icons/
│ └── photos/
└── fonts/
```
### Development vs Production
Use different configurations for development and production:
```
import os
from pathlib import Path
from starlette_templates.staticfiles import StaticFiles
# StaticFiles works for both development and production
static_files = StaticFiles(directories=["static"])
# In production, ensure you have .gz files created for better performance
```
### CDN Integration
For production, consider using a CDN with fallback:
```
# In your context processor
async def add_static_context(request: Request) -> dict:
cdn_url = os.getenv("CDN_URL", "")
return {
"static_url": cdn_url if cdn_url else request.url_for("static", path="/"),
}
```
In templates:
```
```
## Troubleshooting
### File Not Found
If static files aren't loading:
1. Verify the directory path is correct
1. Check file permissions
1. Ensure the mount path is correct
1. Verify the static files route comes before catch-all routes
```
app = Starlette(
routes=[
# Static files BEFORE template files
Mount("/static", StaticFiles(directories=["static"]), name="static"),
# Template files last
Mount("/", TemplateRouter()),
]
)
```
### Gzip Not Working
If pre-compressed files aren't being served:
1. Verify `.gz` files exist
1. Check file naming (must end in `.gz`)
1. Ensure you're using starlette-templates `StaticFiles` (not Starlette's built-in `StaticFiles`)
1. Check browser accepts gzip encoding
### Caching Issues
If files aren't being cached:
1. Check browser caching is enabled
1. Verify ETag headers are being sent
1. Clear browser cache for testing
1. Check proxy/CDN configuration doesn't strip cache headers
# Forms
Starlette-Templates provides type-safe forms with automatic validation and rendering using Pydantic models. The FormModel base class extends Pydantic's `BaseModel` with form-specific functionality.
## Creating forms
Define forms using the FormModel base class and field types:
```
from starlette_templates.forms import (
FormModel, TextField, SelectField, SubmitButtonField
)
class ContactForm(FormModel):
name: str = TextField(
label="Your Name",
placeholder="Enter your name",
required=True,
min_length=2,
)
category: str = SelectField(
default="general",
choices={
"general": "General Inquiry",
"support": "Technical Support",
"sales": "Sales",
},
label="Category",
)
submit: str = SubmitButtonField(text="Send Message")
```
FormModel supports various field types (see [Available Form Fields](#available-form-fields) below) and validation using Pydantic's validators.
The fields, like TextField and SelectField, are [Pydantic model fields](https://docs.pydantic.dev/latest/concepts/fields/) with additional metadata for rendering and validation. These form fields define template components, labels, placeholders, and validation rules, in-addition to the standard Pydantic field behavior.
## Using forms in routes
### Basic usage
```
from starlette.routing import Route
from starlette.requests import Request
from starlette_templates.responses import TemplateResponse
async def contact_page(request: Request) -> TemplateResponse:
# Parse form data and validate without raising on error
form = await ContactForm.from_request(request, raise_on_error=False)
# Check if form is valid and was submitted
if form.is_valid(request):
# Send email, save to database, etc.
await send_contact_email(form.name, form.email, form.category)
return TemplateResponse("success.html", context={"form": form})
# Show form (with errors if validation failed)
return TemplateResponse("contact.html", context={"form": form})
app = Starlette(
routes=[Route("/contact", contact_page, methods=["GET", "POST"])]
)
```
### Error handling
```
from pydantic import ValidationError
async def signup_page(request: Request) -> TemplateResponse:
try:
# Raise ValidationError on invalid data
form = await SignupForm.from_request(request, raise_on_error=True)
if form.is_valid(request):
# Process valid form
user = await create_user(form)
return TemplateResponse("welcome.html", context={"user": user})
except ValidationError as e:
# Handle validation errors
return TemplateResponse(
"signup.html",
context={"errors": e.errors()},
status_code=400
)
# Show empty form for GET requests
form = SignupForm()
return TemplateResponse("signup.html", context={"form": form})
```
## Rendering forms
### Complete form
A FormModel instance can be rendered in templates using the `form()` function or by rendering individual fields.
Render the entire form with a single call:
```
{{ form() }}
```
You can pass form attributes like `method` `action`, and `enctype`:
```
{{ form(
method="POST",
action="/submit",
enctype="multipart/form-data")
}}
```
### Individual fields
Render individual fields with full control:
```
```
### Accessing field values
Access validated field values directly:
```
{% if form.is_valid(request) %}
Thank you, {{ form.name }}!
We'll contact you at {{ form.email }}
{% endif %}
```
### Validation errors
Validation errors are automatically displayed when you render fields. The FormModel injects validation state and error messages into rendered components.
```
```
Check if the form has validation errors:
```
{% if form.has_errors() %}
{{ form.get_error_banner_text() }}
{% endif %}
```
The `has_errors()` method returns `True` if there are any validation errors, and `get_error_banner_text()` returns the configured error banner message (default: "Please correct the errors below").
### How error handling works
When FormModel.from_request() is called with `raise_on_error=False`:
1. The form attempts to validate the request data
1. If validation fails, errors are captured internally in `_field_errors`
1. When you call `form.render('field_name')`, the error is automatically injected into the component
1. The component displays the error message with appropriate styling (e.g., Bootstrap's `is-invalid` class)
This means you don't need to manually access or display errors - they're handled automatically by the rendering system.
## Available form fields
### TextField
TextField - Single-line text input:
```
name: str = TextField(
label="Name",
placeholder="Enter your name",
required=True,
min_length=2,
max_length=100,
)
```
### TextAreaField
TextAreaField - Multi-line text input:
```
message: str = TextAreaField(
label="Message",
placeholder="Enter your message",
rows=5,
required=True,
min_length=10,
)
```
### EmailField
EmailField - Email input with validation:
```
email: str = EmailField(
label="Email",
placeholder="you@example.com",
required=True,
)
```
### IntegerField
IntegerField - Numeric input for integers:
```
age: int = IntegerField(
label="Age",
min_value=18,
max_value=120,
required=True,
)
```
### FloatField
FloatField - Numeric input for floats:
```
price: float = FloatField(
label="Price",
min_value=0.0,
step=0.01,
required=True,
)
```
### CheckboxField
CheckboxField - Boolean checkbox:
```
agree: bool = CheckboxField(
default=False,
label="I agree to the terms",
required=True,
)
```
### SelectField
SelectField - Dropdown select (single or multiple):
```
# Single select
country: str = SelectField(
label="Country",
choices={
"us": "United States",
"uk": "United Kingdom",
"ca": "Canada",
},
default="us",
)
# Multiple select
interests: list[str] = SelectField(
label="Interests",
choices={
"tech": "Technology",
"music": "Music",
"sports": "Sports",
},
multiple=True,
)
```
### DateField
DateField - Date picker with Flatpickr:
```
from datetime import date
birth_date: date = DateField(
label="Birth Date",
required=True,
)
```
### HiddenField
HiddenField - Hidden input:
```
user_id: int = HiddenField(default=0)
```
### SubmitButtonField
SubmitButtonField - Submit button:
```
submit: str = SubmitButtonField(text="Submit")
```
### TagField
TagField - Tag input with comma separation:
```
tags: list[str] = TagField(
label="Tags",
placeholder="Enter tags separated by commas",
)
```
## Form validation
### Pydantic validators
Use [Pydantic validators](https://docs.pydantic.dev/latest/concepts/validators/) for custom validation logic:
```
from pydantic import field_validator, model_validator
class RegistrationForm(FormModel):
username: str = TextField(label="Username", required=True)
password: str = TextField(label="Password", type="password", required=True)
confirm_password: str = TextField(label="Confirm Password", type="password", required=True)
@field_validator('username')
@classmethod
def validate_username(cls, v):
if len(v) < 3:
raise ValueError('Username must be at least 3 characters')
if not v.isalnum():
raise ValueError('Username must be alphanumeric')
return v
@model_validator(mode='after')
def validate_passwords(self):
if self.password != self.confirm_password:
raise ValueError('Passwords do not match')
return self
```
### Custom validation
Add custom validation methods:
```
class PaymentForm(FormModel):
amount: float = FloatField(label="Amount", required=True)
currency: str = SelectField(
label="Currency",
choices={"usd": "USD", "eur": "EUR", "gbp": "GBP"}
)
@field_validator('amount')
@classmethod
def validate_amount(cls, v):
if v <= 0:
raise ValueError('Amount must be positive')
if v > 10000:
raise ValueError('Amount cannot exceed 10,000')
return v
```
## Parsing request data
The model_from_request helper combines path parameters, query parameters, and body data:
```
from starlette_templates.forms import model_from_request
from pydantic import BaseModel
class UserData(BaseModel):
name: str
email: str
age: int
async def create_user(request: Request):
# Combines path params, query params, and body data
# data is a validated UserData instance
data = await model_from_request(request, UserData)
```
If path, query, and body parameters overlap, body data takes precedence over query parameters, which take precedence over path parameters.
If you are using a FormModel, use FormModel.from_request() instead to get form-specific behavior and validation.
```
from starlette_templates.forms import FormModel
class ProfileForm(FormModel):
username: str
bio: str
async def edit_profile(request: Request):
# Combines path params, query params, and body data
# form is a validated ProfileForm instance
form = await ProfileForm.from_request(request)
```
# Components
Components in Starlette-Templates are reusable UI elements built with Jinja2 templates and Pydantic models. They provide:
- Type safety with Pydantic validation
- Reusable, testable UI elements
- Template-based rendering
- Integration with the Jinja2 environment
## Creating Components
Define components using the ComponentModel base class:
```
from starlette_templates.components.base import ComponentModel
from pydantic import Field
class Alert(ComponentModel):
# Path to component template
template: str = "components/alert.html"
# Component properties with validation
message: str = Field(..., description="Alert message")
variant: str = Field(default="info", description="Alert variant")
dismissible: bool = Field(default=False, description="Show close button")
```
### Component Template
Create the template file (`components/alert.html`):
```
{{ message }}
{% if dismissible %}
{% endif %}
```
## Using Components
### In Routes
Create component instances and pass them to templates:
```
from starlette.requests import Request
from starlette_templates.responses import TemplateResponse
async def dashboard(request: Request) -> TemplateResponse:
alert = Alert(
message="Welcome back!",
variant="success",
dismissible=True
)
return TemplateResponse(
"dashboard.html",
context={"alert": alert}
)
```
### In Templates
Render components by calling them:
```
{{ alert }}
```
### Global Registration
Register components globally to use them in any template:
```
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette_templates.middleware import JinjaMiddleware
app = Starlette(
middleware=[
Middleware(
JinjaMiddleware,
template_loaders=[PackageLoader("myapp", "templates")],
extra_components=[Alert], # Register component
)
]
)
```
Then use directly in templates without passing from routes:
```
{{ Alert(message="System error", variant="danger", dismissible=True) }}
```
## Built-in Form Components
Starlette Templates includes Bootstrap-compatible form components:
### Input
Input - Text, email, password, number inputs:
```
from starlette_templates.components.forms import Input
# In route
input_field = Input(
name="username",
label="Username",
type="text",
placeholder="Enter username",
required=True,
)
# In template
{{ Input(name="email", label="Email", type="email", required=True) }}
```
### Textarea
Textarea - Multi-line text input:
```
from starlette_templates.components.forms import Textarea
textarea = Textarea(
name="message",
label="Message",
rows=5,
placeholder="Enter your message",
)
```
### Select
Select - Native select dropdown:
```
from starlette_templates.components.forms import Select
select = Select(
name="country",
label="Country",
choices={
"us": "United States",
"uk": "United Kingdom",
"ca": "Canada",
},
selected="us",
)
```
### Checkbox
Checkbox - Checkbox input:
```
from starlette_templates.components.forms import Checkbox
checkbox = Checkbox(
name="agree",
label="I agree to the terms",
checked=False,
)
```
### Radio
Radio - Radio button:
```
from starlette_templates.components.forms import Radio
# In template
{{ Radio(name="plan", label="Basic Plan", value="basic", checked=True) }}
{{ Radio(name="plan", label="Pro Plan", value="pro") }}
```
### Switch
Switch - Toggle switch:
```
from starlette_templates.components.forms import Switch
switch = Switch(
name="notifications",
label="Enable notifications",
checked=True,
)
```
### FileInput
FileInput - File upload:
```
from starlette_templates.components.forms import FileInput
file_input = FileInput(
name="avatar",
label="Profile Picture",
accept="image/*",
)
```
### Range
Range - Range slider:
```
from starlette_templates.components.forms import Range
range_slider = Range(
name="volume",
label="Volume",
min_value=0,
max_value=100,
step=1,
value=50,
)
```
### ChoicesSelect
ChoicesSelect - Enhanced select with Choices.js:
```
from starlette_templates.components.forms import ChoicesSelect
choices_select = ChoicesSelect(
name="tags",
label="Tags",
choices={
"python": "Python",
"javascript": "JavaScript",
"go": "Go",
},
multiple=True,
)
```
### DatePicker
DatePicker - Date picker with Flatpickr:
```
from starlette_templates.components.forms import DatePicker
date_picker = DatePicker(
name="birth_date",
label="Birth Date",
placeholder="Select date",
)
```
### SubmitButton
SubmitButton - Form submit button:
```
from starlette_templates.components.forms import SubmitButton
submit_btn = SubmitButton(
text="Submit Form",
variant="primary",
)
```
## Component Examples
### Card Component
```
class Card(ComponentModel):
template: str = "components/card.html"
title: str = Field(..., description="Card title")
body: str = Field(..., description="Card body content")
footer: str | None = Field(None, description="Card footer")
variant: str = Field(default="default", description="Card style variant")
```
Template (`components/card.html`):
```