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

FastAPI + HTMX: Full-stack bez kompilacji

# FastAPI + HTMX: Full-stack bez kompilacji

words: 6445 read_time: 25m updated: 2026-03-25 08:21
$ less fastapi-htmx.md

TL;DR: FastAPI + HTMX + Alpine.js + Bootstrap 5 + Jinja2 + czysty CSS pozwala tworzyć produkcyjne aplikacje webowe bez narzędzi budowania, bez node_modules/ i z idealnymi wynikami Lighthouse. Niniejszy przewodnik obejmuje cały system — od architektury po wdrożenie — wykorzystując blakecrosley.com jako produkcyjny przykład obsługujący 37 wpisów blogowych, 20 interaktywnych komponentów JavaScript, 20 przewodników i tłumaczenia na dziesięć języków — wszystko bez bundlera, kompilatora czy transpilera.1

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

To nie jest agitacja. To pomiar. Opisana tu architektura działa w produkcji, 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 niezgodności hydratacji. HTMX sprawia, że odpowiedzi serwera stanowią finalny wynik — bez 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 nie modyfikowano, żadnych PR-ów Dependabota dla tranzytywnych zależności, których nigdy nie importowano. Pipeline wdrożeniowy to git push.
  • Alpine.js obsługuje stan istniejący wyłącznie po stronie klienta, którego HTMX nie jest w stanie obsłużyć. Rozwijane menu, modale, przełączniki nawigacji mobilnej i każdy stan UI istniejący wyłącznie w przeglądarce należą do Alpine.js. Granica jest jasna: jeśli stan wymaga serwera, należy użyć HTMX. Jeśli nie — Alpine.js.
  • Czysty CSS z właściwościami niestandardowymi zastępuje Sass i Tailwind. Właściwości niestandardowe CSS kaskadują, dziedziczą i reagują na media queries 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 jasno określone 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. Framework decyzyjny w sekcji 15 precyzyjnie identyfikuje tę granicę.
  • blakecrosley.com jest dowodem. Każdy wzorzec opisany w tym przewodniku działa w produkcji. Każde twierdzenie ma ścieżkę do pliku, blok konfiguracyjny lub audyt Lighthouse, które 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 Punkt wyjścia Następne kroki
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-BuildFramework decyzyjny Przegląd architektury, Wydajność
Programista FastAPI dodający interaktywność HTMX — szczegółowe omówienieWzorce Alpine.js i18n i lokalizacja, Wdrażanie
Programista full-stack budujący od podstaw Czytaj sekwencyjnie od Przeglądu architektury Karta szybkiego odniesienia do bieżącego użytku

Ctrl+F / Cmd+F umożliwia wyszukiwanie konkretnych wzorców lub atrybutów. Karta szybkiego odniesienia na końcu zawiera zwięzłe podsumowanie.


Teza No-Build

Teza jest wąska i precyzyjna: w przypadku stron zorientowanych na treść, tworzonych przez jednego programistę lub mały zespół, narzędzia budowania rozwiązują problemy, których nie masz, jednocześnie tworząc problemy, które masz.

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żeniowy 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 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 są wychwytywane przez testy i analizę kodu, nie przez kompilator. Sprawdza się to w przypadku jednego programisty. Nie sprawdziłoby się w 10-osobowym zespole współdzielącym interfejsy komponentów.

Brak Hot Module Replacement. Zmiany w CSS wymagają ręcznego odświeżenia przeglądarki. hx-boost w HTMX sprawia, że nawigacja jest wystarczająco szybka, by pełne odświeżenia były 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 dużych modułów narzędziowych.

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

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

Te kompromisy są akceptowalne dla strony zorientowanej na treść z jednym do trzech programistów. Byłyby nieakceptowalne dla produktu SaaS z 15-osobowym zespołem inżynierskim. Sekcja 15 zawiera framework decyzyjny.

Co się zyskuje

Zero błędów budowania. npm install nie może zakończyć się błędem z powodu konfliktów zależności. next build nie może zakończyć się błędem z powodu błędu TypeScript w pliku, którego nie modyfikowano.6

Debugowanie przez View Source. JavaScript działający w przeglądarce to ten sam JavaScript, który został napisany. Mapy źródeł nie są potrzebne.

