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

FastAPI + HTMX: The No-Build Full-Stack

# FastAPI + HTMX: The No-Build Full-Stack

words: 7121 read_time: 27m updated: 2026-04-11 17:13
$ less fastapi-htmx.md

TL;DR: FastAPI + HTMX + Alpine.js + Jinja2 + zwykły CSS umożliwia tworzenie produkcyjnych aplikacji webowych bez narzędzi do budowania, bez node_modules/ i z perfekcyjnymi wynikami Lighthouse. Niniejszy przewodnik obejmuje cały system — od architektury po wdrożenie — wykorzystując blakecrosley.com jako produkcyjny przykład obsługujący ponad 100 wpisów blogowych, interaktywne komponenty JavaScript, liczne kompleksowe przewodniki oraz tłumaczenia na dziewięć języków — wszystko bez żadnego bundlera, kompilatora ani transpilera.1

Współczesny stos technologiczny web developmentu zakłada, że potrzebny jest React, webpack, TypeScript i pipeline do budowania. Dla szerokiej kategorii aplikacji — witryn 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 frontend’owy łańcuch narzędzi do budowania, jednocześnie generując witryny z wynikiem 100/100/100/100 w Lighthouse.2

To nie jest agitacja. To pomiar. Opisana tu architektura działa w środowisku produkcyjnym, obsługuje rzeczywistych użytkowników w dziesięciu językach, a wyniki można zweryfikować.


Kluczowe wnioski

  • Renderowany po stronie serwera HTML eliminuje trzy całe kategorie problemów: zarządzanie stanem po stronie klienta, granice serializacji JSON oraz rozbieżności hydratacji. HTMX sprawia, że odpowiedzi serwera stanowią końcowy wynik — nie ma dodatkowego etapu renderowania po stronie klienta.
  • Brak narzędzi budowania oznacza brak błędów budowania. Żadnych konfliktów zależności npm install, żadnych błędów kompilatora TypeScript w plikach, których się nie modyfikowało, żadnych PR-ów Dependabota dla tranzytywnych zależności, których nigdy się nie importowało. Pipeline wdrożenia to git push.
  • Alpine.js obsługuje stan wyłącznie po stronie klienta, którego HTMX nie jest w stanie obsłużyć. Rozwijane menu, okna modalne, przełączniki nawigacji mobilnej i każdy stan UI istniejący wyłącznie w przeglądarce należą do Alpine.js. Granica jest czytelna: jeśli stan wymaga serwera, należy użyć HTMX. Jeśli nie — 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 wykonania. Zmienne preprocesorów kompilują się do wartości statycznych i znikają. Przeglądarka odczytuje właściwości niestandardowe bezpośrednio — bez etapu kompilacji.
  • To podejście ma wyraźne granice. Nie sprawdzi się w dużych zespołach współdzielących interfejsy komponentów, produktach SaaS ze złożonym stanem po stronie klienta ani w aplikacjach zależnych od bibliotek ekosystemu npm. Ramka decyzyjna w sekcji 15 precyzyjnie określa tę granicę.
  • blakecrosley.com jest tego dowodem. Główne wzorce opisane w tym przewodniku (HTMX, Alpine.js, Jinja2, zwykły CSS) działają produkcyjnie na blakecrosley.com. Sekcje dotyczące Bootstrap i SQLAlchemy opisują standardowe wzorce dla tego stosu, które nie są wykorzystywane na tej konkretnej stronie. Każde twierdzenie posiada ścieżkę pliku, blok konfiguracji lub audyt Lighthouse, który można samodzielnie zweryfikować na PageSpeed Insights.2

Jak korzystać z tego przewodnika

To kompleksowe kompendium. Warto zacząć od miejsca odpowiadającego poziomowi doświadczenia:

