fastapi:~/app$ cat fastapi-htmx.md

FastAPI + HTMX: Der Full-Stack ohne Build-Schritt

# FastAPI + HTMX: Der Full-Stack ohne Build-Schritt

words: 6103 read_time: 31m updated: 2026-03-25 08:20
$ less fastapi-htmx.md

TL;DR: FastAPI + HTMX + Alpine.js + Bootstrap 5 + Jinja2 + reines CSS ergibt 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 nutzt blakecrosley.com als Produktionsreferenz, die 37 Blogartikel, 20 interaktive JavaScript-Komponenten, 20 Guides und Übersetzungen in zehn Sprachen ausliefert — 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 erzeugt dennoch Websites, die 100/100/100/100 bei Lighthouse 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

  • Serverseitig gerendertes HTML eliminiert drei vollständige Problemkategorien: Client-seitige Zustandsverwaltung, JSON-Serialisierungsgrenzen und Hydration-Mismatches. HTMX macht Serverantworten zur endgültigen Ausgabe — kein clientseitiger Rendering-Schritt.
  • Keine Build-Tools bedeutet keine Build-Fehler. Keine npm install-Konflikte mit Peer-Dependencies, 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 ist git push.
  • Alpine.js übernimmt clientseitigen Zustand, den HTMX nicht abbilden kann. Dropdowns, Modals, mobile Navigations-Toggles und jeder 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.
  • Reines 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.
  • Dieser Ansatz hat klare Grenzen. Er eignet sich nicht für große Teams mit gemeinsam genutzten Komponentenschnittstellen, SaaS-Produkte mit komplexem clientseitigem Zustand und Anwendungen, die auf npm-Ökosystem-Bibliotheken angewiesen sind. Das Entscheidungsframework in Abschnitt 15 definiert diese Grenzen präzise.
  • blakecrosley.com ist der Beweis. Jedes Muster in diesem Leitfaden läuft in Produktion. Jede Behauptung hat einen Dateipfad, einen Konfigurationsblock oder ein Lighthouse-Audit, das Sie selbst unter PageSpeed Insights überprüfen können.2

Wie Sie diesen Leitfaden nutzen

Dies ist eine umfassende Referenz. Steigen Sie dort ein, wo Ihr Erfahrungsniveau passt:

Erfahrung Einstiegspunkt Dann erkunden
Python-Entwickler, neu bei HTMX Die No-Build-TheseArchitekturübersichtHTMX Deep Dive Alpine.js-Patterns, Sicherheit
React/Vue-Entwickler, der Alternativen evaluiert Die No-Build-TheseEntscheidungsframework Architekturübersicht, Performance
FastAPI-Entwickler, der Interaktivität ergänzt HTMX Deep DiveAlpine.js-Patterns i18n und Lokalisierung, Deployment
Full-Stack-Entwickler, der von Grund auf aufbaut Sequenziell lesen ab Architekturübersicht Schnellreferenzkarte für den laufenden Gebrauch

Verwenden Sie Strg+F / Cmd+F, um nach bestimmten Patterns oder Attributen zu suchen. Die Schnellreferenzkarte am Ende bietet eine übersichtliche 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 Metriken 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 aufzulisten:

Kein TypeScript. Jede .js-Datei ist reines JavaScript. Typfehler werden durch Tests und Codeanalyse erkannt, nicht durch einen Compiler. Das funktioniert für einen einzelnen Entwickler. Für ein zehnköpfiges Team mit gemeinsam genutzten Komponentenschnittstellen wäre es ungeeignet.

Kein Hot Module Replacement. CSS-Änderungen erfordern ein manuelles Neuladen des Browsers. HTMXs hx-boost macht die Navigation schnell genug, sodass vollständige Seitenaktualisierungen tolerabel sind — bei engen 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 wegen eines TypeScript-Fehlers in einer Datei fehlschlagen, die Sie nicht verändert haben.6

Debugging mit 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). Gesamt: 45–60 KB beim ersten Besuch.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-Deprecation, keine Next.js-App-Router-Migration.


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 ausschließlich clientseitigen Zustand, der den Server nie berührt.

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 Rastersystem, Utility-Klassen, responsives Layout Client (CSS)
Plain CSS Custom 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 Inhalt. 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 echter Kompromiss ist. Für eine einzelne Entwicklerin, die während der Entwicklung uvicorn --reload nutzt, erscheinen Laufzeitfehler sofort im Browser. Für ein großes Team hingegen verhindert TypeScript durch zur Kompilierzeit erkannte Fehler eine ganze Kategorie von Bugs, die Laufzeitfehler nicht abfangen können.


