FastAPI + HTMX: Der Full-Stack ohne Build-Schritt
# FastAPI + HTMX: Der Full-Stack ohne Build-Schritt
TL;DR: FastAPI + HTMX + Alpine.js + Jinja2 + reines CSS erzeugt produktionsreife Webanwendungen ohne Build-Tools, ohne
node_modules/und mit perfekten Lighthouse-Scores. Dieser Leitfaden behandelt das gesamte System von der Architektur bis zum Deployment und verwendet blakecrosley.com als Produktionsreferenz, die über 100 Blogartikel, interaktive JavaScript-Komponenten, mehrere umfassende Leitfäden und neun Sprachübersetzungen bereitstellt — ohne einen einzigen Bundler, Compiler oder Transpiler.1
Der moderne Webentwicklungs-Stack setzt voraus, dass Sie React, webpack, TypeScript und eine Build-Pipeline benötigen. Für eine große Kategorie von Anwendungen — inhaltsgetriebene Websites, interne Tools, CRUD-Anwendungen, Portfolio-Seiten, Dokumentationsplattformen — ist diese Annahme falsch. Der in diesem Leitfaden beschriebene Stack eliminiert die gesamte Frontend-Build-Toolchain und liefert dennoch Websites, die auf Lighthouse 100/100/100/100 erreichen.2
Dies ist kein Plädoyer. Es ist eine Messung. Die hier beschriebene Architektur läuft in Produktion, bedient reale Nutzer in zehn Sprachen, und die Zahlen sind überprüfbar.
Die wichtigsten Erkenntnisse
- Servergerenderte HTML-Dokumente eliminieren drei vollständige Problemkategorien: Client-seitige Zustandsverwaltung, JSON-Serialisierungsgrenzen und Hydration-Mismatches. HTMX macht Serverantworten zur finalen Ausgabe — kein clientseitiger Rendering-Schritt erforderlich.
- Keine Build-Tools bedeutet keine Build-Fehler. Keine
npm install-Konflikte bei Peer-Abhängigkeiten, keine TypeScript-Compilerfehler in Dateien, die Sie nicht verändert haben, keine Dependabot-PRs für transitive Abhängigkeiten, die Sie nie importiert haben. Die Deploy-Pipeline besteht ausgit push. - Alpine.js übernimmt clientseitigen Zustand, den HTMX nicht abbilden kann. Dropdowns, Modals, mobile Navigations-Toggles und jeglicher UI-Zustand, der ausschließlich im Browser existiert, gehören zu Alpine.js. Die Grenze ist klar: Benötigt der Zustand den Server, verwenden Sie HTMX. Andernfalls verwenden Sie Alpine.js.
- Einfaches CSS mit Custom Properties ersetzt Sass und Tailwind. CSS Custom Properties kaskadieren, vererben und reagieren zur Laufzeit auf Media Queries. Präprozessor-Variablen werden zu statischen Werten kompiliert und verschwinden. Der Browser liest Custom Properties direkt — kein Kompilierungsschritt nötig.
- Dieser Ansatz hat klare Grenzen. Er eignet sich nicht für große Teams mit gemeinsamen Komponentenschnittstellen, SaaS-Produkte mit komplexem clientseitigem Zustand oder Anwendungen, die auf npm-Ökosystem-Bibliotheken angewiesen sind. Das Entscheidungsframework in Abschnitt 15 identifiziert diese Grenze präzise.
- blakecrosley.com ist der Beweis. Die Kernmuster in diesem Leitfaden (HTMX, Alpine.js, Jinja2, einfaches CSS) laufen produktiv auf blakecrosley.com. Die Abschnitte zu Bootstrap und SQLAlchemy behandeln Standardmuster für den Stack, die auf dieser spezifischen Seite nicht zum Einsatz kommen. Jede Aussage lässt sich anhand eines Dateipfads, eines Konfigurationsblocks oder eines Lighthouse-Audits verifizieren — überzeugen Sie sich selbst unter PageSpeed Insights.2
Wie Sie diesen Leitfaden verwenden
Dies ist eine umfassende Referenz. Steigen Sie dort ein, wo Ihr Erfahrungsstand passt:
| Erfahrung | Einstieg | Danach erkunden |
|---|---|---|
| Python-Entwickler, neu bei HTMX | Die No-Build-These → Architekturübersicht → HTMX Deep Dive | Alpine.js-Muster, Sicherheit |
| React/Vue-Entwickler auf der Suche nach Alternativen | Die No-Build-These → Entscheidungsframework | Architekturübersicht, Performance |
| FastAPI-Entwickler mit Bedarf an Interaktivität | HTMX Deep Dive → Alpine.js-Muster | i18n und Lokalisierung, Deployment |
| Full-Stack-Entwickler mit Neuaufbau | Sequenziell lesen ab Architekturübersicht | Kurzreferenzkarte für den täglichen Gebrauch |
Verwenden Sie Strg+F / Cmd+F, um nach bestimmten Mustern oder Attributen zu suchen. Die Kurzreferenzkarte am Ende bietet eine schnell erfassbare Zusammenfassung.
Die No-Build-These
Die These ist eng gefasst und spezifisch: Für inhaltsorientierte Websites mit einem einzelnen Entwickler oder kleinem Team lösen Build-Tools Probleme, die Sie nicht haben — und schaffen dabei Probleme, die Sie sehr wohl haben.
Hier die tatsächlichen Messwerte von blakecrosley.com:
| Metrik | blakecrosley.com (No-Build) | Typisches Next.js-Projekt3 |
|---|---|---|
| Abhängigkeiten | 15 Python-Pakete | 311+ npm-Pakete |
| Build-Konfigurationsdateien | 0 | 5–8 (next.config, tsconfig, postcss, tailwind usw.) |
node_modules/-Größe |
Existiert nicht | 187 MB Basis, 250–400 MB mit Erweiterungen |
| Installationszeit | pip install: 8 Sekunden |
npm install: 30–90 Sekunden |
| Build-Schritt | Keiner | next build: 15–60 Sekunden |
| Deploy-Pipeline | git push → live in ~40 Sekunden |
Install → Build → Deploy: 2–5 Minuten |
| Lighthouse Performance | 100 | 70–90 ohne explizite Optimierung4 |
Die 15 Python-Pakete umfassen FastAPI, Jinja2, Pydantic, uvicorn, nh3 und 10 weitere. Keines davon ist ein Build-Tool. Keines ein Compiler. Keines ein Bundler.5
Was Sie aufgeben
Ehrlichkeit erfordert, die tatsächlichen Kosten zu benennen:
Kein TypeScript. Jede .js-Datei ist reines JavaScript. Typfehler werden durch Tests und Codeanalyse erkannt, nicht durch einen Compiler. Für einen einzelnen Entwickler funktioniert das. Für ein Team von 10 Personen mit gemeinsamen Komponentenschnittstellen hingegen nicht.
Kein Hot Module Replacement. CSS-Änderungen erfordern ein manuelles Neuladen des Browsers. Dank HTMXs hx-boost ist die Navigation schnell genug, sodass vollständige Seitenaktualisierungen tolerierbar sind — bei intensiven visuellen Iterationszyklen spart HMR allerdings Zeit.
Kein Tree Shaking. Jedes Byte JavaScript, das Sie schreiben, wird an den Browser ausgeliefert. Diese Einschränkung erzwingt Disziplin: kleine, fokussierte Dateien statt großer Utility-Module.
Keine npm-Komponentenbibliotheken. Kein Radix, kein shadcn/ui, kein Headless UI. Jedes interaktive Element wird von Hand gebaut oder nutzt die integrierten Komponenten von Bootstrap 5.
Keine Design-System-Tokens aus npm. Das Design-System lebt in CSS Custom Properties. Es lässt sich nicht als Paket in ein anderes Projekt importieren.
Diese Kompromisse sind für eine inhaltsorientierte Website mit ein bis drei Entwicklern akzeptabel. Für ein SaaS-Produkt mit einem 15-köpfigen Engineering-Team wären sie es nicht. Abschnitt 15 liefert das Entscheidungsframework.
Was Sie gewinnen
Keine Build-Fehler. Kein npm install kann an Peer-Dependency-Konflikten scheitern. Kein next build kann an einem TypeScript-Fehler in einer Datei scheitern, die Sie nicht verändert haben.6
Debugging per View Source. Das JavaScript, das im Browser läuft, ist das JavaScript, das Sie geschrieben haben. Keine Source Maps erforderlich.
Sofortiger lokaler Start. uvicorn app.main:app --reload startet in unter 2 Sekunden.
Konkreter Request-Wasserfall. Ein Erstbesuch lädt: ein HTML-Dokument (~15 KB gzipped), eine CSS-Datei (~8 KB), HTMX (~14 KB, gecacht), Alpine.js (~14 KB, gecacht) und das interaktive JS der Seite (~4–8 KB). Insgesamt: 45–60 KB beim Erstbesuch.1
Zukunftssicheres Frontend. Der clientseitige Code nutzt HTML, CSS und JavaScript — Standards, die seit 30 Jahren Abwärtskompatibilität gewährleisten.7 Keine Webpack 4 → 5 Migration, keine Create React App-Einstellung, keine Next.js App Router-Migration.
Stack-Vergleich
So schneidet der No-Build-Stack im Vergleich zu gängigen Alternativen in messbaren Dimensionen ab:
| Dimension | FastAPI+HTMX (dieser Leitfaden) | Next.js (React) | Astro | 11ty |
|---|---|---|---|---|
| JS an den Browser ausgeliefert | 32–46 KB (HTMX+Alpine) | 85–250 KB+ (React-Runtime) | 0 KB Standard, opt-in Islands | 0 KB Standard |
| Build-Schritt | Keiner | Erforderlich (webpack/turbopack) | Erforderlich (Vite) | Erforderlich (custom) |
| Konfigurationsdateien | 0 | 5–8 (next.config, tsconfig usw.) | 1–3 (astro.config, tsconfig) | 1–2 (.eleventy.js) |
| Deploy-Pipeline | git push (40 s) |
Install+Build+Deploy (2–5 Min.) | Install+Build+Deploy (1–3 Min.) | Install+Build+Deploy (1–2 Min.) |
| Serverseitige Interaktivität | Nativ (HTMX) | API-Routen + Client-Fetch | Eingeschränkt (Form Actions) | Keine (statische Ausgabe) |
| Client-Zustandsverwaltung | Alpine.js (15 KB) | React State/Context/Redux | Framework-Islands | Manuelles JS |
| Backend-Sprache | Python | JavaScript/TypeScript | JavaScript/TypeScript | JavaScript |
| i18n-Ansatz | Serverseitig (Middleware) | next-intl oder ähnliches Paket | @astrojs/i18n | Manuell |
| Lighthouse Performance | 100 (gemessen) | 70–90 typisch4 | 95–100 typisch | 95–100 typisch |
| Ideal für | Inhaltsseiten, CRUD, Dashboards | Komplexe SPA, große Teams | Inhaltsseiten, Marketing | Statische Blogs, Dokumentation |
Astro und 11ty sind die engsten Konkurrenten für Inhaltsseiten. Beide erzeugen hervorragende statische Ausgaben, erfordern jedoch einen Build-Schritt und eine JavaScript-Toolchain. Der FastAPI+HTMX-Stack tauscht statische-Site-Performance gegen serverseitige Interaktivität (Kategoriefilterung, Formularverarbeitung, Echtzeitsuche) — ohne einen Build-Schritt hinzuzufügen. Ist Ihre Website rein statisch ohne Serverinteraktionen, könnten Astro oder 11ty die bessere Wahl sein.
Architekturübersicht
Ablauf einer Anfrage
Jede Anfrage durchläuft einen einzigen Pfad durch vier Schichten:
Browser FastAPI Jinja2 HTMX/Alpine
| | | |
|--- GET /about ------>| | |
| |-- render template ->| |
| | |-- base.html ------->|
| | | + about.html |
| |<-- full HTML -------| |
|<--- HTML response ---| | |
| |
|--- hx-get /search ------------------------------------------------>|
| |<-- HTMX request ----| |
| |-- render partial -->| |
| | |-- _results.html |
| |<-- HTML fragment ---| |
|<--- HTML fragment ---| | |
|--- DOM swap -------------------------------------------------------->|
Vollständige Seitenaufrufe liefern komplette HTML-Dokumente zurück (Basis-Template + Seiten-Template). HTMX-Anfragen liefern HTML-Fragmente (Partials). Der Server entscheidet anhand des Anfragetyps, was gerendert wird. Alpine.js verwaltet clientseitigen Zustand, der den Server nie erreicht.
Komponentenrollen
| Komponente | Rolle | Geltungsbereich |
|---|---|---|
| FastAPI | Routing, Geschäftslogik, Datenzugriff, Validierung | Server |
| Jinja2 | Template-Rendering, Vererbung, Makros | Server |
| HTMX | Servergesteuerte Interaktivität (Formulare, Paginierung, Suche) | Client ↔ Server |
| Alpine.js | Rein clientseitiger Zustand (Dropdowns, Modals, Toggles) | Nur Client |
| Bootstrap 5 | Grid-System, Utility-Klassen, responsives Layout | Client (CSS) |
| Plain CSS | Benutzerdefinierte Properties, Komponentenstile, Design-Tokens | Client (CSS) |
| Pydantic | Anfrage-/Antwortvalidierung, Einstellungen | Server |
Projektstruktur
app/
├── main.py # FastAPI app, middleware, templates
├── config.py # Pydantic settings management
├── routes/
│ ├── pages.py # Page routes (HTML responses)
│ └── api.py # API routes (JSON/HTML fragment responses)
├── content.py # Markdown loading, blog post parsing
├── security/
│ ├── headers.py # CSP, HSTS, security headers middleware
│ ├── csrf.py # HMAC-signed CSRF tokens
│ ├── rate_limit.py # 3-tier rate limiting
│ └── logging.py # Security event logging
├── i18n/
│ ├── config.py # Supported locales, mappings
│ ├── middleware.py # URL-based locale detection
│ ├── jinja.py # Translation functions for templates
│ └── d1_client.py # Cloudflare D1 translation storage
├── cache_assets.py # Content-hash asset versioning
└── templates/
├── base.html # Base layout with Alpine.js state
├── components/ # Reusable partials (_language_switcher.html, etc.)
└── pages/ # Page templates (home.html, about.html, etc.)
content/
├── blog/ # Markdown blog posts with YAML frontmatter
└── guides/ # Multi-section guide markdown
static/
├── css/ # Plain CSS (no preprocessors)
├── js/ # Vanilla JavaScript (no bundlers)
│ └── vendor/ # Self-hosted HTMX, Alpine.js
└── images/ # Optimized images with WebP srcset
Die Struktur folgt einem einzigen Prinzip: Jedes Verzeichnis enthält genau eine Art von Dateien. Routen befinden sich in routes/. Templates in templates/. Statische Assets in static/. Kein Build-Schritt transformiert das eine in das andere.
Vergleich mit der SPA-Architektur
In einem React + Next.js-Projekt sähe die entsprechende Struktur folgendermaßen aus:
src/
├── components/ # React components (JSX)
├── pages/ # Route handlers (also JSX)
├── api/ # API routes (also in pages/)
├── hooks/ # Custom React hooks
├── context/ # React context providers
├── lib/ # Utility functions
├── styles/ # CSS modules or Tailwind config
└── types/ # TypeScript type definitions
# Plus build configuration
next.config.js
tsconfig.json
postcss.config.js
tailwind.config.js
eslint.config.js
package.json
package-lock.json
node_modules/ # 187+ MB of dependencies
Die SPA-Architektur erfordert eine Build-Zeit-Koordination zwischen diesen Verzeichnissen. TypeScript kompiliert .tsx zu JavaScript. PostCSS verarbeitet Tailwind-Direktiven zu CSS. Webpack (oder Turbopack) bündelt die Ausgabe in Chunks. Jeder Schritt kann unabhängig fehlschlagen.
Die No-Build-Architektur erfordert keinerlei Koordination. Das Template referenziert eine CSS-Datei. Die CSS-Datei existiert in static/css/. Der Browser lädt sie direkt. Benennen Sie eine Datei um, bricht die Template-Referenz zur Laufzeit — nicht zur Build-Zeit. Damit verlagern sich Fehler von der Kompilierzeit zur Laufzeit, was ein echtes Abwägungsproblem darstellt. Für einen einzelnen Entwickler, der während der Entwicklung uvicorn --reload ausführt, erscheinen Laufzeitfehler sofort im Browser. Für ein großes Team hingegen verhindert die Kompilierzeitprüfung durch TypeScript eine ganze Kategorie von Fehlern, die Laufzeitfehler nicht abfangen können.
FastAPI-Muster
Anwendungssetup
Die Anwendung wird in main.py mit einer expliziten Middleware-Reihenfolge initialisiert:
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.gzip import GZipMiddleware
app = FastAPI(
title="Blake Crosley",
docs_url=None, # Disable docs in production
redoc_url=None,
openapi_url=None, # Prevent /openapi.json exposure
)
# Middleware order matters: last added = first executed
app.add_middleware(SecurityHeadersMiddleware)
app.add_middleware(GZipMiddleware, minimum_size=500)
app.add_middleware(LocaleMiddleware)
app.add_middleware(RateLimitMiddleware)
app.add_middleware(SecurityLogMiddleware, site_name="blakecrosley.com")
# Static files
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
# Templates
templates = Jinja2Templates(directory=TEMPLATES_DIR)
Drei Designentscheidungen sind hier von Bedeutung. Erstens deaktivieren docs_url=None und openapi_url=None die automatischen API-Dokumentationsendpunkte. Eine öffentlich zugängliche Content-Website benötigt keine im Internet erreichbaren /docs- oder /openapi.json-Endpunkte.8 Zweitens spielt die Middleware-Reihenfolge eine entscheidende Rolle — das Security-Logging wird zuerst ausgeführt (da zuletzt hinzugefügt), sodass jede Anfrage erfasst wird, einschließlich derjenigen, die vom Rate-Limiting abgelehnt werden. Drittens komprimiert GZipMiddleware alle Antworten über 500 Bytes, was die HTML-Übertragungsgröße typischerweise um 70–80 % reduziert.
Routing
Routen gliedern sich in zwei Kategorien: Seitenrouten liefern vollständige HTML-Dokumente zurück, während API-Routen JSON oder HTML-Fragmente zurückgeben.
# routes/pages.py — full HTML responses
from fastapi import APIRouter, Request
router = APIRouter()
@router.get("/about")
async def about(request: Request):
templates = request.app.state.templates
return templates.TemplateResponse("pages/about.html", {
"request": request,
"page_title": "About — Blake Crosley",
"page_description": "Designer, developer, dad.",
})
# routes/api.py — JSON or HTML fragment responses
@router.get("/api/quiz/{quiz_id}/step")
async def quiz_step(request: Request, quiz_id: str, answers: str = ""):
# Parse answers, compute next question or result
question = get_next_question(quiz_id, answers)
templates = request.app.state.templates
return templates.TemplateResponse("components/_quiz_step.html", {
"request": request,
"question": question,
"answers": answers,
"step": len(answers.split(",")) if answers else 0,
})
Diese Unterscheidung ist für HTMX relevant. Vollständige Seitenrouten liefern Dokumente, die base.html erweitern. API-Routen geben HTML-Fragmente zurück, die HTMX in bestehende DOM-Elemente einfügt. Dieselbe Jinja2-Template-Engine rendert beides — eine separate API-Schicht entfällt.
Dependency Injection
Das Depends()-System von FastAPI ermöglicht eine saubere Trennung zwischen Route-Handlern und gemeinsam genutzter Logik:
from fastapi import Depends, Request
def get_templates(request: Request):
"""Get templates from app state."""
return request.app.state.templates
def get_current_locale(request: Request) -> str:
"""Get locale from middleware-set request state."""
return getattr(request.state, "locale", "en")
@router.get("/blog/{slug}")
async def blog_post(
request: Request,
slug: str,
templates=Depends(get_templates),
locale: str = Depends(get_current_locale),
):
post = load_post_by_slug(slug)
if not post:
raise HTTPException(404, "Post not found")
return templates.TemplateResponse("pages/blog/post.html", {
"request": request,
"post": post,
"locale": locale,
})
Dependencies lassen sich komponieren. Eine get_db-Dependency kann von get_current_locale abhängen, die wiederum vom Request abhängt. FastAPI löst die gesamte Kette automatisch auf.
Pydantic-Einstellungen
Die Konfiguration nutzt Pydantics BaseSettings mit Vorrang für Umgebungsvariablen:
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
D1_WORKER_URL: str = ""
D1_AUTH_SECRET: str = ""
CLOUDFLARE_ACCOUNT_ID: str = ""
CLOUDFLARE_API_TOKEN: str = ""
ANALYTICS_PASSKEY: str = ""
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
settings = Settings()
Umgebungsvariablen überschreiben die Werte aus der .env-Datei. In der Produktion (Railway) werden Secrets als Umgebungsvariablen gesetzt. Lokal stellt eine .env-Datei Standardwerte bereit. Die Settings-Klasse validiert Typen beim Start — ein fehlendes Pflichtfeld schlägt sofort fehl, statt erst zur Laufzeit.
Async-Muster
FastAPI-Routen sind standardmäßig asynchron. Bei I/O-lastigen Operationen (Datenbankabfragen, HTTP-Anfragen, Dateizugriffe) verhindert async das Blockieren des Event-Loops:
@asynccontextmanager
async def lifespan(app: FastAPI):
# Load translations into memory cache at startup
async with httpx.AsyncClient() as client:
for locale in SUPPORTED_LOCALES:
resp = await client.post(f"{D1_URL}/query", ...)
TRANSLATIONS[locale] = resp.json()["results"]
yield
# Cleanup on shutdown (if needed)
app = FastAPI(lifespan=lifespan)
CPU-lastige Operationen (Markdown-Rendering, CSS-Extraktion) können synchrone Funktionen verwenden. FastAPI führt diese automatisch in einem Thread-Pool aus, wenn der Route-Handler nicht als async deklariert ist:
# Sync function — FastAPI runs it in a thread pool
@router.get("/blog/{slug}")
def blog_post(slug: str):
post = load_post_by_slug(slug) # CPU-bound Markdown parsing
return templates.TemplateResponse(...)
Die Regel lautet: Wenn die Funktion auf I/O wartet, deklarieren Sie sie als async. Wenn sie CPU-Arbeit verrichtet, belassen Sie sie synchron. Mischen Sie await nicht mit blockierenden Aufrufen in derselben Funktion.9
Jinja2-Templates
Template-Vererbung
Das Vererbungssystem von Jinja2 ersetzt Reacts Komponentenkomposition durch ein einfacheres Modell. Ein einziges Basis-Template definiert das Seitengerüst. Kind-Templates füllen benannte Blöcke:
<!-- base.html — the skeleton -->
<!DOCTYPE html>
<html lang="{{ lang_attr() }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ page_title | default("Blake Crosley") }}</title>
<meta name="description" content="{{ page_description | default('...') }}">
<!-- CSS — single file, no preprocessor -->
<link rel="stylesheet" href="{{ asset('css/styles.css') }}">
<!-- JSON-LD structured data -->
<script type="application/ld+json">
{ "@context": "https://schema.org", "@graph": [...] }
</script>
{% block head %}{% endblock %}
</head>
<body>
<header class="header">...</header>
<main id="main" role="main">
{% block content %}{% endblock %}
</main>
<footer class="footer">...</footer>
<!-- Scripts deferred for performance -->
<script defer src="{{ asset('js/vendor/alpine.min.js') }}"></script>
<script defer src="{{ asset('js/vendor/htmx.min.js') }}"></script>
<script defer src="{{ asset('js/main.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
<!-- pages/about.html — fills the blocks -->
{% extends "base.html" %}
{% block head %}
<script type="application/ld+json">
{ "@type": "AboutPage", "name": "About Blake Crosley", ... }
</script>
{% endblock %}
{% block content %}
<section class="hero">
<h1>About</h1>
<p>Designer, developer, dad.</p>
</section>
{% endblock %}
Die {% extends %}-Direktive stellt eine Eltern-Kind-Beziehung her. Das Kind-Template definiert nur die Blöcke, die es überschreiben muss. Alles andere — der <head>, der Header, der Footer, die Script-Tags — stammt aus dem Basis-Template. Komposition erfolgt hier durch Subtraktion statt durch Konstruktion.
Das asset()-Global
Statische Assets verwenden Content-Hash-Versionierung für Cache-Busting:
# cache_assets.py
def build_asset_map(static_dir: Path) -> dict[str, str]:
"""Compute MD5 hashes of all static files at startup."""
asset_map = {}
for filepath in static_dir.rglob("*"):
if filepath.is_file():
rel_path = str(filepath.relative_to(static_dir))
content_hash = hashlib.md5(filepath.read_bytes()).hexdigest()[:10]
asset_map[rel_path] = content_hash
return asset_map
def make_asset_url(asset_map: dict, path: str) -> str:
"""Generate versioned URL: /static/css/styles.css?v=a3f8b2c1d0"""
clean_path = path.lstrip("/")
version = asset_map.get(clean_path, "0")
return f"/static/{clean_path}?v={version}"
Im Template rendert {{ asset('css/styles.css') }} zu /static/css/styles.css?v=a3f8b2c1d0. Der Hash ändert sich bei Dateiänderungen und invalidiert damit den CDN-Cache. Webpacks [contenthash]-Dateinamenstrategie wird so durch 30 Zeilen Python ersetzt, die beim Start berechnet werden.
Include für wiederverwendbare Partials
Komponenten, die sich über mehrere Seiten hinweg wiederholen, nutzen {% include %}:
<!-- base.html -->
{% include "components/_language_switcher.html" %}
<!-- components/_language_switcher.html -->
{%- set current = current_locale() -%}
{%- set locales = all_locales() -%}
<div class="language-switcher"
x-data="{ open: false }"
@click.away="open = false">
<button @click="open = !open" :aria-expanded="open">
{{ current_locale_native() }}
</button>
<ul class="language-switcher-menu"
:class="{ 'is-open': open }"
x-cloak>
{% for locale in locales %}
<li>
<a href="{{ locale_url(request.url.path, locale.code) }}"
hreflang="{{ locale.code }}">
{{ locale.native }}
</a>
</li>
{% endfor %}
</ul>
</div>
Der Unterstrich-Präfix (_language_switcher.html) ist eine Konvention für Partials — Template-Fragmente, die nicht eigenständig gerendert werden sollen. Diese Komponente nutzt sowohl Alpine.js (für den Dropdown-Toggle) als auch Jinja2 (für die Sprachliste). Die Grenze ist klar gezogen: Alpine.js verwaltet den Öffnen/Schließen-Zustand, Jinja2 die Daten.
Macros für wiederverwendbare Komponenten
Macros sind die Funktionen von Jinja2 — wiederverwendbare Template-Blöcke mit Parametern:
<!-- components/_macros.html -->
{% macro card(title, description, href, badge=None) %}
<article class="card">
<a href="{{ href }}" class="card__link">
{% if badge %}
<span class="card__badge">{{ badge }}</span>
{% endif %}
<h3 class="card__title">{{ title }}</h3>
{% if description %}
<p class="card__description">{{ description }}</p>
{% endif %}
</a>
</article>
{% endmacro %}
{% macro optimized_image(image_config, loading="lazy") %}
{% if image_config.get("svg") %}
<img src="{{ image_config.svg }}"
width="{{ image_config.width }}"
height="{{ image_config.height }}"
alt="{{ image_config.alt }}">
{% else %}
<picture>
<source type="image/webp"
srcset="{{ image_config.webp_srcset }}"
sizes="(max-width: 768px) 100vw, 50vw">
<img src="{{ image_config.fallback }}"
width="{{ image_config.width }}"
height="{{ image_config.height }}"
alt="{{ image_config.alt }}"
loading="{{ loading }}">
</picture>
{% endif %}
{% endmacro %}
Macros werden in Seiten-Templates importiert und verwendet:
{% from "components/_macros.html" import card, optimized_image %}
<section class="projects">
{% for project in projects %}
{{ card(
title=project.title,
description=project.description,
href=project.link,
badge="New" if project.is_new else None
) }}
{% endfor %}
</section>
Macros ersetzen React-Komponenten für Präsentationsmuster. Sie akzeptieren Parameter, unterstützen Standardwerte und lassen sich mit anderen Macros kombinieren. Der entscheidende Unterschied: Macros werden einmalig auf dem Server gerendert und erzeugen statisches HTML. React-Komponenten hingegen werden auf dem Client gerendert und verwalten einen Zustand. Für die Darstellung von Inhalten sind Macros das richtige Werkzeug.
Template-Kontext und Globals
Jinja2-Globals sind Funktionen, die in jedem Template ohne explizite Übergabe verfügbar sind:
# In main.py — register globals
templates.env.globals["asset"] = lambda path: make_asset_url(_asset_map, path)
templates.env.globals["csrf_token"] = generate_csrf_token
templates.env.globals["analytics_script"] = analytics.tracking_script
Das asset()-Global erzeugt versionierte URLs. Das csrf_token()-Global generiert frische CSRF-Token. Das analytics_script()-Global fügt das Tracking-Snippet ein. Diese Funktionen sind in jedem Template aufrufbar, ohne dass der Route-Handler sie explizit übergeben muss.
Für i18n ist die Einrichtung aufwendiger — Übersetzungsfunktionen benötigen Zugriff auf die Locale des aktuellen Requests:
# i18n/jinja.py
def setup_i18n_jinja(env):
"""Register translation functions as Jinja2 globals."""
env.globals["_"] = get_translation # _('ui.nav.about')
env.globals["locale_prefix"] = get_locale_prefix # '/ja' or ''
env.globals["current_locale"] = get_current_locale
env.globals["all_locales"] = get_all_locales
env.globals["alternate_urls"] = get_alternate_urls
env.globals["lang_attr"] = get_lang_attr # 'ja' for HTML lang
env.globals["og_locale"] = get_og_locale # 'ja_JP' for og:locale
env.globals["jsonld_lang"] = get_jsonld_lang # 'ja-JP' for JSON-LD
Jede Funktion liest die Locale aus der Kontextvariable des Requests, die von der Locale-Middleware gesetzt wird. Im Template liefert der Aufruf {{ _('ui.nav.about') }} den übersetzten String für die Locale des aktuellen Requests — ganz ohne expliziten Locale-Parameter.
Bedingte Blöcke
Das Blocksystem von Jinja2 unterstützt bedingte Überschreibungen:
<!-- base.html -->
{% block head %}{% endblock %}
<!-- pages/blog/post.html -->
{% block head %}
<script type="application/ld+json">
{
"@type": "Article",
"headline": "{{ post.meta.title }}",
"author": { "@id": "https://blakecrosley.com/#person" },
"datePublished": "{{ post.meta.date.isoformat() }}",
"dateModified": "{{ post.meta.updated.isoformat() if post.meta.updated else post.meta.date.isoformat() }}"
}
</script>
{% if post.meta.scripts %}
{% for script in post.meta.scripts %}
<script defer src="{{ asset(script.lstrip('/static/')) }}"></script>
{% endfor %}
{% endif %}
{% if post.meta.styles %}
{% for style in post.meta.styles %}
<link rel="stylesheet" href="{{ asset(style.lstrip('/static/')) }}">
{% endfor %}
{% endif %}
{% endblock %}
Blogbeiträge deklarieren ihre Abhängigkeiten im YAML-Frontmatter (scripts: ["/static/js/boids.js"]). Das Template bindet diese bedingt ein. Seiten ohne zusätzliche Skripte oder Styles liefern auch keine aus — kein toter Code, keine ungenutzten Importe.
Benutzerdefinierte Filter
Jinja2-Filter transformieren Daten während des Renderings. Der sanitize-Filter verhindert XSS in nutzergenerierten Inhalten:
import nh3
ALLOWED_TAGS = {"a", "b", "blockquote", "br", "code", "em", "h1", "h2",
"h3", "h4", "h5", "h6", "hr", "i", "img", "li", "ol",
"p", "pre", "span", "strong", "table", "td", "th", "tr", "ul"}
def sanitize_html(value: str) -> str:
"""Sanitize HTML to prevent XSS attacks."""
if not value:
return ""
return nh3.clean(
value,
tags=ALLOWED_TAGS,
attributes={"a": {"href", "title"}, "img": {"src", "alt"}},
link_rel="noopener noreferrer",
)
templates.env.filters["sanitize"] = sanitize_html
Im Template: {{ user_content | sanitize }}. Die nh3-Bibliothek ist ein Rust-basierter HTML-Sanitizer — schnell und sicher. Sie entfernt sämtliche Tags und Attribute, die nicht auf der Erlaubnisliste stehen, und verhindert so Stored XSS selbst dann, wenn der Inhalt aus einer nicht vertrauenswürdigen Quelle stammt.10
HTMX im Detail
HTMX macht jedes HTML-Element fähig, HTTP-Anfragen zu senden und die Antwort in das DOM einzufügen. Die zentrale Erkenntnis ist architektonischer Natur: Serverseitig gerendertes HTML ist die API. Der Server liefert die endgültige Darstellung. Kein clientseitiges Rendering, keine JSON-Serialisierung, keine Hydration.
Kernattribute
| Attribut | Zweck | Beispiel |
|---|---|---|
hx-get |
GET-Anfrage senden | hx-get="/search?q=term" |
hx-post |
POST-Anfrage senden | hx-post="/contact" |
hx-target |
Ziel für die Antwort | hx-target="#results" |
hx-swap |
Art der Einfügung | hx-swap="innerHTML" (Standard), outerHTML, beforeend |
hx-trigger |
Auslöser für die Anfrage | hx-trigger="click", keyup changed delay:300ms, load |
hx-indicator |
Element, das während der Anfrage angezeigt wird | hx-indicator="#spinner" |
hx-push-url |
Browser-URL aktualisieren | hx-push-url="true" |
hx-replace-url |
URL ohne Verlaufseintrag ersetzen | hx-replace-url="true" |
Muster 1: Interaktives Quiz (mehrstufiger Serverstatus)
blakecrosley.com enthält ein interaktives Quiz, das Benutzer durch die Werkzeugauswahl führt. Der gesamte Quiz-Zustand liegt auf dem Server — kein clientseitiges State Management:
<!-- _quiz_container.html — initial load -->
<div hx-get="/api/quiz/claude-vs-codex/step?answers="
hx-trigger="load"
hx-swap="innerHTML"
id="quiz-wrapper">
<p>Loading quiz...</p>
</div>
<!-- _quiz_step.html — each question -->
<div class="quiz-step" id="quiz-container">
<p>Question {{ step }} of {{ total }}</p>
<h3>{{ question.question }}</h3>
<div class="quiz-step__options">
{% for opt in question.options %}
<button class="quiz-step__btn"
hx-get="/api/quiz/claude-vs-codex/step?answers={{ answers }},{{ opt.value }}"
hx-target="#quiz-container"
hx-swap="outerHTML">
{{ opt.label }}
</button>
{% endfor %}
</div>
</div>
Jeder Klick auf eine Schaltfläche sendet die gesammelten Antworten als Query-Parameter. Der Server berechnet die nächste Frage oder das Endergebnis anhand der Antworthistorie. Der Zustand akkumuliert sich in der URL — keine Cookies, keine Sessions, kein clientseitiges JavaScript. Das Quiz schreitet durch outerHTML-Swaps voran: Jede Antwort ersetzt das gesamte Quiz-Schritt-Element.
Muster 2: Paginierte Blog-Liste
Die Schreibseite nutzt HTMX für nahtlose Paginierung mit URL-Aktualisierung:
<!-- Pagination link -->
<a href="/writing?page=2&category=Engineering"
hx-get="/writing?page=2&category=Engineering"
hx-target="#writing-content"
hx-swap="innerHTML"
hx-replace-url="true"
hx-indicator="#writing-loading"
aria-label="Go to page 2">
2
</a>
Vier Attribute arbeiten zusammen:
hx-getsendet die Anfrage an dieselbe URL wie dashref(Progressive Enhancement — funktioniert auch ohne JavaScript)hx-targetplatziert die Antwort im#writing-content-Containerhx-replace-url="true"aktualisiert die Browser-URL, ohne einen Verlaufseintrag hinzuzufügenhx-indicatorzeigt einen Lade-Spinner während der Anfrage an
Der Server erkennt HTMX-Anfragen über den HX-Request-Header und liefert nur das Beitragslisten-Fragment statt der vollständigen Seite zurück. Deshalb fügt die Security-Headers-Middleware Vary: HX-Request hinzu — damit CDN-Caches die vollständige Seite und das Fragment getrennt speichern.11
Muster 3: Suche mit Debounce
<input type="search" name="q"
hx-get="/api/search"
hx-trigger="keyup changed delay:300ms"
hx-target="#results"
hx-indicator="#search-spinner" />
<div id="results"></div>
Das hx-trigger-Attribut kombiniert drei Modifikatoren:
keyuplöst beim Loslassen einer Taste auschangedlöst nur aus, wenn sich der Wert tatsächlich geändert hat (verhindert doppelte Anfragen durch Modifikatortasten)delay:300msentprellt — wartet 300 ms nach dem letzten Keyup, bevor ausgelöst wird
Der Server liefert ein gerendertes HTML-Fragment zurück:
@router.get("/api/search")
async def search(request: Request, q: str = ""):
results = search_content(q)
return templates.TemplateResponse("components/_search_results.html", {
"request": request,
"results": results,
"query": q,
})
Kein clientseitiger Zustand. Keine Debounce-Bibliothek. Kein useEffect. Das Template rendert die Ergebnisse, HTMX fügt sie ein, und der Server bleibt die einzige Quelle der Wahrheit.
Muster 4: Out-of-Band (OOB) Swaps
Manchmal muss eine einzelne Serveraktion mehrere DOM-Elemente aktualisieren. Der Out-of-Band-Swap-Mechanismus von HTMX bewältigt dies ohne clientseitige Orchestrierung:
<!-- Server returns multiple elements in one response -->
<!-- Primary target: swapped normally via hx-target -->
<div id="cart-items">
<ul>
<li>Widget A — $29.99</li>
<li>Widget B — $14.99</li>
</ul>
</div>
<!-- OOB target: swapped independently via hx-swap-oob -->
<span id="cart-count" hx-swap-oob="true">2 items</span>
<span id="cart-total" hx-swap-oob="true">$44.98</span>
Das Attribut hx-swap-oob="true" weist HTMX an, das Element anhand seiner id überall im DOM zu finden und zu ersetzen — unabhängig vom hx-target. Dies ersetzt Reacts „State nach oben heben”-Muster: Der Server berechnet den gesamten abgeleiteten Zustand und sendet das fertige HTML für jedes Element in einer einzigen Antwort.
Ein Kontaktformular veranschaulicht dies gut: Das Absenden des Formulars könnte den Formularkörper durch eine Erfolgsmeldung ersetzen und gleichzeitig ein Benachrichtigungs-Badge über einen OOB-Swap aktualisieren:
Muster 5: Boosted Links
HTMX kann Standard-Navigationslinks „boosten”, sodass sie AJAX statt vollständiger Seitenladevorgänge verwenden:
<nav hx-boost="true">
<a href="/about">About</a>
<a href="/writing">Writing</a>
<a href="/guides">Guides</a>
</nav>
Mit hx-boost="true" ruft ein Klick auf einen Link die Seite per AJAX ab, tauscht den <body>-Inhalt aus und aktualisiert die URL — ohne vollständigen Seitenneulade. Die Browserhistorie funktioniert normal (Vor-/Zurück-Tasten). Falls JavaScript fehlschlägt, funktionieren die Links als reguläre Navigation.
Der Vorteil liegt in der wahrgenommenen Performance: Geboostete Navigation fühlt sich sofort an, da der Browser weder CSS neu parsen, noch Skripte neu auswerten oder das Layout neu rendern muss. Nur der <body>-Inhalt ändert sich. Boosted Links eignen sich besonders für Hauptnavigationselemente und lassen Seitenwechsel wie eine Single-Page Application wirken — ohne die SPA-Architektur.
Muster 6: HTMX Request-Header
HTMX sendet benutzerdefinierte Header mit jeder Anfrage:
| Header | Wert | Anwendungsfall |
|---|---|---|
HX-Request |
true |
HTMX-Anfragen serverseitig erkennen |
HX-Target |
Element-ID | Zielelement der Antwort identifizieren |
HX-Trigger |
Element-ID | Auslösendes Element identifizieren |
HX-Current-URL |
Vollständige URL | Aktuelle Seite des Benutzers ermitteln |
Der Server kann HX-Request nutzen, um unterschiedliche Antworten zurückzugeben:
@router.get("/writing")
async def writing(request: Request, page: int = 1, category: str = None):
posts = load_all_posts(page=page, category=category)
context = {"request": request, "posts": posts, "current_page": page}
# HTMX request: return only the post list fragment
if request.headers.get("HX-Request"):
return templates.TemplateResponse(
"pages/writing/_post_list.html", context
)
# Normal request: return the full page
return templates.TemplateResponse("pages/writing/index.html", context)
Dieses Dual-Response-Muster ist zentral für die Architektur. Ein vollständiger Seitenladevorgang liefert das komplette Dokument (Basis-Template + Seiteninhalt). Eine HTMX-Navigation liefert nur den geänderten Inhalt. Der Server entscheidet — nicht der Client.
Muster 7: Progressive Enhancement
Jeder HTMX-Link auf blakecrosley.com enthält ein Standard-href-Attribut:
<a href="/writing?page=2"
hx-get="/writing?page=2"
hx-target="#writing-content"
hx-swap="innerHTML">
Next Page
</a>
Falls JavaScript nicht geladen werden kann, funktioniert das href als normaler Link. Wird HTMX geladen, fängt es den Klick ab und führt einen AJAX-Swap durch. Das ist Progressive Enhancement: Die Seite funktioniert ohne JavaScript, und HTMX verbessert das Erlebnis, wenn verfügbar.
Muster 8: Ladezustände
<button hx-post="/api/contact"
hx-target="#form-result"
hx-indicator="#submit-spinner">
<span id="submit-spinner" class="htmx-indicator">Sending...</span>
<span>Send Message</span>
</button>
HTMX fügt dem auslösenden Element während Anfragen die Klasse htmx-request hinzu. Das hx-indicator-Attribut verweist auf ein Element, das während der Anfrage sichtbar wird. Gestylt wird es mit CSS:
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
display: inline;
}
Kein Ladezustand-Management. Kein useState(false). Kein setLoading(true). CSS steuert die Sichtbarkeit, HTMX übernimmt den Klassen-Toggle.
Alpine.js-Muster
Alpine.js füllt die Lücke, die HTMX offenlässt: clientseitiger Zustand, der niemals den Server berühren muss. Wenn ein Benutzer auf ein Dropdown klickt und es sich öffnet, existiert dieser Zustand ausschließlich im Browser. Alpine.js verwaltet ihn mit HTML-Attributen.
Die Grenzregel
Die Grenze zwischen HTMX und Alpine.js ist klar definiert:
| Zustandstyp | Werkzeug | Beispiel |
|---|---|---|
| Benötigt Serverdaten | HTMX | Suchergebnisse, Formularvalidierung, Paginierung |
| Existiert nur im Browser | Alpine.js | Dropdown öffnen/schließen, mobiles Menü umschalten, Modal-Sichtbarkeit |
| Kombiniert beides | Beide | Sprachumschalter (Alpine.js-Toggle, HTMX-artige Navigation) |
Mobile Navigation
Das Basis-Template umschließt den gesamten Header mit einer Alpine.js-Komponente:
<div x-data="{ navOpen: false, langOpen: false }"
@keydown.escape.window="navOpen = false; langOpen = false">
<!-- Mobile hamburger button -->
<button @click="navOpen = !navOpen; langOpen = false"
:aria-expanded="navOpen"
:class="navOpen ? 'nav__toggle is-open' : 'nav__toggle'"
aria-label="Toggle navigation">
<span class="nav__toggle-icon">
<span class="nav__toggle-bar"></span>
<span class="nav__toggle-bar"></span>
<span class="nav__toggle-bar"></span>
</span>
</button>
<!-- Mobile menu panel -->
<div class="mobile-menu" x-show="navOpen" x-cloak>
<nav class="mobile-menu__nav">
<a href="/about" @click="navOpen = false">About</a>
<a href="/#work" @click="navOpen = false">Work</a>
<a href="/writing" @click="navOpen = false">Writing</a>
</nav>
</div>
</div>
Wichtige Alpine.js-Muster:
x-datadeklariert den Komponentenbereich und den Anfangszustandx-showsteuert die Sichtbarkeit basierend auf dem Zustand (nutzt CSSdisplay: none)x-cloakverbirgt das Element, bis Alpine.js initialisiert ist (verhindert ein Aufblitzen ungestylter Inhalte)@clickbindet Klick-Handler mit Ausdrücken:aria-expanded(Kurzform fürx-bind:aria-expanded) setzt Attribute dynamisch@keydown.escape.windowlauscht global auf die Escape-Taste, um Panels zu schließen
Dropdown-Komponente
Der Sprachumschalter verwendet Alpine.js für den Toggle-Zustand mit @click.away zum Schließen bei Klick außerhalb:
<div x-data="{ open: false }"
@click.away="open = false"
@keydown.escape.window="open = false">
<button @click="open = !open"
:aria-expanded="open"
aria-haspopup="listbox">
English
<svg :class="{ 'rotated': open }">...</svg>
</button>
<ul :class="{ 'is-open': open }"
:aria-hidden="!open"
role="listbox"
x-cloak>
<li role="option">
<a href="/ja/about">日本語</a>
</li>
<!-- more languages -->
</ul>
</div>
Der @click.away-Modifier schließt das Dropdown bei Klick außerhalb. Alpine.js erledigt dies mit einem einzigen Attribut — kein Event-Listener-Registrierung, kein Aufräumen, kein Ref-Management.
Wann Alpine.js und wann Vanilla JavaScript
Alpine.js eignet sich, wenn:
- Der Zustand auf ein einzelnes DOM-Element beschränkt ist (Dropdown, Modal, Toggle)
- Interaktionen binär oder einfach sind (öffnen/schließen, ein-/ausblenden, umschalten)
- Mehrere Elemente auf dieselbe Zustandsänderung reagieren müssen
- Barrierefreiheits-Attribute mit der Sichtbarkeit synchron bleiben müssen
Vanilla JavaScript eignet sich, wenn:
- Die Interaktion komplexe Berechnungen erfordert (Visualisierungen, Simulationen)
- Die Komponente eine eigene Render-Schleife hat (Canvas, Animation)
- Performance entscheidend ist (Alpine.js erzeugt Overhead pro
x-data-Komponente) - Die Logik 20–30 Zeilen Alpine.js-Ausdrücke übersteigt
blakecrosley.com verwendet Alpine.js für Navigation, Sprachumschaltung und Inhalts-Toggles. Die 20 interaktiven Blog-Komponenten (Boids-Simulation, Hamming-Code-Visualisierer usw.) nutzen Vanilla JavaScript, da sie Canvas-Rendering und komplexe Zustandsautomaten erfordern.
End-to-End-Beispiel: Kategoriefilterung auf /writing
Dieser Abschnitt verfolgt ein reales Feature aus der Produktions-Codebasis durch jede Schicht: Route, Template, HTMX-Interaktion, Sicherheit, Caching und gerendertes Ergebnis. Das Feature: Kategorie-Tabs auf der Writing-Seite, die Blogbeiträge ohne vollständiges Neuladen der Seite filtern.
Die Route (app/routes/pages.py:508)
async def writing_listing(request: Request, page: int = 1, category: str | None = None):
"""Writing page — blog posts and external publications."""
templates = get_templates(request)
markdown_posts = load_all_posts(published_only=True)
all_posts = CUSTOM_BLOG_POSTS + markdown_posts
# Filter by category if specified
if category and category in CATEGORY_MAP:
display_name = CATEGORY_MAP[category]
all_posts = [
p for p in all_posts
if _get_post_category(p).lower() == display_name.lower()
]
# Pagination
total_pages = max(1, (len(all_posts) + POSTS_PER_PAGE - 1) // POSTS_PER_PAGE)
page = max(1, min(page, total_pages))
paginated = all_posts[(page - 1) * POSTS_PER_PAGE : page * POSTS_PER_PAGE]
template_context = {
"request": request,
"posts": paginated,
"categories": categories,
"current_category": category,
"current_page": page,
"total_pages": total_pages,
# ... SEO: canonical, prev/next URLs
}
# HTMX partial: return just the post list fragment
if request.headers.get("HX-Request"):
return templates.TemplateResponse(
"pages/writing/_post_list.html",
template_context,
)
# Full page for direct navigation
return templates.TemplateResponse(
"pages/writing/index.html",
template_context,
)
Die HX-Request-Header-Prüfung ist das zentrale Muster: dieselbe Route, dieselben Daten, unterschiedliches Template. HTMX erhält ein Fragment. Browser erhalten die vollständige Seite.
Die Kategorie-Tabs (HTMX)
<!-- Category filter tabs -->
<nav class="writing-categories">
<a href="/writing"
hx-get="/writing"
hx-target="#post-list"
hx-push-url="true"
class="category-tab {% if not current_category %}active{% endif %}">
All ({{ total_posts }})
</a>
{% for cat in categories %}
<a href="/writing?category={{ cat.slug }}"
hx-get="/writing?category={{ cat.slug }}"
hx-target="#post-list"
hx-push-url="true"
class="category-tab {% if current_category == cat.slug %}active{% endif %}">
{{ cat.name }} ({{ cat.count }})
</a>
{% endfor %}
</nav>
<div id="post-list">
{% include "pages/writing/_post_list.html" %}
</div>
Jeder Tab hat sowohl href (funktioniert ohne JavaScript) als auch hx-get (tauscht nur die Beitragsliste aus). hx-push-url aktualisiert die Browser-URL, sodass die gefilterte Ansicht teilbar und als Lesezeichen speicherbar ist.
Das Partial (pages/writing/_post_list.html)
Das Partial rendert identisch, ob es beim Seitenladen eingebunden oder von HTMX ausgetauscht wird:
{% for post in posts %}
<article class="post-card">
<a href="{{ locale_prefix() }}/blog/{{ post.meta.slug }}">
<h3>{{ post.meta.title }}</h3>
<p>{{ post.meta.description }}</p>
<time>{{ post.meta.date }}</time> · {{ post.reading_time }}m
</a>
</article>
{% endfor %}
Kein spezielles HTMX-Markup im Partial. Keine clientseitige Rendering-Logik. Dasselbe HTML funktioniert für das initiale Laden der Seite und jeden nachfolgenden Filter.
Sicherheit
Kategoriewerte werden vor dem Filtern gegen CATEGORY_MAP (ein serverseitiges Dictionary) validiert. Ungültige Kategorien werden ignoriert, nicht zurückgegeben. Keine Benutzereingaben werden in SQL oder HTML interpoliert. Der CSP-Header blockiert Inline-Skripte.
Caching
Kategorie-Antworten sind dynamisch (kein CDN-Cache). Statische Assets (CSS, HTMX, Alpine.js) sind jedoch inhaltlich gehasht und nach dem ersten Laden unbegrenzt gecacht. Nachfolgende Kategoriewechsel übertragen nur das HTML-Partial (~3–5 KB) — kein CSS, kein JS, keine Bilder werden erneut geladen.
Was dieses Beispiel zeigt
Ein Feature, echter Produktionscode, null Build-Tools. Der Server filtert und rendert HTML. HTMX tauscht die Beitragsliste aus. Alpine.js ist nicht beteiligt (kein clientseitiger Zustand nötig). Die URL wird für Teilbarkeit aktualisiert. Progressive Enhancement: Die Tabs funktionieren als gewöhnliche Links ohne JavaScript. Eigener JavaScript-Code für dieses Feature insgesamt: null Zeilen.
Optionale Erweiterungen
Die folgenden Abschnitte behandeln Muster, die den Kern-Stack ergänzen, aber auf blakecrosley.com nicht eingesetzt werden. Sie sind enthalten, weil sie die häufigsten Ergänzungen darstellen, die Teams bei der Einführung dieser Architektur vornehmen.
Bootstrap 5 ohne Sass
Hinweis: blakecrosley.com verwendet reines CSS mit Custom Properties — kein Bootstrap. Dieser Abschnitt behandelt Bootstrap 5 als Option für Teams, die ein Utility-Framework ohne Build-Schritt einsetzen möchten. Das kompilierte CSS von Bootstrap lässt sich über ein CDN laden oder in Ihr Stylesheet einbinden. Die folgenden Muster sind generisch und funktionieren zusammen mit dem in vorherigen Abschnitten beschriebenen HTMX- und Alpine.js-Ansatz.
Bootstrap 5 hat die jQuery-Abhängigkeit entfernt und unterstützt die eigenständige Verwendung von CSS. Weder Sass noch PostCSS oder andere Build-Tools sind erforderlich, um Bootstraps Grid-System und Utility-Klassen zu nutzen.
CDN-freies Self-Hosting
blakecrosley.com hostet alle Vendor-Bibliotheken selbst:
<!-- base.html — no CDN, no external requests -->
<script defer src="{{ asset('js/vendor/alpine.min.js') }}"></script>
<script defer src="{{ asset('js/vendor/htmx.min.js') }}"></script>
Self-Hosting eliminiert externe Abhängigkeiten, verhindert, dass CDN-Ausfälle die Seite beeinträchtigen, und ermöglicht unveränderliches Caching mit Content-Hash-URLs. Laden Sie das kompilierte CSS von Bootstrap herunter (nicht die Sass-Quelldateien) und legen Sie es unter static/css/vendor/ ab.
Grid-System
Bootstraps Grid funktioniert mit einfachen HTML-Klassen:
<div class="container">
<div class="row">
<div class="col-12 col-md-8">
<article>Main content</article>
</div>
<div class="col-12 col-md-4">
<aside>Sidebar</aside>
</div>
</div>
</div>
Keine Sass-Mixins. Kein @include make-col(). Das kompilierte CSS enthält bereits die responsiven Grid-Klassen. Für benutzerdefinierte Breakpoints über Bootstraps Standardwerte hinaus schreiben Sie einfache CSS-Media-Queries.
Einfache CSS-Überschreibungen
Überschreiben Sie Bootstraps Standardwerte mit CSS Custom Properties und Standard-Selektoren:
/* Custom design tokens — no Sass, no Tailwind */
:root {
--color-bg-dark: #000000;
--color-text-primary: #ffffff;
--color-text-secondary: rgba(255, 255, 255, 0.65);
--color-text-tertiary: rgba(255, 255, 255, 0.40);
--spacing-sm: 1rem;
--spacing-md: 1.5rem;
--spacing-lg: 2rem;
--gutter: 48px;
--font-size-lg: 1.25rem;
}
/* Responsive override — the browser reads this at runtime */
@media (max-width: 768px) {
:root {
--gutter: var(--spacing-md); /* 48px → 24px on mobile */
}
}
/* Override Bootstrap's default body styles */
body {
background: var(--color-bg-dark);
color: var(--color-text-primary);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
}
CSS Custom Properties kaskadieren durch das DOM, werden von übergeordneten Elementen vererbt und reagieren zur Laufzeit auf Media Queries. Sass-Variablen hingegen werden zu statischen Werten kompiliert und verschwinden. Dieser Unterschied ist entscheidend für Theming: Eine einzige Änderung einer Custom Property kann jeden abgeleiteten Wert aktualisieren — ganz ohne Neukompilierung.12
Utility-Klassen vs. Komponenten-CSS
Verwenden Sie Bootstrap-Utility-Klassen für einmalige Abstände und Layouts. Für wiederkehrende Muster setzen Sie auf Komponenten-CSS:
<!-- Bootstrap utility for one-off spacing -->
<div class="mt-4 mb-3 px-2">One-off layout</div>
<!-- Component class for repeated patterns -->
<article class="writing__item">
<h3 class="writing__item-title">Post Title</h3>
<p class="writing__item-description">Description</p>
</article>
/* Component CSS — BEM naming, reusable */
.writing__item {
padding: var(--spacing-md);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
transition: background 0.15s ease;
}
.writing__item:hover {
background: rgba(255, 255, 255, 0.03);
}
.writing__item-title {
font-size: var(--font-size-lg);
margin-bottom: 0.5rem;
}
Das Prinzip: Bootstrap-Utilities für Layout-Mechaniken (Margin, Padding, Flexbox). Benutzerdefiniertes CSS für die visuelle Identität (Farben, Typografie, Animationen). Mischen Sie niemals Utility-Klassen und Komponenten-Styling für denselben Zweck.
Internationalisierung und Lokalisierung
blakecrosley.com stellt Inhalte in 10 Sprachen bereit: Englisch, Japanisch, Koreanisch, vereinfachtes Chinesisch, traditionelles Chinesisch, Deutsch, Französisch, Spanisch, Polnisch und Portugiesisch (Brasilianisch).
URL-basiertes Locale-Routing
Das Locale befindet sich im URL-Pfad: /about (Englisch), /ja/about (Japanisch), /zh-Hans/about (vereinfachtes Chinesisch). Englisch ist die Standardsprache und hat kein Präfix.
# i18n/config.py
SUPPORTED_LOCALES = [
"en", "zh-Hans", "zh-Hant", "fr", "de", "ja", "ko", "pl", "pt-BR", "es"
]
DEFAULT_LOCALE = "en"
Die Locale-Middleware extrahiert das Locale aus dem URL-Pfad:
# i18n/middleware.py
class LocaleMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
path = request.url.path
# Check if path starts with a supported locale
for locale in SUPPORTED_LOCALES:
if path.startswith(f"/{locale}/") or path == f"/{locale}":
request.state.locale = locale
# Strip locale prefix for route matching
request.scope["path"] = path[len(f"/{locale}"):]
break
else:
request.state.locale = DEFAULT_LOCALE
response = await call_next(request)
return response
Die Middleware entfernt das Locale-Präfix vor dem Route-Matching. Dadurch benötigen Route-Handler keine locale-spezifischen Pfade — /about verarbeitet sowohl Englisch (/about) als auch Japanisch (/ja/about), da die Middleware den Pfad normalisiert.
Übersetzungsfunktionen in Templates
Jinja2-Globals stellen Übersetzungsfunktionen bereit:
<!-- Template usage -->
<h3>{{ _('ui.footer.navigate') | default('Navigate') }}</h3>
<a href="{{ locale_prefix() }}/about">
{{ _('ui.nav.about') | default('About') }}
</a>
Die Funktion _() sucht einen Übersetzungsschlüssel im Speicher-Cache. Der Filter | default() liefert den englischen Fallback, falls die Übersetzung fehlt. Die Funktion locale_prefix() gibt das URL-Präfix für das aktuelle Locale zurück ("" für Englisch, "/ja" für Japanisch).
Hreflang-Tags
Jede Seite enthält Hreflang-Tags für alle unterstützten Locales:
<!-- Generated in base.html -->
{% for alt in alternate_urls(request.url.path) %}
<link rel="alternate" hreflang="{{ alt.hreflang }}" href="{{ alt.url }}">
{% endfor %}
Das erzeugt:
<link rel="alternate" hreflang="en" href="https://blakecrosley.com/about">
<link rel="alternate" hreflang="ja" href="https://blakecrosley.com/ja/about">
<link rel="alternate" hreflang="zh-Hans" href="https://blakecrosley.com/zh-Hans/about">
<!-- ... all 10 locales -->
<link rel="alternate" hreflang="x-default" href="https://blakecrosley.com/about">
Suchmaschinen verwenden Hreflang, um in den Suchergebnissen die korrekte Sprachversion auszuliefern. Der x-default-Eintrag verweist auf die englische Version als Fallback.13
Übersetzungsspeicherung und Speicher-Cache
Übersetzungen werden in Cloudflare D1 (SQLite an der Edge) gespeichert und über den lifespan-Handler in einen In-Memory-Cache geladen:
@asynccontextmanager
async def lifespan(app: FastAPI):
# Load translations into memory at startup
for locale in SUPPORTED_LOCALES:
data = await fetch_translations(locale)
TRANSLATIONS[locale] = data
yield
app = FastAPI(lifespan=lifespan)
Der Speicher-Cache vermeidet Datenbankabfragen bei jedem Seitenaufruf. Übersetzungsaktualisierungen erfordern eine Cache-Aktualisierung (ausgelöst über einen Admin-Endpunkt oder ein Deployment). Diese Architektur opfert Aktualität zugunsten der Performance — Übersetzungen ändern sich selten, Seitenaufrufe hingegen erfolgen bei jeder Anfrage.
Gesundheitsüberwachung
blakecrosley.com enthält einen i18n-Health-Check-Endpunkt, der die Übersetzungsabdeckung pro Locale überwacht:
@app.get("/health/i18n")
async def health_i18n():
cache = get_translation_cache()
result = {
"status": "healthy",
"cache_loaded": cache.is_loaded,
"locales": {},
"alerts": [],
}
# Check coverage for each locale
for locale in SUPPORTED_LOCALES:
coverage = await calculate_coverage(locale, en_count)
result["locales"][locale] = {"coverage": round(coverage, 2)}
if coverage < 99.5:
result["alerts"].append(
f"{locale}: {coverage:.1f}% coverage (threshold: 99.5%)"
)
result["status"] = "warning"
return result
Der Schwellenwert von 99,5 % Abdeckung erkennt fehlende Übersetzungen, bevor Benutzer auf unübersetzte Zeichenketten stoßen. Der Health-Endpunkt ist in das Monitoring von Railway integriert und warnt bei sinkender Abdeckung — beispielsweise nach dem Hinzufügen neuer UI-Zeichenketten, die noch nicht übersetzt wurden.
Locale-abhängiges Content-Rendering
Blogbeiträge und Guides unterstützen locale-spezifische Übersetzungen von Metadaten und Inhalten:
# In route handler
translated = get_blog_translation(post.meta.slug, locale)
return templates.TemplateResponse("pages/blog/post.html", {
"request": request,
"post": post,
"translated_title": translated.title if translated else post.meta.title,
"translated_description": translated.description if translated else post.meta.description,
})
<!-- In template -->
<h1>{{ translated_title }}</h1>
<p class="post__description">{{ translated_description }}</p>
<!-- Body content falls back to English if translation unavailable -->
{{ post.html | sanitize | safe }}
Das Muster ist konsistent: zuerst den übersetzten Inhalt verwenden, bei Fehlen auf Englisch zurückfallen. So ist eine teilweise Übersetzung möglich — ein japanischer Benutzer sieht übersetzte Titel und Beschreibungen, auch wenn der vollständige Artikeltext auf Englisch bleibt. Der Jinja2-Filter | default() kodiert dieses Muster in einer einzigen Pipe:
{{ translated.title if translated else post.meta.title }}
Locale-Datenübersetzung
Statische Inhalte wie Projektbeschreibungen und Navigationsbezeichnungen werden über Hilfsfunktionen übersetzt, die dieselbe Datenstruktur beibehalten und locale-spezifische Zeichenketten einfügen:
# i18n/data.py
def translate_projects(projects: list, locale: str) -> list:
"""Return projects with translated titles and descriptions."""
if locale == "en":
return projects
translated = []
for project in projects:
t = get_translation(f"project.{project['slug']}.title", locale)
d = get_translation(f"project.{project['slug']}.description", locale)
translated.append({
**project,
"title": t or project["title"],
"description": d or project["description"],
})
return translated
Dieser Ansatz hält die Übersetzungsschicht von der Datenschicht getrennt. Routen übergeben unabhängig vom Locale dieselbe projects-Liste. Die Übersetzungsfunktionen umhüllen die Daten transparent.
Sitemap mit Hreflang-Alternativen
Die dynamische Sitemap enthält alle Seiten in allen Locales mit Querverweisen:
@app.get("/sitemap.xml")
async def sitemap():
for page in static_pages:
for locale in SUPPORTED_LOCALES:
# Each URL entry includes alternates for all locales
locale_path = f"/{locale}{path}" if locale != "en" else path
xml_parts.append(f"<loc>{base_url}{locale_path}</loc>")
# Add xhtml:link alternates
for alt_locale in SUPPORTED_LOCALES:
alt_path = f"/{alt_locale}{path}" if alt_locale != "en" else path
hreflang = LOCALE_TO_HREFLANG[alt_locale]
xml_parts.append(
f'<xhtml:link rel="alternate" hreflang="{hreflang}" '
f'href="{base_url}{alt_path}"/>'
)
Das ergibt 10 URL-Einträge pro Seite (einen pro Locale), jeweils mit 11 alternativen Links (10 Locales + x-default). Bei einer Website mit 50 Seiten enthält die Sitemap 500 URL-Einträge mit 5.500 Hreflang-Links. Die Sitemap wird dynamisch generiert und für eine Stunde zwischengespeichert.
Datenbankpatterns
Hinweis: blakecrosley.com verwendet Cloudflare D1 (serverloses SQLite) über HTTP für alle persistenten Daten, nicht SQLAlchemy. Dieser Abschnitt behandelt das standardmäßige asynchrone SQLAlchemy-Pattern für FastAPI-Projekte, die eine relationale Datenbank benötigen — das häufigste Produktions-Setup für diesen Stack.
SQLAlchemy 2.0 Async
Für Anwendungen, die eine relationale Datenbank benötigen, lässt sich der Async-Support von SQLAlchemy 2.0 nahtlos in FastAPI integrieren:
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker, DeclarativeBase
engine = create_async_engine("sqlite+aiosqlite:///./data.db")
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
Dependency Injection für Datenbank-Sessions
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with async_session() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
@router.get("/users/{user_id}")
async def get_user(request: Request, user_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(404, "User not found")
return templates.TemplateResponse("pages/user.html", {
"request": request, "user": user
})
Die get_db-Dependency verwaltet den gesamten Session-Lebenszyklus: Sie öffnet eine Session, übergibt sie dem Route-Handler, führt bei Erfolg einen Commit durch und macht bei Ausnahmen ein Rollback. Sämtliche Datenbankoperationen verwenden parametrisierte Abfragen — niemals String-Interpolation.
Pydantic-Integration
Pydantic-Modelle validieren Eingaben an der API-Grenze und serialisieren Ausgaben für Templates:
from pydantic import BaseModel, EmailStr
class ContactForm(BaseModel):
name: str
email: EmailStr
message: str
@router.post("/contact")
async def submit_contact(request: Request, form: ContactForm):
# form.name, form.email, form.message are validated
await send_email(form)
return templates.TemplateResponse("components/_contact_success.html", {
"request": request
})
Pydantic validiert Typen, Formate (E-Mail, URL) und Einschränkungen (Min-/Max-Länge), bevor der Route-Handler ausgeführt wird. Bei ungültigen Eingaben wird automatisch eine 422-Antwort zurückgegeben. Damit ersetzt es clientseitige Formularvalidierungs-Bibliotheken — der Server validiert, und HTMX tauscht entweder die Erfolgsmeldung oder das Fehler-Feedback ein.
Migrationen mit Alembic
Alembic verwaltet Änderungen am Datenbankschema:
# Generate a migration from model changes
alembic revision --autogenerate -m "add user preferences table"
# Apply migrations
alembic upgrade head
# Roll back one migration
alembic downgrade -1
Die Autogenerate-Funktion vergleicht die SQLAlchemy-Modelle mit dem aktuellen Datenbankschema und generiert Migrationsskripte. Diese Skripte sind versionierte Python-Dateien, die im Repository liegen:
# alembic/versions/001_add_user_preferences.py
def upgrade():
op.create_table(
"user_preferences",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id")),
sa.Column("locale", sa.String(10), default="en"),
sa.Column("theme", sa.String(20), default="dark"),
)
def downgrade():
op.drop_table("user_preferences")
Migrationen werden beim Deployment ausgeführt (bevor die Anwendung startet). Dadurch ist sichergestellt, dass das Datenbankschema mit dem Anwendungscode übereinstimmt. Bei blakecrosley.com liegen die meisten Daten in Cloudflare D1 (Zugriff über HTTP), sodass Alembic-Migrationen auf die lokale SQLite- oder PostgreSQL-Datenbank angewendet werden, die für Sitzungsdaten und Analysen verwendet wird.
Das Cloudflare-D1-Pattern
blakecrosley.com nutzt Cloudflare D1 als Remote-Datenbank, auf die über einen Cloudflare-Worker-Proxy zugegriffen wird:
class D1Client:
"""HTTP client for Cloudflare D1 via Worker proxy."""
def __init__(self, worker_url: str, auth_secret: str):
self.worker_url = worker_url
self.auth_secret = auth_secret
async def fetch_all(self, sql: str, params: list = None) -> list[dict]:
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.worker_url}/query",
json={"sql": sql, "params": params or []},
headers={"Authorization": f"Bearer {self.auth_secret}"},
)
return response.json()["results"]
Dieses Pattern eignet sich für Anwendungen, die eine Datenbank benötigen, aber keinen Datenbankserver verwalten möchten. D1 ist SQLite an Cloudflares Edge, zugänglich über HTTP. Der Worker-Proxy übernimmt Authentifizierung und Rate-Limiting. Der Kompromiss liegt in der Latenz: Jede Abfrage ist ein HTTP-Request (~50–100 ms) im Vergleich zu einer lokalen Datenbankverbindung (~1–5 ms). Der In-Memory-Cache beim Start gleicht dies für leseintensive Workloads wie Übersetzungen aus.
Sicherheit
Security-Headers-Middleware
blakecrosley.com implementiert gehärtete Security-Header über eine eigene Middleware:
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
CSP_DIRECTIVES = {
"default-src": "'self'",
"script-src": "'self' 'unsafe-inline' 'unsafe-eval'",
"style-src": "'self' 'unsafe-inline'",
"img-src": "'self' data: https:",
"connect-src": "'self'",
"frame-ancestors": "'self'",
"base-uri": "'self'",
"form-action": "'self'",
"upgrade-insecure-requests": "",
}
async def dispatch(self, request, call_next):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "SAMEORIGIN"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Strict-Transport-Security"] = (
"max-age=31536000; includeSubDomains"
)
response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
response.headers["Content-Security-Policy"] = self.csp
response.headers["Permissions-Policy"] = self.PERMISSIONS_POLICY
return response
Die CSP enthält 'unsafe-inline' und 'unsafe-eval', da Alpine.js diese für die Auswertung von Ausdrücken benötigt. Die Alternative ist der CSP-kompatible Build von Alpine.js, der allerdings Einschränkungen mit sich bringt.14 Alle anderen Funktionen sind abgesichert: frame-ancestors verhindert Clickjacking, form-action beschränkt Formularübermittlungen auf den gleichen Origin, und upgrade-insecure-requests erzwingt HTTPS.
CDN-Cache-Sicherheit mit HTMX
Die Security-Headers-Middleware fügt HTMX-Antworten Vary: HX-Request hinzu:
if request.headers.get("HX-Request"):
existing_vary = response.headers.get("Vary", "")
if "HX-Request" not in existing_vary:
parts = [v.strip() for v in existing_vary.split(",") if v.strip()]
parts.append("HX-Request")
response.headers["Vary"] = ", ".join(parts)
Ohne diesen Header könnte ein CDN eine HTMX-Fragment-Antwort zwischenspeichern und sie als vollständige Seite an einen Nicht-HTMX-Request ausliefern (oder umgekehrt). Der Vary-Header weist das CDN an, basierend auf dem Wert des HX-Request-Headers separate Cache-Einträge zu speichern.11
CSRF-Schutz
HTMX-Formulare verwenden zustandslose, HMAC-signierte CSRF-Token:
# csrf.py
def generate_csrf_token() -> str:
"""Token format: timestamp:random:HMAC-SHA256-signature"""
timestamp = str(int(time.time()))
random_value = secrets.token_hex(16)
payload = f"{timestamp}:{random_value}"
signature = hmac.new(
CSRF_SECRET.encode(), payload.encode(), hashlib.sha256
).hexdigest()
return f"{payload}:{signature}"
def validate_csrf_token(token: str) -> bool:
"""Verify signature and check expiration (1 hour)."""
timestamp, random_value, signature = token.split(":")
if int(time.time()) - int(timestamp) > 3600:
return False
expected = hmac.new(
CSRF_SECRET.encode(),
f"{timestamp}:{random_value}".encode(),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
Das Token wird im Template über ein Jinja2-Global generiert und in HTMX-Formular-Requests eingebunden:
<form hx-post="/contact" hx-target="#form-result">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- form fields -->
</form>
Zustandslose Token machen serverseitige Session-Speicherung überflüssig. Die HMAC-Signatur stellt sicher, dass das Token vom Server generiert wurde. Der Zeitstempel verhindert Replay-Angriffe. hmac.compare_digest schützt vor Timing-Angriffen.15
HTML-Bereinigung
Benutzergenerierte Inhalte durchlaufen vor dem Rendern eine Bereinigung durch nh3:
templates.env.filters["sanitize"] = sanitize_html
# In templates: {{ content | sanitize }}
Die nh3-Bibliothek entfernt Tags und Attribute, die nicht auf der Allowlist stehen. Links erhalten automatisch rel="noopener noreferrer". Diese Verteidigung ist unabhängig von CSP — sie verhindert gespeichertes XSS auf der Rendering-Ebene, während CSP injizierte Skripte auf der Browser-Ebene blockiert. Verteidigung in der Tiefe.
Eingabevalidierung
Pydantic-Modelle validieren sämtliche Eingaben an der API-Grenze:
from pydantic import BaseModel, Field, EmailStr
class ContactRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
email: EmailStr
message: str = Field(..., min_length=10, max_length=5000)
FastAPI gibt bei ungültigen Eingaben automatisch den Statuscode 422 Unprocessable Entity zurück. In Kombination mit parametrisierten Datenbankabfragen (SQLAlchemy interpoliert niemals Strings) werden so SQL-Injection verhindert und Typsicherheit an den Systemgrenzen gewährleistet.
Leistung
Lighthouse 100/100/100/100
blakecrosley.com erreicht 100 in allen vier Lighthouse-Kategorien: Performance, Accessibility, Best Practices und SEO. Überprüfen Sie dies unter PageSpeed Insights.2
Die wichtigsten Optimierungen:
CSS-Ladestrategie
blakecrosley.com lädt CSS mit einem einzigen <link>-Tag und inhaltsbezogenen Hash-URLs für unveränderliches Caching:
<link rel="stylesheet" href="{{ asset('css/styles.css') }}">
Der asset()-Helfer hängt einen Inhalts-Hash (?v=a3b2c1d4) an, sodass der Browser die Datei unbegrenzt zwischenspeichert, bis sich der Inhalt ändert. Keine kritische CSS-Extraktion, kein Print-Media-Trick, kein JavaScript-basiertes Laden. Die CSS-Datei ist ca. 8 KB gzipped — klein genug, dass der Single-Request-Ansatz bei Lighthouse Performance eine 100 erzielt, ganz ohne Optimierungsgymnastik.
GZip-Komprimierung
app.add_middleware(GZipMiddleware, minimum_size=500)
Antworten über 500 Bytes werden komprimiert. HTML komprimiert um 70–80 %, wodurch ein 15-KB-Dokument auf 3–4 KB reduziert wird.
Unveränderliches Caching statischer Assets
# In security headers middleware
if request.url.path.startswith("/static/"):
if os.environ.get("RAILWAY_ENVIRONMENT"):
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
Statische Assets mit Inhalts-Hash-URLs (?v=a3f8b2c1d0) werden ein Jahr lang mit immutable gecacht. Ändert sich die Datei, ändert sich auch der Hash — Browser und CDNs werden so gezwungen, die neue Version abzurufen.
Verzögertes Laden von Skripten
<script defer src="{{ asset('js/vendor/alpine.min.js') }}"></script>
<script defer src="{{ asset('js/vendor/htmx.min.js') }}"></script>
<script defer src="{{ asset('js/main.js') }}"></script>
Das defer-Attribut lädt Skripte parallel zum HTML-Parsing herunter, führt sie jedoch erst nach dem vollständigen Parsen des Dokuments aus. Dadurch wird Render-Blocking verhindert — ohne die Komplexität von asynchronem Laden und der Verwaltung der Ausführungsreihenfolge.
Bildoptimierung
Bilder verwenden WebP mit responsivem srcset und expliziten Dimensionen:
OPTIMIZED_IMAGES = {
"vision-sprint": {
"webp_srcset": (
"/static/images/optimized/vision-sprint-400w.webp 400w, "
"/static/images/optimized/vision-sprint-800w.webp 800w, "
"/static/images/optimized/vision-sprint-1200w.webp 1200w"
),
"fallback": "/static/images/optimized/vision-sprint-fallback.jpg",
"width": 1200,
"height": 1045,
},
}
<picture>
<source type="image/webp"
srcset="{{ image.webp_srcset }}"
sizes="(max-width: 768px) 100vw, 50vw">
<img src="{{ image.fallback }}"
width="{{ image.width }}"
height="{{ image.height }}"
alt="{{ image.alt }}"
loading="lazy">
</picture>
Explizite width- und height-Attribute verhindern Cumulative Layout Shift (CLS). Das Attribut loading="lazy" verzögert das Laden von Bildern außerhalb des sichtbaren Bereichs. WebP liefert bei vergleichbarer Qualität 25–35 % kleinere Dateien als JPEG.16
Early Hints
# In main.py
app.state.preload_links = [
f'<{make_asset_url(_asset_map, "css/styles.css")}>; rel=preload; as=style',
]
# In security headers middleware
if "text/html" in content_type:
preload_links = getattr(request.app.state, "preload_links", [])
if preload_links:
response.headers["Link"] = ", ".join(preload_links)
Der Link-Header mit rel=preload weist Cloudflare an, eine 103-Early-Hints-Antwort zu senden. So kann der Browser bereits mit dem Abruf der CSS-Datei beginnen, bevor der Server die HTML-Antwort fertig generiert hat.17
Minimaler JavaScript-Footprint
Der gesamte JavaScript-Footprint:
| Bibliothek | Größe (minifiziert + gzipped) |
|---|---|
| HTMX | ~14 KB |
| Alpine.js | ~14 KB |
| Seitenspezifisches JS | 4–8 KB |
| Gesamt | 32–36 KB |
Eine typische React-Anwendung liefert 100–300 KB Framework-JavaScript aus — noch bevor der Anwendungscode hinzukommt.18 Der No-Build-Ansatz liefert weniger JavaScript, weil schlicht weniger JavaScript vorhanden ist.
Deployment
Railway
blakecrosley.com wird per Git-Push auf Railway deployt:
# railway.toml
[build]
builder = "nixpacks"
[deploy]
startCommand = "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}"
healthcheckPath = "/health"
healthcheckTimeout = 300
restartPolicyType = "ON_FAILURE"
restartPolicyMaxRetries = 10
Der Nixpacks-Builder von Railway erkennt das Python-Projekt anhand der requirements.txt, installiert Abhängigkeiten und führt den Startbefehl aus. Kein Dockerfile erforderlich. Der Health-Check-Endpunkt stellt sicher, dass die Anwendung reagiert, bevor sie Traffic empfängt:
@app.get("/health")
async def health():
return {"status": "healthy"}
Die Deploy-Pipeline
git push origin main
→ Railway erkennt den Push
→ Nixpacks installiert Python + requirements.txt (gecacht)
→ uvicorn startet
→ Health Check bestanden
→ Traffic wird zum neuen Deployment geroutet
→ ~40 Sekunden insgesamt
Kein npm install. Kein npm run build. Keine webpack-Kompilierung. Keine TypeScript-Kompilierung. Der einzige Installationsschritt ist pip install -r requirements.txt, und dieser wird zwischen Deployments gecacht.
Procfile
web: uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}
Das Procfile bietet eine Heroku-kompatible Alternative. Railway unterstützt sowohl railway.toml als auch Procfile. Die Syntax ${PORT:-8000} verwendet den von der Plattform bereitgestellten Port oder nutzt standardmäßig 8000 für die lokale Entwicklung.
Uvicorn-Produktionskonfiguration
Für Deployments mit höherem Traffic verwenden Sie mehrere Worker:
uvicorn app.main:app \
--host 0.0.0.0 \
--port ${PORT:-8000} \
--workers 4 \
--loop uvloop \
--http httptools
--workers 4startet vier Worker-Prozesse (Faustregel: 2 × CPU-Kerne + 1)--loop uvloopverwendet die schnellere uvloop-Event-Loop (Drop-in-Ersatz für asyncio)--http httptoolsverwendet den schnelleren httptools-HTTP-Parser
Für die Entwicklung überwacht --reload Dateiänderungen:
uvicorn app.main:app --reload --port 8000
Docker-Alternative
Für Plattformen, die Docker erfordern:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Das schlanke Basis-Image hält den Container klein. --no-cache-dir verhindert, dass pip heruntergeladene Pakete im Image-Layer speichert.
Cloudflare CDN
blakecrosley.com nutzt Cloudflare für CDN-Caching, DNS und Workers:
# Cache headers for HTML pages (set in security middleware)
response.headers["Cache-Control"] = (
"public, max-age=300, s-maxage=3600, "
"stale-while-revalidate=86400"
)
max-age=300— Browser cached 5 Minutens-maxage=3600— CDN cached 1 Stundestale-while-revalidate=86400— veraltete Inhalte werden ausgeliefert, während im Hintergrund 24 Stunden lang revalidiert wird
Statische Assets erhalten max-age=31536000, immutable, da Inhalts-Hash-URLs die Aktualität garantieren.
Entscheidungsrahmen
Brauchen Sie Build-Tools?
Beantworten Sie vier Fragen:
1. Teilen sich mehr als fünf Entwickler JavaScript-Schnittstellen? Falls ja, verhindert die Compile-Time-Typprüfung von TypeScript Integrationsfehler, die Runtime-Tests erst zu spät erkennen. Fügen Sie einen Build-Schritt hinzu.
2. Verwaltet Ihre Anwendung komplexen clientseitigen State? Wenn Drag-and-Drop, Echtzeit-Kollaboration oder Offline-First-Daten Kernfunktionen sind (nicht nur Nice-to-haves), rechtfertigt ein Framework wie React oder Svelte seine Komplexität. Fügen Sie einen Build-Schritt hinzu.
3. Nutzen mehrere Produkte eine gemeinsame Komponentenbibliothek? Falls ja, benötigt diese Bibliothek npm-Packaging, semantische Versionierung und Tree Shaking. Fügen Sie einen Build-Schritt hinzu.
4. Sind Sie auf npm-Ökosystem-Bibliotheken angewiesen, die einen Bundler voraussetzen? Wenn Radix, Framer Motion, TanStack Query oder ähnliche Bibliotheken zum Kern des Produkts gehören, ist eine Build-Pipeline zwingend erforderlich.
Lauten alle vier Antworten „Nein”, ist der No-Build-Ansatz praktikabel. Lautet auch nur eine Antwort „Ja”, lösen Build-Tools ein reales Problem. Der Fehler besteht darin, Build-Tools einzuführen, wenn alle vier Antworten „Nein” lauten — damit lösen Sie Probleme, die Sie nicht haben, und schaffen gleichzeitig einen Overhead bei der Abhängigkeitsverwaltung, den Sie ebenfalls nicht brauchen.1
Stack-Vergleich
| Kategorie | No-Build (dieser Leitfaden) | React + Build-Tools |
|---|---|---|
| Ideal für | Content-Sites, Portfolios, interne Tools, CRUD-Apps | SaaS-Produkte, komplexe SPAs, Design-System-Konsumenten |
| Teamgröße | 1–5 Entwickler | 5–50+ Entwickler |
| State-Management | Server (HTMX) + Client (Alpine.js) | Client (React State, Redux, Zustand) |
| Typsicherheit | Runtime (Pydantic serverseitig) | Compile-Time (TypeScript) |
| Komponentenwiederverwendung | Jinja2-Includes + Makros | npm-Pakete, gemeinsame Bibliotheken |
| SEO | Standardmäßig servergerendert | Erfordert SSR/SSG-Konfiguration |
| Performance-Untergrenze | Hoch (minimales JS, servergerendert) | Variiert (Framework-Overhead) |
| Komplexitätsobergrenze | Niedriger (kein Offline, kein reichhaltiger Client-State) | Höher (jede Client-Interaktion möglich) |
| Abhängigkeiten | 15 Python-Pakete | 300+ npm-Pakete |
| Build-Zeit | 0 Sekunden | 15–60 Sekunden |
Wann HTMX die falsche Wahl ist
HTMX ersetzt clientseitigen State durch Server-Roundtrips. Das funktioniert — bis Latenz zum Problem wird:
- Drag-and-Drop-Oberflächen — ein 200-ms-Server-Roundtrip pro Drag-Event ist inakzeptabel
- Echtzeit-Kollaboration — WebSocket-getriebener State erfordert clientseitige Konfliktlösung
- Offline-First-Anwendungen — ohne Server kein HTMX
- Komplexe, an State gekoppelte Animationen — Framer Motion und React Spring setzen ein React-Reconciliation-Modell voraus
- Canvas/WebGL-Anwendungen — die Render-Schleife ist von Natur aus clientseitig
Für diese Anwendungsfälle ist ein clientseitiges Framework das richtige Werkzeug. Der No-Build-Ansatz versucht nicht, diese zu ersetzen.
Kurzreferenzkarte
FastAPI
# Development
source venv/bin/activate
uvicorn app.main:app --reload --port 8000
# Production
uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}
# Testing
python -m pytest -v --cov=app
# Database migrations
alembic upgrade head
alembic revision --autogenerate -m "description"
HTMX-Attribute
hx-get="/url" <!-- GET request -->
hx-post="/url" <!-- POST request -->
hx-target="#element" <!-- Where to put response -->
hx-swap="innerHTML" <!-- How to insert (innerHTML, outerHTML, beforeend) -->
hx-trigger="click" <!-- What triggers request -->
hx-trigger="keyup changed delay:300ms" <!-- Debounced input -->
hx-trigger="load" <!-- Fire on element load -->
hx-indicator="#spinner" <!-- Show during request -->
hx-push-url="true" <!-- Update browser URL -->
hx-replace-url="true" <!-- Replace URL (no history) -->
Alpine.js-Attribute
x-data="{ open: false }" <!-- Component scope + state -->
x-show="open" <!-- Toggle visibility -->
x-cloak <!-- Hide until Alpine inits -->
@click="open = !open" <!-- Event handler -->
@click.away="open = false" <!-- Outside click -->
@keydown.escape="open = false" <!-- Keyboard event -->
:class="{ 'active': open }" <!-- Dynamic class -->
:aria-expanded="open" <!-- Dynamic attribute -->
x-text="count" <!-- Dynamic text content -->
x-init="fetchData()" <!-- Run on init -->
CSS Custom Properties
:root {
--color-bg: #000000;
--color-text: #ffffff;
--spacing-sm: 1rem;
--spacing-md: 1.5rem;
--font-size-lg: 1.25rem;
}
@media (max-width: 768px) {
:root { --gutter: 24px; }
}
Sicherheits-Header
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-eval'
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Referrer-Policy: strict-origin-when-cross-origin
Cross-Origin-Opener-Policy: same-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
Checkliste für die Projekteinrichtung
[ ] FastAPI app with Jinja2Templates
[ ] Security headers middleware (CSP, HSTS, X-Frame-Options)
[ ] CSRF token generation and validation
[ ] GZip middleware (minimum_size=500)
[ ] Content-hash asset versioning (cache busting)
[ ] HTMX self-hosted in /static/js/vendor/
[ ] Alpine.js self-hosted in /static/js/vendor/
[ ] CSS custom properties for design tokens
[ ] Health check endpoint (/health)
[ ] Error handlers (404, 500)
[ ] robots.txt, sitemap.xml, llms.txt
[ ] JSON-LD structured data in base template
[ ] Hreflang tags for i18n (if multi-language)
[ ] HTML sanitization filter (nh3)
[ ] Rate limiting middleware
[ ] Deferred script loading
FAQ
Ist HTMX produktionsreif für echte Webanwendungen?
Ja. HTMX ist seit 2020 stabil und wird branchenübergreifend in Produktionsumgebungen eingesetzt. Carson Gross, der Entwickler, betrachtet Abwärtskompatibilität als zentrales Designprinzip — die HTMX-Dokumentation besagt, dass die Bibliothek innerhalb einer Hauptversion keine bestehenden Anwendungen brechen wird.19 Die Bibliothek ist 14 KB minifiziert und gzipped, hat keine Abhängigkeiten und folgt Semantic Versioning. blakecrosley.com betreibt HTMX seit drei Jahren in Produktion — ohne einen einzigen HTMX-bezogenen Bug.
Kann ich TypeScript ohne Build-Schritt verwenden?
Teilweise. TypeScript-Dateien lassen sich mit tsc --noEmit typprüfen, ohne Ausgabedateien zu erzeugen — das bietet Compile-Time-Prüfung als eine Art Linter. Allerdings können Browser .ts-Dateien nicht direkt ausführen, sodass für die Auslieferung weiterhin ein Build-Schritt erforderlich ist. Die Alternative sind JSDoc-Typannotationen in normalen .js-Dateien, die TypeScript ohne Kompilierung prüfen kann. So erhalten Sie Typsicherheit während der Entwicklung und liefern gleichzeitig Standard-JavaScript aus.
Wie schneidet dieser Ansatz im Vergleich zu Astro oder 11ty ab?
Astro und 11ty sind statische Seitengeneratoren, die einfaches HTML mit minimalem clientseitigem JavaScript erzeugen — erfordern dafür aber einen Build-Schritt (Node.js, npm install, einen Build-Befehl). Der No-Build-Ansatz eliminiert diesen Schritt vollständig: Der Server rendert HTML bei jeder Anfrage. Der Kompromiss: Astro/11ty erzeugen schnellere statische Seiten (keine Serverberechnung), während FastAPI + HTMX dynamische Inhalte nativ verarbeitet (benutzerspezifische Daten, Formularübermittlungen, Echtzeit-Updates) — ohne eine separate API-Schicht.
Was ist mit serverseitigem Rendering (SSR) mit React?
Next.js SSR und der FastAPI + HTMX-Ansatz verfolgen dasselbe Ziel: servergerendertes HTML an den Browser senden. Der Unterschied liegt in dem, was nach dem initialen Rendering passiert. Next.js hydriert die Seite mit React und liefert dabei die Framework-Runtime und den Komponentencode an den Client aus. FastAPI + HTMX hydriert nicht — das HTML ist die endgültige Ausgabe. Nachfolgende Interaktionen behandelt HTMX, indem es neue HTML-Fragmente vom Server anfordert. Das Ergebnis: FastAPI + HTMX liefert insgesamt 30–40 KB JavaScript aus, verglichen mit 100–300 KB bei einer Next.js-Anwendung.18
Wie handhabe ich Formularvalidierung mit diesem Stack?
Serverseitig. Pydantic validiert die Eingabe beim Absenden des Formulars. Schlägt die Validierung fehl, gibt der Server das Formular mit Fehlermeldungen zurück. HTMX tauscht die Antwort im DOM aus:
<form hx-post="/contact" hx-target="#form-container" hx-swap="outerHTML">
<input type="email" name="email" required>
<button type="submit">Send</button>
</form>
@router.post("/contact")
async def contact(request: Request, email: str = Form(...)):
if not validate_email(email):
return templates.TemplateResponse("components/_contact_form.html", {
"request": request,
"error": "Please enter a valid email address",
"email": email, # Preserve input
})
await send_email(email)
return templates.TemplateResponse("components/_contact_success.html", {
"request": request
})
Der Server validiert, der Server rendert Fehlerzustände, und HTMX tauscht das Ergebnis aus. Eine clientseitige Validierungsbibliothek ist nicht erforderlich. Das HTML-Attribut required bietet als erste Verteidigungslinie eine grundlegende Validierung auf Browserebene.
Kann ich Echtzeit-Funktionen (WebSockets) hinzufügen?
Ja. FastAPI bietet integrierte WebSocket-Unterstützung:
from fastapi import WebSocket
@app.websocket("/ws/notifications")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
while True:
data = await get_notification()
await websocket.send_text(render_notification_html(data))
HTMX verfügt über eine WebSocket-Erweiterung (hx-ws), die Elemente mit WebSocket-Endpunkten verbindet:
<!-- HTMX 2.x WebSocket extension syntax -->
<div hx-ext="ws" ws-connect="/ws/notifications">
<div id="notifications" ws-send></div>
</div>
Hinweis: HTMX 1.x verwendete die Syntax
hx-ws="connect:...". In HTMX 2.x wurde die WebSocket-Unterstützung in eine separate Erweiterung (htmx-ext-ws) mit den oben gezeigten Attributenws-connectundws-sendausgelagert. Bei Verwendung von HTMX 1.x funktioniert die altehx-ws-Syntax weiterhin.
Nachrichten vom Server werden mit denselben Targeting- und Swap-Mechanismen wie bei HTTP-Antworten in das DOM eingefügt. Der Server sendet HTML-Fragmente über den WebSocket, und HTMX fügt sie ein.
Wie geht dieser Stack mit SEO um?
Servergerendertes HTML ist von Natur aus SEO-freundlich, da Crawler den vollständigen Seiteninhalt erhalten, ohne JavaScript ausführen zu müssen. blakecrosley.com fügt mehrere SEO-Ebenen hinzu:
- JSON-LD Structured Data im
<head>für jede Seite (Person-, Article-, WebSite-, FAQPage-Schemas) - Dynamische Sitemap mit Hreflang-Alternativen für alle 10 Sprachen
- RSS-Feed unter
/blog/feed.xml llms.txtim Root-Verzeichnis für die Auffindbarkeit durch KI-Crawler- Canonical URLs und Open Graph Tags im Basis-Template
- Semantisches HTML:
<article>,<section>,<main>, korrekte Überschriftenhierarchie
Keine SSR-Konfiguration erforderlich. Kein getStaticProps. Kein ISR. Das HTML wird bei jeder Anfrage gerendert — das ist das Standardverhalten, keine Optimierung.
Wie steil ist die Lernkurve im Vergleich zu React?
Für Python-Entwickler ist die Lernkurve deutlich flacher. Sie kennen die Sprache bereits. Die Route-Handler von FastAPI geben Template-Responses zurück — dasselbe mentale Modell wie bei Flask- oder Django-Views. HTMX fügt eine Handvoll HTML-Attribute hinzu (hx-get, hx-target, hx-swap). Alpine.js ergänzt einige weitere (x-data, x-show, @click). Es gibt kein JSX, kein Virtual DOM, kein Hooks-System, keine State-Management-Bibliothek und keine Build-Tool-Konfiguration zu erlernen.
Die HTMX-Dokumentation passt auf eine einzige lange Seite. Die Alpine.js-Dokumentation umfasst wenige Seiten. Reacts Dokumentation erstreckt sich über Hunderte von Seiten zu Hooks, Context, Refs, Effects, Suspense, Server Components und Streaming SSR.
Für JavaScript/React-Entwickler ist der Wandel eher konzeptionell als syntaktisch. Die zentrale Erkenntnis: Der Server besitzt den State und der Server rendert das HTML. Clientseitiges State-Management wird zu serverseitigem Route-Handling. Clientseitiges Datenabrufen wird zu HTMX-Attributen auf HTML-Elementen. Die Syntax ist einfacher — das mentale Modell erfordert, die SPA-Annahme abzulegen, dass der Client das Rendering besitzt.
Änderungsprotokoll
| Datum | Änderung |
|---|---|
| 24. März 2026 | Erstveröffentlichung |
Referenzen
Dieser Leitfaden behandelt das vollständige System, mit dem blakecrosley.com erstellt wurde. Das No-Build Manifesto liefert die philosophische Argumentation. Der Beitrag Lighthouse Perfect Score dokumentiert die Performance-Optimierungsreise. Der Beitrag Vibe Coding vs. Engineering untersucht, wo KI-gestützte Entwicklung in diesen Workflow passt.
-
Produktionsmetriken von blakecrosley.com, Stand April 2026. Die Website liefert über 100 Blogbeiträge, interaktive JavaScript-Komponenten, 9 umfassende Leitfäden und Übersetzungen in 9 Sprachen – bei minimalen Python-Abhängigkeiten und ganz ohne Build-Tools. Verifiziert anhand der Live-Website und der
requirements.txt. ↩↩↩ -
Google PageSpeed Insights (pagespeed.web.dev) führt Lighthouse-Audits für beliebige öffentliche URLs durch. blakecrosley.com erreicht 100/100/100/100 (Performance, Barrierefreiheit, Best Practices, SEO), Stand März 2026. Die Ergebnisse sind öffentlich überprüfbar. Siehe From 76 to 100: Achieving a Perfect Lighthouse Score für die vollständige Optimierungsreise. ↩↩↩
-
Ein frisches
npx create-next-app@latest(Next.js 15, getestet Februar 2026) installiert 311 Pakete innode_modules/mit insgesamt 187 MB. Produktionsprojekte mit zusätzlichen Abhängigkeiten tendieren zu höheren Werten. Einzelne Projekte können abweichen. Quelle: Tests des Autors, dokumentiert in The No-Build Manifesto. ↩ -
Vercels Next.js-Performance-Dokumentation empfiehlt spezifische Optimierungen (Bildoptimierung, Schriftladen, Code-Splitting), um Werte über 90 zu erreichen. Siehe nextjs.org/docs/app/building-your-application/optimizing. Der Bereich 70–90 spiegelt die Standardeinstellungen wider, bevor diese Optimierungen angewandt werden. ↩↩
-
Vollständige Abhängigkeitsliste, verifiziert anhand der
requirements.txtvon blakecrosley.com, Stand März 2026. Kein einziges Paket ist ein Build-Tool, Compiler oder Bundler. ↩ -
Basierend auf der Erfahrung des Autors mit der Wartung von Next.js-Projekten (2021–2024) generiert das JavaScript-Ökosystem 15–25 Dependabot-PRs pro Monat für aktive Projekte – die meisten aktualisieren transitive Abhängigkeiten, die der Entwickler nie direkt importiert hat. ↩
-
Tim Berners-Lee formulierte Abwärtskompatibilität als Webdesign-Prinzip: „a browser should be backwards-compatible.” Eine Seite von 1996 wird in Chrome 2026 korrekt dargestellt. Siehe w3.org/DesignIssues/Principles. ↩
-
OWASP empfiehlt, API-Dokumentationsendpunkte in der Produktion zu deaktivieren, um die Angriffsfläche zu reduzieren. Der
/openapi.json-Endpunkt legt sämtliche Routendefinitionen, Parameter und Antwortmodelle offen. ↩ -
FastAPI-Dokumentation zu async- vs. sync-Handlern: fastapi.tiangolo.com/async/. Das Mischen von
awaitmit blockierenden Aufrufen inasync-Funktionen blockiert die Event-Loop. ↩ -
nh3 ist ein Rust-basierter HTML-Sanitizer und der Nachfolger der Bleach-Bibliothek. Er wird vom PyO3-Projekt gepflegt und bietet Allowlist-basierte HTML-Bereinigung. Siehe github.com/messense/nh3. ↩
-
Der
Vary-Header ist in RFC 9110, Abschnitt 12.5.5 definiert. Er weist Caches an, separate Antworten basierend auf den angegebenen Request-Header-Werten zu speichern. OhneVary: HX-Requestkönnte ein CDN ein HTMX-Fragment als vollständige Seitenantwort ausliefern. Siehe httpwg.org/specs/rfc9110.html#field.vary. ↩↩ -
CSS Custom Properties (CSS-Variablen) werden von über 97 % der Browser weltweit unterstützt. Sie kaskadieren, vererben und reagieren zur Laufzeit auf Media Queries – Fähigkeiten, die Präprozessor-Variablen fehlen. Quelle: caniuse.com/css-variables. ↩
-
Googles hreflang-Dokumentation: developers.google.com/search/docs/specialty/international/localized-versions. Der Wert
x-defaultkennzeichnet die Fallback-Seite für Benutzer, deren Sprache nicht in der hreflang-Liste enthalten ist. ↩ -
Alpine.js erfordert
'unsafe-eval'in der Content Security Policy für seine Ausdrucksauswertungs-Engine. Der CSP-kompatible Build (@alpinejs/csp) umgeht diese Anforderung, hat allerdings Einschränkungen. Siehe alpinejs.dev/advanced/csp. ↩ -
HMAC-basierte CSRF-Tokens folgen dem „Signed Double-Submit Cookie”-Muster, das im OWASP CSRF Prevention Cheat Sheet beschrieben wird.
hmac.compare_digestverwendet einen Konstantzeit-Vergleich, um Timing-Seitenkanalangriffe zu verhindern. Siehe cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html. ↩ -
WebP liefert 25–35 % kleinere Dateien als JPEG bei vergleichbarer visueller Qualität. Googles WebP-Studie: developers.google.com/speed/webp/docs/webp_study. ↩
-
103 Early Hints ermöglicht es dem Server (oder CDN), eine vorläufige Antwort mit Preload-Hinweisen zu senden, bevor die endgültige Antwort bereit ist. Cloudflare unterstützt Early Hints für
Link-Header mitrel=preload. Siehe developer.chrome.com/blog/early-hints. ↩ -
React 18 + ReactDOM wiegt etwa 42 KB minifiziert + gzipped. Mit Router, State-Management-Bibliothek und Build-Framework-Runtime liefern typische React-Anwendungen 100–300 KB an Framework-JavaScript aus. Quelle: bundlephobia.com/package/[email protected]. ↩↩
-
Die Versionierungsrichtlinie und das Abwärtskompatibilitätsversprechen von HTMX sind dokumentiert unter htmx.org/migration-guide-htmx-1/. Carson Gross hat das Prinzip der Abwärtskompatibilität in Hypermedia Systems (2023) von Gross, Stepinski und Cotter dargelegt: hypermedia.systems. ↩