Doświadczenie Zacznij tutaj Następnie poznaj
Programista Python, nowy w HTMX Teza no-buildPrzegląd architekturyHTMX — szczegółowe omówienie Wzorce Alpine.js, Bezpieczeństwo
Programista React/Vue oceniający alternatywy Teza no-buildRamka decyzyjna Przegląd architektury, Wydajność
Programista FastAPI dodający interaktywność HTMX — szczegółowe omówienieWzorce Alpine.js i18n i lokalizacja, Wdrożenie
Programista full-stack budujący od podstaw Czytaj sekwencyjnie od Przegląd architektury Karta szybkiego dostępu do bieżącego użytku

Ctrl+F / Cmd+F umożliwia wyszukiwanie konkretnych wzorców lub atrybutów. Karta szybkiego dostępu na końcu stanowi skondensowane podsumowanie.


Teza no-build

Teza jest wąska i konkretna: w przypadku stron opartych na treści z jednym programistą lub małym zespołem, narzędzia budowania rozwiązują problemy, których nie ma, jednocześnie tworząc problemy, które się pojawiają.

Oto rzeczywiste metryki z blakecrosley.com:

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

15 pakietów Python obejmuje FastAPI, Jinja2, Pydantic, uvicorn, nh3 i 10 innych. Żaden z nich nie jest narzędziem budowania. Żaden nie jest kompilatorem. Żaden nie jest bundlerem.5

Czego się rezygnuje

Uczciwość wymaga wymienienia rzeczywistych kosztów:

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

Brak Hot Module Replacement. Zmiany CSS wymagają ręcznego odświeżenia przeglądarki. hx-boost w HTMX sprawia, że nawigacja jest na tyle szybka, iż pełne odświeżenie jest tolerowalne, ale przy intensywnej iteracji wizualnej HMR oszczędza czas.

Brak Tree Shaking. Każdy bajt napisanego JavaScript trafia do przeglądarki. To ograniczenie wymusza dyscyplinę: małe, skoncentrowane pliki zamiast rozbudowanych modułów narzędziowych.

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

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

Te kompromisy są akceptowalne w przypadku strony opartej na treści z jednym do trzech programistów. Byłyby nieakceptowalne dla produktu SaaS z 15-osobowym zespołem inżynierskim. Sekcja 15 zawiera ramkę decyzyjną.

Co się zyskuje

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

Debugowanie przez View Source. JavaScript działający w przeglądarce to JavaScript, który się napisało. Mapy źródłowe nie są potrzebne.

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 gzip), jeden plik CSS (~8KB), HTMX (~14KB, z cache), Alpine.js (~14KB, z cache) oraz interaktywny JS strony (~4-8KB). Łącznie: 45-60KB przy pierwszej wizycie.1

Przyszłościowy frontend. Kod po stronie klienta wykorzystuje HTML, CSS i JavaScript — standardy, które utrzymują wsteczną kompatybilność od 30 lat.7 Żadnej migracji Webpack 4 → 5, żadnego wycofania Create React App, żadnej migracji 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 32-46KB (HTMX+Alpine) 85-250KB+ (runtime React) 0KB domyślnie, opt-in islands 0KB domyślnie
Etap budowania Brak Wymagany (webpack/turbopack) Wymagany (Vite) Wymagany (niestandardowy)
Pliki konfiguracji 0 5-8 (next.config, tsconfig itp.) 1-3 (astro.config, tsconfig) 1-2 (.eleventy.js)
Pipeline wdrożenia git push (40s) Instalacja+budowanie+wdrożenie (2-5 min) Instalacja+budowanie+wdrożenie (1-3 min) Instalacja+budowanie+wdrożenie (1-2 min)
Interaktywność po stronie serwera Natywna (HTMX) Trasy API + fetch po stronie klienta Ograniczona (akcje formularzy) Brak (wyjście statyczne)
Zarządzanie stanem klienta Alpine.js (15KB) Stan React/context/Redux Wyspy frameworkowe 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
Lighthouse Performance 100 (zmierzone) 70-90 typowo4 95-100 typowo 95-100 typowo
Najlepsze dla Strony treściowe, CRUD, dashboardy Złożone SPA, duże zespoły Strony treściowe, marketing Statyczne blogi, dokumentacja