FastAPI-Muster

Anwendungseinrichtung

Die Anwendung wird in main.py mit expliziter 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 wesentlich. Erstens deaktivieren docs_url=None und openapi_url=None die automatischen API-Dokumentationsendpunkte. Eine öffentlich zugängliche Content-Website muss /docs oder /openapi.json nicht im Internet offenlegen.8 Zweitens spielt die Middleware-Reihenfolge eine Rolle — das Security-Logging wird zuerst ausgeführt (zuletzt hinzugefügt), sodass jede Anfrage erfasst wird, einschließlich derer, 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 unterteilen sich in zwei Kategorien: Seitenrouten geben vollständige HTML-Dokumente zurück, und API-Routen liefern JSON oder HTML-Fragmente.

# 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,
    })

Der Unterschied ist für HTMX entscheidend. Vollständige Seitenrouten geben Dokumente zurück, die base.html erweitern. API-Routen liefern HTML-Fragmente, die HTMX in bestehende DOM-Elemente einfügt. Dieselbe Jinja2-Template-Engine rendert beides — eine separate API-Schicht ist nicht erforderlich.

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 zusammensetzen. Eine get_db-Dependency kann von get_current_locale abhängen, die wiederum vom Request abhängt. FastAPI löst die 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 der .env-Datei. In der Produktion (Railway) werden Secrets als Umgebungsvariablen gesetzt. Lokal stellt eine .env-Datei die Standardwerte bereit. Die Settings-Klasse validiert Typen beim Start — ein fehlendes Pflichtfeld schlägt sofort fehl, statt erst zur Laufzeit.

Asynchrone Muster

FastAPI-Routen sind standardmäßig asynchron. Bei I/O-gebundenen Operationen (Datenbankabfragen, HTTP-Anfragen, Dateizugriffe) verhindert async das Blockieren der Event Loop:

@app.on_event("startup")
async def startup_load_translations():
    """Load translations from D1 into memory at startup."""
    client = init_d1_client(
        worker_url=settings.D1_WORKER_URL,
        auth_secret=settings.D1_AUTH_SECRET,
    )
    if not client.is_configured:
        logger.warning("i18n: D1 not configured, translations use defaults")
        return
    cache = await load_translations(client)
    logger.info(f"i18n: Loaded {cache.stats['key_count']} keys")

CPU-gebundene 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, machen Sie sie 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 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 etabliert eine Eltern-Kind-Beziehung. 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. Das ist Komposition 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') }} als /static/css/styles.css?v=a3f8b2c1d0. Der Hash ändert sich, wenn sich die Datei ändert, und invalidiert damit den CDN-Cache. Dies ersetzt webpacks [contenthash]-Dateinamenstrategie durch 30 Zeilen Python, die beim Start berechnet werden.

Include für wiederverwendbare Partials

Komponenten, die sich seitenübergreifend wiederholen, verwenden {% 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>

Das Unterstrich-Präfix (_language_switcher.html) ist eine Konvention, die ein Partial kennzeichnet — ein Template-Fragment, das nicht eigenständig gerendert werden soll. Diese Komponente nutzt sowohl Alpine.js (für den Dropdown-Toggle) als auch Jinja2 (für die Sprachliste). Die Grenze ist klar: Alpine.js verwaltet den Öffnen/Schließen-Zustand, Jinja2 verwaltet die Daten.

Makros für wiederverwendbare Komponenten

Makros 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 %}

Importieren und verwenden Sie Makros in Seiten-Templates:

