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

FastAPI + HTMX: The No-Build Full-Stack

# The complete system for building production web applications without React, webpack, or build tools. FastAPI backend, HTMX for server-driven interactivity, Alpine.js for client state, Jinja2 templates, and plain CSS. Includes Bootstrap 5 patterns for teams that want a utility framework.

words: 10539 read_time: 53m updated: 2026-03-24 00:00
$ less fastapi-htmx.md

TL;DR: FastAPI + HTMX + Alpine.js + Jinja2 + plain CSS produces production web applications with zero build tools, zero node_modules/, and perfect Lighthouse scores. This guide covers the entire system from architecture to deployment, using blakecrosley.com as a production reference that serves 37 blog posts, 20 interactive JavaScript components, 20 guides, and ten language translations without a single bundler, compiler, or transpiler.1

The modern web development stack assumes you need React, webpack, TypeScript, and a build pipeline. For a large category of applications — content-driven sites, internal tools, CRUD applications, portfolio sites, documentation platforms — that assumption is wrong. The stack described in this guide eliminates the entire frontend build toolchain while producing sites that score 100/100/100/100 on Lighthouse.2

This is not advocacy. It is a measurement. The architecture described here runs in production, serves real users across ten languages, and the numbers are verifiable.


Key Takeaways

  • Server-rendered HTML eliminates three entire problem categories: client state management, JSON serialization boundaries, and hydration mismatches. HTMX makes server responses the final output — no client-side rendering step.
  • Zero build tools means zero build failures. No npm install peer dependency conflicts, no TypeScript compiler errors in files you did not touch, no Dependabot PRs for transitive dependencies you never imported. The deploy pipeline is git push.
  • Alpine.js handles client-only state that HTMX cannot. Dropdowns, modals, mobile navigation toggles, and any UI state that exists purely in the browser belong to Alpine.js. The boundary is clear: if the state needs the server, use HTMX. If it does not, use Alpine.js.
  • Plain CSS with custom properties replaces Sass and Tailwind. CSS custom properties cascade, inherit, and respond to media queries at runtime. Preprocessor variables compile to static values and vanish. The browser reads custom properties directly — no compilation step.
  • This approach has clear boundaries. It is wrong for large teams sharing component interfaces, SaaS products with complex client-side state, and applications that depend on npm ecosystem libraries. The decision framework in Section 15 identifies the boundary precisely.
  • blakecrosley.com is the proof. The core patterns in this guide (HTMX, Alpine.js, Jinja2, plain CSS) run in production on blakecrosley.com. The Bootstrap and SQLAlchemy sections cover standard patterns for the stack that are not used on this specific site. Every claim has a file path, a configuration block, or a Lighthouse audit you can verify yourself at PageSpeed Insights.2

How to Use This Guide

This is a comprehensive reference. Start where your experience level fits:

Experience Start Here Then Explore
Python developer, new to HTMX The No-Build ThesisArchitecture OverviewHTMX Deep Dive Alpine.js Patterns, Security
React/Vue developer evaluating alternatives The No-Build ThesisDecision Framework Architecture Overview, Performance
FastAPI developer adding interactivity HTMX Deep DiveAlpine.js Patterns i18n and Localization, Deployment
Full-stack developer building from scratch Read sequentially from Architecture Overview Quick Reference Card for ongoing use

Use Ctrl+F / Cmd+F to search for specific patterns or attributes. The Quick Reference Card at the end provides a scannable summary.


The No-Build Thesis

The thesis is narrow and specific: for content-driven sites with a solo developer or small team, build tools solve problems you do not have while creating problems you do.

Here are the real metrics from blakecrosley.com:

Metric blakecrosley.com (No-Build) Typical Next.js Project3
Dependencies 15 Python packages 311+ npm packages
Build config files 0 5-8 (next.config, tsconfig, postcss, tailwind, etc.)
node_modules/ size Does not exist 187 MB baseline, 250-400 MB with additions
Install time pip install: 8 seconds npm install: 30-90 seconds
Build step None next build: 15-60 seconds
Deploy pipeline git push → live in ~40 seconds Install → build → deploy: 2-5 minutes
Lighthouse Performance 100 70-90 without explicit optimization4

The 15 Python packages include FastAPI, Jinja2, Pydantic, uvicorn, nh3, and 10 others. None is a build tool. None is a compiler. None is a bundler.5

What You Give Up

Honesty requires listing the real costs:

No TypeScript. Every .js file is vanilla JavaScript. Type errors are caught by testing and code analysis, not a compiler. This works for a solo developer. It would not work for a team of 10 sharing component interfaces.

No Hot Module Replacement. CSS changes require a manual browser refresh. HTMX’s hx-boost makes navigation fast enough that full refreshes are tolerable, but on tight visual iteration cycles, HMR saves time.

No Tree Shaking. Every byte of JavaScript you write ships to the browser. The constraint forces discipline: small, focused files instead of large utility modules.

No npm Component Libraries. No Radix, no shadcn/ui, no Headless UI. Every interactive element is hand-built or uses Bootstrap 5’s built-in components.

No Design System Tokens from npm. The design system lives in CSS custom properties. It cannot be imported as a package in another project.

These tradeoffs are acceptable for a content-driven site with one to three developers. They would be unacceptable for a SaaS product with a 15-person engineering team. Section 15 provides the decision framework.

What You Gain

Zero build failures. No npm install can fail due to peer dependency conflicts. No next build can fail due to a TypeScript error in a file you did not touch.6

Debug with View Source. The JavaScript running in the browser is the JavaScript you wrote. No source maps required.

Instant local startup. uvicorn app.main:app --reload starts in under 2 seconds.

Concrete request waterfall. A first visit loads: one HTML document (~15KB gzipped), one CSS file (~8KB), HTMX (~14KB, cached), Alpine.js (~14KB, cached), and the page’s interactive JS (~4-8KB). Total: 45-60KB on first visit.1

Future-proof frontend. The client-side code uses HTML, CSS, and JavaScript — standards that have maintained backward compatibility for 30 years.7 No Webpack 4 → 5 migration, no Create React App deprecation, no Next.js App Router migration.


Architecture Overview

Request Flow

Every request follows a single path through four layers:

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