Astro i 11ty to najbliżsi konkurenci w kategorii stron treściowych. Oba generują doskonałe wyjście statyczne, ale wymagają etapu budowania i toolchaina JavaScript. Stos FastAPI+HTMX wymienia wydajność strony statycznej na interaktywność po stronie serwera (filtrowanie kategorii, obsługa formularzy, wyszukiwanie w czasie rzeczywistym) bez dodawania etapu budowania. Jeśli strona jest czysto statyczna i nie wymaga interakcji z serwerem, Astro lub 11ty mogą być 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 jest inicjalizowana w pliku main.py z jawnie określoną 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)

Trzy decyzje projektowe mają tu szczególne znaczenie. Po pierwsze, docs_url=None i openapi_url=None wyłączają automatyczne endpointy dokumentacji API. Publiczna strona z treścią nie potrzebuje udostępnionych w internecie ścieżek /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, w tym te odrzucone przez limiter zapytań. Po trzecie, GZipMiddleware kompresuje wszystkie odpowiedzi powyżej 500 bajtów, co zazwyczaj zmniejsza rozmiar transferu HTML o 70–80%.

Routing

Trasy dzielą się na dwie kategorie: trasy stron zwracają pełne dokumenty HTML, a trasy API zwracają JSON lub fragmenty 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 typy — nie ma osobnej warstwy API.

Wstrzykiwanie zależności

System Depends() w FastAPI zapewnia czyste rozdzielenie 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 komponować. Zależność get_db może zależeć od get_current_locale, która z kolei zależy od żądania. FastAPI rozwiązuje ten łańcuch automatycznie.

Ustawienia Pydantic

Konfiguracja wykorzystuje BaseSettings z Pydantic z priorytetem 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 środowisku produkcyjnym (Railway) sekrety są ustawiane jako zmienne środowiskowe. Lokalnie plik .env dostarcza wartości domyślne. Klasa Settings waliduje typy przy starcie — brakujące wymagane pole powoduje natychmiastowy błąd zamiast awarii w trakcie działania.

Wzorce asynchroniczne

Trasy FastAPI są domyślnie asynchroniczne. W przypadku operacji ograniczonych przez I/O (zapytania do bazy danych, żądania HTTP, odczyt plików) asynchroniczność 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)

Operacje ograniczone przez CPU (renderowanie Markdown, ekstrakcja CSS) mogą korzystać z funkcji synchronicznych. FastAPI uruchamia je automatycznie 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 prosta: jeśli funkcja oczekuje na operację I/O, należy użyć async. Jeśli wykonuje obliczenia CPU, powinna pozostać synchroniczna. 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 bazodanowe

Uwaga: blakecrosley.com wykorzystuje Cloudflare D1 (bezserwerowe SQLite) poprzez HTTP do przechowywania wszystkich danych, a nie SQLAlchemy. Ta sekcja opisuje standardowy asynchroniczny wzorzec SQLAlchemy dla projektów FastAPI, które potrzebują relacyjnej bazy danych — najczęstszą konfigurację produkcyjną dla tego stosu technologicznego.

SQLAlchemy 2.0 Async

Dla aplikacji wymagających relacyjnej bazy danych, asynchroniczne wsparcie SQLAlchemy 2.0 integruje się płynnie 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

Wstrzykiwanie zależności dla sesji bazodanowych

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 handlera trasy, zatwierdza przy sukcesie i wycofuje przy wyjątku. Każda operacja bazodanowa wykorzystuje zapytania parametryzowane — nigdy interpolację 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 handler trasy zostanie wykonany. Nieprawidłowe dane wejściowe automatycznie zwracają odpowiedź 422. Zastępuje to biblioteki walidacji formularzy po stronie klienta — serwer waliduje, a HTMX podmienia albo komunikat o sukcesie, albo informację 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 autogeneracji porównuje modele SQLAlchemy z aktualnym schematem bazy danych i generuje skrypty migracji. Skrypty te są wersjonowanymi plikami 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 uruchamiane są 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), więc migracje Alembic dotyczą lokalnej bazy SQLite lub PostgreSQL używanej do danych sesyjnych i analityki.