Natychmiastowy start lokalny. uvicorn app.main:app --reload uruchamia się w niecałe 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

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


Przegląd architektury

Przepływ żądań

Każde żądanie przechodzi przez cztery warstwy jedną ścieżką:

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 ładowania stron zwracają kompletne dokumenty HTML (szablon bazowy + szablon strony). Żądania HTMX zwracają fragmenty HTML (częściowe szablony). Serwer decyduje, co renderować, 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 serwerem (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)
Zwykły CSS Właściwoś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 zasobów. Trasy znajdują się w routes/. Szablony w templates/. Zasoby statyczne w static/. Żaden etap budowania nie przekształca jednych w drugie.

Porównanie z architekturą SPA

W projekcie React + Next.js równoważna 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 .tsx do JavaScript. PostCSS przetwarza dyrektywy Tailwind na CSS. Webpack (lub Turbopack) pakuje wynik w porcje (chunks). Każdy z tych kroków 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 referencji w szablonie w momencie żądania — nie w momencie budowania. To przenosi błędy z etapu kompilacji na etap wykonania, co stanowi rzeczywisty kompromis. Dla samodzielnego programisty korzystającego z uvicorn --reload podczas pracy, błędy 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 wykonania nie są w stanie wykryć.


Wzorce FastAPI

Konfiguracja aplikacji

Aplikacja inicjalizuje się w 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 znaczenie. Po pierwsze, docs_url=None i openapi_url=None wyłączają automatyczne endpointy dokumentacji API. Publiczna strona z treścią nie potrzebuje /docs ani /openapi.json wystawionych w internecie.8 Po drugie, kolejność middleware ma znaczenie — logowanie bezpieczeństwa wykonuje się jako pierwsze (dodane ostatnie), dzięki czemu rejestruje każde żądanie, łącznie z tymi odrzuconymi przez rate limiting. 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 — bez oddzielnej 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 automatycznie rozwiązuje cały łańcuch.

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 produkcji (Railway) sekrety ustawiane są 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 czasie działania.

Wzorce asynchroniczne

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

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

Operacje ograniczone przez CPU (renderowanie Markdown, ekstrakcja CSS) mogą wykorzystywać funkcje synchroniczne. 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 I/O, należy ją zadeklarować jako async. Jeśli wykonuje operacje CPU, warto pozostawić ją synchroniczną. Nie należy mieszać await z blokującymi wywołaniami w tej samej funkcji.9


Szablony Jinja2

Dziedziczenie szablonów

System dziedziczenia Jinja2 zastępuje kompozycję komponentów Reacta 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-dziecko. Szablon potomny definiuje jedynie te bloki, które musi nadpisać. Cała reszta — <head>, nagłówek, stopka, tagi skryptów — pochodzi z szablonu bazowego. To kompozycja przez odejmowanie, nie przez budowanie.

Funkcja globalna asset()

Zasoby statyczne wykorzystują wersjonowanie oparte na hashu zawartości w celu 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') }} generuje /static/css/styles.css?v=a3f8b2c1d0. Hash zmienia się wraz ze zmianą pliku, wymuszając odświeżenie pamięci podręcznej CDN. Zastępuje to strategię nazw plików [contenthash] webpacka — 30 linii Python obliczanych przy starcie aplikacji.

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 podkreślenia (_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 czytelna: Alpine.js zarządza stanem otwarcia/zamknięcia, Jinja2 zarządza danymi.

Makra jako komponenty wielokrotnego użytku

Makra to funkcje Jinja2 — wielokrotnie używalne bloki szablonów 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 %}

Import i użycie 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 Reacta w przypadku wzorców prezentacyjnych. Przyjmują parametry, obsługują wartości domyślne i można je komponować z innymi makrami. Kluczowa różnica: makra renderują się jednokrotnie po stronie serwera i generują statyczny HTML. Komponenty Reacta renderują się po stronie klienta i utrzymują stan. Do wyświetlania treści makra są właściwym narzędziem.

Kontekst szablonów i zmienne globalne

Zmienne globalne Jinja2 to funkcje dostępne w każdym szablonie bez 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