Full page loads return complete HTML documents (base template + page template). HTMX requests return HTML fragments (partials). The server decides what to render based on the request type. Alpine.js manages client-only state that never touches the server.

Component Roles

Component Role Scope
FastAPI Routing, business logic, data access, validation Server
Jinja2 Template rendering, inheritance, macros Server
HTMX Server-driven interactivity (forms, pagination, search) Client ↔ Server
Alpine.js Client-only state (dropdowns, modals, toggles) Client only
Bootstrap 5 Grid system, utility classes, responsive layout Client (CSS)
Plain CSS Custom properties, component styles, design tokens Client (CSS)
Pydantic Request/response validation, settings Server

Project Structure

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

The structure follows a single principle: each directory contains one type of thing. Routes live in routes/. Templates live in templates/. Static assets live in static/. No build step transforms one into another.

Contrast with SPA Architecture

In a React + Next.js project, the equivalent structure would include:

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

The SPA architecture requires build-time coordination between these directories. TypeScript compiles .tsx to JavaScript. PostCSS processes Tailwind directives into CSS. Webpack (or Turbopack) bundles the output into chunks. Each step can fail independently.

The no-build architecture requires no coordination. The template references a CSS file. The CSS file exists in static/css/. The browser loads it directly. If you rename a file, the template reference breaks at request time — not at build time. This shifts errors from compile-time to runtime, which is a genuine tradeoff. For a solo developer running uvicorn --reload during development, runtime errors appear immediately in the browser. For a large team, compile-time errors caught by TypeScript prevent a category of bugs that runtime errors cannot.


FastAPI Patterns

Application Setup

The application initializes in main.py with explicit middleware ordering:

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)

Three design decisions matter here. First, docs_url=None and openapi_url=None disable the automatic API documentation endpoints. A public-facing content site does not need /docs or /openapi.json exposed to the internet.8 Second, middleware order matters — security logging executes first (added last) so it captures every request, including those rejected by rate limiting. Third, GZipMiddleware compresses all responses over 500 bytes, which typically reduces HTML transfer size by 70-80%.

Routing

Routes separate into two categories: page routes return full HTML documents, and API routes return JSON or HTML fragments.

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

The distinction matters for HTMX. Full page routes return documents that extend base.html. API routes return HTML fragments that HTMX swaps into existing DOM elements. The same Jinja2 template engine renders both — no separate API layer.

Dependency Injection

FastAPI’s Depends() system provides clean separation between route handlers and shared logic:

from fastapi import Depends, Request

def get_templates(request: Request):
    """Get templates from app state."""
    return request.app.state.templates

def get_current_locale(request: Request) -> str:
    """Get locale from middleware-set request state."""
    return getattr(request.state, "locale", "en")

@router.get("/blog/{slug}")
async def blog_post(
    request: Request,
    slug: str,
    templates=Depends(get_templates),
    locale: str = Depends(get_current_locale),
):
    post = load_post_by_slug(slug)
    if not post:
        raise HTTPException(404, "Post not found")
    return templates.TemplateResponse("pages/blog/post.html", {
        "request": request,
        "post": post,
        "locale": locale,
    })

Dependencies compose. A get_db dependency can depend on get_current_locale which depends on the request. FastAPI resolves the chain automatically.

Pydantic Settings

Configuration uses Pydantic’s BaseSettings with environment variable precedence:

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

Environment variables override .env file values. In production (Railway), secrets are set as environment variables. Locally, a .env file provides defaults. The Settings class validates types at startup — a missing required field fails fast rather than at runtime.

Async Patterns

FastAPI routes are async by default. For I/O-bound operations (database queries, HTTP requests, file reads), async prevents blocking the event loop:

@asynccontextmanager
async def lifespan(app: FastAPI):
    # Load translations into memory cache at startup
    async with httpx.AsyncClient() as client:
        for locale in SUPPORTED_LOCALES:
            resp = await client.post(f"{D1_URL}/query", ...)
            TRANSLATIONS[locale] = resp.json()["results"]
    yield
    # Cleanup on shutdown (if needed)

app = FastAPI(lifespan=lifespan)

CPU-bound operations (Markdown rendering, CSS extraction) can use synchronous functions. FastAPI runs them in a thread pool automatically when the route handler is not declared 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(...)

The rule: if the function awaits I/O, make it async. If it does CPU work, leave it synchronous. Do not mix await with blocking calls in the same function.9


Jinja2 Templates

Template Inheritance

Jinja2’s inheritance system replaces React’s component composition with a simpler model. One base template defines the page skeleton. Child templates fill named blocks:

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

The {% extends %} directive establishes a parent-child relationship. The child template only defines the blocks it needs to override. Everything else — the <head>, the header, the footer, the script tags — comes from the base. This is composition through subtraction rather than construction.

The asset() Global

Static assets use content-hash versioning for cache busting:

# cache_assets.py
def build_asset_map(static_dir: Path) -> dict[str, str]:
    """Compute MD5 hashes of all static files at startup."""
    asset_map = {}
    for filepath in static_dir.rglob("*"):
        if filepath.is_file():
            rel_path = str(filepath.relative_to(static_dir))
            content_hash = hashlib.md5(filepath.read_bytes()).hexdigest()[:10]
            asset_map[rel_path] = content_hash
    return asset_map

def make_asset_url(asset_map: dict, path: str) -> str:
    """Generate versioned URL: /static/css/styles.css?v=a3f8b2c1d0"""
    clean_path = path.lstrip("/")
    version = asset_map.get(clean_path, "0")
    return f"/static/{clean_path}?v={version}"

In the template: {{ asset('css/styles.css') }} renders as /static/css/styles.css?v=a3f8b2c1d0. The hash changes when the file changes, busting the CDN cache. This replaces webpack’s [contenthash] filename strategy with 30 lines of Python computed at startup.

Include for Reusable Partials