Wzorzec Cloudflare D1

blakecrosley.com wykorzystuje Cloudflare D1 jako zdalną bazę danych dostępną poprzez 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 bazodanowym. D1 to SQLite na krawędzi sieci Cloudflare, dostępne przez HTTP. Proxy Worker obsługuje uwierzytelnianie i ograniczanie liczby żądań. Kompromisem jest opóźnienie: każde zapytanie to żądanie HTTP (~50–100 ms) w porównaniu z lokalnym połączeniem bazodanowym (~1–5 ms). Pamięć podręczna ładowana przy starcie aplikacji łagodzi ten problem dla obciążeń z przewagą odczytów, takich jak tłumaczenia.


Bezpieczeństwo

Middleware nagłówków bezpieczeństwa

blakecrosley.com implementuje wzmocnione nagłówki bezpieczeństwa za pomocą niestandardowego 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 build Alpine.js kompatybilny z CSP, który ma pewne ograniczenia.14 Każda inna funkcja jest zablokowana: frame-ancestors zapobiega clickjackingowi, form-action ogranicza wysyłanie formularzy do tej samej domeny, a upgrade-insecure-requests wymusza HTTPS.

Bezpieczeństwo cache 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 fragment odpowiedzi HTMX i serwować go jako pełną stronę dla żądania bez HTMX (lub odwrotnie). Nagłówek Vary informuje CDN, że należy przechowywać osobne wpisy cache w zależności od wartości nagłówka HX-Request.11

Ochrona przed CSRF

Formularze HTMX wykorzystują bezstanowe tokeny CSRF podpisane 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 generowany jest w szablonie za pomocą globalnej funkcji 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ą konieczność przechowywania sesji po stronie serwera. Podpis HMAC gwarantuje, że token został wygenerowany przez serwer. Znacznik czasowy zapobiega atakom powtórzenia. Funkcja hmac.compare_digest chroni przed atakami czasowymi.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 obrona jest niezależna od CSP — zapobiega przechowywanym atakom XSS na warstwie renderowania, podczas gdy CSP blokuje wstrzyknięte skrypty na warstwie przeglądarki. Obrona w głąb.

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


Wydajność

Lighthouse 100/100/100/100

blakecrosley.com uzyskuje wynik 100 we wszystkich czterech kategoriach Lighthouse: Wydajność, Dostępność, Najlepsze praktyki i SEO. Można to zweryfikować na PageSpeed Insights.2

Kluczowe optymalizacje:

Strategia ładowania CSS

blakecrosley.com ładuje CSS za pomocą pojedynczego tagu <link> i adresów URL z hashem zawartości dla niezmiennego buforowania:

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

Helper asset() dodaje hash zawartości (?v=a3b2c1d4), dzięki czemu przeglądarka buforuje plik bezterminowo, aż do zmiany zawartości. Bez ekstrakcji krytycznego CSS, bez sztuczki z mediami druku, bez ładowania opartego na JavaScript. Plik CSS ma ~8KB po kompresji gzip — jest wystarczająco mały, aby podejście z pojedynczym żądaniem uzyskało wynik 100 w Lighthouse Performance bez gimnastyki optymalizacyjnej.

Kompresja GZip

app.add_middleware(GZipMiddleware, minimum_size=500)

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

Niezmienne buforowanie zasobów statycznych

# 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 zawartości (?v=a3f8b2c1d0) są buforowane na rok z flagą immutable. Hash zmienia się wraz ze zmianą pliku, wymuszając pobranie nowej wersji przez przeglądarki i CDN.

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 asynchronicznego ładowania i zarządzania kolejnością wykonywania.

Optymalizacja obrazów

Obrazy wykorzystują format 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ą skumulowanemu przesunięciu układu (Cumulative Layout Shift, CLS). Atrybut loading="lazy" odracza ładowanie obrazów poza ekranem. WebP zapewnia 25-35% mniejsze pliki niż JPEG przy porównywalnej 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 o konieczności wysłania odpowiedzi 103 Early Hints, umożliwiając przeglądarce rozpoczęcie pobierania CSS jeszcze przed zakończeniem generowania odpowiedzi HTML przez serwer.17

