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

FastAPI + HTMX: full-stack bez procesu build

# Tworzenie produkcyjnych aplikacji webowych bez Reacta ani webpacka: FastAPI, HTMX, Alpine.js, Jinja2, zwykły CSS, wzorce Bootstrap, i18n, wdrożenie, SEO i wydajność.

words: 8847 read_time: 34m updated: 2026-06-22 17:07
$ less fastapi-htmx.md

TL;DR: FastAPI + HTMX + Alpine.js + Jinja2 + zwykły CSS pozwala tworzyć produkcyjne aplikacje webowe bez narzędzi budowania, bez node_modules/ i z idealnymi wynikami Lighthouse. Ten przewodnik omawia cały system, od architektury po wdrożenie, wykorzystując blakecrosley.com jako produkcyjny przykład referencyjny, który obsługuje 210 wpisów blogowych, interaktywne komponenty JavaScript, 11 głównych przewodników, 48 studiów projektowych oraz język angielski i 9 przetłumaczonych lokalizacji bez ani jednego bundlera, kompilatora czy transpiler.1

Współczesny stos tworzenia aplikacji webowych zakłada, że potrzebne są React, webpack, TypeScript i potok budowania. Dla dużej kategorii aplikacji — serwisów opartych na treści, narzędzi wewnętrznych, aplikacji CRUD, stron portfolio, platform dokumentacyjnych — to założenie jest błędne. Stos opisany w tym przewodniku eliminuje cały frontendowy łańcuch narzędzi budowania, a jednocześnie pozwala tworzyć strony osiągające w Lighthouse wynik 100/100/100/100.2

To nie jest agitacja. To pomiar. Opisana tutaj architektura działa produkcyjnie, obsługuje rzeczywistych użytkowników w dziesięciu językach, a liczby można zweryfikować.


Kluczowe wnioski

  • Renderowany po stronie serwera HTML eliminuje trzy całe kategorie problemów: zarządzanie stanem klienta, granice serializacji JSON oraz niezgodności hydratacji. HTMX sprawia, że odpowiedzi serwera stają się ostatecznym wynikiem — bez kroku renderowania po stronie klienta.
  • Brak narzędzi do budowania oznacza brak błędów budowania. Żadnych konfliktów zależności peer przy npm install, żadnych błędów kompilatora TypeScript w plikach, których się nie tknęło, żadnych PR-ów Dependabota dla zależności tranzytywnych, których nigdy się nie importowało. Pipeline wdrożeniowy to git push.
  • Alpine.js obsługuje stan wyłącznie kliencki, którego HTMX obsłużyć nie potrafi. Rozwijane menu, modale, przełączniki nawigacji mobilnej oraz wszelki stan UI istniejący wyłącznie w przeglądarce należą do Alpine.js. Granica jest jasna: jeśli stan wymaga serwera, należy użyć HTMX. Jeśli nie wymaga, należy użyć Alpine.js.
  • Zwykły CSS z właściwościami niestandardowymi zastępuje Sass i Tailwind. Właściwości niestandardowe CSS kaskadują, dziedziczą i reagują na zapytania medialne w czasie wykonywania. Zmienne preprocesora kompilują się do statycznych wartości i znikają. Przeglądarka odczytuje właściwości niestandardowe bezpośrednio — bez kroku kompilacji.
  • Podejście to ma jasne granice. Jest błędne dla dużych zespołów współdzielących interfejsy komponentów, produktów SaaS ze złożonym stanem po stronie klienta oraz aplikacji zależnych od bibliotek z ekosystemu npm. Framework decyzyjny w sekcji 15 precyzyjnie identyfikuje tę granicę.
  • blakecrosley.com jest dowodem. Podstawowe wzorce z tego przewodnika (HTMX, Alpine.js, Jinja2, zwykły CSS) działają w środowisku produkcyjnym na blakecrosley.com. Sekcje dotyczące Bootstrap i SQLAlchemy obejmują standardowe wzorce dla stosu, które nie są używane na tej konkretnej witrynie. Każde stwierdzenie ma ścieżkę pliku, blok konfiguracyjny lub audyt Lighthouse, który można samodzielnie zweryfikować na PageSpeed Insights.2

Jak korzystać z tego przewodnika

To kompleksowe źródło informacji. Warto rozpocząć od miejsca pasującego do poziomu doświadczenia:

Doświadczenie Zacznij tutaj Następnie zgłęb
Programista Python, nowy w HTMX Teza no-buildPrzegląd architekturyDogłębna analiza HTMX Wzorce Alpine.js, Bezpieczeństwo
Programista React/Vue oceniający alternatywy Teza no-buildFramework decyzyjny Przegląd architektury, Wydajność
Programista FastAPI dodający interaktywność Dogłębna analiza HTMXWzorce Alpine.js i18n i lokalizacja, Wdrażanie
Programista full-stack budujący od podstaw Czytanie sekwencyjne od Przegląd architektury Karta szybkiego odniesienia do bieżącego użytku

Można użyć Ctrl+F / Cmd+F do wyszukania konkretnych wzorców lub atrybutów. Karta szybkiego odniesienia na końcu zawiera zwięzłe podsumowanie do przeglądania.


Teza no-build

Teza jest wąska i precyzyjna: dla witryn opartych na treści, prowadzonych przez pojedynczego programistę lub mały zespół, narzędzia do budowania rozwiązują problemy, których się nie ma, jednocześnie tworząc te, które się ma.

Oto rzeczywiste metryki z blakecrosley.com:

Metryka blakecrosley.com (no-build) Typowy projekt Next.js3
Zależności 17 pakietów Python 311+ pakietów npm
Pliki konfiguracyjne budowania 0 5-8 (next.config, tsconfig, postcss, tailwind itd.)
Rozmiar node_modules/ Nie istnieje 187 MB bazowo, 250-400 MB z dodatkami
Czas instalacji pip install: 8 sekund npm install: 30-90 sekund
Krok budowania Brak next build: 15-60 sekund
Pipeline wdrożeniowy git push → na żywo w ~40 sekund Instalacja → budowanie → wdrożenie: 2-5 minut
Wydajność Lighthouse 100 70-90 bez jawnej optymalizacji4

17 pakietów Python obejmuje FastAPI, Jinja2, Pydantic, uvicorn, nh3 i 12 innych. Żaden nie jest narzędziem do budowania. Żaden nie jest kompilatorem. Żaden nie jest bundlerem.5

Z czego się rezygnuje

Uczciwość wymaga wyliczenia rzeczywistych kosztów:

Brak TypeScript. Każdy plik .js to czysty JavaScript. Błędy typów wychwytywane są przez testy i analizę kodu, a nie przez kompilator. Sprawdza się to dla pojedynczego programisty. Nie sprawdziłoby się dla zespołu 10 osób współdzielących interfejsy komponentów.

Brak Hot Module Replacement. Zmiany w CSS wymagają ręcznego odświeżenia przeglądarki. hx-boost z HTMX sprawia, że nawigacja jest na tyle szybka, by pełne odświeżenia były tolerowalne, jednak przy intensywnych cyklach iteracji wizualnych HMR oszczędza czas.

Brak Tree Shaking. Każdy bajt JavaScript, który zostanie napisany, trafia do przeglądarki. Ograniczenie to wymusza dyscyplinę: małe, skupione pliki zamiast dużych modułów narzędziowych.

Brak bibliotek komponentów z npm. Żadnego Radix, żadnego shadcn/ui, żadnego Headless UI. Każdy element interaktywny jest budowany ręcznie lub korzysta z wbudowanych komponentów Bootstrap 5.

Brak tokenów systemu projektowego z npm. System projektowy żyje w niestandardowych właściwościach CSS. Nie można go zaimportować jako pakietu w innym projekcie.

Te kompromisy są akceptowalne dla witryny opartej na treści z jednym do trzech programistów. Byłyby nie do przyjęcia dla produktu SaaS z 15-osobowym zespołem inżynieryjnym. Sekcja 15 zawiera framework decyzyjny.

Co się zyskuje

Brak błędów budowania. Żaden npm install nie może zawieść z powodu konfliktów zależności peer. Żaden next build nie może zawieść z powodu błędu TypeScript w pliku, którego się nie tknęło.6

Debugowanie przez View Source. JavaScript działający w przeglądarce to ten sam JavaScript, który został napisany. Source mapy nie są wymagane.

Natychmiastowy start lokalny. uvicorn app.main:app --reload uruchamia się w mniej niż 2 sekundy.

Konkretny waterfall żądań. Pierwsza wizyta ładuje: jeden dokument HTML (~15KB po gzip), jeden plik CSS (~8KB), HTMX (~16KB, w cache), Alpine.js (~15KB, w cache) oraz interaktywny JS strony (~4-8KB). Łącznie: około 55-65KB przy pierwszej wizycie.1

Frontend odporny na przyszłość. Kod po stronie klienta używa HTML, CSS i JavaScript — standardów, które utrzymują wsteczną kompatybilność od 30 lat.7 Żadnej migracji Webpack 4 → 5, żadnego wycofywania Create React App, żadnej migracji do Next.js App Router.

Porównanie stosów

Jak stos no-build wypada w porównaniu z popularnymi alternatywami w mierzalnych wymiarach:

Wymiar FastAPI+HTMX (ten przewodnik) Next.js (React) Astro 11ty
JS wysyłany do przeglądarki 35-40KB (HTMX+Alpine+małe skrypty stron) 85-250KB+ (środowisko uruchomieniowe React) 0KB domyślnie, wyspy opt-in 0KB domyślnie
Krok budowania Brak Wymagany (webpack/turbopack) Wymagany (Vite) Wymagany (niestandardowy)
Pliki konfiguracyjne 0 5-8 (next.config, tsconfig itd.) 1-3 (astro.config, tsconfig) 1-2 (.eleventy.js)
Pipeline wdrożeniowy git push (40s) Instalacja+budowanie+wdrożenie (2-5min) Instalacja+budowanie+wdrożenie (1-3min) Instalacja+budowanie+wdrożenie (1-2min)
Interaktywność po stronie serwera Natywna (HTMX) Trasy API + fetch klienta Ograniczona (akcje formularzy) Brak (statyczne wyjście)
Zarządzanie stanem klienta Alpine.js (15KB) Stan/context/Redux w React Wyspy framework Ręczny JS
Język backendu Python JavaScript/TypeScript JavaScript/TypeScript JavaScript
Podejście do i18n Po stronie serwera (middleware) next-intl lub podobny pakiet @astrojs/i18n Ręczne
Wydajność Lighthouse 100 (zmierzone) 70-90 typowo4 95-100 typowo 95-100 typowo
Najlepsze dla Witryny treściowe, CRUD, dashboardy Złożone SPA, duże zespoły Witryny treściowe, marketing Statyczne blogi, dokumentacja