Components that repeat across pages use {% 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>

The underscore prefix (_language_switcher.html) is a convention indicating a partial — a template fragment not meant to be rendered standalone. This component uses both Alpine.js (for the dropdown toggle) and Jinja2 (for the locale list). The boundary is clean: Alpine.js owns the open/close state, Jinja2 owns the data.

Macros for Reusable Components

Macros are Jinja2’s functions — reusable template blocks with parameters:

<!-- 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 and use macros in page templates:

{% from "components/_macros.html" import card, optimized_image %}

<section class="projects">
  {% for project in projects %}
    {{ card(
      title=project.title,
      description=project.description,
      href=project.link,
      badge="New" if project.is_new else None
    ) }}
  {% endfor %}
</section>

Macros replace React components for presentational patterns. They accept parameters, support default values, and compose with other macros. The difference: macros render once on the server and produce static HTML. React components render on the client and maintain state. For content display, macros are the right tool.

Template Context and Globals

Jinja2 globals are functions available in every template without explicit passing:

# 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

The asset() global generates versioned URLs. The csrf_token() global generates fresh CSRF tokens. The analytics_script() global injects the tracking snippet. These functions are callable in any template without the route handler passing them explicitly.

For i18n, the setup is more involved — translation functions need access to the current request’s locale:

# 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

Each function reads the locale from the request context variable set by the locale middleware. The template calls {{ _('ui.nav.about') }} and gets the translated string for the current request’s locale without any explicit locale parameter.

Conditional Blocks

Jinja2’s block system supports conditional overrides:

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

Blog posts declare their dependencies in YAML frontmatter (scripts: ["/static/js/boids.js"]). The template conditionally includes them. Pages that do not need extra scripts or styles ship none — no dead code, no unused imports.

Custom Filters

Jinja2 filters transform data during rendering. The sanitize filter prevents XSS in user-generated content:

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

In templates: {{ user_content | sanitize }}. The nh3 library is a Rust-based HTML sanitizer — fast and secure. It strips any tags or attributes not in the allowlist, preventing stored XSS even if the content comes from an untrusted source.10


HTMX Deep Dive

HTMX makes any HTML element capable of issuing HTTP requests and swapping the response into the DOM. The key insight is architectural: server-rendered HTML is the API. The server returns the final representation. No client-side rendering, no JSON serialization, no hydration.

Core Attributes

Attribute Purpose Example
hx-get Issue GET request hx-get="/search?q=term"
hx-post Issue POST request hx-post="/contact"
hx-target Where to place the response hx-target="#results"
hx-swap How to insert the response hx-swap="innerHTML" (default), outerHTML, beforeend
hx-trigger What triggers the request hx-trigger="click", keyup changed delay:300ms, load
hx-indicator Element to show during request hx-indicator="#spinner"
hx-push-url Update browser URL hx-push-url="true"
hx-replace-url Replace URL without history entry hx-replace-url="true"

Pattern 1: Interactive Quiz (Multi-Step Server State)

blakecrosley.com includes an interactive quiz that walks users through tool selection. The entire quiz state lives on the server — no client-side state management:

<!-- _quiz_container.html — initial load -->
<div hx-get="/api/quiz/claude-vs-codex/step?answers="
     hx-trigger="load"
     hx-swap="innerHTML"
     id="quiz-wrapper">
  <p>Loading quiz...</p>
</div>
<!-- _quiz_step.html — each question -->
<div class="quiz-step" id="quiz-container">
  <p>Question {{ step }} of {{ total }}</p>
  <h3>{{ question.question }}</h3>
  <div class="quiz-step__options">
    {% for opt in question.options %}
    <button class="quiz-step__btn"
            hx-get="/api/quiz/claude-vs-codex/step?answers={{ answers }},{{ opt.value }}"
            hx-target="#quiz-container"
            hx-swap="outerHTML">
      {{ opt.label }}
    </button>
    {% endfor %}
  </div>
</div>

Each button click sends the accumulated answers as a query parameter. The server computes the next question or the final result based on the answer history. The state accumulates in the URL — no cookies, no sessions, no client-side JavaScript. The quiz progresses through outerHTML swaps: each response replaces the entire quiz step element.

Pattern 2: Paginated Blog List

The writing page uses HTMX for seamless pagination that updates the 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>

Four attributes working together:

  1. hx-get issues the request to the same URL as the href (progressive enhancement — works without JavaScript)
  2. hx-target places the response in the #writing-content container
  3. hx-replace-url="true" updates the browser URL without adding a history entry
  4. hx-indicator shows a loading spinner during the request

The server detects HTMX requests via the HX-Request header and returns only the post list fragment instead of the full page. This is why the security headers middleware adds Vary: HX-Request — so CDN caches store the full page and the fragment separately.11

Pattern 3: Search with 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>

The hx-trigger attribute combines three modifiers:

  • keyup fires on key release
  • changed fires only if the value actually changed (prevents duplicate requests from modifier keys)
  • delay:300ms debounces — waits 300ms after the last keyup before firing

The server returns a rendered HTML fragment:

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

No client-side state. No debounce library. No useEffect. The template renders the results, HTMX swaps them in, and the server is the single source of truth.

Pattern 4: Out-of-Band (OOB) Swaps

Sometimes a single server action needs to update multiple DOM elements. HTMX’s out-of-band swap mechanism handles this without client-side orchestration:

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

The hx-swap-oob="true" attribute tells HTMX to find the element by id anywhere in the DOM and replace it, regardless of the hx-target. This replaces React’s “lift state up” pattern — the server computes all derived state and sends the final HTML for each element in a single response.

A contact form demonstrates this well: submitting the form could replace the form body with a success message and simultaneously update a notification badge via OOB swap:

HTMX can “boost” standard navigation links to use AJAX instead of full page loads:

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

With hx-boost="true", clicking a link fetches the page via AJAX, swaps the <body> content, and updates the URL — without a full page reload. The browser history works normally (back/forward buttons). If JavaScript fails, the links work as standard navigation.

The benefit is perceived performance: boosted navigation feels instant because the browser does not need to re-parse CSS, re-evaluate scripts, or re-render the layout. Only the <body> content changes. Boosted links work well for main navigation elements, which makes page transitions feel like a single-page application without the SPA architecture.

Pattern 6: HTMX Request Headers

HTMX sends custom headers with every request:

Header Value Use Case
HX-Request true Detect HTMX requests server-side
HX-Target Element ID Know which element will receive the response
HX-Trigger Element ID Know which element triggered the request
HX-Current-URL Full URL Know the user’s current page

The server can use HX-Request to return different responses:

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

This dual-response pattern is central to the architecture. A full page load returns the complete document (base template + page content). An HTMX navigation returns only the changed content. The server decides, not the client.

Pattern 7: Progressive Enhancement

Every HTMX link on blakecrosley.com includes a standard href attribute:

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

If JavaScript fails to load, the href works as a normal link. If HTMX loads, it intercepts the click and performs an AJAX swap. This is progressive enhancement: the site works without JavaScript, and HTMX enhances the experience when available.

Pattern 8: Loading States

<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 adds the htmx-request class to the triggering element during requests. The hx-indicator attribute points to an element that becomes visible during the request. Style it with CSS:

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

No loading state management. No useState(false). No setLoading(true). CSS handles visibility, HTMX handles the class toggle.


Alpine.js Patterns

Alpine.js fills the gap HTMX leaves: client-only state that never needs to touch the server. If the user clicks a dropdown and it opens, that state exists only in the browser. Alpine.js manages it with HTML attributes.

The Boundary Rule

The boundary between HTMX and Alpine.js is precise:

State Type Tool Example
Needs server data HTMX Search results, form validation, pagination
Exists only in browser Alpine.js Dropdown open/close, mobile menu toggle, modal visibility
Combines both Both Language switcher (Alpine.js toggle, HTMX-like navigation)

Mobile Navigation

The base template wraps the entire header in an Alpine.js component:

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

Key Alpine.js patterns:

  • x-data declares the component scope and initial state
  • x-show toggles visibility based on state (uses CSS display: none)
  • x-cloak hides the element until Alpine.js initializes (prevents flash of unstyled content)
  • @click binds click handlers with expressions
  • :aria-expanded (shorthand for x-bind:aria-expanded) dynamically sets attributes
  • @keydown.escape.window listens for Escape key globally to close panels

The language switcher uses Alpine.js for toggle state with @click.away for outside-click closing:

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

The @click.away modifier closes the dropdown when clicking outside. Alpine.js handles this with a single attribute — no event listener registration, no cleanup, no ref management.

When to Use Alpine.js vs. Vanilla JavaScript

Alpine.js is appropriate when:

  • State is scoped to a single DOM element (dropdown, modal, toggle)
  • Interactions are binary or simple (open/close, show/hide, toggle)
  • Multiple elements need to react to the same state change
  • Accessibility attributes must stay synchronized with visibility

Vanilla JavaScript is appropriate when:

  • The interaction involves complex computation (visualizations, simulations)
  • The component has its own rendering loop (canvas, animation)
  • Performance is critical (Alpine.js adds overhead per x-data component)
  • The logic exceeds 20-30 lines of Alpine.js expressions

blakecrosley.com uses Alpine.js for navigation, language switching, and content toggles. The 20 interactive blog components (boids simulation, Hamming code visualizer, etc.) use vanilla JavaScript because they require canvas rendering and complex state machines.


Bootstrap 5 Without Sass

Note: blakecrosley.com uses plain CSS with custom properties — no Bootstrap. This section covers Bootstrap 5 as an option for teams that want a utility framework without a build step. Bootstrap’s compiled CSS can be loaded from a CDN or bundled into your stylesheet. The patterns below are generic and work alongside the HTMX + Alpine.js approach described in previous sections.

Bootstrap 5 dropped jQuery as a dependency and supports standalone CSS usage. You do not need Sass, PostCSS, or any build tool to use Bootstrap’s grid system and utility classes.

CDN-Free Self-Hosting

blakecrosley.com self-hosts all vendor libraries:

<!-- base.html — no CDN, no external requests -->
<script defer src="{{ asset('js/vendor/alpine.min.js') }}"></script>
<script defer src="{{ asset('js/vendor/htmx.min.js') }}"></script>

Self-hosting eliminates external dependencies, prevents CDN outages from breaking the site, and allows immutable caching with content-hash URLs. Download Bootstrap’s compiled CSS (not the Sass source) and place it in static/css/vendor/.

Grid System

Bootstrap’s grid works with plain HTML classes:

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

No Sass mixins. No @include make-col(). The compiled CSS includes the responsive grid classes. For custom breakpoints beyond Bootstrap’s defaults, write plain CSS media queries.

Plain CSS Overrides

Override Bootstrap’s defaults with CSS custom properties and standard selectors:

/* Custom design tokens — no Sass, no Tailwind */
:root {
  --color-bg-dark:        #000000;
  --color-text-primary:   #ffffff;
  --color-text-secondary: rgba(255, 255, 255, 0.65);
  --color-text-tertiary:  rgba(255, 255, 255, 0.40);
  --spacing-sm:           1rem;
  --spacing-md:           1.5rem;
  --spacing-lg:           2rem;
  --gutter:               48px;
  --font-size-lg:         1.25rem;
}

/* Responsive override — the browser reads this at runtime */
@media (max-width: 768px) {
  :root {
    --gutter: var(--spacing-md);  /* 48px → 24px on mobile */
  }
}

/* Override Bootstrap's default body styles */
body {
  background: var(--color-bg-dark);
  color: var(--color-text-primary);
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
}

CSS custom properties cascade through the DOM, inherit from parent elements, and respond to media queries at runtime. Sass variables compile to static values and disappear. This distinction matters for theming: a single custom property change can update every derived value without recompilation.12

Utility Classes vs. Component CSS

Use Bootstrap utility classes for one-off spacing and layout. Use component CSS for repeated patterns:

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

The principle: Bootstrap utilities for layout mechanics (margin, padding, flexbox). Custom CSS for visual identity (colors, typography, animations). Never mix utility classes with component styling for the same concern.


i18n and Localization

blakecrosley.com serves content in 10 languages: English, Japanese, Korean, Simplified Chinese, Traditional Chinese, German, French, Spanish, Polish, and Portuguese (Brazilian).

URL-Based Locale Routing

The locale lives in the URL path: /about (English), /ja/about (Japanese), /zh-Hans/about (Simplified Chinese). English is the default and has no prefix.

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

The locale middleware extracts the locale from the URL path:

# 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

The middleware strips the locale prefix before route matching. This means route handlers do not need locale-specific paths — /about handles both English (/about) and Japanese (/ja/about) because the middleware normalizes the path.

Translation Functions in Templates

Jinja2 globals provide translation functions:

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

The _() function looks up a translation key in the memory cache. The | default() filter provides the English fallback if the translation is missing. The locale_prefix() function returns the URL prefix for the current locale ("" for English, "/ja" for Japanese).

Hreflang Tags

Every page includes hreflang tags for all supported locales:

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

This produces:

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

Search engines use hreflang to serve the correct language version in search results. The x-default entry points to the English version as the fallback.13

Translation Storage and Memory Cache

Translations are stored in Cloudflare D1 (SQLite at the edge) and loaded into an in-memory cache via the lifespan handler:

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

The memory cache avoids database queries on every page render. Translation updates require a cache refresh (triggered via an admin endpoint or a deployment). This architecture trades freshness for performance — translations change infrequently, but page renders happen on every request.

Health Monitoring

blakecrosley.com includes an i18n health check endpoint that monitors translation coverage per 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

The 99.5% coverage threshold catches missing translations before users encounter untranslated strings. The health endpoint integrates with Railway’s monitoring to alert when coverage drops — for example, after adding new UI strings that have not been translated yet.

Locale-Aware Content Rendering

Blog posts and guides support per-locale translations of metadata and content:

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

The pattern is consistent: try translated content first, fall back to English. This allows partial translation — a Japanese user sees translated titles and descriptions even if the full article body remains in English. The | default() Jinja2 filter encodes this pattern in a single pipe:

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

Locale Data Translation

Static content like project descriptions and navigation labels are translated through helper functions that maintain the same data structure while swapping in locale-specific strings:

# 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

This approach keeps the translation layer separate from the data layer. Routes pass the same projects list regardless of locale. The translation functions wrap the data transparently.

Sitemap with Hreflang Alternates

The dynamic sitemap includes all pages in all locales with cross-references:

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

This produces 10 URL entries per page (one per locale), each with 11 alternate links (10 locales + x-default). For a site with 50 pages, the sitemap contains 500 URL entries with 5,500 hreflang links. The sitemap is generated dynamically and cached for one hour.


Database Patterns

Note: blakecrosley.com uses Cloudflare D1 (serverless SQLite) via HTTP for all persistent data, not SQLAlchemy. This section covers the standard SQLAlchemy async pattern for FastAPI projects that need a relational database — the most common production setup for this stack.

SQLAlchemy 2.0 Async

For applications that need a relational database, SQLAlchemy 2.0’s async support integrates cleanly with 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

Dependency Injection for Database Sessions

from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession

async def get_db() -> AsyncGenerator[AsyncSession, None]:
    async with async_session() as session:
        try:
            yield session
            await session.commit()
        except Exception:
            await session.rollback()
            raise

@router.get("/users/{user_id}")
async def get_user(request: Request, user_id: int, db: AsyncSession = Depends(get_db)):
    result = await db.execute(select(User).where(User.id == user_id))
    user = result.scalar_one_or_none()
    if not user:
        raise HTTPException(404, "User not found")
    return templates.TemplateResponse("pages/user.html", {
        "request": request, "user": user
    })

The get_db dependency manages the session lifecycle: it opens a session, yields it to the route handler, commits on success, and rolls back on exception. Every database operation uses parameterized queries — never string interpolation.

Pydantic Integration

Pydantic models validate input at the API boundary and serialize output for templates:

from pydantic import BaseModel, EmailStr

class ContactForm(BaseModel):
    name: str
    email: EmailStr
    message: str

@router.post("/contact")
async def submit_contact(request: Request, form: ContactForm):
    # form.name, form.email, form.message are validated
    await send_email(form)
    return templates.TemplateResponse("components/_contact_success.html", {
        "request": request
    })

Pydantic validates types, formats (email, URL), and constraints (min/max length) before the route handler executes. Invalid input returns a 422 response automatically. This replaces client-side form validation libraries — the server validates, and HTMX swaps in either the success message or the error feedback.

Migrations with Alembic

Alembic manages database schema changes:

# 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

The autogenerate feature compares SQLAlchemy models against the current database schema and generates migration scripts. These scripts are versioned Python files that live in the repository:

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

Migrations run during deployment (before the application starts). This ensures the database schema matches the application code. For blakecrosley.com, most data lives in Cloudflare D1 (accessed via HTTP), so Alembic migrations apply to the local SQLite or PostgreSQL database used for session data and analytics.

The Cloudflare D1 Pattern

blakecrosley.com uses Cloudflare D1 as a remote database accessed through a Cloudflare Worker proxy:

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

This pattern works for applications that need a database but do not want to manage a database server. D1 is SQLite at Cloudflare’s edge, accessed via HTTP. The Worker proxy handles authentication and rate limiting. The trade-off is latency: every query is an HTTP request (~50-100ms) versus a local database connection (~1-5ms). The in-memory cache at startup mitigates this for read-heavy workloads like translations.


Security

Security Headers Middleware

blakecrosley.com implements hardened security headers via custom 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

The CSP includes 'unsafe-inline' and 'unsafe-eval' because Alpine.js requires them for expression evaluation. The alternative is Alpine.js’s CSP-compatible build, which has limitations.14 Every other feature is locked down: frame-ancestors prevents clickjacking, form-action restricts form submissions to the same origin, and upgrade-insecure-requests forces HTTPS.

CDN Cache Safety with HTMX

The security headers middleware adds Vary: HX-Request to HTMX responses:

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)