Minimalny JavaScript

Całkowity rozmiar JavaScript:

Biblioteka Rozmiar (zminifikowany + gzip)
HTMX ~14 KB
Alpine.js ~14 KB
JS specyficzny dla strony 4-8 KB
Łącznie 32-36 KB

Typowa aplikacja React dostarcza 100-300 KB kodu JavaScript samego frameworka, zanim doliczy się kod aplikacji.18 Podejście bez budowania dostarcza mniej JavaScript, ponieważ po prostu jest mniej JavaScript do dostarczenia.


Wdrożenie

Railway

blakecrosley.com jest wdrażany na Railway za pomocą 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 Railway wykrywa projekt Python na podstawie requirements.txt, instaluje zależności i uruchamia polecenie startowe. Nie jest wymagany Dockerfile. Endpoint sprawdzania kondycji zapewnia, że aplikacja odpowiada przed przekierowaniem 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. Jedynym krokiem instalacji jest pip install -r requirements.txt, który jest buforowany między wdrożeniami.

Procfile

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

Procfile stanowi alternatywę kompatybilną z Heroku. Railway obsługuje zarówno railway.toml, jak i Procfile. Składnia ${PORT:-8000} używa portu udostępnionego przez platformę lub domyślnie portu 8000 do lokalnego rozwoju.

Konfiguracja produkcyjna Uvicorn

Dla wdrożeń o większym ruchu warto 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 zasada: 2 * liczba rdzeni CPU + 1)
  • --loop uvloop używa szybszej pętli zdarzeń uvloop (zamiennik asyncio typu drop-in)
  • --http httptools używa szybszego parsera HTTP httptools

Do rozwoju lokalnego --reload obserwuje zmiany w plikach:

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

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

Szczupły obraz bazowy utrzymuje mały rozmiar kontenera. Flaga --no-cache-dir zapobiega przechowywaniu pobranych pakietów przez pip w warstwie obrazu.

CDN Cloudflare

blakecrosley.com wykorzystuje Cloudflare jako CDN do buforowania, 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 buforuje przez 5 minut
  • s-maxage=3600 — CDN buforuje przez 1 godzinę
  • stale-while-revalidate=86400 — serwuje nieaktualne treści podczas rewalidacji przez 24 godziny

Zasoby statyczne otrzymują max-age=31536000, immutable, ponieważ adresy URL z hashem zawartości gwarantują aktualność.


Schemat decyzyjny

Czy potrzebujesz narzędzi budowania?

Należy odpowiedzieć na cztery pytania:

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

2. Czy aplikacja zarządza złożonym stanem po stronie klienta? Jeśli przeciągnij-i-upuść, współpraca w czasie rzeczywistym lub dane offline-first stanowią podstawowe funkcje (a nie miły dodatek), framework taki jak React czy Svelte uzasadnia swoją złożoność. Warto dodać krok budowania.

3. Czy wiele produktów korzysta ze współdzielonej biblioteki komponentów? Jeśli tak, biblioteka ta wymaga pakietowania npm, wersjonowania semantycznego i tree shaking. Warto dodać krok budowania.

4. Czy zależysz od bibliotek ekosystemu npm zakładających obecność bundlera? Jeśli Radix, Framer Motion, TanStack Query lub podobne biblioteki stanowią rdzeń produktu, pipeline budowania jest niezbędny.

Jeśli wszystkie cztery odpowiedzi brzmią „nie”, podejście bez budowania jest wykonalne. Jeśli którakolwiek odpowiedź brzmi „tak”, narzędzia budowania rozwiązują realny problem. Błędem jest dodawanie narzędzi budowania, gdy wszystkie cztery odpowiedzi brzmią „nie” — rozwiązujesz wtedy problemy, których nie masz, jednocześnie tworząc narzut związany z zarządzaniem zależnościami.1

Porównanie stosów technologicznych