Astro i 11ty to najbliżsi konkurenci dla witryn treściowych. Oba produkują doskonałe statyczne wyjście, ale wymagają kroku budowania i łańcucha narzędzi JavaScript. Stos FastAPI+HTMX wymienia wydajność strony statycznej na interaktywność po stronie serwera (filtrowanie kategorii, obsługa formularzy, wyszukiwanie w czasie rzeczywistym) bez dodawania kroku budowania. Jeśli witryna jest czysto statyczna i nie ma interakcji z serwerem, Astro lub 11ty mogą okazać się lepszym wyborem.


Przegląd architektury

Przepływ żądań

Każde żądanie przechodzi przez pojedynczą ścieżkę obejmującą cztery warstwy:

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

Pełne ładowanie strony zwraca kompletne dokumenty HTML (szablon bazowy + szablon strony). Żądania HTMX zwracają fragmenty HTML (cząstkowe szablony). Serwer decyduje, co wyrenderować, na podstawie typu żądania. Alpine.js zarządza stanem po stronie klienta, który nigdy nie trafia na serwer.

Role komponentów

Komponent Rola Zakres
FastAPI Routing, logika biznesowa, dostęp do danych, walidacja Serwer
Jinja2 Renderowanie szablonów, dziedziczenie, makra Serwer
HTMX Interaktywność sterowana przez serwer (formularze, paginacja, wyszukiwanie) Klient ↔ Serwer
Alpine.js Stan wyłącznie po stronie klienta (rozwijane menu, modale, przełączniki) Tylko klient
Bootstrap 5 System siatki, klasy narzędziowe, responsywny układ Klient (CSS)
Czysty CSS Własności niestandardowe, style komponentów, tokeny projektowe Klient (CSS)
Pydantic Walidacja żądań/odpowiedzi, ustawienia Serwer

Struktura projektu

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

Struktura opiera się na jednej zasadzie: każdy katalog zawiera jeden typ rzeczy. Trasy znajdują się w routes/. Szablony znajdują się w templates/. Zasoby statyczne znajdują się w static/. Żaden etap budowania nie przekształca jednego w drugie.

Porównanie z architekturą SPA

W projekcie React + Next.js odpowiadająca struktura obejmowałaby:

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

Architektura SPA wymaga koordynacji między tymi katalogami na etapie budowania. TypeScript kompiluje pliki .tsx do JavaScript. PostCSS przetwarza dyrektywy Tailwind na CSS. Webpack (lub Turbopack) łączy wynik w paczki. Każdy krok może zakończyć się niepowodzeniem niezależnie.

Architektura bez etapu budowania nie wymaga żadnej koordynacji. Szablon odwołuje się do pliku CSS. Plik CSS istnieje w static/css/. Przeglądarka ładuje go bezpośrednio. Zmiana nazwy pliku powoduje błąd odwołania w szablonie w czasie wykonania — nie w czasie budowania. To przenosi błędy z etapu kompilacji do etapu wykonania, co stanowi rzeczywisty kompromis. Dla samodzielnego programisty uruchamiającego uvicorn --reload podczas rozwoju, błędy czasu wykonania pojawiają się natychmiast w przeglądarce. W przypadku dużego zespołu błędy kompilacji wychwycone przez TypeScript zapobiegają kategorii błędów, których błędy czasu wykonania nie są w stanie wykryć.


Wzorce FastAPI

Konfiguracja aplikacji

Aplikacja inicjalizuje się w main.py z jawną kolejnością middleware:

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)

Znaczenie mają tu 3 decyzje projektowe. Po pierwsze, docs_url=None i openapi_url=None wyłączają automatyczne endpointy dokumentacji API. Publiczna strona treściowa nie potrzebuje wystawionych w internecie /docs ani /openapi.json.8 Po drugie, kolejność middleware ma znaczenie — logowanie bezpieczeństwa wykonuje się jako pierwsze (dodane jako ostatnie), dzięki czemu rejestruje każde żądanie, także te odrzucone przez ograniczanie częstotliwości. Po trzecie, GZipMiddleware kompresuje wszystkie odpowiedzi powyżej 500 bajtów, co zwykle zmniejsza rozmiar transferu HTML o 70-80%.

Routing

Trasy dzielą się na 2 kategorie: trasy stron zwracają pełne dokumenty HTML, a trasy API zwracają fragmenty JSON lub HTML.

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

To rozróżnienie ma znaczenie dla HTMX. Trasy pełnych stron zwracają dokumenty rozszerzające base.html. Trasy API zwracają fragmenty HTML, które HTMX podmienia w istniejących elementach DOM. Ten sam silnik szablonów Jinja2 renderuje oba przypadki — bez oddzielnej warstwy API.

Dependency Injection

System Depends() w FastAPI zapewnia czysty podział między handlerami tras a współdzieloną 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,
    })

Zależności można składać. Zależność get_db może zależeć od get_current_locale, która zależy od żądania. FastAPI automatycznie rozwiązuje cały łańcuch.

Ustawienia Pydantic

Konfiguracja używa BaseSettings z Pydantic z pierwszeństwem zmiennych środowiskowych:

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()

Zmienne środowiskowe nadpisują wartości z pliku .env. W produkcji (Railway) sekrety ustawia się jako zmienne środowiskowe. Lokalnie plik .env zapewnia wartości domyślne. Klasa Settings waliduje typy podczas startu — brak wymaganego pola powoduje szybkie przerwanie zamiast błędu w czasie działania.

Wzorce async

Trasy FastAPI są domyślnie async. W przypadku operacji związanych z I/O (zapytania do bazy danych, żądania HTTP, odczyty plików) async zapobiega blokowaniu pętli zdarzeń:

@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)

Lifespan jest obecnie jedyną ścieżką startup/shutdown. Starlette osiągnął pierwsze stabilne wydanie, 1.0, w marcu 2026 roku (1.3.1 na 12 czerwca) i usunął od dawna przestarzałe hooki on_event, on_startup oraz on_shutdownlifespan (powyżej) jest jedynym mechanizmem, a @app.route() / @app.websocket_route() zostały zastąpione przez Route / WebSocketRoute na liście routes. FastAPI 0.137.0 (14 czerwca 2026) przypina Starlette do linii 1.x i refaktoryzuje własne wewnętrzne mechanizmy routera: router.routes nie jest już płaską listą obiektów APIRoute, lecz drzewem węzłów pośrednich, dlatego należy traktować ją jako detal wewnętrzny, a nie coś do iterowania. Korzyść jest taka, że trasy dodane do routera po include_router() są teraz odzwierciedlane na żywo, a sub-router można dołączyć, zanim jego trasy zostaną zdefiniowane.24 Nie zmienia to wzorców opisanych w tym przewodniku — wszędzie używa on lifespan i standardowej deklaracji tras — ale jeśli utrzymywane są narzędzia przechodzące po router.routes albo nadal działają starsze handlery @app.on_event, 0.137.0 / Starlette 1.0 wprowadzają zmiany łamiące zgodność. FastAPI 0.137.2 (18 czerwca 2026) dodaje następnie iter_route_contexts(), czyli obsługiwany sposób enumeracji tras teraz, gdy router.routes jest wewnętrzne. FastAPI 0.138.0 (20 czerwca 2026) dodaje potem app.frontend("/", directory="dist") / router.frontend(...) do serwowania zbudowanego statycznego frontendu — przydatne, jeśli dostarczany jest osobny build SPA, ale niezależne od podejścia tego przewodnika bez builda i z renderowaniem po stronie serwera (montuje katalog dist/, zamiast renderować HTML na serwerze).25

Operacje obciążające CPU (renderowanie Markdown, ekstrakcja CSS) mogą używać funkcji synchronicznych. FastAPI automatycznie uruchamia je w puli wątków, gdy handler trasy nie jest zadeklarowany jako async:

# 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(...)

Zasada jest taka: jeśli funkcja oczekuje na I/O, należy zrobić ją async. Jeśli wykonuje pracę CPU, należy pozostawić ją synchroniczną. Nie należy mieszać await z wywołaniami blokującymi w tej samej funkcji.9


Szablony Jinja2

Dziedziczenie szablonów

System dziedziczenia Jinja2 zastępuje kompozycję komponentów z React prostszym modelem. Jeden szablon bazowy definiuje szkielet strony. Szablony potomne wypełniają nazwane bloki:

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

Dyrektywa {% extends %} ustanawia relację rodzic-potomek. Szablon potomny definiuje jedynie te bloki, które chce nadpisać. Cała reszta — <head>, nagłówek, stopka, tagi skryptów — pochodzi z szablonu bazowego. To kompozycja przez odejmowanie, a nie budowanie.

Funkcja globalna asset()

Zasoby statyczne wykorzystują wersjonowanie oparte na haszu zawartości do wymuszania odświeżenia pamięci podręcznej:

# 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}"

W szablonie: {{ asset('css/styles.css') }} renderuje się jako /static/css/styles.css?v=a3f8b2c1d0. Hasz zmienia się wraz ze zmianą pliku, wymuszając odświeżenie pamięci podręcznej CDN. To zastępuje strategię nazw plików [contenthash] z webpack za pomocą 30 linii Python obliczanych przy starcie.

Include dla komponentów wielokrotnego użytku