Without this header, a CDN could cache an HTMX fragment response and serve it as the full page to a non-HTMX request (or vice versa). The Vary header tells the CDN to store separate cache entries based on the HX-Request header value.11

CSRF Protection

HTMX forms use stateless HMAC-signed CSRF tokens:

# csrf.py
def generate_csrf_token() -> str:
    """Token format: timestamp:random:HMAC-SHA256-signature"""
    timestamp = str(int(time.time()))
    random_value = secrets.token_hex(16)
    payload = f"{timestamp}:{random_value}"
    signature = hmac.new(
        CSRF_SECRET.encode(), payload.encode(), hashlib.sha256
    ).hexdigest()
    return f"{payload}:{signature}"

def validate_csrf_token(token: str) -> bool:
    """Verify signature and check expiration (1 hour)."""
    timestamp, random_value, signature = token.split(":")
    if int(time.time()) - int(timestamp) > 3600:
        return False
    expected = hmac.new(
        CSRF_SECRET.encode(),
        f"{timestamp}:{random_value}".encode(),
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)

The token is generated in the template via a Jinja2 global and included in HTMX form requests:

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

Stateless tokens eliminate server-side session storage. The HMAC signature ensures the token was generated by the server. The timestamp prevents replay attacks. hmac.compare_digest prevents timing attacks.15