Kategoria Bez budowania (ten przewodnik) React + narzędzia budowania
Najlepsze dla Strony treściowe, portfolio, narzędzia wewnętrzne, aplikacje CRUD Produkty SaaS, złożone SPA, konsumenci systemów projektowych
Wielkość zespołu 1-5 programistów 5-50+ programistów
Zarządzanie stanem Serwer (HTMX) + klient (Alpine.js) Klient (stan React, Redux, Zustand)
Bezpieczeństwo typów W czasie wykonywania (Pydantic po stronie serwera) W czasie kompilacji (TypeScript)
Reużywalność komponentów Includy i makra Jinja2 Pakiety npm, współdzielone biblioteki
SEO Renderowanie serwerowe domyślnie Wymaga konfiguracji SSR/SSG
Minimalna wydajność Wysoka (minimalny JS, renderowanie serwerowe) Zmienna (narzut frameworka)
Pułap złożoności Niższy (bez trybu offline, bez bogatego stanu klienta) Wyższy (dowolna interakcja kliencka możliwa)
Zależności 15 pakietów Python 300+ pakietów npm
Czas budowania 0 sekund 15-60 sekund

Kiedy HTMX to złe rozwiązanie

HTMX zastępuje stan klienta komunikacją z serwerem. Działa to do momentu, gdy opóźnienie ma znaczenie:

  • Interfejsy przeciągnij-i-upuść — 200ms opóźnienia 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 budowania nie próbuje ich zastąpić.


Karta szybkiego odniesienia

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

Najczęściej zadawane pytania

Czy HTMX nadaje się do produkcyjnych aplikacji webowych?

Tak. HTMX jest stabilny od 2020 roku i wykorzystywany produkcyjnie w wielu branżach. Carson Gross, twórca biblioteki, traktuje wsteczną kompatybilność jako fundamentalną zasadę projektową — dokumentacja HTMX stwierdza, że biblioteka nie będzie łamać istniejących aplikacji w ramach danej wersji głównej.19 Biblioteka ma 14 KB w wersji zminifikowanej i skompresowanej gzip, nie posiada żadnych zależności i stosuje wersjonowanie semantyczne. Serwis blakecrosley.com działa na HTMX w produkcji od trzech lat bez ani jednego błędu związanego z HTMX.

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

Częściowo. Pliki TypeScript można sprawdzać typami za pomocą tsc --noEmit bez generowania plików wyjściowych, co zapewnia weryfikację typów na etapie kompilacji w roli lintera. Przeglądarki nie potrafią jednak bezpośrednio wykonywać plików .ts, więc etap budowania nadal jest konieczny do ich serwowania. Alternatywą są adnotacje typów JSDoc w zwykłych plikach .js, które TypeScript potrafi weryfikować bez kompilacji. Daje to bezpieczeństwo typów podczas programowania przy jednoczesnym dostarczaniu standardowego JavaScript.

Jak to podejście wypada na tle Astro lub 11ty?

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

A co z renderowaniem po stronie serwera (SSR) w React?

Next.js SSR i podejście FastAPI + HTMX mają wspólny cel: wysłanie wyrenderowanego na serwerze HTML do przeglądarki. Różnica polega na tym, co dzieje się po początkowym renderowaniu. Next.js hydratyzuje stronę za pomocą React, dostarczając do klienta środowisko uruchomieniowe frameworka oraz kod komponentów. FastAPI + HTMX nie przeprowadza hydracji — HTML jest ostatecznym wynikiem. HTMX obsługuje kolejne interakcje, żądając nowych fragmentów HTML z serwera. Rezultat: FastAPI + HTMX dostarcza łącznie 30–40 KB JavaScript, w porównaniu ze 100–300 KB w przypadku aplikacji Next.js.18

Jak obsługiwać walidację formularzy w tym stosie technologicznym?

Po stronie serwera. Pydantic waliduje dane wejściowe w momencie przesłania 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. Żadna biblioteka walidacji po stronie klienta nie jest potrzebna. Atrybut HTML required zapewnia podstawową walidację na poziomie przeglądarki jako pierwszą linię obrony.