Komponenty powtarzające się na wielu stronach wykorzystują {% 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>

Prefiks z podkreślnikiem (_language_switcher.html) to konwencja oznaczająca partial — fragment szablonu nieprzeznaczony do samodzielnego renderowania. Ten komponent wykorzystuje zarówno Alpine.js (do przełączania rozwijanego menu), jak i Jinja2 (do listy lokalizacji). Granica jest wyraźna: Alpine.js zarządza stanem otwierania/zamykania, Jinja2 zarządza danymi.

Makra jako komponenty wielokrotnego użytku

Makra to funkcje Jinja2 — bloki szablonów wielokrotnego użytku z parametrami:

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

Importowanie i używanie makr w szablonach stron:

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

Makra zastępują komponenty React w przypadku wzorców prezentacyjnych. Przyjmują parametry, obsługują wartości domyślne i komponują się z innymi makrami. Kluczowa różnica: makra renderują się jednokrotnie na serwerze i generują statyczny HTML. Komponenty React renderują się po stronie klienta i utrzymują stan. Do wyświetlania treści makra są odpowiednim narzędziem.

Kontekst szablonów i zmienne globalne

Zmienne globalne Jinja2 to funkcje dostępne w każdym szablonie bez konieczności ich jawnego przekazywania:

# 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

Funkcja globalna asset() generuje wersjonowane adresy URL. Funkcja globalna csrf_token() generuje świeże tokeny CSRF. Funkcja globalna analytics_script() wstrzykuje fragment kodu śledzenia. Wszystkie te funkcje można wywoływać w dowolnym szablonie, bez konieczności jawnego przekazywania ich przez handler trasy.

W przypadku i18n konfiguracja jest bardziej złożona — funkcje tłumaczące potrzebują dostępu do lokalizacji bieżącego żądania:

# 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

Każda funkcja odczytuje lokalizację ze zmiennej kontekstowej żądania ustawianej przez middleware lokalizacji. Szablon wywołuje {{ _('ui.nav.about') }} i otrzymuje przetłumaczony ciąg znaków dla lokalizacji bieżącego żądania, bez konieczności jawnego przekazywania parametru lokalizacji.

Bloki warunkowe

System bloków Jinja2 obsługuje warunkowe nadpisywanie:

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

Wpisy blogowe deklarują swoje zależności w frontmatter YAML (scripts: ["/static/js/boids.js"]). Szablon warunkowo je dołącza. Strony, które nie potrzebują dodatkowych skryptów ani stylów, nie ładują żadnych — bez martwego kodu, bez nieużywanych importów.

Filtry niestandardowe

Filtry Jinja2 przekształcają dane podczas renderowania. Filtr sanitize zapobiega atakom XSS w treściach generowanych przez użytkowników:

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

W szablonach: {{ user_content | sanitize }}. Biblioteka nh3 to napisany w Rust sanitizer HTML — szybki i bezpieczny. Usuwa wszelkie tagi i atrybuty spoza listy dozwolonych, zapobiegając stored XSS nawet wtedy, gdy treść pochodzi z niezaufanego źródła.10


HTMX — szczegółowe omówienie

HTMX umożliwia dowolnemu elementowi HTML wysyłanie żądań HTTP i podmianę odpowiedzi w drzewie DOM. Kluczowa idea ma charakter architektoniczny: renderowany po stronie serwera HTML stanowi API. Serwer zwraca finalną reprezentację. Bez renderowania po stronie klienta, bez serializacji JSON, bez hydratacji.

Podstawowe atrybuty

Atrybut Przeznaczenie Przykład
hx-get Wysłanie żądania GET hx-get="/search?q=term"
hx-post Wysłanie żądania POST hx-post="/contact"
hx-target Miejsce umieszczenia odpowiedzi hx-target="#results"
hx-swap Sposób wstawienia odpowiedzi hx-swap="innerHTML" (domyślnie), outerHTML, beforeend
hx-trigger Zdarzenie wyzwalające żądanie hx-trigger="click", keyup changed delay:300ms, load
hx-indicator Element wyświetlany podczas żądania hx-indicator="#spinner"
hx-push-url Aktualizacja adresu URL przeglądarki hx-push-url="true"
hx-replace-url Zamiana URL bez wpisu w historii hx-replace-url="true"

Wzorzec 1: Interaktywny quiz (wieloetapowy stan na serwerze)

Strona blakecrosley.com zawiera interaktywny quiz, który prowadzi użytkowników przez wybór narzędzi. Cały stan quizu znajduje się na serwerze — bez zarządzania stanem po stronie klienta:

<!-- _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>

Każde kliknięcie przycisku wysyła dotychczas zebrane odpowiedzi jako parametr zapytania. Serwer na podstawie historii odpowiedzi oblicza kolejne pytanie lub wynik końcowy. Stan kumuluje się w adresie URL — bez ciasteczek, bez sesji, bez JavaScript po stronie klienta. Quiz postępuje poprzez podmiany outerHTML: każda odpowiedź zastępuje cały element kroku quizu.

Wzorzec 2: Stronicowana lista wpisów na blogu

Strona z wpisami wykorzystuje HTMX do płynnej paginacji z aktualizacją adresu URL:

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

Cztery atrybuty współpracujące ze sobą:

  1. hx-get wysyła żądanie pod ten sam adres URL co href (stopniowe wzbogacanie — działa bez JavaScript)
  2. hx-target umieszcza odpowiedź w kontenerze #writing-content
  3. hx-replace-url="true" aktualizuje adres URL przeglądarki bez dodawania wpisu do historii
  4. hx-indicator wyświetla wskaźnik ładowania podczas trwania żądania

Serwer wykrywa żądania HTMX za pomocą nagłówka HX-Request i zwraca jedynie fragment z listą wpisów zamiast pełnej strony. Dlatego middleware nagłówków bezpieczeństwa dodaje Vary: HX-Request — aby pamięci podręczne CDN przechowywały pełną stronę i fragment osobno.11

Wzorzec 3: Wyszukiwanie z opóźnieniem (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>

Atrybut hx-trigger łączy trzy modyfikatory:

  • keyup — wyzwalany przy zwolnieniu klawisza
  • changed — wyzwalany tylko wtedy, gdy wartość faktycznie się zmieniła (zapobiega duplikowaniu żądań przez klawisze modyfikujące)
  • delay:300ms — opóźnienie (debounce) — czeka 300 ms od ostatniego zdarzenia keyup przed wysłaniem żądania

Serwer zwraca wyrenderowany fragment HTML:

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

Bez stanu po stronie klienta. Bez biblioteki debounce. Bez useEffect. Szablon renderuje wyniki, HTMX podmienia je w DOM, a serwer pozostaje jedynym źródłem prawdy.

Wzorzec 4: Podmiany poza głównym celem (OOB Swaps)

Zdarza się, że pojedyncza akcja serwera musi zaktualizować wiele elementów DOM jednocześnie. Mechanizm podmian OOB (out-of-band) w HTMX obsługuje to bez orkiestracji po stronie klienta:

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

Atrybut hx-swap-oob="true" instruuje HTMX, aby znalazł element po id w dowolnym miejscu drzewa DOM i zastąpił go, niezależnie od wartości hx-target. Zastępuje to wzorzec „podnoszenia stanu” (lift state up) z React — serwer oblicza cały stan pochodny i wysyła finalny HTML dla każdego elementu w jednej odpowiedzi.

Formularz kontaktowy dobrze to ilustruje: wysłanie formularza może zastąpić jego treść komunikatem o sukcesie, a jednocześnie zaktualizować plakietkę powiadomień poprzez podmianę OOB:

HTMX potrafi „wzmocnić” standardowe linki nawigacyjne, aby korzystały z AJAX zamiast pełnego przeładowania strony:

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

Dzięki hx-boost="true" kliknięcie w link pobiera stronę przez AJAX, podmienia zawartość <body> i aktualizuje adres URL — bez pełnego przeładowania strony. Historia przeglądarki działa normalnie (przyciski wstecz/dalej). Jeśli JavaScript nie zadziała, linki funkcjonują jako standardowa nawigacja.

Korzyścią jest postrzegana szybkość: wzmocniona nawigacja sprawia wrażenie natychmiastowej, ponieważ przeglądarka nie musi ponownie parsować CSS, ponownie ewaluować skryptów ani ponownie renderować układu. Zmienia się jedynie zawartość <body>. Wzmocnione linki sprawdzają się doskonale w głównych elementach nawigacyjnych, dzięki czemu przejścia między stronami przypominają aplikację jednostronicową — bez architektury SPA.

Wzorzec 6: Nagłówki żądań HTMX

HTMX wysyła niestandardowe nagłówki z każdym żądaniem:

Nagłówek Wartość Zastosowanie
HX-Request true Wykrywanie żądań HTMX po stronie serwera
HX-Target ID elementu Informacja, który element otrzyma odpowiedź
HX-Trigger ID elementu Informacja, który element wyzwolił żądanie
HX-Current-URL Pełny URL Informacja o bieżącej stronie użytkownika

Serwer może wykorzystać HX-Request do zwracania różnych odpowiedzi:

@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)

Ten wzorzec podwójnej odpowiedzi stanowi rdzeń architektury. Pełne załadowanie strony zwraca kompletny dokument (szablon bazowy + zawartość strony). Nawigacja przez HTMX zwraca jedynie zmienioną treść. Decyduje serwer, nie klient.

Wzorzec 7: Stopniowe wzbogacanie (Progressive Enhancement)

Każdy link HTMX na blakecrosley.com zawiera standardowy atrybut href:

<a href="/writing?page=2"
   hx-get="/writing?page=2"
   hx-target="#writing-content"
   hx-swap="innerHTML">
  Next Page
</a>

Jeśli JavaScript nie załaduje się, href działa jako zwykły link. Jeśli HTMX się załaduje, przechwytuje kliknięcie i wykonuje podmianę AJAX. To właśnie stopniowe wzbogacanie: strona działa bez JavaScript, a HTMX wzbogaca doświadczenie, gdy jest dostępny.

Wzorzec 8: Stany ładowania

<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 dodaje klasę htmx-request do elementu wyzwalającego na czas trwania żądania. Atrybut hx-indicator wskazuje element, który staje się widoczny podczas żądania. Stylowanie odbywa się za pomocą CSS:

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

Bez zarządzania stanami ładowania. Bez useState(false). Bez setLoading(true). CSS obsługuje widoczność, HTMX przełącza klasę.


Wzorce Alpine.js

Alpine.js wypełnia lukę, którą pozostawia HTMX: stan istniejący wyłącznie po stronie klienta, który nigdy nie musi komunikować się z serwerem. Gdy użytkownik klika rozwijane menu i ono się otwiera, ten stan istnieje tylko w przeglądarce. Alpine.js zarządza nim za pomocą atrybutów HTML.

Zasada granicy

Granica między HTMX a Alpine.js jest precyzyjna:

Typ stanu Narzędzie Przykład
Wymaga danych z serwera HTMX Wyniki wyszukiwania, walidacja formularzy, paginacja
Istnieje tylko w przeglądarce Alpine.js Otwieranie/zamykanie menu, przełącznik menu mobilnego, widoczność modala
Łączy oba podejścia Oba Przełącznik języka (przełączanie Alpine.js, nawigacja w stylu HTMX)

Nawigacja mobilna

Szablon bazowy opakowuje cały nagłówek w komponent Alpine.js:

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

Kluczowe wzorce Alpine.js:

  • x-data deklaruje zakres komponentu i stan początkowy
  • x-show przełącza widoczność na podstawie stanu (wykorzystuje CSS display: none)
  • x-cloak ukrywa element do momentu inicjalizacji Alpine.js (zapobiega migotaniu niestylizowanej treści)
  • @click wiąże procedury obsługi kliknięć z wyrażeniami
  • :aria-expanded (skrót od x-bind:aria-expanded) dynamicznie ustawia atrybuty
  • @keydown.escape.window nasłuchuje klawisza Escape globalnie, aby zamknąć panele

Komponent rozwijany

Przełącznik języka wykorzystuje Alpine.js do zarządzania stanem z @click.away do zamykania przy kliknięciu poza komponentem:

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

Modyfikator @click.away zamyka menu rozwijane przy kliknięciu na zewnątrz. Alpine.js obsługuje to za pomocą jednego atrybutu — bez rejestrowania nasłuchiwania zdarzeń, bez czyszczenia, bez zarządzania referencjami.

Kiedy używać Alpine.js, a kiedy czystego JavaScript

Alpine.js sprawdza się, gdy:

  • Stan jest ograniczony do pojedynczego elementu DOM (menu rozwijane, modal, przełącznik)
  • Interakcje są binarne lub proste (otwórz/zamknij, pokaż/ukryj, przełącz)
  • Wiele elementów musi reagować na tę samą zmianę stanu
  • Atrybuty dostępności muszą być zsynchronizowane z widocznością

Czysty JavaScript sprawdza się, gdy:

  • Interakcja wymaga złożonych obliczeń (wizualizacje, symulacje)
  • Komponent ma własną pętlę renderowania (canvas, animacje)
  • Wydajność jest kluczowa (Alpine.js dodaje narzut na każdy komponent x-data)
  • Logika przekracza 20–30 linii wyrażeń Alpine.js

blakecrosley.com używa Alpine.js do nawigacji, przełączania języków i przełączników treści. 20 interaktywnych komponentów bloga (symulacja boidów, wizualizator kodu Hamminga itd.) korzysta z czystego JavaScript, ponieważ wymaga renderowania na canvas i złożonych maszyn stanów.


Kompleksowy przykład: filtrowanie kategorii na /writing

Ta sekcja śledzi rzeczywistą funkcję z produkcyjnego kodu przez wszystkie warstwy: trasę, szablon, interakcję HTMX, bezpieczeństwo, cachowanie i wyrenderowany wynik. Funkcja: zakładki kategorii na stronie z wpisami, które filtrują posty bloga bez pełnego przeładowania strony.

Trasa (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,
    )

Sprawdzenie nagłówka HX-Request to kluczowy wzorzec: ta sama trasa, te same dane, inny szablon. HTMX otrzymuje fragment. Przeglądarki otrzymują pełną stronę.

Zakładki kategorii (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>

Każda zakładka ma zarówno href (działa bez JavaScript), jak i hx-get (podmienia tylko listę postów). hx-push-url aktualizuje adres URL przeglądarki, dzięki czemu przefiltrowany widok można udostępniać i dodawać do zakładek.

Fragment (pages/writing/_post_list.html)

Fragment renderuje się identycznie niezależnie od tego, czy jest dołączony przy ładowaniu strony, czy podmieniony przez HTMX:

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

Brak specjalnego znacznika HTMX we fragmencie. Brak logiki renderowania po stronie klienta. Ten sam HTML obsługuje zarówno początkowe ładowanie strony, jak i każde kolejne filtrowanie.

Bezpieczeństwo

Wartości kategorii są walidowane względem CATEGORY_MAP (słownika po stronie serwera) przed filtrowaniem. Nieprawidłowe kategorie są ignorowane, a nie zwracane w odpowiedzi. Żadne dane wejściowe użytkownika nie są interpolowane do SQL ani HTML. Nagłówek CSP blokuje skrypty inline.

Cachowanie

Odpowiedzi kategorii są dynamiczne (bez cache CDN). Natomiast zasoby statyczne (CSS, HTMX, Alpine.js) mają hash w nazwie i są cachowane bezterminowo po pierwszym pobraniu. Kolejne przełączenia kategorii przesyłają jedynie fragment HTML (~3–5 KB) — bez ponownego pobierania CSS, JS ani obrazów.

Co to demonstruje

Jedna funkcja, prawdziwy kod produkcyjny, zero narzędzi do budowania. Serwer filtruje i renderuje HTML. HTMX podmienia listę postów. Alpine.js nie jest zaangażowany (nie potrzeba stanu po stronie klienta). URL aktualizuje się, umożliwiając udostępnianie. Progresywne ulepszanie: zakładki działają jako zwykłe linki bez JavaScript. Łączna ilość niestandardowego JavaScript w tej funkcji: zero linii.


Opcjonalne rozszerzenia

Poniższe sekcje opisują wzorce uzupełniające podstawowy stos, które nie są używane na blakecrosley.com. Zostały uwzględnione, ponieważ reprezentują najczęstsze dodatki wprowadzane przez zespoły adoptujące tę architekturę.


Bootstrap 5 bez Sass

Uwaga: blakecrosley.com używa zwykłego CSS z właściwościami niestandardowymi — bez Bootstrap. Ta sekcja opisuje Bootstrap 5 jako opcję dla zespołów, które chcą korzystać z frameworka narzędziowego bez etapu budowania. Skompilowany CSS Bootstrap można załadować z CDN lub dołączyć do arkusza stylów. Poniższe wzorce są uniwersalne i działają równolegle z podejściem HTMX + Alpine.js opisanym w poprzednich sekcjach.

Bootstrap 5 zrezygnował z jQuery jako zależności i obsługuje samodzielne użycie CSS. Nie potrzeba Sass, PostCSS ani żadnego narzędzia budowania, aby korzystać z systemu siatki i klas narzędziowych Bootstrap.

Samodzielny hosting bez CDN

blakecrosley.com hostuje wszystkie biblioteki zewnętrzne lokalnie:

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

Samodzielny hosting eliminuje zależności zewnętrzne, zapobiega awariom CDN wpływającym na działanie witryny i umożliwia niezmienne buforowanie z adresami URL zawierającymi skrót zawartości. Należy pobrać skompilowany CSS Bootstrap (nie źródła Sass) i umieścić go w static/css/vendor/.

System siatki

System siatki Bootstrap działa ze zwykłymi klasami HTML:

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

Bez mixinów Sass. Bez @include make-col(). Skompilowany CSS zawiera responsywne klasy siatki. W przypadku niestandardowych punktów przełamania wykraczających poza domyślne wartości Bootstrap wystarczy napisać zwykłe zapytania medialne CSS.

Nadpisywanie za pomocą zwykłego CSS

Domyślne style Bootstrap można nadpisać za pomocą właściwości niestandardowych CSS i standardowych selektorów:

/* 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;
}

Właściwości niestandardowe CSS kaskadowo przechodzą przez DOM, są dziedziczone z elementów nadrzędnych i reagują na zapytania medialne w czasie wykonywania. Zmienne Sass kompilują się do wartości statycznych i znikają. To rozróżnienie ma znaczenie przy tworzeniu motywów: pojedyncza zmiana właściwości niestandardowej może zaktualizować każdą wartość pochodną bez ponownej kompilacji.12

Klasy narzędziowe a CSS komponentów

Klasy narzędziowe Bootstrap sprawdzają się przy jednorazowych odstępach i układach. CSS komponentów należy stosować do powtarzających się wzorców:

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

Zasada jest następująca: klasy narzędziowe Bootstrap do mechaniki układu (marginesy, wypełnienia, flexbox). Niestandardowy CSS do tożsamości wizualnej (kolory, typografia, animacje). Nie należy mieszać klas narzędziowych ze stylowaniem komponentów w zakresie tego samego zagadnienia.


Internacjonalizacja i lokalizacja

blakecrosley.com udostępnia treści w 10 językach: angielskim, japońskim, koreańskim, chińskim uproszczonym, chińskim tradycyjnym, niemieckim, francuskim, hiszpańskim, polskim i portugalskim (brazylijskim).

Routing locale oparty na URL

Locale znajduje się w ścieżce URL: /about (angielski), /ja/about (japoński), /zh-Hans/about (chiński uproszczony). Angielski jest domyślny i nie ma prefiksu.

# i18n/config.py
SUPPORTED_LOCALES = [
    "en", "zh-Hans", "zh-Hant", "fr", "de", "ja", "ko", "pl", "pt-BR", "es"
]
DEFAULT_LOCALE = "en"

Middleware locale wyodrębnia locale ze ścieżki URL:

# 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

Middleware usuwa prefiks locale przed dopasowaniem tras. Oznacza to, że handlery tras nie potrzebują ścieżek specyficznych dla locale — /about obsługuje zarówno angielski (/about), jak i japoński (/ja/about), ponieważ middleware normalizuje ścieżkę.

Funkcje tłumaczeń w szablonach

Zmienne globalne Jinja2 udostępniają funkcje tłumaczeń:

<!-- Template usage -->
<h3>{{ _('ui.footer.navigate') | default('Navigate') }}</h3>
<a href="{{ locale_prefix() }}/about">
  {{ _('ui.nav.about') | default('About') }}
</a>

Funkcja _() wyszukuje klucz tłumaczenia w pamięci podręcznej. Filtr | default() zapewnia angielski fallback w przypadku braku tłumaczenia. Funkcja locale_prefix() zwraca prefiks URL dla bieżącego locale ("" dla angielskiego, "/ja" dla japońskiego).

Tagi hreflang

Każda strona zawiera tagi hreflang dla wszystkich obsługiwanych locale:

<!-- Generated in base.html -->
{% for alt in alternate_urls(request.url.path) %}
<link rel="alternate" hreflang="{{ alt.hreflang }}" href="{{ alt.url }}">
{% endfor %}

Generuje to:

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

Wyszukiwarki wykorzystują hreflang do wyświetlania prawidłowej wersji językowej w wynikach wyszukiwania. Wpis x-default wskazuje na wersję angielską jako fallback.13

Przechowywanie tłumaczeń i pamięć podręczna

Tłumaczenia są przechowywane w Cloudflare D1 (SQLite na brzegu sieci) i ładowane do pamięci podręcznej za pośrednictwem handlera lifespan:

@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)

Pamięć podręczna eliminuje zapytania do bazy danych przy każdym renderowaniu strony. Aktualizacja tłumaczeń wymaga odświeżenia cache (wyzwalanego przez endpoint administracyjny lub wdrożenie). Taka architektura poświęca świeżość danych na rzecz wydajności — tłumaczenia zmieniają się rzadko, natomiast renderowanie stron odbywa się przy każdym żądaniu.

Monitorowanie stanu

blakecrosley.com zawiera endpoint sprawdzania stanu i18n, który monitoruje pokrycie tłumaczeń dla poszczególnych locale:

@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

Próg pokrycia 99,5% wykrywa brakujące tłumaczenia, zanim użytkownicy napotkają nieprzetłumaczone ciągi tekstowe. Endpoint stanu integruje się z monitoringiem Railway, aby alertować o spadku pokrycia — na przykład po dodaniu nowych ciągów UI, które nie zostały jeszcze przetłumaczone.

Renderowanie treści z uwzględnieniem locale

Wpisy blogowe i przewodniki obsługują tłumaczenia metadanych i treści dla poszczególnych locale:

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

Wzorzec jest spójny: najpierw próba wyświetlenia przetłumaczonej treści, w razie jej braku — fallback do angielskiego. Umożliwia to częściowe tłumaczenie — japoński użytkownik widzi przetłumaczone tytuły i opisy, nawet jeśli pełna treść artykułu pozostaje w języku angielskim. Filtr | default() Jinja2 koduje ten wzorzec w jednym pipe:

{{ translated.title if translated else post.meta.title }}

Tłumaczenie danych locale

Treści statyczne, takie jak opisy projektów i etykiety nawigacji, są tłumaczone za pomocą funkcji pomocniczych, które zachowują tę samą strukturę danych, podmieniając ciągi tekstowe specyficzne dla danego locale:

# 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

Takie podejście utrzymuje warstwę tłumaczeń oddzieloną od warstwy danych. Trasy przekazują tę samą listę projects niezależnie od locale. Funkcje tłumaczeń opakowują dane w sposób transparentny.

Mapa witryny z alternatywami hreflang

Dynamiczna mapa witryny zawiera wszystkie strony we wszystkich locale z wzajemnymi odwołaniami:

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

Generuje to 10 wpisów URL na stronę (po jednym na locale), z których każdy zawiera 11 linków alternatywnych (10 locale + x-default). Dla witryny z 50 stronami mapa witryny zawiera 500 wpisów URL z 5500 linkami hreflang. Mapa witryny jest generowana dynamicznie i cache’owana na jedną godzinę.


Wzorce baz danych

Uwaga: blakecrosley.com używa Cloudflare D1 (serverless SQLite) przez HTTP dla wszystkich trwałych danych, a nie SQLAlchemy. Ta sekcja omawia standardowy wzorzec async SQLAlchemy dla projektów FastAPI, które potrzebują relacyjnej bazy danych — najczęstszej konfiguracji produkcyjnej dla tego stosu.

Async w SQLAlchemy 2.0

W aplikacjach, które potrzebują relacyjnej bazy danych, obsługa async w SQLAlchemy 2.0 dobrze integruje się z FastAPI:

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

Uwaga dotycząca instalacji (SQLAlchemy 2.0.50+): od wersji 2.0.50 zależność greenlet dla stosu async nie jest już instalowana domyślnie. Należy użyć dodatku asyncio, aby została pobrana, w przeciwnym razie pierwsze await względem silnika zakończy się błędem braku greenlet:23

pip install "sqlalchemy[asyncio]" aiosqlite

SQLAlchemy 2.0.50 wymaga także Python 3.10+ (porzucono obsługę 3.7–3.9) i dodaje koła dla free-threaded (3.13t).23

Dependency Injection dla sesji bazy danych

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

Zależność get_db zarządza cyklem życia sesji: otwiera sesję, przekazuje ją do route handlera, zatwierdza po powodzeniu i wycofuje w razie wyjątku. Każda operacja na bazie danych używa zapytań parametryzowanych — nigdy interpolacji ciągów znaków.

Integracja z Pydantic

Modele Pydantic walidują dane wejściowe na granicy API i serializują dane wyjściowe dla szablonów:

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 waliduje typy, formaty (email, URL) oraz ograniczenia (minimalna/maksymalna długość), zanim route handler zostanie wykonany. Nieprawidłowe dane wejściowe automatycznie zwracają odpowiedź 422. Zastępuje to biblioteki walidacji formularzy po stronie klienta — serwer waliduje dane, a HTMX podmienia albo komunikat sukcesu, albo informację zwrotną o błędzie.

Migracje z Alembic

Alembic zarządza zmianami schematu bazy danych:

# 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

Funkcja autogenerate porównuje modele SQLAlchemy z bieżącym schematem bazy danych i generuje skrypty migracji. Te skrypty to wersjonowane pliki Python, które znajdują się w repozytorium:

# 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")

Migracje są uruchamiane podczas wdrożenia (przed startem aplikacji). Dzięki temu schemat bazy danych odpowiada kodowi aplikacji. W przypadku blakecrosley.com większość danych znajduje się w Cloudflare D1 (dostęp przez HTTP), dlatego migracje Alembic dotyczą lokalnej bazy SQLite lub PostgreSQL używanej do danych sesji i analityki.

Wzorzec Cloudflare D1

blakecrosley.com używa Cloudflare D1 jako zdalnej bazy danych dostępnej przez proxy Cloudflare Worker:

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"]

Ten wzorzec sprawdza się w aplikacjach, które potrzebują bazy danych, ale nie chcą zarządzać serwerem bazy danych. D1 to SQLite na brzegu sieci Cloudflare, dostępne przez HTTP. Proxy Worker obsługuje uwierzytelnianie i ograniczanie liczby żądań. Kompromisem jest opóźnienie: każde zapytanie jest żądaniem HTTP (~50-100 ms), podczas gdy lokalne połączenie z bazą danych zajmuje ~1-5 ms. Pamięć podręczna w pamięci operacyjnej inicjalizowana przy starcie łagodzi ten problem w obciążeniach z przewagą odczytu, takich jak tłumaczenia.


Bezpieczeństwo

Middleware nagłówków bezpieczeństwa

blakecrosley.com implementuje wzmocnione nagłówki bezpieczeństwa przez niestandardowe 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

CSP zawiera 'unsafe-inline' i 'unsafe-eval', ponieważ Alpine.js wymaga ich do ewaluacji wyrażeń. Alternatywą jest zgodna z CSP kompilacja Alpine.js, która ma ograniczenia.14 Każda inna funkcja jest zablokowana: frame-ancestors zapobiega clickjackingowi, form-action ogranicza przesyłanie formularzy do tego samego źródła, a upgrade-insecure-requests wymusza HTTPS.

Bezpieczne cache’owanie CDN z HTMX

Middleware nagłówków bezpieczeństwa dodaje Vary: HX-Request do odpowiedzi HTMX:

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)

Bez tego nagłówka CDN mógłby zapisać w cache odpowiedź fragmentu HTMX i zwrócić ją jako pełną stronę dla żądania innego niż HTMX (albo odwrotnie). Nagłówek Vary informuje CDN, aby przechowywać osobne wpisy cache na podstawie wartości nagłówka HX-Request.11

Ochrona CSRF

Formularze HTMX używają bezstanowych tokenów CSRF podpisanych HMAC:

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

Token jest generowany w szablonie przez globalny obiekt Jinja2 i dołączany do żądań formularzy HTMX:

<form hx-post="/contact" hx-target="#form-result">
  <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
  <!-- form fields -->
</form>

Tokeny bezstanowe eliminują przechowywanie sesji po stronie serwera. Podpis HMAC gwarantuje, że token został wygenerowany przez serwer. Znacznik czasu zapobiega atakom replay. hmac.compare_digest zapobiega atakom timingowym.15

Sanityzacja HTML

Treści generowane przez użytkowników przechodzą przez nh3 przed renderowaniem:

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

Biblioteka nh3 usuwa tagi i atrybuty spoza listy dozwolonych. Linki automatycznie otrzymują rel="noopener noreferrer". Ta ochrona jest niezależna od CSP — zapobiega stored XSS na warstwie renderowania, podczas gdy CSP zapobiega wstrzykniętym skryptom na warstwie przeglądarki. Obrona warstwowa.

Walidacja danych wejściowych

Modele Pydantic walidują wszystkie dane wejściowe na granicy API:

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 automatycznie zwraca 422 Unprocessable Entity dla nieprawidłowych danych wejściowych. W połączeniu z parametryzowanymi zapytaniami do bazy danych (SQLAlchemy nigdy nie interpoluje stringów) zapobiega to SQL injection i zapewnia bezpieczeństwo typów na granicach.


Wydajność

Lighthouse 100/100/100/100

blakecrosley.com uzyskuje wynik 100 we wszystkich czterech kategoriach Lighthouse: Performance, Accessibility, Best Practices i SEO. Można to zweryfikować w PageSpeed Insights.2

Kluczowe optymalizacje:

Strategia ładowania CSS

blakecrosley.com ładuje CSS za pomocą jednego tagu <link> i adresów URL z hashem treści do niezmiennego cache’owania:

<link rel="stylesheet" href="{{ asset('css/styles.css') }}">

Helper asset() dodaje hash treści (?v=a3b2c1d4), dzięki czemu przeglądarka przechowuje plik w cache bezterminowo, dopóki treść się nie zmieni. Bez wyodrębniania krytycznego CSS, bez sztuczki z print-media, bez ładowania opartego na JavaScript. Plik CSS ma około 8 KB po gzip — wystarczająco mało, aby podejście z pojedynczym żądaniem uzyskiwało 100 punktów w Lighthouse Performance bez optymalizacyjnej gimnastyki.

Kompresja GZip

app.add_middleware(GZipMiddleware, minimum_size=500)

Odpowiedzi powyżej 500 bajtów są kompresowane. HTML kompresuje się o 70-80%, zmniejszając dokument 15 KB do 3-4 KB.

Niezmienne cache’owanie statycznych zasobów

# 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"

Zasoby statyczne z adresami URL zawierającymi hash treści (?v=a3f8b2c1d0) są cache’owane przez rok z immutable. Hash zmienia się, gdy zmienia się plik, co wymusza na przeglądarkach i CDN pobranie nowej wersji.

Odroczone ładowanie skryptów

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

Atrybut defer pobiera skrypty równolegle z parsowaniem HTML, ale wykonuje je dopiero po sparsowaniu dokumentu. Zapobiega to blokowaniu renderowania bez złożoności ładowania async i zarządzania kolejnością wykonywania.

Optymalizacja obrazów

Obrazy używają WebP z responsywnym srcset i jawnymi wymiarami:

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>

Jawne atrybuty width i height zapobiegają Cumulative Layout Shift (CLS). Atrybut loading="lazy" odracza obrazy poza ekranem. WebP zapewnia pliki o 25-35% mniejsze niż JPEG przy równoważnej jakości.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)

Nagłówek Link z rel=preload informuje Cloudflare, aby wysłać odpowiedź 103 Early Hints, co pozwala przeglądarce rozpocząć pobieranie CSS, zanim serwer zakończy generowanie odpowiedzi HTML.17

Minimalny JavaScript

Całkowity rozmiar JavaScript:

Biblioteka Rozmiar (zminifikowany + gzip)
HTMX ~16 KB
Alpine.js ~15 KB
JS specyficzny dla strony 4-8 KB
Razem 35-39 KB

Typowa aplikacja React wysyła 100-300 KB frameworkowego JavaScript przed kodem aplikacji.18 Podejście bez builda wysyła mniej JavaScript, ponieważ do wysłania jest mniej JavaScript.

Deployment

Railway

blakecrosley.com jest wdrażany do Railway przez git push:

# 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

Builder Nixpacks w Railway wykrywa projekt Python na podstawie requirements.txt, instaluje zależności i uruchamia polecenie startowe. Plik Docker nie jest wymagany. Endpoint health check zapewnia, że aplikacja odpowiada przed przyjęciem ruchu:

@app.get("/health")
async def health():
    return {"status": "healthy"}

Pipeline wdrożeniowy

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

Bez npm install. Bez npm run build. Bez kompilacji webpack. Bez kompilacji TypeScript. Jedyny krok instalacji to pip install -r requirements.txt, który jest cache’owany między wdrożeniami.

Procfile

web: uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}

Procfile zapewnia alternatywę zgodną z Heroku. Railway obsługuje zarówno railway.toml, jak i Procfile. Składnia ${PORT:-8000} używa portu dostarczonego przez platformę albo domyślnie ustawia 8000 dla lokalnego developmentu.

Konfiguracja produkcyjna Uvicorn

Przy wdrożeniach o większym ruchu należy użyć wielu workerów:

uvicorn app.main:app \
  --host 0.0.0.0 \
  --port ${PORT:-8000} \
  --workers 4 \
  --loop uvloop \
  --http httptools
  • --workers 4 uruchamia cztery procesy workerów (ogólna reguła: 2 * rdzenie CPU + 1)
  • --loop uvloop używa szybszej pętli zdarzeń uvloop (zamiennik asyncio typu drop-in)
  • --http httptools używa szybszego parsera HTTP httptools

W developmentcie --reload obserwuje zmiany plików:

uvicorn app.main:app --reload --port 8000

Alternatywa Docker

Dla platform wymagających Docker:

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"]

Smukły obraz bazowy utrzymuje mały rozmiar kontenera. --no-cache-dir uniemożliwia pip przechowywanie pobranych pakietów w warstwie obrazu.

CDN Cloudflare

blakecrosley.com używa Cloudflare do cache’owania CDN, DNS i 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 — przeglądarka cache’uje przez 5 minut
  • s-maxage=3600 — CDN cache’uje przez 1 godzinę
  • stale-while-revalidate=86400 — serwuje nieaktualną treść podczas ponownej walidacji przez 24 godziny

Statyczne zasoby otrzymują max-age=31536000, immutable, ponieważ adresy URL z hashem treści gwarantują świeżość.


Ramy decyzyjne

Czy narzędzia build są potrzebne?

Należy odpowiedzieć na cztery pytania:

1. Czy więcej niż pięciu developerów współdzieli interfejsy JavaScript? Jeśli tak, sprawdzanie typów w czasie kompilacji przez TypeScript zapobiega błędom integracji, które testy runtime wykrywają zbyt późno. Warto dodać krok build.

2. Czy aplikacja zarządza złożonym stanem po stronie klienta? Jeśli drag-and-drop, współpraca w czasie rzeczywistym albo dane offline-first są funkcjami kluczowymi, a nie dodatkami, framework taki jak React lub Svelte uzasadnia swoją złożoność. Warto dodać krok build.

3. Czy wiele produktów korzysta ze wspólnej biblioteki komponentów? Jeśli tak, taka biblioteka potrzebuje pakietowania npm, wersjonowania semantycznego i tree shaking. Warto dodać krok build.

4. Czy zależność obejmuje biblioteki z ekosystemu npm, które zakładają użycie bundlera? Jeśli Radix, Framer Motion, TanStack Query albo podobne biblioteki są kluczowe dla produktu, pipeline build jest obowiązkowy.

Jeśli wszystkie cztery odpowiedzi brzmią „nie”, podejście bez builda jest wykonalne. Jeśli którakolwiek odpowiedź brzmi „tak”, narzędzia build rozwiązują rzeczywisty problem. Błędem jest dodawanie narzędzi build, gdy wszystkie cztery odpowiedzi brzmią „nie” — rozwiązywanie problemów, których nie ma, przy jednoczesnym tworzeniu narzutu związanego z zarządzaniem zależnościami.1

Porównanie stacków

Kategoria Bez builda (ten przewodnik) React + narzędzia build
Najlepsze dla Strony contentowe, portfolio, narzędzia wewnętrzne, aplikacje CRUD Produkty SaaS, złożone SPA, konsumenci design systemu
Wielkość zespołu 1-5 developerów 5-50+ developerów
Zarządzanie stanem Serwer (HTMX) + klient (Alpine.js) Klient (stan React, Redux, Zustand)
Bezpieczeństwo typów Runtime (Pydantic po stronie serwera) Czas kompilacji (TypeScript)
Ponowne użycie komponentów Include’y Jinja2 + makra Pakiety npm, współdzielone biblioteki
SEO Domyślnie renderowane po stronie serwera Wymaga konfiguracji SSR/SSG
Bazowy poziom wydajności Wysoki (minimalny JS, renderowanie serwerowe) Różny (narzut frameworka)
Górny limit złożoności Niższy (brak offline, brak bogatego stanu klienta) Wyższy (możliwa dowolna interakcja po stronie klienta)
Zależności 17 pakietów Python Ponad 300 pakietów npm
Czas builda 0 sekund 15-60 sekund

Kiedy HTMX jest niewłaściwy

HTMX zastępuje stan klienta rundami żądań do serwera. Działa to do momentu, gdy znaczenie zaczyna mieć opóźnienie:

  • Interfejsy drag-and-drop — 200 ms rundy do serwera na każde zdarzenie przeciągania jest nieakceptowalne
  • Współpraca w czasie rzeczywistym — stan oparty na WebSocket wymaga rozwiązywania konfliktów po stronie klienta
  • Aplikacje offline-first — brak serwera oznacza brak HTMX
  • Złożone animacje powiązane ze stanem — Framer Motion i React Spring zakładają model rekoncyliacji React
  • Aplikacje Canvas/WebGL — pętla renderowania jest z natury po stronie klienta

Dla tych przypadków użycia framework po stronie klienta jest właściwym narzędziem. Podejście bez builda nie próbuje ich zastępować.


Szybka karta referencyjna

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"

Atrybuty HTMX

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

Atrybuty Alpine.js

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

Właściwości niestandardowe CSS

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

Nagłówki bezpieczeństwa

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=()

Lista kontrolna konfiguracji projektu

[ ] 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

Czy HTMX jest gotowy do produkcji w rzeczywistych aplikacjach webowych?

Tak. HTMX jest stabilny od 2020 roku i jest używany produkcyjnie w wielu branżach. Carson Gross, twórca biblioteki, utrzymuje kompatybilność wsteczną jako podstawową zasadę projektową — dokumentacja HTMX stwierdza, że biblioteka nie będzie psuć istniejących aplikacji w obrębie wersji głównej.19 Biblioteka ma około 16 KB po minifikacji i kompresji gzip, nie ma żadnych zależności i stosuje semantic versioning. blakecrosley.com działa z HTMX produkcyjnie od trzech lat bez żadnych błędów związanych z HTMX.20

Czy można używać TypeScript bez etapu build?

Częściowo. Pliki TypeScript można sprawdzać pod kątem typów za pomocą tsc --noEmit bez generowania plików wyjściowych, co zapewnia kontrolę w czasie kompilacji działającą jak linter. Przeglądarki nie mogą jednak wykonywać plików .ts bezpośrednio, więc etap build nadal jest wymagany, aby serwować TypeScript. Alternatywą są adnotacje typów JSDoc w zwykłych plikach .js, które TypeScript może sprawdzać bez kompilacji. Daje to bezpieczeństwo typów podczas developmentu, przy jednoczesnym wysyłaniu standardowego JavaScript.

Jak to podejście wypada w porównaniu z Astro lub 11ty?

Astro i 11ty to generatory stron statycznych, które tworzą zwykły HTML z minimalną ilością klienckiego JavaScript, ale wymagają etapu build (Node.js, npm install, polecenie build). Podejście bez build eliminuje ten krok — serwer renderuje HTML przy każdym żądaniu. Kompromis jest następujący: Astro/11ty generują szybsze strony statyczne (bez obliczeń po stronie serwera), natomiast FastAPI + HTMX natywnie obsługują treści dynamiczne (dane specyficzne dla użytkownika, przesyłanie formularzy, aktualizacje w czasie rzeczywistym) bez osobnej warstwy API.

Co z server-side rendering (SSR) w React?

Next.js SSR oraz podejście FastAPI + HTMX mają wspólny cel: wysłać do przeglądarki HTML wyrenderowany po stronie serwera. Różnica polega na tym, co dzieje się po początkowym renderowaniu. Next.js hydratyzuje stronę za pomocą React, wysyłając do klienta runtime frameworka i kod komponentów. FastAPI + HTMX nie wykonuje hydratacji — HTML jest finalnym wynikiem. HTMX obsługuje kolejne interakcje, żądając od serwera nowych fragmentów HTML. Rezultat: FastAPI + HTMX wysyła łącznie około 35-40 KB JavaScript, w porównaniu ze 100-300 KB w aplikacji Next.js.18

Jak obsługiwać walidację formularzy w tym stacku?

Po stronie serwera. Pydantic waliduje dane wejściowe po przesłaniu formularza. Jeśli walidacja się nie powiedzie, serwer zwraca formularz z komunikatami o błędach. HTMX podmienia odpowiedź w DOM:

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

Serwer waliduje, serwer renderuje stany błędów, a HTMX podmienia wynik. Nie jest potrzebna biblioteka walidacji po stronie klienta. Atrybut HTML required zapewnia podstawową walidację na poziomie przeglądarki jako pierwszą linię obrony.

Czy można dodać funkcje czasu rzeczywistego (WebSockets)?

Tak. FastAPI ma wbudowaną obsługę WebSocket:

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 ma rozszerzenie WebSocket (hx-ws), które łączy elementy z endpointami WebSocket:

<!-- HTMX 2.x WebSocket extension syntax -->
<div hx-ext="ws" ws-connect="/ws/notifications">
  <div id="notifications" ws-send></div>
</div>

Uwaga: HTMX 1.x używał składni hx-ws="connect:...". W HTMX 2.x obsługa WebSocket została przeniesiona do osobnego rozszerzenia (htmx-ext-ws) z atrybutami ws-connect i ws-send pokazanymi powyżej. Jeśli używany jest HTMX 1.x, stara składnia hx-ws nadal działa.

Ścieżka beta HTMX 4.0: htmx 4.0.0-beta4 jest już dostępny pod tagiem npm next oraz w dokumentacji 4.0, podczas gdy szybki start na htmx.org i tag npm latest pozostają przy wersji 2.0.10. Ten przewodnik nadal jest ukierunkowany na HTMX 2.x, która pozostaje rekomendowaną wersją do pracy produkcyjnej, dopóki 4.0 nie będzie stabilne; migracja z 2.x do 4.x to skok generacyjny, a nie wydanie punktowe 2.x. Wzorzec wersjonowania big-skies-software pomija nieparzyste wersje główne, więc 4.0 jest następnym krokiem po 2.x.2122

Co warto śledzić w dokumentacji 4.0. Dwa dodatki szczególnie zasługują na uwagę w przeglądzie bezpieczeństwa i architektury przed 4.0 GA: nowe rozszerzenie hx-live wprowadza wyrażenia reaktywne wobec DOM, które są ponownie obliczane, gdy zmienia się wskazany stan, a nowe rozszerzenie hx-nonce ogranicza przetwarzanie atrybutów htmx za pomocą CSP nonces. Przewodnik migracji 4.0 przenosi też kilka koncepcji konfiguracyjnych, przywraca lub zmienia część zachowań związanych ze zdarzeniami i historią oraz usuwa z core część helperów JavaScript. Wersję 4.0 należy traktować jako projekt migracyjny, a nie jako zamiennik patch 2.x typu drop-in.21

Wiadomości z serwera są podmieniane w DOM przy użyciu tych samych mechanizmów targetowania i swap co odpowiedzi HTTP. Serwer wysyła fragmenty HTML przez WebSocket, a HTMX je wstawia.

Jak ten stack obsługuje SEO?

Renderowany po stronie serwera HTML jest z natury przyjazny dla SEO, ponieważ crawlery otrzymują pełną treść strony bez wykonywania JavaScript. blakecrosley.com dodaje kilka warstw SEO:

  • Dane strukturalne JSON-LD w <head> dla każdej strony (schematy Person, Article, WebSite, FAQPage)
  • Dynamiczna sitemap z alternatywami hreflang dla wszystkich 10 locale
  • Kanał RSS pod /blog/feed.xml
  • llms.txt w katalogu głównym dla wykrywalności przez crawlery AI
  • Canonical URLs i tagi Open Graph w szablonie bazowym
  • Semantyczny HTML: <article>, <section>, <main>, właściwa hierarchia nagłówków

Nie jest potrzebna żadna konfiguracja SSR. Nie ma getStaticProps. Nie ma ISR. HTML jest renderowany przy każdym żądaniu — to zachowanie domyślne, a nie optymalizacja.

Jaka jest krzywa uczenia się w porównaniu z React?

Dla developerów Python krzywa uczenia się jest znacznie niższa. Język jest już znany. Route handlers w FastAPI zwracają odpowiedzi szablonów — to ten sam model mentalny co widoki we Flask lub Django. HTMX dodaje kilka atrybutów HTML (hx-get, hx-target, hx-swap). Alpine.js dodaje kilka kolejnych (x-data, x-show, @click). Nie ma JSX, virtual DOM, systemu hooks, biblioteki do zarządzania stanem ani konfiguracji narzędzia build, której trzeba się nauczyć.

Dokumentacja HTMX mieści się na jednej długiej stronie. Dokumentacja Alpine.js mieści się na kilku stronach. Dokumentacja React obejmuje setki stron dotyczących hooks, context, refs, effects, suspense, server components i streaming SSR.

Dla developerów JavaScript/React zmiana jest bardziej koncepcyjna niż składniowa. Kluczowa obserwacja: to serwer jest właścicielem stanu i to serwer renderuje HTML. Zarządzanie stanem po stronie klienta staje się obsługą tras po stronie serwera. Pobieranie danych po stronie klienta staje się atrybutami HTMX na elementach HTML. Składnia jest prostsza — model mentalny wymaga oduczenia się założenia SPA, że to klient odpowiada za renderowanie.


Dziennik zmian

Data Zmiana
2026-06-22 FastAPI 0.138.0 + 0.137.2. 0.138.0 (20 czerwca) dodaje app.frontend("/", directory="dist") / router.frontend(...) do serwowania zbudowanego statycznego frontendu (wynik dist/ dla SPA) — jest to rozwiązanie niezależne od tezy tego przewodnika o renderowaniu po stronie serwera bez etapu build, odnotowane jako kontrast w sekcji Async Patterns. 0.137.2 (18 czerwca) dodaje iter_route_contexts() jako obsługiwany sposób wyliczania tras, ponieważ router.routes jest teraz wewnętrzne (od 0.137.0). Obie wersje dodają funkcje, bez zmian łamiących zgodność; Starlette (1.3.1), Pydantic (2.13.4), HTMX (2.0.10), Alpine.js (3.15.12), Bootstrap (5.3.8), SQLAlchemy (2.0.51) pozostają bez zmian.
2026-06-16 FastAPI 0.137.0/0.137.1 + Starlette 1.0→1.3.1. FastAPI 0.137.0 (14 czerwca) refaktoryzuje wewnętrzne mechanizmy routera: router.routes jest teraz wewnętrznym drzewem, a nie płaską listą APIRoute (zmiana łamiąca zgodność dla wszystkiego, co po niej iteruje), jednocześnie umożliwiając dodawanie tras po include_router() oraz nowe hooki APIRouter.matches()/.handle(); 0.137.1 (15 czerwca) poprawia typowanie APIRoute oraz routery bez prefiksu z pustą ścieżką. Starlette wydał pierwszą stabilną wersję 1.0 (22 marca), a obecnie jest w wersji 1.3.1 (12 czerwca), usuwając przestarzałe hooki on_event/on_startup/on_shutdown oraz dekoratory @app.route()/@app.websocket_route() — jedynymi ścieżkami są lifespan i Route/WebSocketRoute; FastAPI 0.137.0 przypina Starlette 1.3.1. Do sekcji Async Patterns dodano uwagę o lifespan/router. SQLAlchemy 2.0.51 (15 czerwca) zawiera wyłącznie poprawki błędów.
2026-06-08 Zmiana instalacji async w SQLAlchemy 2.0.50. Od SQLAlchemy 2.0.50 zależność greenlet stosu async nie instaluje się już domyślnie — należy zainstalować dodatek sqlalchemy[asyncio] (w przeciwnym razie pierwsze await wobec engine zakończy się błędem brakującego greenlet). 2.0.50 wymaga też Python 3.10+ (porzucono 3.7–3.9) i dodaje koła dla free-threaded 3.13t. Do sekcji SQLAlchemy 2.0 Async dodano notatkę instalacyjną. Bez zmian w treści dla reszty stosu: najnowsza wersja FastAPI to nadal 0.136.3 (2026-05-23, bez wydania czerwcowego), stabilny htmx pozostaje w wersji 2.0.10 (4.0.0-beta4 „The Fetchening” jest w wersji beta z docelową stabilną wersją około początku 2027 roku, więc nie jest jeszcze rekomendacją produkcyjną), Alpine.js 3.15.12, Bootstrap 5.3.x bez zmian. Rekomendacja produkcyjna pozostaje bez zmian: HTMX 2.x do czasu stabilnego wydania 4.0.23
2026-05-24 Przegląd utrzymaniowy: lokalna inwentaryzacja treści nadal pokazuje 210 wpisów blogowych, 11 głównych przewodników, 48 studiów projektowych oraz 10 obsługiwanych lokalizacji, w tym angielską. Najnowsza wersja FastAPI to 0.136.3 (2026-05-23); jedyna refaktoryzacja widoczna dla aplikacji, wskazana w informacjach o wydaniu, to bardziej rygorystyczna obsługa nagłówków z podkreśleniami, gdy convert_underscores=True, a 0.136.2 waliduje pola Server-Sent Event, aby uniknąć uszkodzonych danych zdarzeń. Stabilny htmx pozostaje w wersji 2.0.10, podczas gdy npm next i dokumentacja 4.0 wskazują teraz na 4.0.0-beta4; najnowsza wersja SQLAlchemy 2.0 to 2.0.50; najnowsza wersja Pydantic pozostaje 2.13.4. Rekomendacja produkcyjna pozostaje bez zmian: używać HTMX 2.x, dopóki 4.0 nie stanie się stabilne.122
2026-05-18 Odświeżenie inwentaryzacji witryny: lokalna inwentaryzacja treści pokazuje teraz 210 wpisów blogowych, 11 głównych przewodników, 48 studiów projektowych oraz 10 obsługiwanych lokalizacji, w tym angielską. Najnowsza wersja FastAPI pozostaje 0.136.1; stabilny htmx pozostaje 2.0.10, z npm next na 4.0.0-beta3; najnowsza wersja npm Alpine.js pozostaje 3.15.12. Rekomendacja produkcyjna pozostaje bez zmian: używać HTMX 2.x, dopóki 4.0 nie stanie się stabilne.12021
2026-05-15 Przegląd utrzymaniowy: najnowsza wersja FastAPI pozostaje 0.136.1; to lokalne środowisko witryny importuje FastAPI 0.128.0 i Starlette 0.50.0; stabilny htmx pozostaje 2.0.10, a npm next to teraz 4.0.0-beta3; najnowsza wersja npm Alpine.js to 3.15.12; najnowsza wersja Bootstrap to 5.3.8; najnowsza wersja SQLAlchemy 2.0 to 2.0.49; najnowsza wersja Pydantic to 2.13.4. Rekomendacja produkcyjna bez zmian: używać HTMX 2.x, dopóki 4.0 nie stanie się stabilne.2021
2026-05-09 Śledzenie htmx 4.0.0-beta3 (8 maja 2026): htmx 4.0.0-beta3 jest dostępny pod tagiem npm next oraz w dokumentacji 4.0, podczas gdy npm latest pozostaje 2.0.10. Najważniejsze elementy warte śledzenia przed GA: nowe rozszerzenie hx-live (wyrażenia reaktywne względem DOM), nowe rozszerzenie hx-nonce (ochrona CSP nonce dla atrybutów htmx) oraz zmiany w przewodniku migracji dotyczące konfiguracji, historii, zdarzeń i podstawowych helperów JavaScript. Rekomendacja produkcyjna bez zmian: htmx 2.x pozostaje najnowszym tagiem npm i rekomendowaną wersją do czasu GA wersji 4.0.21
2026-05-07 Przegląd utrzymaniowy: najnowsza wersja FastAPI pozostaje 0.136.1; stabilny htmx to 2.0.10, a v4 pozostaje w wersji beta z celem na lato 2026; najnowsza wersja npm Alpine.js to 3.15.12; najnowsza wersja Bootstrap to 5.3.8; najnowsza wersja SQLAlchemy 2.0 to 2.0.49; najnowsza wersja Pydantic to 2.13.4. Lokalne metryki witryny odświeżono do 182 wpisów blogowych, 11 przewodników, 10 obsługiwanych lokalizacji i 17 wymagań Python. Wskazówki migracyjne bez zmian: używać HTMX 2.x w produkcji, dopóki 4.0 nie stanie się stabilne.20
2026-04-25 FastAPI 0.136.1 (23 kwietnia 2026): porządkowanie deprecations Pydantic v2 (bez zmian behawioralnych dla kodu aplikacji). Śledzona oś czasu HTMX 4.0: wydano htmx 4.0.0-beta1 (6 kwietnia) i 4.0.0-beta2 (14 kwietnia). Wskazówki migracyjne bez zmian — htmx 2.x pozostaje przy tagu npm latest, dopóki 4.0 nie będzie stabilne; poprawki bezpieczeństwa są kontynuowane, bez presji na aktualizację. Najważniejsze zmiany 4.0, które warto uwzględniać już teraz w projekcie: (1) fetch() zastępuje XMLHttpRequest jako podstawowa infrastruktura ajax, (2) dziedziczenie atrybutów staje się domyślnie jawne, (3) obsługa historii wysyła żądanie sieciowe po przywróconą treść (bez lokalnej migawki DOM). FastAPI 0.135.4 (16 kwietnia) usunął primaaprilisowy dekorator @app.vibe(), który trafił do 0.135.3.
2026-04-16 Dodano świadomość HTMX 4.0-beta (odniesienie na przyszłość). Odnotowano obsługę Python 3.14t free-threaded builds w FastAPI 0.136.0. Funkcje Pydantic 2.13.x (fabryki wartości domyślnych prywatnych atrybutów z dostępem do zwalidowanych danych modelu, przestrzeń nazw pydantic.v1 do 1.10.26 z obsługą 3.14). Poprawki Alpine.js 3.15.11: modyfikator x-anchor.noflip, ostrzeżenie x-for o wielu elementach głównych, poprawka regresji morph dla $refs.
2026-03-24 Pierwsza publikacja

Źródła


Ten przewodnik omawia kompletny system używany do budowy blakecrosley.com. No-Build Manifesto przedstawia argument filozoficzny. Wpis Lighthouse Perfect Score dokumentuje drogę optymalizacji wydajności. Wpis Vibe Coding vs. Engineering analizuje, gdzie w tym workflow mieści się rozwój wspomagany przez AI.


  1. Metryki produkcyjne blakecrosley.com według stanu na 18 maja 2026 roku. Witryna ma 210 wpisów blogowych, interaktywne komponenty JavaScript, 11 głównych przewodników, 48 studiów projektowych, wersję angielską oraz 9 przetłumaczonych lokalizacji, minimalne zależności Python i zero narzędzi build. Zweryfikowano na podstawie lokalnego spisu treści, app/i18n/config.py oraz requirements.txt

  2. Google PageSpeed Insights (pagespeed.web.dev) uruchamia audyty Lighthouse dla dowolnego publicznego URL. Według stanu z marca 2026 roku blakecrosley.com uzyskuje wynik 100/100/100/100 (Performance, Accessibility, Best Practices, SEO). Wyniki można zweryfikować publicznie. Pełną drogę optymalizacji opisuje Od 76 do 100: osiągnięcie idealnego wyniku Lighthouse

  3. Świeże npx create-next-app@latest (Next.js 15, testowane w lutym 2026 roku) instaluje 311 pakietów w node_modules/ o łącznym rozmiarze 187 MB. Projekty produkcyjne z dodatkowymi zależnościami zwykle rosną bardziej. Poszczególne projekty mogą się różnić. Źródło: testy autora, udokumentowane w The No-Build Manifesto

  4. Dokumentacja wydajności Vercel dla Next.js zaleca konkretne optymalizacje (optymalizację obrazów, ładowanie fontów, dzielenie kodu), aby osiągnąć wyniki powyżej 90. Zob. nextjs.org/docs/app/building-your-application/optimizing. Zakres 70-90 odzwierciedla ustawienia domyślne przed zastosowaniem tych optymalizacji. 

  5. Pełną listę zależności zweryfikowano na podstawie requirements.txt blakecrosley.com według stanu z maja 2026 roku. Plik ma obecnie 17 wpisów wymagań Python oraz zero narzędzi build, kompilatorów lub bundlerów. 

  6. Na podstawie doświadczenia autora w utrzymywaniu projektów Next.js (2021-2024) ekosystem JavaScript generuje 15-25 PR-ów Dependabot miesięcznie w aktywnych projektach, przy czym większość aktualizuje zależności przechodnie, których developer nigdy nie importował bezpośrednio. 

  7. Tim Berners-Lee sformułował kompatybilność wsteczną jako zasadę projektowania sieci: „a browser should be backwards-compatible”. Strona z 1996 roku renderuje się w Chrome 2026. Zob. w3.org/DesignIssues/Principles

  8. OWASP zaleca wyłączanie endpointów dokumentacji API w produkcji, aby ograniczyć powierzchnię ataku. Endpoint /openapi.json ujawnia wszystkie definicje tras, parametry i modele odpowiedzi. 

  9. Dokumentacja FastAPI dotycząca handlerów async i sync: fastapi.tiangolo.com/async/. Łączenie await z blokującymi wywołaniami w funkcjach async zagładza pętlę zdarzeń. 

  10. nh3 to oparty na Rust sanitizer HTML, następca biblioteki Bleach. Jest utrzymywany przez projekt PyO3 i zapewnia sanityzację HTML opartą na allowlist. Zob. github.com/messense/nh3

  11. Nagłówek Vary zdefiniowano w RFC 9110, sekcja 12.5.5. Instruuje cache, aby przechowywał oddzielne odpowiedzi na podstawie określonych wartości nagłówków żądania. Bez Vary: HX-Request CDN mógłby zwrócić fragment HTMX jako odpowiedź pełnej strony. Zob. httpwg.org/specs/rfc9110.html#field.vary

  12. CSS Custom Properties (CSS Variables) są obsługiwane przez ponad 97% globalnych przeglądarek. Kaskadują, dziedziczą wartości i reagują na media queries w czasie działania — to możliwości, których nie mają zmienne preprocesorów. Źródło: caniuse.com/css-variables

  13. Dokumentacja hreflang Google: developers.google.com/search/docs/specialty/international/localized-versions. Wartość x-default wskazuje stronę zapasową dla użytkowników, których języka nie ma na liście hreflang. 

  14. Alpine.js wymaga 'unsafe-eval' w Content Security Policy dla swojego silnika ewaluacji wyrażeń. Build zgodny z CSP (@alpinejs/csp) omija to wymaganie, ale ma ograniczenia. Zob. alpinejs.dev/advanced/csp

  15. Tokeny CSRF oparte na HMAC stosują wzorzec „Signed Double-Submit Cookie” opisany w OWASP CSRF Prevention Cheat Sheet. hmac.compare_digest używa porównania w czasie stałym, aby zapobiegać atakom kanałem bocznym opartym na czasie. Zob. cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html

  16. WebP zapewnia pliki o 25-35% mniejsze niż JPEG przy równoważnej jakości wizualnej. Badanie WebP Google: developers.google.com/speed/webp/docs/webp_study

  17. 103 Early Hints pozwala serwerowi (lub CDN) wysłać wstępną odpowiedź ze wskazówkami preload, zanim gotowa będzie odpowiedź końcowa. Cloudflare obsługuje Early Hints dla nagłówków Link z rel=preload. Zob. developer.chrome.com/blog/early-hints

  18. React 18 + ReactDOM waży około 42 KB po minifikacji i gzip. Z routerem, biblioteką zarządzania stanem oraz runtime frameworka build typowe aplikacje React wysyłają 100-300 KB frameworkowego JavaScript. Źródło: bundlephobia.com/package/[email protected]

  19. Polityka wersjonowania HTMX i zobowiązanie do kompatybilności wstecznej są udokumentowane na htmx.org/migration-guide-htmx-1/. Carson Gross sformułował zasadę kompatybilności wstecznej w Hypermedia Systems (2023) autorstwa Grossa, Stepinskiego i Cottera: hypermedia.systems

  20. Kontrola utrzymaniowa z 15 maja 2026 roku. FastAPI PyPI i informacje o wydaniach podają wersję 0.136.1; lokalna weryfikacja importu zwróciła FastAPI 0.128.0 i Starlette 0.50.0 dla środowiska tej witryny; htmx.org podaje 2.0.10 w quick start; npm view htmx.org version dist-tags zwróciło latest=2.0.10 i next=4.0.0-beta3; npm view alpinejs version oraz npm view @alpinejs/csp version zwróciły 3.15.12; oficjalny blog Bootstrap i metadane pakietu npm podają 5.3.8; SQLAlchemy PyPI i dokumentacja podają 2.0.49; Pydantic PyPI podaje 2.13.4. 

  21. Metadane pakietu htmx 4.0.0-beta3 podawały publikację 8 maja 2026 roku, a npm next wskazywał 4.0.0-beta3; npm latest pozostawał przy 2.0.10. Dokumentacja 4.0 na four.htmx.org pokazywała [email protected], indeks rozszerzeń 4.0 wymieniał hx-live i hx-nonce, a przewodnik migracji 4.0 dokumentował zmiany migracyjne, które należy przejrzeć przed przeniesieniem aplikacji produkcyjnych z 2.x. Zastąpione w śledzeniu najnowszej linii przez 22

  22. Kontrola utrzymaniowa z 24 maja 2026 roku. Lokalne polecenia inwentaryzacyjne zwróciły 210 wpisów blogowych Markdown, 11 plików przewodników najwyższego poziomu i 48 plików studiów projektowych. FastAPI informacje o wydaniu podają 0.136.3 z 2026-05-23 ze ściślejszą obsługą nagłówków z podkreśleniami, gdy convert_underscores=True; 0.136.2 waliduje pola Server-Sent Event. python3 -m pip index versions fastapi zwróciło najnowszą wersję 0.136.3; python3 -m pip index versions sqlalchemy zwróciło najnowszą wersję 2.0.50; python3 -m pip index versions pydantic zwróciło najnowszą wersję 2.13.4. npm view htmx.org dist-tags version time.modified --json zwróciło latest=2.0.10, next=4.0.0-beta4 oraz time.modified=2026-05-22T15:56:21.948Z; dokumentacja instalacji four.htmx.org pokazuje [email protected]

  23. Changelog SQLAlchemy 2.0.50 i wpis o wydaniu, wydane 2026-05-24. Zależność asyncio greenlet nie instaluje się już domyślnie; aby ją pobrać, wymagany jest teraz cel instalacji sqlalchemy[asyncio]. 2.0.50 porzuca też Python 3.7/3.8/3.9 (teraz 3.10+), dodaje koła Python free-threaded i parametr ramki okna over(..., exclude=...). Najnowszą wersję zweryfikowano na PyPI według stanu na 2026-06-08. htmx 4.0.0-beta4 („The Fetchening”, 2026-05-22) pozostaje wersją beta ze stabilnym celem na początek 2027 roku; FastAPI 0.136.3 (2026-05-23), Alpine.js 3.15.12 i Bootstrap 5.3.x pozostają w tym okresie bez zmian. 

  24. FastAPI informacje o wydaniach: 0.137.0 (2026-06-14) refaktoryzuje wewnętrzne mechanizmy routera, przez co router.routes nie jest już płaską listą obiektów APIRoute, lecz drzewem obiektów pośrednich (należy traktować to jako wewnętrzne); umożliwia też dodawanie tras po include_router(), w tym sub-routera przed zdefiniowaniem jego tras, unika kopiowania tras oraz dodaje APIRouter.matches()/.handle(); przypina Starlette 1.3.1. 0.137.1 (2026-06-15) naprawia typowanie APIRoute i pustą ścieżkę w routerze bez prefiksu. Starlette informacje o wydaniach: 1.0.0 (2026-03-22), pierwsze stabilne wydanie od około 8 lat, usunęło on_startup/on_shutdown/on_event() oraz dekoratory @app.route()/@app.websocket_route() (należy używać lifespan i Route/WebSocketRoute); najnowsza wersja to 1.3.1 (2026-06-12). SQLAlchemy 2.0.51 (changelog, 2026-06-15) zawiera wyłącznie poprawki błędów, bez wpływu na async ani instalację. Zweryfikowano przez PyPI i oficjalne informacje o wydaniach 2026-06-16. 

  25. FastAPI informacje o wydaniach: 0.138.0 (2026-06-20) dodaje app.frontend("/", directory="dist") i router.frontend("/", directory="dist") do serwowania zbudowanego statycznego frontendu (PR #15800; dokumentacja Frontend) — funkcję serwowania statycznego SPA z dist/, a nie wzorzec renderowany po stronie serwera; brak zmiany łamiącej kompatybilność. 0.137.2 (2026-06-18) dodaje iter_route_contexts() do zaawansowanego użycia, które wcześniej przechodziło po router.routes (wewnętrzne od 0.137.0); brak zmiany łamiącej kompatybilność. Brak wydania nowszego niż 0.138.0 według stanu na 2026-06-22. Starlette 1.3.1, Pydantic 2.13.4, Uvicorn 0.49.0, SQLAlchemy 2.0.51, HTMX 2.0.10, Alpine.js 3.15.12, Bootstrap 5.3.8 pozostają bez zmian. Zweryfikowano przez PyPI i oficjalne informacje o wydaniach 2026-06-22. 

NORMAL fastapi-htmx.md EOF