HTML Sanitization

User-generated content passes through nh3 before rendering:

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

The nh3 library strips tags and attributes not in the allowlist. Links automatically get rel="noopener noreferrer". This defense is independent of CSP — it prevents stored XSS at the rendering layer, while CSP prevents injected scripts at the browser layer. Defense in depth.

Input Validation

Pydantic models validate all input at the API boundary:

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 returns 422 Unprocessable Entity for invalid input automatically. Combined with parameterized database queries (SQLAlchemy never interpolates strings), this prevents SQL injection and ensures type safety at the boundaries.


Performance

Lighthouse 100/100/100/100

blakecrosley.com scores 100 in all four Lighthouse categories: Performance, Accessibility, Best Practices, and SEO. Verify at PageSpeed Insights.2

The key optimizations:

CSS Loading Strategy

blakecrosley.com loads CSS with a single <link> tag and content-hashed URLs for immutable caching:

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

The asset() helper appends a content hash (?v=a3b2c1d4) so the browser caches the file indefinitely until the content changes. No critical CSS extraction, no print-media trick, no JavaScript-based loading. The CSS file is ~8KB gzipped — small enough that the single-request approach scores 100 on Lighthouse Performance without optimization gymnastics.

GZip Compression

app.add_middleware(GZipMiddleware, minimum_size=500)