Globalna funkcja asset() generuje wersjonowane adresy URL. Globalna funkcja csrf_token() generuje świeże tokeny CSRF. Globalna funkcja analytics_script() wstrzykuje skrypt śledzący. Funkcje te 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łumaczeniowe 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 kontekstu żądania ustawionej przez middleware lokalizacji. Szablon wywołuje {{ _('ui.nav.about') }} i otrzymuje przetłumaczony ciąg znaków dla lokalizacji bieżącego żądania bez jawnego 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 we frontmatterze YAML (scripts: ["/static/js/boids.js"]). Szablon warunkowo je dołącza. Strony niewymagające 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 składowanym atakom XSS nawet gdy treść pochodzi z niezaufanego źródła.10


HTMX — szczegółowe omówienie

HTMX sprawia, że dowolny element HTML może wysyłać żądania HTTP i podmieniać odpowiedź 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 w przeglądarce hx-push-url="true"
hx-replace-url Podmiana adresu URL bez wpisu w historii hx-replace-url="true"

Wzorzec 1: Interaktywny quiz (wieloetapowy stan po stronie serwera)

blakecrosley.com zawiera interaktywny quiz, który prowadzi użytkowników przez wybór narzędzi. Cały stan quizu rezyduje 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 zgromadzone odpowiedzi jako parametr zapytania. Serwer oblicza kolejne pytanie lub końcowy wynik na podstawie historii odpowiedzi. Stan akumuluje się w adresie URL — bez ciasteczek, bez sesji, bez JavaScript po stronie klienta. Quiz przechodzi do kolejnych kroków za pomocą podmian outerHTML: każda odpowiedź zastępuje cały element kroku quizu.

Wzorzec 2: Paginowana lista wpisów na blogu

Strona z artykułami 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 działające wspólnie:

  1. hx-get wysyła żądanie pod ten sam adres URL co href (stopniowe ulepszanie — działa bez JavaScript)
  2. hx-target umieszcza odpowiedź w kontenerze #writing-content
  3. hx-replace-url="true" aktualizuje adres URL w przeglądarce bez dodawania wpisu w 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. Właśnie dlatego middleware nagłówków bezpieczeństwa dodaje Vary: HX-Request — aby cache CDN przechowywał pełną stronę i fragment osobno.11

Wzorzec 3: Wyszukiwanie z 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 wyzwala się po zwolnieniu klawisza
  • changed wyzwala się tylko wtedy, gdy wartość faktycznie się zmieniła (zapobiega duplikatom żądań od klawiszy modyfikujących)
  • delay:300ms stosuje debounce — czeka 300 ms od ostatniego 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 out-of-band (OOB)

Czasami pojedyncza akcja serwera musi zaktualizować wiele elementów DOM. Mechanizm podmian 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" informuje HTMX, aby znalazł element po id w dowolnym miejscu drzewa DOM i zastąpił go, niezależnie od hx-target. Zastępuje to wzorzec „przenoszenia stanu w górę” znany z React — serwer oblicza cały stan pochodny i wysyła finalny HTML dla każdego elementu w jednej odpowiedzi.

Na blakecrosley.com ten wzorzec pojawia się w formularzu kontaktowym: wysłanie formularza zastępuje jego treść komunikatem o powodzeniu i jednocześnie aktualizuje plakietkę powiadomień za pomocą podmiany 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 linku 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 załaduje się, linki działają jako standardowa nawigacja.

Korzyścią jest odczuwalna wydajność: wzmocniona nawigacja sprawia wrażenie natychmiastowej, ponieważ przeglądarka nie musi ponownie parsować CSS, ponownie uruchamiać skryptów ani ponownie renderować układu. Zmienia się jedynie zawartość <body>. blakecrosley.com wykorzystuje wzmocnione linki w głównej nawigacji, 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 Identyfikacja elementu, który otrzyma odpowiedź
HX-Trigger ID elementu Identyfikacja elementu, który 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 fundament architektury. Pełne załadowanie strony zwraca kompletny dokument (szablon bazowy + zawartość strony). Nawigacja przez HTMX zwraca jedynie zmienioną zawartość. O tym decyduje serwer, nie klient.