Czy mogę dodać funkcje czasu rzeczywistego (WebSocket)?

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 posiada rozszerzenie WebSocket (hx-ws), które łączy elementy z punktami końcowymi 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:...". HTMX 2.x przeniósł obsługę WebSocket do osobnego rozszerzenia (htmx-ext-ws) z atrybutami ws-connect i ws-send przedstawionymi powyżej. W przypadku HTMX 1.x stara składnia hx-ws nadal działa.

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

Jak ten stos radzi sobie z SEO?

HTML renderowany po stronie serwera jest z natury przyjazny SEO, ponieważ roboty indeksujące otrzymują pełną zawartość strony bez konieczności wykonywania JavaScript. Serwis blakecrosley.com dodaje kilka warstw SEO:

  • Dane strukturalne JSON-LD w <head> na każdej stronie (schematy Person, Article, WebSite, FAQPage)
  • Dynamiczna mapa witryny z alternates hreflang dla wszystkich 10 lokalizacji
  • Kanał RSS pod adresem /blog/feed.xml
  • llms.txt w katalogu głównym dla wykrywalności przez roboty AI
  • Kanoniczne adresy URL i tagi Open Graph w szablonie bazowym
  • Semantyczny HTML: <article>, <section>, <main>, prawidłowa hierarchia nagłówków

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

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

Dla programistów Python krzywa uczenia się jest znacznie łagodniejsza. Język jest już znany. Handlery tras FastAPI zwracają odpowiedzi szablonowe — ten sam model mentalny co widoki we Flask lub Django. HTMX dodaje garść atrybutów HTML (hx-get, hx-target, hx-swap). Alpine.js dodaje kilka kolejnych (x-data, x-show, @click). Nie ma JSX, wirtualnego DOM, systemu hooków, biblioteki zarządzania stanem ani konfiguracji narzędzi budowania do nauki.

Dokumentacja HTMX mieści się na jednej długiej stronie. Dokumentacja Alpine.js zajmuje kilka stron. Dokumentacja React obejmuje setki stron dotyczących hooków, kontekstu, refów, efektów, suspense, komponentów serwerowych i streamingowego SSR.

Dla programistów JavaScript/React zmiana ma charakter koncepcyjny, nie składniowy. Kluczowa idea polega na tym, że serwer jest właścicielem stanu i 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 jest właścicielem renderowania.


Historia zmian

Data Zmiana
24 marca 2026 Pierwsza publikacja

Przypisy