Responses over 500 bytes are compressed. HTML compresses 70-80%, reducing a 15KB document to 3-4KB.

Immutable Static Asset Caching

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

Static assets with content-hash URLs (?v=a3f8b2c1d0) are cached for one year with immutable. The hash changes when the file changes, forcing browsers and CDNs to fetch the new version.

Deferred Script Loading

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

The defer attribute downloads scripts in parallel with HTML parsing but executes them after the document is parsed. This prevents render-blocking without the complexity of async loading and execution order management.

Image Optimization

Images use WebP with responsive srcset and explicit dimensions:

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>

Explicit width and height attributes prevent Cumulative Layout Shift (CLS). The loading="lazy" attribute defers off-screen images. WebP provides 25-35% smaller files than JPEG at equivalent quality.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)

The Link header with rel=preload tells Cloudflare to send a 103 Early Hints response, allowing the browser to start fetching the CSS before the server finishes generating the HTML response.17

Minimal JavaScript

The total JavaScript footprint:

Library Size (minified + gzipped)
HTMX ~14 KB
Alpine.js ~14 KB
Page-specific JS 4-8 KB
Total 32-36 KB

A typical React application ships 100-300 KB of framework JavaScript before application code.18 The no-build approach ships less JavaScript because there is less JavaScript to ship.


Deployment

Railway

blakecrosley.com deploys to Railway via 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

Railway’s Nixpacks builder detects the Python project from requirements.txt, installs dependencies, and runs the start command. No Dockerfile required. The health check endpoint ensures the application is responsive before receiving traffic:

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

The Deploy Pipeline

git push origin main
  → Railway detects push
  → Nixpacks installs Python + requirements.txt (cached)
  → uvicorn starts
  → Health check passes
  → Traffic routes to new deployment
  → ~40 seconds total

No npm install. No npm run build. No webpack compilation. No TypeScript compilation. The only install step is pip install -r requirements.txt, which is cached between deployments.

Procfile

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

The Procfile provides a Heroku-compatible alternative. Railway supports both railway.toml and Procfile. The ${PORT:-8000} syntax uses the platform-provided port or defaults to 8000 for local development.

Uvicorn Production Configuration

For higher-traffic deployments, use multiple workers:

uvicorn app.main:app \
  --host 0.0.0.0 \
  --port ${PORT:-8000} \
  --workers 4 \
  --loop uvloop \
  --http httptools
  • --workers 4 runs four worker processes (general rule: 2 * CPU cores + 1)
  • --loop uvloop uses the faster uvloop event loop (drop-in replacement for asyncio)
  • --http httptools uses the faster httptools HTTP parser

For development, --reload watches for file changes:

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

Docker Alternative

For platforms that require 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"]

The slim base image keeps the container small. --no-cache-dir prevents pip from storing downloaded packages in the image layer.

Cloudflare CDN

blakecrosley.com uses Cloudflare for CDN caching, DNS, and Workers:

# Cache headers for HTML pages (set in security middleware)
response.headers["Cache-Control"] = (
    "public, max-age=300, s-maxage=3600, "
    "stale-while-revalidate=86400"
)
  • max-age=300 — browser caches for 5 minutes
  • s-maxage=3600 — CDN caches for 1 hour
  • stale-while-revalidate=86400 — serve stale content while revalidating for 24 hours

Static assets get max-age=31536000, immutable because content-hash URLs guarantee freshness.


Decision Framework

Do You Need Build Tools?

Answer four questions:

1. Do more than five developers share JavaScript interfaces? If yes, TypeScript’s compile-time type checking prevents integration bugs that runtime testing catches too late. Add a build step.

2. Does your application manage complex client-side state? If drag-and-drop, real-time collaboration, or offline-first data are core features (not nice-to-haves), a framework like React or Svelte earns its complexity. Add a build step.

3. Do multiple products consume a shared component library? If yes, that library needs npm packaging, semantic versioning, and tree shaking. Add a build step.

4. Do you depend on npm ecosystem libraries that assume a bundler? If Radix, Framer Motion, TanStack Query, or similar libraries are core to the product, a build pipeline is mandatory.

If all four answers are “no,” the no-build approach is viable. If any answer is “yes,” build tools solve a real problem. The mistake is adding build tools when all four answers are “no” — solving problems you do not have while creating dependency management overhead you do.1

Stack Comparison