Wzorzec 7: Stopniowe ulepszanie (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 ulepszanie: strona działa bez JavaScript, a HTMX wzbogaca doświadczenie, gdy jest dostępny.

Wzorzec 5: 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 podczas trwania żądania. Atrybut hx-indicator wskazuje element, który staje się widoczny podczas żądania. Wystarczy ostylować go 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 obsługuje przełączanie klasy.


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 (toggle 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 błyskowi niestylizowanej treści)
  • @click wiąże obsługę 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 toggle z @click.away do zamykania po kliknięciu poza elementem:

<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 po kliknięciu na zewnątrz. Alpine.js obsługuje to za pomocą jednego atrybutu — bez rejestracji nasłuchiwaczy zdarzeń, bez sprzątania, 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ą pozostawać 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 wykorzystuje Alpine.js do nawigacji, przełączania języków i rozwijania treści. 20 interaktywnych komponentów bloga (symulacja boidów, wizualizator kodu Hamminga itp.) używa czystego JavaScript, ponieważ wymagają renderowania na canvas i złożonych maszyn stanów.


Bootstrap 5 bez Sass

Bootstrap 5 porzucił jQuery jako zależność 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 powodującym problemy z witryną i umożliwia niezmienne buforowanie z adresami URL opartymi na hashach treści. Wystarczy pobrać skompilowany CSS Bootstrap (nie źródła Sass) i umieścić go w static/css/vendor/.

System siatki

Siatka 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 breakpointów wykraczających poza domyślne wartości Bootstrap wystarczy napisać zwykłe zapytania medialne CSS.

Nadpisywanie za pomocą czystego 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 kaskadują przez DOM, dziedziczą z elementów nadrzędnych i reagują na zapytania medialne w czasie wykonania. Zmienne Sass kompilują się do statycznych wartości i znikają. To rozróżnienie ma znaczenie przy tworzeniu motywów: pojedyncza zmiana właściwości niestandardowej może zaktualizować każdą pochodną wartość bez ponownej kompilacji.12

Klasy narzędziowe a komponentowy CSS

Klas narzędziowych Bootstrap warto używać do jednorazowych odstępów i układu. Komponentowego CSS — do powtarzalnych 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, paddingi, flexbox). Niestandardowy CSS do tożsamości wizualnej (kolory, typografia, animacje). Nigdy nie należy mieszać klas narzędziowych ze stylami komponentowymi w ramach 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 oparty na lokalizacji w URL

Lokalizacja 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 lokalizacji wyodrębnia lokalizację 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 lokalizacji przed dopasowaniem tras. Oznacza to, że handlery tras nie potrzebują ścieżek specyficznych dla lokalizacji — /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 angielską wersję zastępczą, jeśli tłumaczenie jest niedostępne. Funkcja locale_prefix() zwraca prefiks URL dla bieżącej lokalizacji ("" dla angielskiego, "/ja" dla japońskiego).

Tagi hreflang

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

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

Generuje to następujący kod:

<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 odpowiedniej wersji językowej w wynikach wyszukiwania. Wpis x-default wskazuje na angielską wersję jako domyślną.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 przy starcie aplikacji:

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

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

Monitorowanie stanu

blakecrosley.com zawiera endpoint kontroli stanu i18n, który monitoruje pokrycie tłumaczeń dla każdej lokalizacji:

@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 znaków. Endpoint stanu integruje się z monitoringiem Railway, aby powiadamiać 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 lokalizacji

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

# 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 przetłumaczona treść, w razie braku — angielska wersja zastępcza. 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 potoku:

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

Tłumaczenie danych lokalizacyjnych

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 znaków specyficzne dla danej lokalizacji:

# 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 oddziela warstwę tłumaczeń od warstwy danych. Trasy przekazują tę samą listę projects niezależnie od lokalizacji. Funkcje tłumaczące opakowują dane w sposób przezroczysty.

Mapa witryny z alternatywami hreflang

Dynamiczna mapa witryny zawiera wszystkie strony we wszystkich lokalizacjach z wzajemnymi odniesieniami:

@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 lokalizację), z których każdy zawiera 11 linków alternatywnych (10 lokalizacji + x-default). Dla witryny z 50 stronami mapa witryny zawiera 500 wpisów URL z 5500 linkami hreflang. Mapa witryny jest generowana dynamicznie i buforowana na jedną godzinę.