Niniejszy poradnik obejmuje kompletny system wykorzystany do budowy blakecrosley.com. No-Build Manifesto przedstawia argumentację filozoficzną. Wpis Lighthouse Perfect Score dokumentuje proces optymalizacji wydajności. Artykuł Vibe Coding vs. Engineering analizuje, jakie miejsce w tym procesie zajmuje programowanie wspierane przez AI.


  1. Dane produkcyjne blakecrosley.com na kwiecień 2026. Witryna obsługuje ponad 100 wpisów blogowych, interaktywne komponenty JavaScript, 9 obszernych poradników oraz tłumaczenia na 9 języków — przy minimalnych zależnościach Python i zerowej liczbie narzędzi budowania. Zweryfikowano na podstawie działającej witryny oraz pliku requirements.txt

  2. Google PageSpeed Insights (pagespeed.web.dev) uruchamia audyty Lighthouse dla dowolnego publicznego adresu URL. blakecrosley.com uzyskuje wynik 100/100/100/100 (Wydajność, Dostępność, Dobre praktyki, SEO) na marzec 2026. Wyniki można zweryfikować publicznie. Szczegóły w artykule From 76 to 100: Achieving a Perfect Lighthouse Score, opisującym pełną ścieżkę optymalizacji. 

  3. Świeże wywołanie npx create-next-app@latest (Next.js 15, testowane w lutym 2026) instaluje 311 pakietów w node_modules/ o łącznym rozmiarze 187 MB. Projekty produkcyjne z dodatkowymi zależnościami mają tendencję wzrostową. Wyniki mogą się różnić w zależności od projektu. Źródło: testy autora, udokumentowane w The No-Build Manifesto

  4. Dokumentacja wydajności Next.js firmy Vercel zaleca określone optymalizacje (optymalizacja obrazów, ładowanie czcionek, dzielenie kodu) w celu osiągnięcia wyników 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łna lista zależności zweryfikowana na podstawie pliku requirements.txt witryny blakecrosley.com na marzec 2026. Zero pakietów stanowi narzędzia budowania, kompilatory lub bundlery. 

  6. Na podstawie doświadczenia autora w utrzymywaniu projektów Next.js (2021–2024), ekosystem JavaScript generuje 15–25 zgłoszeń Dependabot miesięcznie dla aktywnych projektów, z których większość dotyczy aktualizacji zależności przejściowych, których programista nigdy bezpośrednio nie importował. 

  7. Tim Berners-Lee sformułował wsteczną kompatybilność jako zasadę projektowania sieci: „przeglądarka powinna być wstecznie kompatybilna”. Strona z 1996 roku renderuje się w Chrome 2026. Zob. w3.org/DesignIssues/Principles

  8. OWASP zaleca wyłączanie punktów końcowych dokumentacji API w środowisku produkcyjnym w celu zmniejszenia powierzchni ataku. Punkt końcowy /openapi.json ujawnia wszystkie definicje tras, parametry i modele odpowiedzi. 

  9. Dokumentacja FastAPI dotycząca asynchronicznych i synchronicznych procedur obsługi: fastapi.tiangolo.com/async/. Mieszanie await z blokującymi wywołaniami w funkcjach async wyczerpuje 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 listach dozwolonych elementów. Zob. github.com/messense/nh3

  11. Nagłówek Vary jest zdefiniowany w RFC 9110, sekcja 12.5.5. Instruuje pamięć podręczną, aby przechowywała osobne odpowiedzi na podstawie określonych wartości nagłówków żądania. Bez Vary: HX-Request sieć CDN mogłaby serwować fragment HTMX jako pełną odpowiedź strony. Zob. httpwg.org/specs/rfc9110.html#field.vary

  12. CSS Custom Properties (zmienne CSS) są obsługiwane przez ponad 97% przeglądarek na świecie. Kaskadują, dziedziczą i reagują na zapytania mediów w czasie wykonywania — funkcje, których brakuje zmiennym preprocesorów. Źródło: caniuse.com/css-variables

  13. Dokumentacja Google dotycząca hreflang: developers.google.com/search/docs/specialty/international/localized-versions. Wartość x-default oznacza stronę zastępczą dla użytkowników, których język nie znajduje się na liście hreflang. 

  14. Alpine.js wymaga 'unsafe-eval' w Content Security Policy dla swojego silnika ewaluacji wyrażeń. Wersja kompatybilna z CSP (@alpinejs/csp) eliminuje ten wymóg, ale ma pewne ograniczenia. Zob. alpinejs.dev/advanced/csp

  15. Tokeny CSRF oparte na HMAC stosują wzorzec „Signed Double-Submit Cookie” opisany w arkuszu OWASP CSRF Prevention Cheat Sheet. Funkcja hmac.compare_digest wykorzystuje porównanie w stałym czasie, aby zapobiec atakom kanałem bocznym opartym na pomiarze czasu. Zob. cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html

  16. WebP zapewnia pliki o 25–35% mniejsze niż JPEG przy porównywalnej jakości wizualnej. Badanie Google dotyczące WebP: developers.google.com/speed/webp/docs/webp_study

  17. 103 Early Hints pozwala serwerowi (lub sieci CDN) wysłać wstępną odpowiedź ze wskazówkami preload przed przygotowaniem odpowiedzi właściwej. 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 kompresji gzip. Wraz z routerem, biblioteką zarządzania stanem i środowiskiem uruchomieniowym frameworka budowania, typowe aplikacje React dostarczają 100–300 KB JavaScript frameworka. Źródło: bundlephobia.com/package/[email protected]

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

NORMAL fastapi-htmx.md EOF