Category No-Build (This Guide) React + Build Tools
Best for Content sites, portfolios, internal tools, CRUD apps SaaS products, complex SPAs, design system consumers
Team size 1-5 developers 5-50+ developers
State management Server (HTMX) + client (Alpine.js) Client (React state, Redux, Zustand)
Type safety Runtime (Pydantic server-side) Compile-time (TypeScript)
Component reuse Jinja2 includes + macros npm packages, shared libraries
SEO Server-rendered by default Requires SSR/SSG configuration
Performance floor High (minimal JS, server-rendered) Varies (framework overhead)
Complexity ceiling Lower (no offline, no rich client state) Higher (any client interaction possible)
Dependencies 15 Python packages 300+ npm packages
Build time 0 seconds 15-60 seconds

When HTMX Is Wrong

HTMX replaces client state with server round-trips. This works until latency matters:

  • Drag-and-drop interfaces — 200ms server round-trip per drag event is unacceptable
  • Real-time collaboration — WebSocket-driven state requires client-side conflict resolution
  • Offline-first applications — no server means no HTMX
  • Complex animations tied to state — Framer Motion and React Spring assume a React reconciliation model
  • Canvas/WebGL applications — the rendering loop is inherently client-side

For these use cases, a client-side framework is the right tool. The no-build approach does not attempt to replace them.


Quick Reference Card

FastAPI

# Development
source venv/bin/activate
uvicorn app.main:app --reload --port 8000

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

# Testing
python -m pytest -v --cov=app

# Database migrations
alembic upgrade head
alembic revision --autogenerate -m "description"

HTMX Attributes

hx-get="/url"                     <!-- GET request -->
hx-post="/url"                    <!-- POST request -->
hx-target="#element"              <!-- Where to put response -->
hx-swap="innerHTML"               <!-- How to insert (innerHTML, outerHTML, beforeend) -->
hx-trigger="click"                <!-- What triggers request -->
hx-trigger="keyup changed delay:300ms"  <!-- Debounced input -->
hx-trigger="load"                 <!-- Fire on element load -->
hx-indicator="#spinner"           <!-- Show during request -->
hx-push-url="true"                <!-- Update browser URL -->
hx-replace-url="true"             <!-- Replace URL (no history) -->

Alpine.js Attributes

x-data="{ open: false }"         <!-- Component scope + state -->
x-show="open"                    <!-- Toggle visibility -->
x-cloak                          <!-- Hide until Alpine inits -->
@click="open = !open"            <!-- Event handler -->
@click.away="open = false"       <!-- Outside click -->
@keydown.escape="open = false"   <!-- Keyboard event -->
:class="{ 'active': open }"      <!-- Dynamic class -->
:aria-expanded="open"            <!-- Dynamic attribute -->
x-text="count"                   <!-- Dynamic text content -->
x-init="fetchData()"             <!-- Run on init -->

CSS Custom Properties

:root {
  --color-bg:     #000000;
  --color-text:   #ffffff;
  --spacing-sm:   1rem;
  --spacing-md:   1.5rem;
  --font-size-lg: 1.25rem;
}
@media (max-width: 768px) {
  :root { --gutter: 24px; }
}

Security Headers

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

Project Setup Checklist

[ ] FastAPI app with Jinja2Templates
[ ] Security headers middleware (CSP, HSTS, X-Frame-Options)
[ ] CSRF token generation and validation
[ ] GZip middleware (minimum_size=500)
[ ] Content-hash asset versioning (cache busting)
[ ] HTMX self-hosted in /static/js/vendor/
[ ] Alpine.js self-hosted in /static/js/vendor/
[ ] CSS custom properties for design tokens
[ ] Health check endpoint (/health)
[ ] Error handlers (404, 500)
[ ] robots.txt, sitemap.xml, llms.txt
[ ] JSON-LD structured data in base template
[ ] Hreflang tags for i18n (if multi-language)
[ ] HTML sanitization filter (nh3)
[ ] Rate limiting middleware
[ ] Deferred script loading

FAQ

Is HTMX production-ready for real web applications?

Yes. HTMX has been stable since 2020 and is used in production across multiple industries. Carson Gross, the creator, maintains backward compatibility as a core design principle — the HTMX docs state that the library will not break existing applications within a major version.19 The library is 14KB minified and gzipped, has zero dependencies, and follows semantic versioning. blakecrosley.com has run HTMX in production for three years with zero HTMX-related bugs.

Can I use TypeScript without a build step?

Partially. TypeScript files can be type-checked with tsc --noEmit without generating output files, providing compile-time checking as a linter. However, browsers cannot execute .ts files directly, so a build step is still required to serve TypeScript. The alternative is JSDoc type annotations in plain .js files, which TypeScript can check without compilation. This gives type safety during development while shipping standard JavaScript.

How does this approach compare to Astro or 11ty?

Astro and 11ty are static site generators that produce plain HTML with minimal client JavaScript, but they require a build step (Node.js, npm install, a build command). The no-build approach eliminates that step — the server renders HTML on each request. The tradeoff: Astro/11ty produce faster static pages (no server computation), while FastAPI + HTMX handles dynamic content natively (user-specific data, form submissions, real-time updates) without a separate API layer.

What about server-side rendering (SSR) with React?

Next.js SSR and the FastAPI + HTMX approach share a goal: send server-rendered HTML to the browser. The difference is what happens after the initial render. Next.js hydrates the page with React, shipping the framework runtime and component code to the client. FastAPI + HTMX does not hydrate — the HTML is the final output. HTMX handles subsequent interactions by requesting new HTML fragments from the server. The result: FastAPI + HTMX ships 30-40KB of JavaScript total versus 100-300KB for a Next.js application.18

How do I handle form validation with this stack?

Server-side. Pydantic validates the input when the form is submitted. If validation fails, the server returns the form with error messages. HTMX swaps the response into the 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
    })

The server validates, the server renders error states, and HTMX swaps the result. No client-side validation library needed. The HTML required attribute provides basic browser-level validation as a first line of defense.

Can I add real-time features (WebSockets)?

Yes. FastAPI has built-in WebSocket support:

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 has a WebSocket extension (hx-ws) that connects elements to WebSocket endpoints:

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

Note: HTMX 1.x used hx-ws="connect:..." syntax. HTMX 2.x moved WebSocket support to a separate extension (htmx-ext-ws) with the ws-connect and ws-send attributes shown above. If using HTMX 1.x, the old hx-ws syntax still works.

Messages from the server are swapped into the DOM using the same targeting and swap mechanics as HTTP responses. The server sends HTML fragments over the WebSocket, and HTMX inserts them.

How does this stack handle SEO?