{% 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>

Makros ersetzen React-Komponenten für Darstellungsmuster. Sie akzeptieren Parameter, unterstützen Standardwerte und lassen sich mit anderen Makros kombinieren. Der Unterschied: Makros werden einmalig auf dem Server gerendert und erzeugen statisches HTML. React-Komponenten werden auf dem Client gerendert und verwalten einen Zustand. Für die Inhaltsanzeige sind Makros das richtige Werkzeug.

Template-Kontext und Globals

Jinja2-Globals sind Funktionen, die in jedem Template verfügbar sind, ohne explizit übergeben werden zu müssen:

# 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-Tokens. 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 Request-Kontextvariable, die von der Locale-Middleware gesetzt wird. Das Template ruft {{ _('ui.nav.about') }} auf und erhält den übersetzten String für die Locale des aktuellen Requests — ohne expliziten Locale-Parameter.

Bedingte Blöcke

Das Block-System 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 sie bedingt ein. Seiten, die keine zusätzlichen Scripts oder Styles benötigen, liefern auch keine aus — kein toter Code, keine ungenutzten Imports.

Benutzerdefinierte Filter

Jinja2-Filter transformieren Daten während des Renderings. Der sanitize-Filter verhindert XSS in nutzergeneriertem Inhalt:

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 alle Tags oder Attribute, die nicht in der Allowlist stehen, und verhindert so gespeichertes XSS, selbst 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 entscheidende architektonische Erkenntnis lautet: Vom Server 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 der 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-Status lebt auf dem Server — keine clientseitige Zustandsverwaltung:

<!-- _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 daraus die nächste Frage oder das Endergebnis basierend auf dem Antwortverlauf. Der Zustand akkumuliert sich in der URL — keine Cookies, keine Sessions, kein clientseitiges JavaScript. Das Quiz schreitet über 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:

  1. hx-get sendet die Anfrage an dieselbe URL wie das href (Progressive Enhancement — funktioniert auch ohne JavaScript)
  2. hx-target platziert die Antwort im #writing-content-Container
  3. hx-replace-url="true" aktualisiert die Browser-URL, ohne einen Verlaufseintrag hinzuzufügen
  4. hx-indicator zeigt einen Ladespinner 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:

  • keyup löst beim Loslassen einer Taste aus
  • changed löst nur aus, wenn sich der Wert tatsächlich geändert hat (verhindert doppelte Anfragen durch Modifikatortasten)
  • delay:300ms entprellt — wartet 300 ms nach dem letzten Tastendruck, bevor die Anfrage gesendet wird

Der Server gibt 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 gleichzeitig aktualisieren. Der Out-of-Band-Swap-Mechanismus von HTMX löst 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 „Lift State Up”-Muster: Der Server berechnet den gesamten abgeleiteten Zustand und sendet das fertige HTML für jedes Element in einer einzigen Antwort.

Auf blakecrosley.com findet sich dieses Muster im Kontaktformular: Beim Absenden wird der Formularkörper durch eine Erfolgsmeldung ersetzt und gleichzeitig ein Benachrichtigungsbadge per OOB-Swap aktualisiert.

HTMX kann Standard-Navigationslinks per AJAX „boosten”, anstatt vollständige Seitenladevorgänge durchzuführen:

<nav hx-boost="true">
  <a href="/about">About</a>
  <a href="/writing">Writing</a>
  <a href="/guides">Guides</a>
</nav>

Mit hx-boost="true" wird beim Klick auf einen Link die Seite per AJAX abgerufen, der <body>-Inhalt ausgetauscht und die URL aktualisiert — ohne vollständigen Seitenneuaufbau. Die Browserverlaufsfunktion arbeitet normal weiter (Vor-/Zurück-Schaltflächen). Falls JavaScript nicht geladen werden kann, funktionieren die Links als normale 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. blakecrosley.com verwendet geboostete Links in der Hauptnavigation, wodurch sich Seitenwechsel wie eine Single-Page-Application anfühlen — ohne die SPA-Architektur.

Muster 6: HTMX Request-Header

HTMX sendet mit jeder Anfrage benutzerdefinierte Header:

Header Wert Anwendungsfall
HX-Request true HTMX-Anfragen serverseitig erkennen
HX-Target Element-ID Wissen, welches Element die Antwort empfängt
HX-Trigger Element-ID Wissen, welches Element die Anfrage ausgelöst hat
HX-Current-URL Vollständige URL Aktuelle Seite des Benutzers kennen

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 Seitenaufruf 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 es verfügbar ist.

Muster 5: 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 der Anfrage die Klasse htmx-request hinzu. Das hx-indicator-Attribut verweist auf ein Element, das während der Anfrage sichtbar wird. Gestalten Sie es mit CSS:

.htmx-indicator {
  display: none;
}
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
  display: inline;
}

Keine Ladezustandsverwaltung. Kein useState(false). Kein setLoading(true). CSS steuert die Sichtbarkeit, HTMX übernimmt den Klassenwechsel.


Alpine.js-Patterns

