12 KiB
Jinja2 / FastAPI — Referenz
Referenz für den Einsatz von CSS-Framework in FastAPI-Apps mit Jinja2-Templates und HTMX. Schnellreferenz für Komponenten und Tokens: SKILL.md
Setup
# main.py
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
app = FastAPI()
app.add_middleware(SessionMiddleware, secret_key="...")
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")
Statische Dateien werden direkt über den Pfad /static/... referenziert — kein url_for im Template nötig.
Basis-Template
templates/base.html:
<!DOCTYPE html>
<html lang="de" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}App{% endblock %}</title>
<!-- Theme vor dem Rendern setzen — verhindert FOUC, ohne Inline-Skript (CSP) -->
<script src="/static/js/theme.js"></script>
<!-- HTMX ohne eigene Inline-Styles und ohne eval (CSP) -->
<meta name="htmx-config"
content='{"includeIndicatorStyles": false, "allowEval": false, "allowScriptTags": false}'>
<link rel="stylesheet" href="/static/css/main.css">
{% block head %}{% endblock %}
</head>
<body>
<!-- Mobile Topbar -->
<div class="mobile-topbar">
<button data-action="sidebar-open" class="btn btn-ghost btn-icon">☰</button>
<span>{% block mobile_title %}App{% endblock %}</span>
</div>
<div id="sidebar-backdrop" class="sidebar-backdrop" data-action="sidebar-close"></div>
<div class="with-sidebar">
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<a href="/" class="sidebar-brand">App</a>
</div>
<nav class="sidebar-nav">
{% block sidebar %}
<div class="sidebar-section">
<p class="sidebar-section-title">Navigation</p>
<a href="/"
class="sidebar-link {% if request.url.path == '/' %}active{% endif %}">
Übersicht
</a>
<a href="/einstellungen"
class="sidebar-link {% if request.url.path.startswith('/einstellungen') %}active{% endif %}">
Einstellungen
</a>
</div>
{% endblock %}
</nav>
</aside>
<main class="stack stack-lg min-w-0">
<!-- Flash Messages (via SessionMiddleware) -->
{% for msg in request.session.pop('_messages', []) %}
<div class="alert alert-{{ msg.category }}">
<div class="alert-content"><p>{{ msg.text }}</p></div>
<button class="alert-dismiss" data-action="alert-dismiss">✕</button>
</div>
{% endfor %}
{% block content %}{% endblock %}
</main>
</div>
<!-- Toast Container -->
<div class="toast-container" id="toast-container"></div>
<!-- HTMX -->
<script src="/static/js/htmx.min.js" defer></script>
<!-- Framework JS: data-action und globale Funktionen -->
<script src="/static/js/framework.js" defer></script>
{% block scripts %}{% endblock %}
</body>
</html>
Template rendern
from fastapi import Request
from fastapi.responses import HTMLResponse
@router.get("/", response_class=HTMLResponse)
async def index(request: Request):
return templates.TemplateResponse(request, "index.html", {
"items": await get_items()
})
request wird immer explizit übergeben — Jinja2Templates macht ihn im Template als request verfügbar.
Flash Messages
FastAPI hat kein eingebautes Flash-System. Einfache Session-Lösung mit SessionMiddleware:
# Helfer-Funktion
def flash(request: Request, text: str, category: str = "info"):
messages = request.session.setdefault('_messages', [])
messages.append({'text': text, 'category': category})
# In einer Route
@router.post("/profil")
async def profil_speichern(request: Request):
flash(request, 'Profil aktualisiert', 'success')
flash(request, 'Ungültige Eingabe', 'danger')
flash(request, 'Bitte bestätigen', 'warning')
return RedirectResponse("/profil", status_code=303)
Im Template werden die Messages über request.session.pop('_messages', []) gelesen (siehe Basis-Template oben).
HTMX-Muster
Button Loading State
hx-disabled-elt="this" sperrt den Button; .htmx-indicator blendet den Spinner ein solange die Anfrage läuft.
<button class="btn btn-primary"
hx-post="/api/speichern"
hx-target="#ergebnis"
hx-swap="outerHTML"
hx-disabled-elt="this">
<span class="htmx-indicator spinner spinner-sm"></span>
Speichern
</button>
<div id="ergebnis"></div>
Formular mit Inline-Validation (422)
FastAPI antwortet bei Fehlern mit HTTP 422 und gibt das Formular-Partial zurück:
from fastapi import Form, status
from fastapi.responses import HTMLResponse
@router.post("/api/formular", response_class=HTMLResponse)
async def formular(
request: Request,
email: str = Form(...)
):
errors = {}
if "@" not in email:
errors["email"] = "Ungültige E-Mail-Adresse"
if errors:
return templates.TemplateResponse(
request, "_form.html",
{"form": {"email": email}, "errors": errors},
status_code=422
)
# Erfolg
return templates.TemplateResponse(request, "_form_success.html", {})
<!-- templates/_form.html — wird per hx-swap="outerHTML" ersetzt -->
<form hx-post="/api/formular"
hx-swap="outerHTML"
id="mein-formular">
<div class="form-group">
<label for="email">E-Mail</label>
<input type="email" id="email" name="email"
value="{{ form.email or '' }}"
class="{{ 'error' if errors and errors.email }}">
{% if errors and errors.email %}
<span class="form-error">{{ errors.email }}</span>
{% endif %}
</div>
<button type="submit" class="btn btn-primary">
<span class="htmx-indicator spinner spinner-sm"></span>
Absenden
</button>
</form>
Skeleton als Ladeindikator
<div hx-get="/api/tabelle" hx-trigger="load" hx-swap="outerHTML">
<div class="stack">
<div class="skeleton skeleton-title"></div>
<div class="skeleton skeleton-line"></div>
<div class="skeleton skeleton-line is-short"></div>
</div>
</div>
Modal via HTMX befüllen
<!-- Auslöser (z.B. in einer Tabellenzeile) -->
<button class="btn btn-ghost btn-sm"
hx-get="/api/detail/{{ item.id }}"
hx-target="#modal-body"
hx-swap="innerHTML"
data-action="modal-open" data-target="detail-modal">
Details
</button>
<!-- Modal-Gerüst — einmalig im Template -->
<div class="modal-backdrop" id="detail-modal"
data-action="modal-backdrop">
<div class="modal">
<div class="modal-header">
<h3>Detail</h3>
<button class="btn btn-ghost btn-icon btn-sm"
data-action="modal-close" data-target="detail-modal">✕</button>
</div>
<div class="modal-body" id="modal-body">
<div class="skeleton skeleton-block"></div>
</div>
</div>
</div>
Toast über Server-Response (HX-Trigger)
import json
from fastapi.responses import HTMLResponse
@router.post("/api/speichern", response_class=HTMLResponse)
async def speichern(request: Request):
content = templates.TemplateResponse(request, "_partial.html", {})
content.headers['HX-Trigger'] = json.dumps({
'showToast': {'message': 'Gespeichert', 'type': 'success'}
})
return content
Listener im <body> des Basis-Templates:
<body hx-on::after-request="
var h = event.detail.xhr.getResponseHeader('HX-Trigger');
if (h) {
try {
var t = JSON.parse(h);
if (t.showToast) showToast(t.showToast.message, t.showToast.type);
} catch(e) {}
}
">
Live-Suche
<input type="search"
name="q"
placeholder="Suchen…"
hx-get="/api/suche"
hx-trigger="input changed delay:300ms"
hx-target="#ergebnisse"
hx-swap="innerHTML">
<div id="ergebnisse"></div>
@router.get("/api/suche", response_class=HTMLResponse)
async def suche(request: Request, q: str = ""):
results = await search_items(q)
return templates.TemplateResponse(request, "_ergebnisse.html", {"results": results})
Unendliches Nachladen (Intersection Observer)
<div id="liste">
{% for item in items %}
<!-- Zeile -->
{% endfor %}
{% if has_more %}
<div hx-get="/api/liste?page={{ next_page }}"
hx-trigger="intersect once"
hx-target="#liste"
hx-swap="beforeend">
<div class="cluster justify-center p-4">
<div class="spinner"></div>
</div>
</div>
{% endif %}
</div>
Formular-Validation mit Pydantic
from pydantic import BaseModel, EmailStr, ValidationError
class ProfilForm(BaseModel):
name: str
email: EmailStr
@router.post("/api/profil", response_class=HTMLResponse)
async def profil_speichern(
request: Request,
name: str = Form(...),
email: str = Form(...)
):
errors = {}
try:
ProfilForm(name=name, email=email)
except ValidationError as e:
for err in e.errors():
errors[err['loc'][0]] = err['msg']
if errors:
return templates.TemplateResponse(
request, "_profil_form.html",
{"form": {"name": name, "email": email}, "errors": errors},
status_code=422
)
await update_profil(name=name, email=email)
flash(request, 'Profil aktualisiert', 'success')
return RedirectResponse("/profil", status_code=303)
Pagination
# Route
@router.get("/liste", response_class=HTMLResponse)
async def liste(request: Request, page: int = 1, per_page: int = 20):
total = await count_items()
items = await get_items(offset=(page - 1) * per_page, limit=per_page)
return templates.TemplateResponse(request, "liste.html", {
"items": items,
"page": page,
"per_page": per_page,
"total": total,
"pages": (total + per_page - 1) // per_page,
})
{% if pages > 1 %}
<nav class="pagination">
{% if page > 1 %}
<a class="page-item" href="?page={{ page - 1 }}">‹</a>
{% endif %}
{% for p in range(1, pages + 1) %}
<a class="page-item {{ 'active' if p == page }}"
href="?page={{ p }}">{{ p }}</a>
{% endfor %}
{% if page < pages %}
<a class="page-item" href="?page={{ page + 1 }}">›</a>
{% endif %}
</nav>
{% endif %}
Dos & Don'ts
Do:
- Farben und Abstände ausschließlich über Tokens:
var(--accent),var(--space-4)usw. - Semantisches HTML classless einsetzen:
<h2>,<p>,<table>brauchen keine Klassen requestimmer explizit anTemplateResponseübergeben — Starlette macht ihn im Template verfügbar- HTMX-Attribute direkt auf Framework-Elemente — kein Wrapper-
<div>nötig - Globale JS-Funktionen nutzen (
openModal,showToast,sortTableusw.)
Don't:
- Keine
style-Attribute, keineon…-Attribute, kein Inline-<script>: Die App läuft mit strenger CSP. Klassen statt Styles (style="color:#333"→class="text-muted"),data-actionstattonclick, eigene Skripte als Datei - Variable Breiten (Fortschritt) über
data-progress="75", nicht überstyle - Kein
hx-on(brauchteval) - Kein paralleles Einbinden von Tailwind oder anderem Utility-Framework
- Keine neuen CSS-Custom-Properties die nicht in
tokens.cssdes Frameworks stehen main.cssnicht verändern — nur per<link>einbinden