Server-rendered HTML is inherently SEO-friendly because crawlers receive the complete page content without executing JavaScript. blakecrosley.com adds several SEO layers:

  • JSON-LD structured data in <head> for every page (Person, Article, WebSite, FAQPage schemas)
  • Dynamic sitemap with hreflang alternates for all 10 locales
  • RSS feed at /blog/feed.xml
  • llms.txt at the root for AI crawler discoverability
  • Canonical URLs and Open Graph tags in the base template
  • Semantic HTML: <article>, <section>, <main>, proper heading hierarchy

No SSR configuration needed. No getStaticProps. No ISR. The HTML is rendered on every request — that is the default behavior, not an optimization.

What is the learning curve compared to React?

For Python developers, the learning curve is significantly lower. You already know the language. FastAPI’s route handlers return template responses — the same mental model as Flask or Django views. HTMX adds a handful of HTML attributes (hx-get, hx-target, hx-swap). Alpine.js adds a few more (x-data, x-show, @click). There is no JSX, no virtual DOM, no hooks system, no state management library, and no build tool configuration to learn.

The HTMX documentation fits on a single long page. The Alpine.js documentation fits on a few pages. React’s documentation spans hundreds of pages covering hooks, context, refs, effects, suspense, server components, and streaming SSR.

For JavaScript/React developers, the shift is conceptual rather than syntactic. The core insight is that the server owns the state and the server renders the HTML. Client-side state management becomes server-side route handling. Client-side data fetching becomes HTMX attributes on HTML elements. The syntax is simpler — the mental model requires unlearning the SPA assumption that the client owns rendering.


Changelog

Date Change
2026-03-24 Initial publication

References


This guide covers the complete system used to build blakecrosley.com. The No-Build Manifesto provides the philosophical argument. The Lighthouse Perfect Score post documents the performance optimization journey. The Vibe Coding vs. Engineering post explores where AI-assisted development fits into this workflow.


  1. blakecrosley.com production metrics as of March 2026. The site serves 37 blog posts, 20 interactive JavaScript components, 20 guide sections, and 10 language translations with 15 Python packages and zero build tools. Full dependency list: fastapi, uvicorn, starlette, pydantic, pydantic-settings, jinja2, markdown, pygments, beautifulsoup4, lxml, nh3, resend, python-multipart, httpx, analytics-941. Verified from requirements.txt

  2. Google PageSpeed Insights (pagespeed.web.dev) runs Lighthouse audits against any public URL. blakecrosley.com scores 100/100/100/100 (Performance, Accessibility, Best Practices, SEO) as of March 2026. Results are publicly verifiable. See From 76 to 100: Achieving a Perfect Lighthouse Score for the full optimization journey. 

  3. A fresh npx create-next-app@latest (Next.js 15, tested February 2026) installs 311 packages in node_modules/ totaling 187 MB. Production projects with additional dependencies trend higher. Individual projects vary. Source: author’s testing, documented in The No-Build Manifesto

  4. Vercel’s Next.js performance documentation recommends specific optimizations (image optimization, font loading, code splitting) to achieve scores above 90. See nextjs.org/docs/app/building-your-application/optimizing. The 70-90 range reflects default settings before applying these optimizations. 

  5. Full dependency list verified from blakecrosley.com’s requirements.txt as of March 2026. Zero packages are build tools, compilers, or bundlers. 

  6. Based on the author’s experience maintaining Next.js projects (2021-2024), the JavaScript ecosystem generates 15-25 Dependabot PRs per month for active projects, most updating transitive dependencies the developer never imported directly. 

  7. Tim Berners-Lee articulated backward compatibility as a web design principle: “a browser should be backwards-compatible.” A page from 1996 renders in Chrome 2026. See w3.org/DesignIssues/Principles

  8. OWASP recommends disabling API documentation endpoints in production to reduce attack surface. The /openapi.json endpoint exposes all route definitions, parameters, and response models. 

  9. FastAPI documentation on async vs sync handlers: fastapi.tiangolo.com/async/. Mixing await with blocking calls in async functions starves the event loop. 

  10. nh3 is a Rust-based HTML sanitizer, the successor to the Bleach library. It is maintained by the PyO3 project and provides allowlist-based HTML sanitization. See github.com/messense/nh3

  11. The Vary header is defined in RFC 9110 Section 12.5.5. It instructs caches to store separate responses based on the specified request header values. Without Vary: HX-Request, a CDN could serve an HTMX fragment as a full page response. See httpwg.org/specs/rfc9110.html#field.vary

  12. CSS Custom Properties (CSS Variables) are supported in 97%+ of global browsers. They cascade, inherit, and respond to media queries at runtime — capabilities that preprocessor variables lack. Source: caniuse.com/css-variables

  13. Google’s hreflang documentation: developers.google.com/search/docs/specialty/international/localized-versions. The x-default value designates the fallback page for users whose language is not in the hreflang list. 

  14. Alpine.js requires 'unsafe-eval' in the Content Security Policy for its expression evaluation engine. The CSP-compatible build (@alpinejs/csp) avoids this requirement but has limitations. See alpinejs.dev/advanced/csp

  15. HMAC-based CSRF tokens follow the “Signed Double-Submit Cookie” pattern described in the OWASP CSRF Prevention Cheat Sheet. hmac.compare_digest uses constant-time comparison to prevent timing side-channel attacks. See cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html

  16. WebP provides 25-35% smaller files than JPEG at equivalent visual quality. Google’s WebP study: developers.google.com/speed/webp/docs/webp_study

  17. 103 Early Hints allows the server (or CDN) to send a preliminary response with preload hints before the final response is ready. Cloudflare supports Early Hints for Link headers with rel=preload. See developer.chrome.com/blog/early-hints

  18. React 18 + ReactDOM weighs approximately 42 KB minified + gzipped. With a router, state management library, and build framework runtime, typical React applications ship 100-300 KB of framework JavaScript. Source: bundlephobia.com/package/[email protected]

  19. HTMX versioning policy and backward compatibility commitment are documented at htmx.org/migration-guide-htmx-1/. Carson Gross has stated the backward compatibility principle in Hypermedia Systems (2023) by Gross, Stepinski, and Cotter: hypermedia.systems

NORMAL fastapi-htmx.md EOF