Alpine.js füllt genau die Lücke, die HTMX offenlässt: clientseitigen 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-Patterns:

  • x-data deklariert den Komponentenbereich und den Anfangszustand
  • x-show schaltet die Sichtbarkeit basierend auf dem Zustand um (nutzt CSS display: none)
  • x-cloak verbirgt das Element, bis Alpine.js initialisiert ist (verhindert ein Aufblitzen ungestylter Inhalte)
  • @click bindet Klick-Handler mit Ausdrücken
  • :aria-expanded (Kurzform für x-bind:aria-expanded) setzt Attribute dynamisch
  • @keydown.escape.window lauscht global auf die Escape-Taste, um Panels zu schließen

Der Sprachumschalter nutzt 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 einem Klick außerhalb. Alpine.js erledigt dies mit einem einzigen Attribut — ohne Event-Listener-Registrierung, ohne Aufräumlogik, ohne Ref-Verwaltung.

Wann Alpine.js statt reinem JavaScript verwenden

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

Reines JavaScript eignet sich, wenn:

  • Die Interaktion komplexe Berechnungen erfordert (Visualisierungen, Simulationen)
  • Die Komponente eine eigene Render-Schleife besitzt (Canvas, Animation)
  • Performanz entscheidend ist (Alpine.js verursacht Overhead pro x-data-Komponente)
  • Die Logik 20–30 Zeilen Alpine.js-Ausdrücke übersteigt

blakecrosley.com setzt Alpine.js für Navigation, Sprachumschaltung und Inhalts-Toggles ein. Die 20 interaktiven Blog-Komponenten (Boids-Simulation, Hamming-Code-Visualisierer usw.) verwenden reines JavaScript, da sie Canvas-Rendering und komplexe Zustandsautomaten erfordern.


Bootstrap 5 ohne Sass

Bootstrap 5 hat jQuery als Abhängigkeit entfernt und unterstützt die eigenständige Nutzung von CSS. Sie benötigen weder Sass noch PostCSS oder irgendein Build-Tool, um Bootstraps Grid-System und Utility-Klassen zu verwenden.

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 Website beeinträchtigen, und ermöglicht unveränderliches Caching mit Content-Hash-URLs. Laden Sie Bootstraps kompiliertes CSS herunter (nicht die Sass-Quellen) und platzieren Sie es in static/css/vendor/.

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 jenseits der Bootstrap-Standardwerte schreiben Sie einfach CSS-Media-Queries.

Einfache CSS-Überschreibungen

Überschreiben Sie Bootstraps Standardwerte mit CSS Custom Properties und regulären 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, erben von übergeordneten Elementen und reagieren zur Laufzeit auf Media Queries. Sass-Variablen hingegen werden zu statischen Werten kompiliert und verschwinden. Dieser Unterschied ist beim Theming entscheidend: Eine einzige Änderung an 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 Abstands- und Layout-Anpassungen. 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-Mechanik (Margin, Padding, Flexbox). Benutzerdefiniertes CSS für die visuelle Identität (Farben, Typografie, Animationen). Mischen Sie niemals Utility-Klassen mit Komponenten-Styling für denselben Zweck.


Internationalisierung und Lokalisierung

blakecrosley.com liefert Inhalte in 10 Sprachen aus: 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 nach. 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 %}

Dies 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 richtige Sprachversion auszuliefern. Der Eintrag x-default verweist auf die englische Version als Fallback.13

Übersetzungsspeicherung und Speicher-Cache

Übersetzungen werden in Cloudflare D1 (SQLite an der Edge) gespeichert und beim Start in einen In-Memory-Cache geladen:

@app.on_event("startup")
async def startup_load_translations():
    client = init_d1_client(worker_url=settings.D1_WORKER_URL,
                            auth_secret=settings.D1_AUTH_SECRET)
    cache = await load_translations(client)
    logger.info(f"i18n: Loaded {cache.stats['key_count']} keys")

Der Speicher-Cache vermeidet Datenbankabfragen bei jedem Seitenaufruf. Übersetzungsaktualisierungen erfordern eine Cache-Aktualisierung (ausgelöst über einen Admin-Endpunkt oder ein Deployment). Diese Architektur tauscht Aktualität gegen Performance — Übersetzungen ändern sich selten, Seitenaufrufe erfolgen jedoch bei jeder Anfrage.

Zustandsü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 löst Warnungen aus, wenn die Abdeckung sinkt — beispielsweise nach dem Hinzufügen neuer UI-Zeichenketten, die noch nicht übersetzt wurden.