Wzorce bazodanowe

SQLAlchemy 2.0 Async

W przypadku 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(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ę, udostępnia ją handlerowi trasy, zatwierdza przy sukcesie i wycofuje zmiany w przypadku 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(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 (e-mail, 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 komunikat sukcesu lub 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, przechowywanymi 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). Zapewnia to zgodność schematu bazy danych z kodem aplikacji. W przypadku blakecrosley.com większość danych znajduje się w Cloudflare D1 (dostępnym przez HTTP), więc migracje Alembic dotyczą lokalnej bazy SQLite lub PostgreSQL używanej do danych sesji i analityki.

Wzorzec Cloudflare D1

blakecrosley.com wykorzystuje Cloudflare D1 jako zdalną bazę danych dostępną przez proxy Cloudflare Worker:

class D1Client:
    """HTTP client for Cloudflare D1 via Worker proxy."""

    def __init__(self, worker_url: str, auth_secret: str):
        self.worker_url = worker_url
        self.auth_secret = auth_secret

    async def fetch_all(self, sql: str, params: list = None) -> list[dict]:
        async with httpx.AsyncClient() as client:
            response = await client.post(
                f"{self.worker_url}/query",
                json={"sql": sql, "params": params or []},
                headers={"Authorization": f"Bearer {self.auth_secret}"},
            )
            return response.json()["results"]

Wzorzec ten sprawdza się w aplikacjach wymagających bazy danych, ale bez konieczności zarządzania serwerem bazodanowym. D1 to SQLite na krawędzi sieci Cloudflare, dostępny 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 w przypadku 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 jednak ograniczenia.14 Wszystkie pozostałe funkcje są zablokowane: frame-ancestors zapobiega clickjackingowi, form-action ogranicza wysyłanie formularzy do tego samego originu, 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 zbuforować odpowiedź fragmentu HTMX i serwować ją jako pełną stronę dla żądania spoza HTMX (lub odwrotnie). Nagłówek Vary informuje CDN o konieczności przechowywania oddzielnych wpisów 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 jest generowany 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>

Bezstanowe tokeny eliminują konieczność przechowywania sesji po stronie serwera. Podpis HMAC gwarantuje, że token został wygenerowany przez serwer. Znacznik czasu zapobiega atakom powtórzeniowym. 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". Ochrona ta jest niezależna od CSP — zapobiega składowanemu XSS na warstwie renderowania, podczas gdy CSP blokuje wstrzyknięte skrypty na poziomie 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 automatycznie zwraca 422 Unprocessable Entity w przypadku nieprawidłowych danych wejściowych. W połączeniu z parametryzowanymi zapytaniami bazodanowymi (SQLAlchemy nigdy nie interpoluje ciągów znaków) zapobiega to 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:

Krytyczny CSS

Krytyczny (widoczny bez przewijania) CSS jest wyodrębniany i wstawiany bezpośrednio w <head>. Pełny arkusz stylów ładuje się asynchronicznie:

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

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

Sztuczka z media="print" informuje przeglądarkę, że arkusz stylów nie jest potrzebny do renderowania ekranu, dzięki czemu nie blokuje pierwszego wyświetlenia strony. Handler onload przełącza go na media="all" po załadowaniu. Fallback <noscript> zapewnia załadowanie arkusza stylów nawet bez JavaScript.16

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 15 KB dokument do 3-4 KB.

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

Opóźnione ł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 związanej z asynchronicznym ładowaniem i zarządzaniem 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 (CLS). Atrybut loading="lazy" odracza ładowanie obrazów poza ekranem. WebP zapewnia 25-35% mniejsze pliki niż JPEG przy porównywalnej jakości.17

Early Hints

# In main.py
app.state.preload_links = [
    f'<{make_asset_url(_asset_map, "css/styles.css")}>; rel=preload; as=style',
]

# In security headers middleware
if "text/html" in content_type:
    preload_links = getattr(request.app.state, "preload_links", [])
    if preload_links:
        response.headers["Link"] = ", ".join(preload_links)

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 zanim serwer zakończy generowanie odpowiedzi HTML.18

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 wysyła 100-300 KB frameworkowego JavaScript jeszcze przed kodem aplikacji.19 Podejście bez budowania wysyła mniej JavaScript, ponieważ po prostu jest mniej JavaScript do wysłania.


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 żaden Docker. Endpoint health check zapewnia responsywność aplikacji przed rozpoczęciem obsługi 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

Żadnego npm install. Żadnego npm run build. Żadnej kompilacji webpack. Żadnej 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} wykorzystuje port udostępniony przez platformę lub domyślnie ustawia 8000 dla lokalnego środowiska deweloperskiego.

Konfiguracja produkcyjna Uvicorn

Przy 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 robocze (ogólna zasada: 2 * liczba rdzeni CPU + 1)
  • --loop uvloop wykorzystuje szybszą pętlę zdarzeń uvloop (bezpośredni zamiennik asyncio)
  • --http httptools wykorzystuje szybszy parser HTTP httptools

Do celów deweloperskich --reload monitoruje 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"]

Lekki obraz bazowy utrzymuje kontener w niewielkim rozmiarze. Flaga --no-cache-dir zapobiega przechowywaniu pobranych pakietów przez pip w warstwie obrazu.

CDN Cloudflare

blakecrosley.com wykorzystuje Cloudflare jako CDN, DNS i Workers:

# Cache headers for HTML pages (set in security middleware)
response.headers["Cache-Control"] = (
    "public, max-age=300, s-maxage=3600, "
    "stale-while-revalidate=86400"
)
  • max-age=300 — przeglądarka 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 treści gwarantują aktualność.


Ramy decyzyjne

Czy potrzebne są narzędzia do budowania?

Należy odpowiedzieć na cztery pytania:

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

2. Czy aplikacja zarządza złożonym stanem po stronie klienta? Jeśli przeciąganie i upuszczanie, współpraca w czasie rzeczywistym lub praca offline stanowią kluczowe funkcje (nie jedynie miłe dodatki), framework taki jak React lub 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 pakowania npm, wersjonowania semantycznego i tree shaking. Warto dodać krok budowania.

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

Jeśli na wszystkie cztery pytania odpowiedź brzmi „nie”, podejście bez budowania jest realne. Jeśli na jakiekolwiek pytanie odpowiedź brzmi „tak”, narzędzia do budowania rozwiązują rzeczywisty problem. Błędem jest dodawanie narzędzi do budowania, gdy na wszystkie cztery pytania odpowiedź brzmi „nie” — rozwiązywanie problemów, których się nie ma, jednocześnie generując narzut związany z zarządzaniem zależnościami.1

Porównanie stosów technologicznych

Kategoria Bez budowania (ten przewodnik) React + narzędzia do budowania
Najlepsze do Strony treściowe, portfolio, narzędzia wewnętrzne, aplikacje CRUD Produkty SaaS, złożone SPA, konsumenci systemów projektowych
Rozmiar zespołu 1-5 deweloperów 5-50+ deweloperów
Zarządzanie stanem Serwer (HTMX) + klient (Alpine.js) Klient (React state, Redux, Zustand)
Bezpieczeństwo typów W czasie wykonywania (Pydantic po stronie serwera) W czasie kompilacji (TypeScript)
Ponowne użycie komponentów Jinja2 includes + makra Pakiety npm, współdzielone biblioteki
SEO Renderowanie serwerowe domyślnie Wymaga konfiguracji SSR/SSG
Dolna granica wydajności Wysoka (minimalny JS, renderowanie serwerowe) Zmienna (narzut frameworka)
Górna granica złożoności Niższa (brak offline, brak bogatego stanu klienta) Wyższa (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 nie jest właściwym wyborem

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

  • Interfejsy przeciągnij i upuść — 200 ms opóźnienia serwera na każde zdarzenie przeciągania jest nie do zaakceptowania
  • 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 uzgadniania React
  • Aplikacje Canvas/WebGL — pętla renderowania jest z natury po stronie klienta

W tych przypadkach framework kliencki jest właściwym narzędziem. Podejście bez budowania nie próbuje go 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 kompatybilność wsteczną jako fundamentalną zasadę projektową — dokumentacja HTMX stwierdza, że biblioteka nie będzie łamać istniejących aplikacji w ramach głównej wersji.20 Biblioteka waży 14 KB po minifikacji i gzip, nie ma żadnych zależności i stosuje wersjonowanie semantyczne. Witryna 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 kompilacji?

Częściowo. Pliki TypeScript można sprawdzać typami za pomocą tsc --noEmit bez generowania plików wyjściowych, co zapewnia kontrolę typów na etapie kompilacji — działając jak linter. Przeglądarki nie potrafią jednak bezpośrednio wykonywać plików .ts, więc etap kompilacji jest nadal konieczny do ich serwowania. Alternatywą są adnotacje typów JSDoc w zwykłych plikach .js, które TypeScript może sprawdzać bez kompilacji. Daje to bezpieczeństwo typów podczas rozwoju, jednocześnie dostarczając standardowy JavaScript.

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

Astro i 11ty to generatory stron statycznych, które tworzą czysty HTML z minimalnym JavaScript po stronie klienta, ale wymagają etapu kompilacji (Node.js, npm install, polecenie build). Podejście bez kompilacji eliminuje ten krok — serwer renderuje HTML przy każdym żądaniu. Kompromis: Astro/11ty generują szybsze strony statyczne (brak obliczeń serwerowych), natomiast FastAPI + HTMX natywnie obsługuje treści dynamiczne (dane użytkownika, formularze, aktualizacje w czasie rzeczywistym) bez dodatkowej 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łać wyrenderowany na serwerze HTML do przeglądarki. Różnica tkwi w tym, co dzieje się po początkowym renderowaniu. Next.js hydratuje stronę za pomocą React, wysyłając runtime frameworka i kod komponentów do klienta. FastAPI + HTMX nie wykonuje hydracji — HTML jest końcowym produktem. HTMX obsługuje kolejne interakcje, żądając nowych fragmentów HTML z serwera. Rezultat: FastAPI + HTMX dostarcza łącznie 30–40 KB JavaScript, podczas gdy aplikacja Next.js 100–300 KB.19

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

Po stronie serwera. Pydantic waliduje dane wejściowe w momencie wysł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. Nie jest potrzebna żadna biblioteka walidacji po stronie klienta. 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:

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

Wiadomości z serwera są wstawiane do 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 stack radzi sobie z SEO?

HTML renderowany po stronie serwera jest z natury przyjazny dla SEO, ponieważ crawlery otrzymują pełną zawartość strony bez konieczności wykonywania JavaScript. Witryna 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 alternatywami hreflang dla wszystkich 10 lokalizacji
  • Kanał RSS pod adresem /blog/feed.xml
  • llms.txt w katalogu głównym dla wykrywalności przez crawlery AI
  • Kanoniczne adresy URL i tagi Open Graph w szablonie bazowym
  • Semantyczny HTML: <article>, <section>, <main>, prawidłowa hierarchia nagłówków

Nie trzeba konfigurować SSR. Ż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 Flask czy Django. HTMX dodaje kilka atrybutów HTML (hx-get, hx-target, hx-swap). Alpine.js dodaje kilka kolejnych (x-data, x-show, @click). Nie ma JSX, wirtualnego DOM, systemu hooków, biblioteki zarządzania stanem ani konfiguracji narzędzi kompilacji do opanowania.

Dokumentacja HTMX mieści się na jednej długiej stronie. Dokumentacja Alpine.js na kilku stronach. Dokumentacja React obejmuje setki stron poświęconych hookom, kontekstowi, refom, efektom, suspense, komponentom serwerowym i strumieniowemu SSR.

Dla programistów JavaScript/React zmiana jest raczej koncepcyjna niż składniowa. Kluczowe spostrzeżenie: serwer jest właścicielem stanu i serwer renderuje HTML. Zarządzanie stanem po stronie klienta zamienia się w obsługę tras po stronie serwera. Pobieranie danych po stronie klienta zamienia się w atrybuty HTMX na elementach HTML. Składnia jest prostsza — model mentalny wymaga oduczenia się założenia SPA, że klient jest właścicielem renderowania.


Dziennik zmian

Data Zmiana
24 marca 2026 Pierwsza publikacja

Przypisy


Ten przewodnik obejmuje kompletny system użyty do budowy blakecrosley.com. The No-Build Manifesto przedstawia argumentację filozoficzną. Wpis Lighthouse Perfect Score dokumentuje ścieżkę optymalizacji wydajności. Artykuł Vibe Coding vs. Engineering analizuje, w jaki sposób programowanie wspomagane przez AI wpisuje się w ten przepływ pracy.


  1. Dane produkcyjne blakecrosley.com na marzec 2026. Strona obsługuje 37 wpisów blogowych, 20 interaktywnych komponentów JavaScript, 20 sekcji przewodników oraz 10 tłumaczeń językowych przy użyciu 15 pakietów Python i zerowej liczbie narzędzi do budowania. Pełna lista zależności: fastapi, uvicorn, starlette, pydantic, pydantic-settings, jinja2, markdown, pygments, beautifulsoup4, lxml, nh3, resend, python-multipart, httpx, analytics-941. Zweryfikowano na podstawie requirements.txt

  2. Google PageSpeed Insights (pagespeed.web.dev) przeprowadza audyty Lighthouse dla dowolnego publicznego adresu URL. blakecrosley.com uzyskuje wynik 100/100/100/100 (Wydajność, Dostępność, Najlepsze 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ża instalacja npx create-next-app@latest (Next.js 15, testowane w lutym 2026) pobiera 311 pakietów do node_modules/ o łącznym rozmiarze 187 MB. Projekty produkcyjne z dodatkowymi zależnościami osiągają wyższe wartości. Poszczególne projekty mogą się różnić. Źródło: testy autora, udokumentowane w The No-Build Manifesto

  4. Dokumentacja wydajności Next.js firmy Vercel zaleca konkretne optymalizacje (optymalizacja obrazów, ładowanie czcionek, dzielenie kodu) w celu osiągnięcia wyników powyżej 90. Patrz 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 blakecrosley.com na marzec 2026. Żaden pakiet nie jest narzędziem do budowania, kompilatorem ani bundlerem. 

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

  7. Tim Berners-Lee sformułował kompatybilność wsteczną jako zasadę projektowania sieci: „przeglądarka powinna być kompatybilna wstecz”. Strona z 1996 roku renderuje się w Chrome 2026. Patrz 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 oraz modele odpowiedzi. 

  9. Dokumentacja FastAPI dotycząca obsługi asynchronicznej i synchronicznej: fastapi.tiangolo.com/async/. Mieszanie await z blokującymi wywołaniami w funkcjach async powoduje zagłodzenie pętli zdarzeń. 

  10. nh3 to oparty na Rust sanitizer HTML, następca biblioteki Bleach. Jest utrzymywany przez projekt PyO3 i zapewnia sanityzację HTML opartą na liście dozwolonych elementów. Patrz github.com/messense/nh3

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

  12. CSS Custom Properties (zmienne CSS) są obsługiwane w ponad 97% przeglądarek na świecie. Podlegają kaskadowości, dziedziczeniu i reagują na zapytania mediów w czasie wykonywania — możliwości, których zmienne preprocesorów nie posiadają. Ź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. Patrz alpinejs.dev/advanced/csp

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

  16. Technika asynchronicznego ładowania CSS za pomocą media="print" jest udokumentowana przez zespół web.dev. Przeglądarka traktuje arkusz stylów jako nieblokujący renderowanie, ponieważ jest zadeklarowany dla mediów drukowanych. Procedura obsługi onload zmienia go na media all po pobraniu. Patrz web.dev/articles/defer-non-critical-css

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

  18. 103 Early Hints umożliwia serwerowi (lub CDN) wysłanie wstępnej odpowiedzi ze wskazówkami dotyczącymi wstępnego ładowania przed przygotowaniem ostatecznej odpowiedzi. Cloudflare obsługuje Early Hints dla nagłówków Link z rel=preload. Patrz developer.chrome.com/blog/early-hints

  19. React 18 + ReactDOM waży około 42 KB po minifikacji i kompresji gzip. Z routerem, biblioteką zarządzania stanem i środowiskiem uruchomieniowym frameworka do budowania typowe aplikacje React dostarczają 100-300 KB kodu JavaScript frameworka. Źródło: bundlephobia.com/package/[email protected]

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

NORMAL fastapi-htmx.md EOF