Locale-bewusstes 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 bleibt konsistent: Zuerst wird der übersetzte Inhalt verwendet, bei fehlendem Inhalt erfolgt ein Fallback auf Englisch. Dies ermöglicht partielle Übersetzungen — japanische Benutzer sehen ü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 }}

Lokalisierung statischer Daten

Statische Inhalte wie Projektbeschreibungen und Navigationsbezeichnungen werden über Hilfsfunktionen übersetzt, die dieselbe Datenstruktur beibehalten und lediglich locale-spezifische Zeichenketten einsetzen:

# 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. Routes übergeben unabhängig vom Locale dieselbe projects-Liste. Die Übersetzungsfunktionen umhüllen die Daten transparent.

Sitemap mit Hreflang-Alternates

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}"/>'
                )

Dies erzeugt 10 URL-Einträge pro Seite (einen pro Locale), jeweils mit 11 Alternate-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 gecacht.


Datenbankmuster

SQLAlchemy 2.0 Async

Für Anwendungen, die eine relationale Datenbank benötigen, lässt sich die Async-Unterstützung 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 Datenbanksitzungen

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(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 Sitzungslebenszyklus: Sie öffnet eine Sitzung, übergibt sie an den Route-Handler, führt bei Erfolg einen Commit durch und macht bei einer Exception 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(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. Dies ersetzt clientseitige Formularvalidierungsbibliotheken — 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 SQLAlchemy-Modelle mit dem aktuellen Datenbankschema und erzeugt Migrationsskripte. Diese Skripte sind versionierte Python-Dateien, die im Repository gespeichert werden:

# 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 während des Deployments ausgeführt (bevor die Anwendung startet). Dadurch wird 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-Muster

blakecrosley.com verwendet 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 Muster eignet sich für Anwendungen, die eine Datenbank benötigen, aber keinen Datenbankserver verwalten möchten. D1 ist SQLite am Cloudflare-Edge, erreichbar über HTTP. Der Worker-Proxy übernimmt Authentifizierung und Ratenbegrenzung. 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 leselastige Workloads wie Übersetzungen aus.


Sicherheit

Middleware für Sicherheitsheader

blakecrosley.com implementiert gehärtete Sicherheitsheader über eine benutzerdefinierte 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 strikt abgesichert: frame-ancestors verhindert Clickjacking, form-action beschränkt Formularübermittlungen auf den gleichen Ursprung, und upgrade-insecure-requests erzwingt HTTPS.

CDN-Cache-Sicherheit mit HTMX

Die Sicherheitsheader-Middleware fügt HTMX-Antworten den Header 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, separate Cache-Einträge basierend auf dem Wert des HX-Request-Headers zu speichern.11

CSRF-Schutz

HTMX-Formulare verwenden zustandslose, HMAC-signierte CSRF-Tokens:

# 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 eine Jinja2-Globale 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 Tokens eliminieren serverseitige Sitzungsspeicherung. 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 nh3, bevor sie gerendert werden:

templates.env.filters["sanitize"] = sanitize_html
# In templates: {{ content | sanitize }}

Die nh3-Bibliothek entfernt Tags und Attribute, die nicht in 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 eingeschleuste Skripte auf der Browser-Ebene blockiert. Verteidigung in der Tiefe.

Eingabevalidierung

Pydantic-Modelle validieren alle 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 erzielt in allen vier Lighthouse-Kategorien die Höchstwertung von 100: Performance, Accessibility, Best Practices und SEO. Überprüfen Sie dies unter PageSpeed Insights.2

Die wichtigsten Optimierungen:

Kritisches CSS

Kritisches (above-the-fold) CSS wird extrahiert und direkt in <head> eingebettet. Das vollständige Stylesheet lädt asynchron:

<!-- Critical CSS inlined for instant first paint -->
<style>{% include "components/_critical.css" %}</style>

<!-- Full CSS loads async — doesn't block render -->
<link rel="stylesheet" href="/static/css/styles.css"
      media="print" onload="this.media='all'">
<noscript>
  <link rel="stylesheet" href="/static/css/styles.css">
</noscript>

Der media="print"-Trick signalisiert dem Browser, dass das Stylesheet nicht für die Bildschirmdarstellung benötigt wird – es blockiert daher nicht den ersten Seitenaufbau. Der onload-Handler schaltet nach dem Laden auf media="all" um. Der <noscript>-Fallback stellt sicher, dass das Stylesheet auch ohne JavaScript geladen wird.16

GZip-Komprimierung

app.add_middleware(GZipMiddleware, minimum_size=500)

Antworten über 500 Bytes werden komprimiert. HTML lässt sich um 70–80 % komprimieren, wodurch ein 15-KB-Dokument auf 3–4 KB schrumpft.

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 Content-Hash-URLs (?v=a3f8b2c1d0) werden ein Jahr lang mit immutable zwischengespeichert. Bei Dateiänderungen ändert sich der Hash, sodass Browser und CDNs die neue Version abrufen müssen.

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. Dies verhindert Render-Blocking, ohne die Komplexität von asynchronem Laden und der Verwaltung der Ausführungsreihenfolge einzuführen.

Bildoptimierung

Bilder verwenden WebP mit responsivem srcset und expliziten Abmessungen:

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 loading="lazy"-Attribut verzögert das Laden von Bildern außerhalb des sichtbaren Bereichs. WebP liefert bei vergleichbarer Qualität 25–35 % kleinere Dateien als JPEG.17

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. Dadurch kann der Browser bereits mit dem Abruf des CSS beginnen, bevor der Server die HTML-Antwort fertig generiert hat.18

Minimaler JavaScript-Umfang

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 eigentliche Anwendungscode hinzukommt.19 Der Ansatz ohne Build-Tools liefert weniger JavaScript, weil schlicht weniger JavaScript vorhanden ist.


Deployment

Railway

blakecrosley.com wird per Git Push auf Railway bereitgestellt:

# 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 die 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 detects push
  → Nixpacks installs Python + requirements.txt (cached)
  → uvicorn starts
  → Health check passes
  → Traffic routes to new deployment
  → ~40 seconds total

Kein npm install. Kein npm run build. Keine webpack-Kompilierung. Keine TypeScript-Kompilierung. Der einzige Installationsschritt ist pip install -r requirements.txt, der zwischen Deployments zwischengespeichert wird.

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 fällt für die lokale Entwicklung auf 8000 zurück.

Uvicorn-Produktionskonfiguration

Für Deployments mit höherem Traffic können mehrere Worker eingesetzt werden:

uvicorn app.main:app \
  --host 0.0.0.0 \
  --port ${PORT:-8000} \
  --workers 4 \
  --loop uvloop \
  --http httptools
  • --workers 4 startet vier Worker-Prozesse (Faustregel: 2 × CPU-Kerne + 1)
  • --loop uvloop nutzt die schnellere uvloop-Ereignisschleife (Drop-in-Ersatz für asyncio)
  • --http httptools nutzt 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 in der Image-Schicht 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 — der Browser speichert für 5 Minuten zwischen
  • s-maxage=3600 — das CDN speichert für 1 Stunde zwischen
  • stale-while-revalidate=86400 — veraltete Inhalte werden während der Revalidierung für 24 Stunden weiterhin ausgeliefert

Statische Assets erhalten max-age=31536000, immutable, da Content-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 aufdecken. Fügen Sie einen Build-Schritt hinzu.

2. Verwaltet Ihre Anwendung komplexen clientseitigen State? Falls Drag-and-Drop, Echtzeit-Kollaboration oder Offline-First-Daten zu den Kernfunktionen gehören (nicht nur Nice-to-have), 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? Falls Radix, Framer Motion, TanStack Query oder ähnliche Bibliotheken zentral für das Produkt sind, ist eine Build-Pipeline zwingend erforderlich.

Lauten alle vier Antworten „Nein”, ist der Ansatz ohne Build-Tools 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 – man löst dann Probleme, die gar nicht existieren, und schafft sich stattdessen Aufwand für die Verwaltung von Abhängigkeiten.1

Stack-Vergleich

Kategorie Ohne Build-Tools (dieser Guide) React + Build-Tools
Ideal für Content-Seiten, Portfolios, interne Tools, CRUD-Anwendungen 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 serverseitig gerendert Erfordert SSR/SSG-Konfiguration
Performance-Untergrenze Hoch (minimales JS, serverseitig gerendert) Variabel (Framework-Overhead)
Komplexitätsobergrenze Niedriger (kein Offline, kein komplexer 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-Interfaces — 200 ms Server-Roundtrip pro Drag-Event sind inakzeptabel
  • Echtzeit-Kollaboration — WebSocket-getriebener State erfordert clientseitige Konfliktauflösung
  • Offline-First-Anwendungen — ohne Server kein HTMX
  • Komplexe Animationen, die an State gebunden sind — 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 Ansatz ohne Build-Tools 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 -->

Benutzerdefinierte CSS-Eigenschaften

: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; }
}

Sicherheitsheader

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 mit Jinja2Templates
[ ] Sicherheitsheader-Middleware (CSP, HSTS, X-Frame-Options)
[ ] CSRF-Token-Generierung und -Validierung
[ ] GZip-Middleware (minimum_size=500)
[ ] Content-Hash-Asset-Versionierung (Cache-Busting)
[ ] HTMX selbst gehostet in /static/js/vendor/
[ ] Alpine.js selbst gehostet in /static/js/vendor/
[ ] Benutzerdefinierte CSS-Eigenschaften für Design-Tokens
[ ] Health-Check-Endpunkt (/health)
[ ] Fehlerbehandlung (404, 500)
[ ] robots.txt, sitemap.xml, llms.txt
[ ] JSON-LD-strukturierte Daten im Basis-Template
[ ] Hreflang-Tags für i18n (bei Mehrsprachigkeit)
[ ] HTML-Sanitisierungsfilter (nh3)
[ ] Rate-Limiting-Middleware
[ ] Verzögertes Laden von Skripten

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 — laut der HTMX-Dokumentation wird die Bibliothek bestehende Anwendungen innerhalb einer Hauptversion nicht brechen.20 Die Bibliothek ist 14 KB minifiziert und gzipped, hat keine Abhängigkeiten und folgt der semantischen Versionierung. blakecrosley.com betreibt HTMX seit drei Jahren in Produktion ohne einen einzigen HTMX-bezogenen Fehler.

Kann ich TypeScript ohne Build-Schritt verwenden?

Teilweise. TypeScript-Dateien lassen sich mit tsc --noEmit typprüfen, ohne Ausgabedateien zu erzeugen — eine Art Compile-Time-Linting. 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 standardkonformes JavaScript aus.

Wie schneidet dieser Ansatz im Vergleich zu Astro oder 11ty ab?

Astro und 11ty sind Static-Site-Generatoren, die reines HTML mit minimalem Client-JavaScript erzeugen, jedoch einen Build-Schritt erfordern (Node.js, npm install, ein Build-Kommando). Der No-Build-Ansatz eliminiert diesen Schritt — 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 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 geschieht. Next.js hydriert die Seite mit React und liefert die Framework-Laufzeitumgebung samt Komponentencode an den Client. FastAPI + HTMX hydriert nicht — das HTML ist die endgültige Ausgabe. Nachfolgende Interaktionen behandelt HTMX, indem neue HTML-Fragmente vom Server angefordert werden. Das Ergebnis: FastAPI + HTMX liefert insgesamt 30–40 KB JavaScript aus, verglichen mit 100–300 KB bei einer Next.js-Anwendung.19

Wie handhabe ich Formularvalidierung mit diesem Stack?

Serverseitig. Pydantic validiert die Eingabe bei der Formularübermittlung. 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. Keine clientseitige Validierungsbibliothek 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:

<div hx-ws="connect:/ws/notifications">
  <div id="notifications" hx-ws="send"></div>
</div>

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 ergänzt mehrere SEO-Ebenen:

  • JSON-LD-strukturierte Daten im <head> für jede Seite (Person-, Article-, WebSite-, FAQPage-Schemas)
  • Dynamische Sitemap mit Hreflang-Alternaten für alle 10 Sprachversionen
  • RSS-Feed unter /blog/feed.xml
  • llms.txt im Stammverzeichnis für die Auffindbarkeit durch KI-Crawler
  • Kanonische 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 virtuelles 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. Die React-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 Wechsel eher konzeptionell als syntaktisch. Die zentrale Erkenntnis: Der Server besitzt den State und der Server rendert das HTML. Clientseitiges State-Management wird zu serverseitiger Route-Behandlung. Clientseitiges Datenabrufen wird zu HTMX-Attributen auf HTML-Elementen. Die Syntax ist einfacher — das mentale Modell erfordert, die SPA-Annahme zu verlernen, dass der Client das Rendering besitzt.


Änderungsprotokoll

Datum Änderung
24.03.2026 Erstveröffentlichung

Referenzen


Dieser Guide behandelt das vollständige System, mit dem blakecrosley.com gebaut wurde. Das No-Build Manifesto liefert die philosophische Argumentation. Der Beitrag Lighthouse Perfect Score dokumentiert den Weg der Performance-Optimierung. Der Beitrag Vibe Coding vs. Engineering untersucht, welche Rolle KI-gestützte Entwicklung in diesem Workflow einnimmt.


  1. Produktionsmetriken von blakecrosley.com, Stand März 2026. Die Website umfasst 37 Blogbeiträge, 20 interaktive JavaScript-Komponenten, 20 Guide-Abschnitte und 10 Sprachübersetzungen mit 15 Python-Paketen und ohne jegliche Build-Tools. Vollständige Abhängigkeitsliste: fastapi, uvicorn, starlette, pydantic, pydantic-settings, jinja2, markdown, pygments, beautifulsoup4, lxml, nh3, resend, python-multipart, httpx, analytics-941. Überprüft anhand der requirements.txt

  2. 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 den vollständigen Optimierungsprozess. 

  3. Eine frische npx create-next-app@latest-Installation (Next.js 15, getestet Februar 2026) installiert 311 Pakete in node_modules/ mit insgesamt 187 MB. Produktionsprojekte mit zusätzlichen Abhängigkeiten tendieren zu höheren Werten. Einzelne Projekte variieren. Quelle: Tests des Autors, dokumentiert in The No-Build Manifesto

  4. Vercels Next.js-Performance-Dokumentation empfiehlt spezifische Optimierungen (Bildoptimierung, Schriftarten-Laden, Code-Splitting), um Werte über 90 zu erreichen. Siehe nextjs.org/docs/app/building-your-application/optimizing. Der Bereich von 70–90 spiegelt die Standardeinstellungen vor Anwendung dieser Optimierungen wider. 

  5. Vollständige Abhängigkeitsliste überprüft anhand der requirements.txt von blakecrosley.com, Stand März 2026. Kein einziges Paket ist ein Build-Tool, Compiler oder Bundler. 

  6. 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, wobei die meisten transitive Abhängigkeiten aktualisieren, die der Entwickler nie direkt importiert hat. 

  7. 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

  8. 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 Response-Modelle offen. 

  9. FastAPI-Dokumentation zu async- vs. sync-Handlern: fastapi.tiangolo.com/async/. Das Mischen von await mit blockierenden Aufrufen in async-Funktionen blockiert den Event Loop. 

  10. nh3 ist ein Rust-basierter HTML-Sanitizer und der Nachfolger der Bleach-Bibliothek. Das Projekt wird von PyO3 gepflegt und bietet Allowlist-basierte HTML-Bereinigung. Siehe github.com/messense/nh3

  11. Der Vary-Header ist in RFC 9110, Abschnitt 12.5.5 definiert. Er weist Caches an, basierend auf den angegebenen Request-Header-Werten separate Antworten zu speichern. Ohne Vary: HX-Request könnte ein CDN ein HTMX-Fragment als vollständige Seitenantwort ausliefern. Siehe httpwg.org/specs/rfc9110.html#field.vary

  12. CSS Custom Properties (CSS-Variablen) werden von über 97 % aller 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

  13. Googles hreflang-Dokumentation: developers.google.com/search/docs/specialty/international/localized-versions. Der Wert x-default kennzeichnet die Fallback-Seite für Benutzer, deren Sprache nicht in der hreflang-Liste enthalten ist. 

  14. 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 jedoch Einschränkungen. Siehe alpinejs.dev/advanced/csp

  15. HMAC-basierte CSRF-Tokens folgen dem „Signed Double-Submit Cookie”-Muster, das im OWASP CSRF Prevention Cheat Sheet beschrieben wird. hmac.compare_digest verwendet einen Konstantzeit-Vergleich, um Timing-Seitenkanalangriffe zu verhindern. Siehe cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html

  16. Die asynchrone CSS-Ladetechnik mit media="print" ist vom web.dev-Team dokumentiert. Der Browser behandelt das Stylesheet als nicht-render-blockierend, da es für Print-Medien deklariert ist. Der onload-Handler stuft es nach dem Download auf all-Medien hoch. Siehe web.dev/articles/defer-non-critical-css

  17. WebP liefert bei vergleichbarer visueller Qualität 25–35 % kleinere Dateien als JPEG. Googles WebP-Studie: developers.google.com/speed/webp/docs/webp_study

  18. 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 mit rel=preload. Siehe developer.chrome.com/blog/early-hints

  19. React 18 + ReactDOM wiegt etwa 42 KB minifiziert und 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]

  20. 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

NORMAL fastapi-htmx.md EOF