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

FastAPI + HTMX: Full-Stack sin compilación

# FastAPI + HTMX: Full-Stack sin compilación

words: 7818 read_time: 36m updated: 2026-03-25 08:22
$ less fastapi-htmx.md

TL;DR: FastAPI + HTMX + Alpine.js + Bootstrap 5 + Jinja2 + CSS puro produce aplicaciones web de producción sin herramientas de compilación, sin node_modules/ y con puntuaciones perfectas en Lighthouse. Esta guía cubre el sistema completo, desde la arquitectura hasta el despliegue, usando blakecrosley.com como referencia de producción que sirve 37 artículos de blog, 20 componentes interactivos de JavaScript, 20 guías y traducciones en diez idiomas sin un solo bundler, compilador o transpilador.1

El stack moderno de desarrollo web asume que necesitas React, webpack, TypeScript y un pipeline de compilación. Para una amplia categoría de aplicaciones —sitios orientados a contenido, herramientas internas, aplicaciones CRUD, portafolios, plataformas de documentación— esa suposición es incorrecta. El stack descrito en esta guía elimina por completo la cadena de herramientas de compilación del frontend, y aun así produce sitios que obtienen 100/100/100/100 en Lighthouse.2

Esto no es una defensa ideológica, sino una medición. La arquitectura aquí descrita funciona en producción, sirve a usuarios reales en diez idiomas, y los números son verificables.


Puntos Clave

  • El HTML renderizado en el servidor elimina tres categorías enteras de problemas: gestión de estado en el cliente, fronteras de serialización de JSON, y desajustes de hidratación. HTMX convierte las respuestas del servidor en la salida final — sin paso de renderizado en el cliente.
  • Cero herramientas de compilación significa cero fallos de compilación. Sin conflictos de dependencias en npm install, sin errores del compilador de TypeScript en archivos que no tocaste, sin PRs de Dependabot para dependencias transitivas que nunca importaste. El pipeline de despliegue es git push.
  • Alpine.js maneja el estado exclusivo del cliente que HTMX no puede. Menús desplegables, modales, toggles de navegación móvil y cualquier estado de UI que existe puramente en el navegador pertenecen a Alpine.js. La frontera es clara: si el estado necesita el servidor, usa HTMX. Si no, usa Alpine.js.
  • CSS plano con custom properties reemplaza a Sass y Tailwind. Las custom properties de CSS se propagan en cascada, heredan y responden a media queries en tiempo de ejecución. Las variables de preprocesadores se compilan a valores estáticos y desaparecen. El navegador lee las custom properties directamente — sin paso de compilación.
  • Este enfoque tiene límites claros. No es adecuado para equipos grandes que comparten interfaces de componentes, productos SaaS con estado complejo en el cliente, ni aplicaciones que dependen de bibliotecas del ecosistema npm. El marco de decisión en la Sección 15 identifica esos límites con precisión.
  • blakecrosley.com es la prueba. Cada patrón en esta guía funciona en producción. Cada afirmación tiene una ruta de archivo, un bloque de configuración o una auditoría de Lighthouse que puedes verificar tú mismo en PageSpeed Insights.2

Cómo Usar Esta Guía

Esta es una referencia integral. Comienza donde se ajuste a tu nivel de experiencia:

Experiencia Comienza Aquí Luego Explora
Desarrollador Python, nuevo en HTMX La Tesis No-BuildVisión General de la ArquitecturaHTMX a Fondo Patrones de Alpine.js, Seguridad
Desarrollador React/Vue evaluando alternativas La Tesis No-BuildMarco de Decisión Visión General de la Arquitectura, Rendimiento
Desarrollador FastAPI agregando interactividad HTMX a FondoPatrones de Alpine.js i18n y Localización, Despliegue
Desarrollador full-stack construyendo desde cero Lee secuencialmente desde Visión General de la Arquitectura Tarjeta de Referencia Rápida para uso continuo

Usa Ctrl+F / Cmd+F para buscar patrones o atributos específicos. La Tarjeta de Referencia Rápida al final proporciona un resumen escaneable.


La Tesis No-Build

La tesis es específica y concreta: para sitios orientados a contenido con un desarrollador solo o un equipo pequeño, las herramientas de compilación resuelven problemas que no tienes mientras crean problemas que sí.

Estas son las métricas reales de blakecrosley.com:

Métrica blakecrosley.com (No-Build) Proyecto Típico con Next.js3
Dependencias 15 paquetes Python 311+ paquetes npm
Archivos de configuración de build 0 5-8 (next.config, tsconfig, postcss, tailwind, etc.)
Tamaño de node_modules/ No existe 187 MB base, 250-400 MB con adiciones
Tiempo de instalación pip install: 8 segundos npm install: 30-90 segundos
Paso de build Ninguno next build: 15-60 segundos
Pipeline de despliegue git push → en producción en ~40 segundos Instalar → compilar → desplegar: 2-5 minutos
Rendimiento en Lighthouse 100 70-90 sin optimización explícita4

Los 15 paquetes Python incluyen FastAPI, Jinja2, Pydantic, uvicorn, nh3 y 10 más. Ninguno es una herramienta de compilación. Ninguno es un compilador. Ninguno es un bundler.5

Lo Que Sacrificas

La honestidad exige enumerar los costos reales:

Sin TypeScript. Cada archivo .js es JavaScript puro. Los errores de tipo se detectan mediante pruebas y análisis de código, no con un compilador. Esto funciona para un desarrollador solo. No funcionaría para un equipo de 10 personas compartiendo interfaces de componentes.

Sin Hot Module Replacement. Los cambios en CSS requieren actualizar manualmente el navegador. El hx-boost de HTMX hace que la navegación sea lo suficientemente rápida para que las actualizaciones completas sean tolerables, pero en ciclos de iteración visual ajustados, HMR ahorra tiempo.

Sin Tree Shaking. Cada byte de JavaScript que escribes llega al navegador. La restricción impone disciplina: archivos pequeños y enfocados en lugar de módulos utilitarios grandes.

Sin bibliotecas de componentes de npm. Sin Radix, sin shadcn/ui, sin Headless UI. Cada elemento interactivo se construye a mano o usa los componentes integrados de Bootstrap 5.

Sin tokens de Design System desde npm. El sistema de diseño vive en custom properties de CSS. No puede importarse como paquete en otro proyecto.

Estas concesiones son aceptables para un sitio orientado a contenido con uno a tres desarrolladores. Serían inaceptables para un producto SaaS con un equipo de ingeniería de 15 personas. La Sección 15 proporciona el marco de decisión.

Lo Que Ganas

Cero fallos de compilación. Ningún npm install puede fallar por conflictos de dependencias. Ningún next build puede fallar por un error de TypeScript en un archivo que no tocaste.6

Depura con View Source. El JavaScript que se ejecuta en el navegador es el JavaScript que escribiste. Sin necesidad de source maps.

Inicio local instantáneo. uvicorn app.main:app --reload arranca en menos de 2 segundos.

Cascada de solicitudes concreta. Una primera visita carga: un documento HTML (~15KB comprimido con gzip), un archivo CSS (~8KB), HTMX (~14KB, en caché), Alpine.js (~14KB, en caché) y el JS interactivo de la página (~4-8KB). Total: 45-60KB en la primera visita.1

Frontend a prueba de futuro. El código del lado del cliente usa HTML, CSS y JavaScript — estándares que han mantenido compatibilidad hacia atrás durante 30 años.7 Sin migración de Webpack 4 → 5, sin deprecación de Create React App, sin migración al App Router de Next.js.


Descripción general de la arquitectura

Flujo de solicitudes

Cada solicitud sigue un único camino a través de cuatro capas:

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

Las cargas de página completas devuelven documentos HTML completos (plantilla base + plantilla de página). Las solicitudes HTMX devuelven fragmentos HTML (parciales). El servidor decide qué renderizar según el tipo de solicitud. Alpine.js gestiona el estado del lado del cliente que nunca llega al servidor.

Roles de los componentes

Componente Rol Alcance
FastAPI Enrutamiento, lógica de negocio, acceso a datos, validación Servidor
Jinja2 Renderizado de plantillas, herencia, macros Servidor
HTMX Interactividad dirigida por el servidor (formularios, paginación, búsqueda) Cliente ↔ Servidor
Alpine.js Estado solo del cliente (menús desplegables, modales, toggles) Solo cliente
Bootstrap 5 Sistema de grillas, clases utilitarias, diseño responsivo Cliente (CSS)
CSS puro Propiedades personalizadas, estilos de componentes, tokens de diseño Cliente (CSS)
Pydantic Validación de solicitudes/respuestas, configuración Servidor

Estructura del proyecto

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

La estructura sigue un único principio: cada directorio contiene un solo tipo de elemento. Las rutas están en routes/. Las plantillas están en templates/. Los archivos estáticos están en static/. Ningún paso de compilación transforma uno en otro.

Contraste con la arquitectura SPA

En un proyecto con React + Next.js, la estructura equivalente incluiría:

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

La arquitectura SPA requiere coordinación en tiempo de compilación entre estos directorios. TypeScript compila archivos .tsx a JavaScript. PostCSS procesa las directivas de Tailwind y las convierte en CSS. Webpack (o Turbopack) empaqueta la salida en fragmentos. Cada paso puede fallar de forma independiente.

La arquitectura sin compilación no requiere coordinación. La plantilla referencia un archivo CSS. El archivo CSS existe en static/css/. El navegador lo carga directamente. Si renombras un archivo, la referencia en la plantilla falla en tiempo de ejecución, no en tiempo de compilación. Esto traslada los errores de tiempo de compilación a tiempo de ejecución, lo cual es un compromiso real. Para un desarrollador individual ejecutando uvicorn --reload durante el desarrollo, los errores en tiempo de ejecución aparecen inmediatamente en el navegador. Para un equipo grande, los errores en tiempo de compilación detectados por TypeScript previenen una categoría de errores que los errores en tiempo de ejecución no pueden detectar.


Patrones de FastAPI

Configuración de la aplicación

La aplicación se inicializa en main.py con un orden explícito de 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)

Tres decisiones de diseño son importantes aquí. Primero, docs_url=None y openapi_url=None desactivan los endpoints de documentación automática de API. Un sitio de contenido público no necesita exponer /docs ni /openapi.json a internet.8 Segundo, el orden del middleware importa: el registro de seguridad se ejecuta primero (se agrega de último), de modo que captura cada solicitud, incluyendo las rechazadas por el rate limiting. Tercero, GZipMiddleware comprime todas las respuestas superiores a 500 bytes, lo que normalmente reduce el tamaño de transferencia de HTML entre un 70-80%.

Enrutamiento

Las rutas se dividen en dos categorías: las rutas de página devuelven documentos HTML completos, mientras que las rutas API devuelven JSON o fragmentos 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,
    })

La distinción importa para HTMX. Las rutas de página completa devuelven documentos que extienden base.html. Las rutas API devuelven fragmentos HTML que HTMX intercambia en elementos DOM existentes. El mismo motor de plantillas Jinja2 renderiza ambos, sin necesidad de una capa API separada.

Inyección de dependencias

El sistema Depends() de FastAPI proporciona una separación limpia entre los manejadores de rutas y la lógica compartida:

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

Las dependencias se componen entre sí. Una dependencia get_db puede depender de get_current_locale, que a su vez depende del request. FastAPI resuelve la cadena automáticamente.

Configuración con Pydantic

La configuración utiliza BaseSettings de Pydantic con precedencia de variables de entorno:

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

Las variables de entorno sobreescriben los valores del archivo .env. En producción (Railway), los secretos se configuran como variables de entorno. Localmente, un archivo .env proporciona valores por defecto. La clase Settings valida los tipos al iniciar — un campo requerido faltante falla de inmediato en lugar de hacerlo en tiempo de ejecución.

Patrones async

Las rutas de FastAPI son async por defecto. Para operaciones limitadas por I/O (consultas a base de datos, solicitudes HTTP, lectura de archivos), async evita bloquear el event loop:

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

Las operaciones limitadas por CPU (renderizado de Markdown, extracción de CSS) pueden usar funciones síncronas. FastAPI las ejecuta automáticamente en un pool de hilos cuando el manejador de ruta no se declara como 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(...)

La regla es simple: si la función espera I/O, hazla async. Si realiza trabajo de CPU, déjala síncrona. No mezcles await con llamadas bloqueantes en la misma función.9


Plantillas Jinja2

Herencia de plantillas

El sistema de herencia de Jinja2 reemplaza la composición de componentes de React con un modelo más simple. Una plantilla base define el esqueleto de la página. Las plantillas hijas rellenan bloques con nombre:

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

La directiva {% extends %} establece una relación padre-hijo. La plantilla hija solo define los bloques que necesita sobrescribir. Todo lo demás —el <head>, el encabezado, el pie de página, las etiquetas de script— proviene de la base. Esto es composición por sustracción en lugar de construcción.

El global asset()

Los archivos estáticos utilizan versionado por hash de contenido para invalidar la caché:

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

En la plantilla: {{ asset('css/styles.css') }} se renderiza como /static/css/styles.css?v=a3f8b2c1d0. El hash cambia cuando el archivo cambia, invalidando la caché del CDN. Esto reemplaza la estrategia de nombres con [contenthash] de webpack con 30 líneas de Python calculadas al inicio.

Include para parciales reutilizables

Los componentes que se repiten en distintas páginas usan {% 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>

El prefijo con guion bajo (_language_switcher.html) es una convención que indica un parcial —un fragmento de plantilla que no está diseñado para renderizarse de forma independiente. Este componente usa tanto Alpine.js (para el toggle del desplegable) como Jinja2 (para la lista de locales). La frontera es clara: Alpine.js controla el estado de abrir/cerrar, Jinja2 controla los datos.

Macros para componentes reutilizables

Los macros son las funciones de Jinja2 —bloques de plantilla reutilizables con parámetros:

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

Importa y usa macros en las plantillas de página:

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

Los macros reemplazan a los componentes de React para patrones de presentación. Aceptan parámetros, soportan valores por defecto y se componen con otros macros. La diferencia: los macros se renderizan una sola vez en el servidor y producen HTML estático. Los componentes de React se renderizan en el cliente y mantienen estado. Para mostrar contenido, los macros son la herramienta correcta.

Contexto de plantillas y globales

Los globales de Jinja2 son funciones disponibles en toda plantilla sin necesidad de pasarlas explícitamente:

# 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

El global asset() genera URLs versionadas. El global csrf_token() genera tokens CSRF frescos. El global analytics_script() inyecta el snippet de seguimiento. Estas funciones se pueden llamar en cualquier plantilla sin que el handler de la ruta las pase explícitamente.

Para i18n, la configuración es más elaborada —las funciones de traducción necesitan acceso al locale de la solicitud actual:

# 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

Cada función lee el locale desde la variable de contexto de la solicitud, establecida por el middleware de locale. La plantilla llama a {{ _('ui.nav.about') }} y obtiene la cadena traducida para el locale de la solicitud actual sin ningún parámetro de locale explícito.

Bloques condicionales

El sistema de bloques de Jinja2 soporta sobrescrituras condicionales:

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

Los posts del blog declaran sus dependencias en el frontmatter YAML (scripts: ["/static/js/boids.js"]). La plantilla las incluye condicionalmente. Las páginas que no necesitan scripts o estilos adicionales no envían ninguno —sin código muerto, sin imports sin usar.

Filtros personalizados

Los filtros de Jinja2 transforman datos durante el renderizado. El filtro sanitize previene XSS en contenido generado por usuarios:

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

En las plantillas: {{ user_content | sanitize }}. La biblioteca nh3 es un sanitizador de HTML basado en Rust —rápido y seguro. Elimina cualquier etiqueta o atributo que no esté en la lista permitida, previniendo XSS almacenado incluso si el contenido proviene de una fuente no confiable.10


Inmersión Profunda en HTMX

HTMX permite que cualquier elemento HTML realice solicitudes HTTP e inserte la respuesta en el DOM. La idea clave es arquitectónica: el HTML renderizado en el servidor es la API. El servidor devuelve la representación final. Sin renderizado del lado del cliente, sin serialización JSON, sin hidratación.

Atributos Principales

Atributo Propósito Ejemplo
hx-get Realizar solicitud GET hx-get="/search?q=term"
hx-post Realizar solicitud POST hx-post="/contact"
hx-target Dónde colocar la respuesta hx-target="#results"
hx-swap Cómo insertar la respuesta hx-swap="innerHTML" (por defecto), outerHTML, beforeend
hx-trigger Qué desencadena la solicitud hx-trigger="click", keyup changed delay:300ms, load
hx-indicator Elemento a mostrar durante la solicitud hx-indicator="#spinner"
hx-push-url Actualizar la URL del navegador hx-push-url="true"
hx-replace-url Reemplazar URL sin entrada en el historial hx-replace-url="true"

Patrón 1: Quiz Interactivo (Estado Multi-Paso en el Servidor)

blakecrosley.com incluye un quiz interactivo que guía a los usuarios en la selección de herramientas. Todo el estado del quiz vive en el servidor — sin gestión de estado del lado del cliente:

<!-- _quiz_container.html — carga inicial -->
<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 — cada pregunta -->
<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>

Cada clic en un botón envía las respuestas acumuladas como parámetro de consulta. El servidor calcula la siguiente pregunta o el resultado final basándose en el historial de respuestas. El estado se acumula en la URL — sin cookies, sin sesiones, sin JavaScript del lado del cliente. El quiz avanza mediante intercambios outerHTML: cada respuesta reemplaza el elemento completo del paso del quiz.

Patrón 2: Lista de Blog Paginada

La página de escritura utiliza HTMX para una paginación fluida que actualiza la URL:

<!-- Enlace de paginación -->
<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>

Cuatro atributos trabajando en conjunto:

  1. hx-get realiza la solicitud a la misma URL que el href (mejora progresiva — funciona sin JavaScript)
  2. hx-target coloca la respuesta en el contenedor #writing-content
  3. hx-replace-url="true" actualiza la URL del navegador sin agregar una entrada al historial
  4. hx-indicator muestra un indicador de carga durante la solicitud

El servidor detecta las solicitudes HTMX mediante el encabezado HX-Request y devuelve solo el fragmento de la lista de publicaciones en lugar de la página completa. Por eso el middleware de encabezados de seguridad agrega Vary: HX-Request — para que las cachés del CDN almacenen la página completa y el fragmento por separado.11

Patrón 3: Búsqueda con 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>

El atributo hx-trigger combina tres modificadores:

  • keyup se activa al soltar una tecla
  • changed se activa solo si el valor realmente cambió (evita solicitudes duplicadas por teclas modificadoras)
  • delay:300ms aplica debounce — espera 300ms después del último keyup antes de disparar

El servidor devuelve un fragmento HTML renderizado:

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

Sin estado del lado del cliente. Sin biblioteca de debounce. Sin useEffect. La plantilla renderiza los resultados, HTMX los inserta, y el servidor es la única fuente de verdad.

Patrón 4: Intercambios Out-of-Band (OOB)

A veces una sola acción del servidor necesita actualizar múltiples elementos del DOM. El mecanismo de intercambio out-of-band de HTMX resuelve esto sin orquestación del lado del cliente:

<!-- El servidor devuelve múltiples elementos en una sola respuesta -->
<!-- Objetivo principal: se intercambia normalmente vía hx-target -->
<div id="cart-items">
  <ul>
    <li>Widget A — $29.99</li>
    <li>Widget B — $14.99</li>
  </ul>
</div>

<!-- Objetivo OOB: se intercambia independientemente vía 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>

El atributo hx-swap-oob="true" le indica a HTMX que busque el elemento por id en cualquier parte del DOM y lo reemplace, independientemente del hx-target. Esto sustituye el patrón de React de “elevar el estado” — el servidor calcula todo el estado derivado y envía el HTML final para cada elemento en una sola respuesta.

En blakecrosley.com, este patrón aparece en el formulario de contacto: al enviar el formulario se reemplaza el cuerpo del formulario con un mensaje de éxito y simultáneamente se actualiza una insignia de notificación mediante intercambio OOB.

Patrón 5: Enlaces Potenciados

HTMX puede “potenciar” los enlaces de navegación estándar para usar AJAX en lugar de cargas de página completas:

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

Con hx-boost="true", al hacer clic en un enlace se obtiene la página mediante AJAX, se intercambia el contenido del <body> y se actualiza la URL — sin una recarga completa de la página. El historial del navegador funciona normalmente (botones de avance y retroceso). Si JavaScript falla, los enlaces funcionan como navegación estándar.

El beneficio es el rendimiento percibido: la navegación potenciada se siente instantánea porque el navegador no necesita re-analizar CSS, re-evaluar scripts ni re-renderizar el diseño. Solo cambia el contenido del <body>. blakecrosley.com utiliza enlaces potenciados en la navegación principal, lo que hace que las transiciones entre páginas se sientan como una aplicación de página única sin la arquitectura SPA.

Patrón 6: Encabezados de Solicitud HTMX

HTMX envía encabezados personalizados con cada solicitud:

Encabezado Valor Caso de Uso
HX-Request true Detectar solicitudes HTMX en el servidor
HX-Target ID del elemento Saber qué elemento recibirá la respuesta
HX-Trigger ID del elemento Saber qué elemento desencadenó la solicitud
HX-Current-URL URL completa Conocer la página actual del usuario

El servidor puede usar HX-Request para devolver respuestas diferentes:

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

Este patrón de respuesta dual es central para la arquitectura. Una carga de página completa devuelve el documento entero (plantilla base + contenido de la página). Una navegación HTMX devuelve solo el contenido que cambió. El servidor decide, no el cliente.

Patrón 7: Mejora Progresiva

Cada enlace HTMX en blakecrosley.com incluye un atributo href estándar:

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

Si JavaScript no se carga, el href funciona como un enlace normal. Si HTMX se carga, intercepta el clic y realiza un intercambio AJAX. Esto es mejora progresiva: el sitio funciona sin JavaScript, y HTMX mejora la experiencia cuando está disponible.

Patrón 5: Estados de Carga

<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 agrega la clase htmx-request al elemento que desencadena la solicitud durante las peticiones. El atributo hx-indicator apunta a un elemento que se hace visible durante la solicitud. Se estiliza con CSS:

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

Sin gestión de estados de carga. Sin useState(false). Sin setLoading(true). CSS maneja la visibilidad, HTMX maneja el cambio de clase.


Patrones de Alpine.js

Alpine.js llena el vacío que HTMX deja: estado que existe solo en el cliente y nunca necesita comunicarse con el servidor. Si el usuario hace clic en un menú desplegable y este se abre, ese estado existe únicamente en el navegador. Alpine.js lo gestiona con atributos HTML.

La regla del límite

El límite entre HTMX y Alpine.js es preciso:

Tipo de estado Herramienta Ejemplo
Necesita datos del servidor HTMX Resultados de búsqueda, validación de formularios, paginación
Existe solo en el navegador Alpine.js Abrir/cerrar desplegables, menú móvil, visibilidad de modales
Combina ambos Ambos Selector de idioma (toggle con Alpine.js, navegación tipo HTMX)

La plantilla base envuelve todo el encabezado en un componente 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>

Patrones clave de Alpine.js:

  • x-data declara el alcance del componente y el estado inicial
  • x-show alterna la visibilidad según el estado (usa CSS display: none)
  • x-cloak oculta el elemento hasta que Alpine.js se inicializa (evita el destello de contenido sin estilos)
  • @click vincula manejadores de clic con expresiones
  • :aria-expanded (abreviatura de x-bind:aria-expanded) establece atributos dinámicamente
  • @keydown.escape.window escucha la tecla Escape de forma global para cerrar paneles

Componente desplegable

El selector de idioma usa Alpine.js para el estado de alternancia con @click.away para cerrarse al hacer clic fuera:

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

El modificador @click.away cierra el desplegable al hacer clic fuera de él. Alpine.js resuelve esto con un solo atributo, sin necesidad de registrar event listeners, sin limpieza ni gestión de referencias.

Cuándo usar Alpine.js vs. JavaScript puro

Alpine.js es apropiado cuando:

  • El estado está acotado a un solo elemento del DOM (desplegable, modal, toggle)
  • Las interacciones son binarias o simples (abrir/cerrar, mostrar/ocultar, alternar)
  • Múltiples elementos necesitan reaccionar al mismo cambio de estado
  • Los atributos de accesibilidad deben mantenerse sincronizados con la visibilidad

JavaScript puro es apropiado cuando:

  • La interacción involucra cómputo complejo (visualizaciones, simulaciones)
  • El componente tiene su propio ciclo de renderizado (canvas, animación)
  • El rendimiento es crítico (Alpine.js añade sobrecarga por cada componente x-data)
  • La lógica excede 20-30 líneas de expresiones Alpine.js

blakecrosley.com usa Alpine.js para navegación, cambio de idioma y toggles de contenido. Los 20 componentes interactivos del blog (simulación de boids, visualizador de código Hamming, etc.) usan JavaScript puro porque requieren renderizado en canvas y máquinas de estado complejas.


Bootstrap 5 sin Sass

Bootstrap 5 eliminó jQuery como dependencia y admite el uso de CSS de forma independiente. No necesitas Sass, PostCSS ni ninguna herramienta de compilación para usar el sistema de grillas y las clases utilitarias de Bootstrap.

Hospedaje propio sin CDN

blakecrosley.com aloja todas las bibliotecas de terceros localmente:

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

El hospedaje propio elimina dependencias externas, evita que caídas del CDN rompan el sitio y permite caché inmutable con URLs basadas en hash de contenido. Descarga el CSS compilado de Bootstrap (no el código fuente Sass) y colócalo en static/css/vendor/.

Sistema de grillas

La grilla de Bootstrap funciona con clases HTML simples:

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

Sin mixins de Sass. Sin @include make-col(). El CSS compilado ya incluye las clases responsivas de la grilla. Para breakpoints personalizados más allá de los valores por defecto de Bootstrap, escribe media queries en CSS puro.

Sobrescrituras con CSS puro

Sobrescribe los valores por defecto de Bootstrap con propiedades personalizadas de CSS y selectores estándar:

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

Las propiedades personalizadas de CSS se propagan a través del DOM, se heredan de los elementos padre y responden a media queries en tiempo de ejecución. Las variables de Sass se compilan a valores estáticos y desaparecen. Esta distinción importa para la tematización: un solo cambio en una propiedad personalizada puede actualizar todos los valores derivados sin recompilación.12

Clases utilitarias vs. CSS de componentes

Usa las clases utilitarias de Bootstrap para espaciado y maquetación puntuales. Usa CSS de componentes para patrones repetidos:

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

El principio: clases utilitarias de Bootstrap para mecánicas de maquetación (margen, padding, flexbox). CSS personalizado para identidad visual (colores, tipografía, animaciones). Nunca mezcles clases utilitarias con estilos de componente para la misma responsabilidad.


i18n y localización

blakecrosley.com ofrece contenido en 10 idiomas: inglés, japonés, coreano, chino simplificado, chino tradicional, alemán, francés, español, polaco y portugués (brasileño).

Enrutamiento de locale basado en URL

El locale se encuentra en la ruta de la URL: /about (inglés), /ja/about (japonés), /zh-Hans/about (chino simplificado). El inglés es el idioma predeterminado y no tiene prefijo.

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

El middleware de locale extrae el locale de la ruta de la 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

El middleware elimina el prefijo de locale antes de hacer coincidir las rutas. Esto significa que los manejadores de rutas no necesitan rutas específicas por locale — /about maneja tanto inglés (/about) como japonés (/ja/about) porque el middleware normaliza la ruta.

Funciones de traducción en plantillas

Los globals de Jinja2 proporcionan funciones de traducción:

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

La función _() busca una clave de traducción en la caché en memoria. El filtro | default() proporciona el texto en inglés como respaldo si falta la traducción. La función locale_prefix() devuelve el prefijo de URL para el locale actual ("" para inglés, "/ja" para japonés).

Etiquetas hreflang

Cada página incluye etiquetas hreflang para todos los locales soportados:

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

Esto produce:

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

Los motores de búsqueda utilizan hreflang para mostrar la versión correcta del idioma en los resultados de búsqueda. La entrada x-default apunta a la versión en inglés como respaldo.13

Almacenamiento de traducciones y caché en memoria

Las traducciones se almacenan en Cloudflare D1 (SQLite en el edge) y se cargan en una caché en memoria al inicio:

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

La caché en memoria evita consultas a la base de datos en cada renderizado de página. Las actualizaciones de traducciones requieren una recarga de la caché (activada mediante un endpoint de administración o un despliegue). Esta arquitectura sacrifica frescura por rendimiento: las traducciones cambian con poca frecuencia, pero los renderizados de página ocurren en cada solicitud.

Monitoreo de salud

blakecrosley.com incluye un endpoint de verificación de salud de i18n que monitorea la cobertura de traducciones por 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

El umbral de cobertura del 99,5% detecta traducciones faltantes antes de que los usuarios encuentren cadenas sin traducir. El endpoint de salud se integra con el monitoreo de Railway para alertar cuando la cobertura baja — por ejemplo, después de agregar nuevas cadenas de interfaz que aún no se han traducido.

Renderizado de contenido según el locale

Las publicaciones del blog y las guías admiten traducciones por locale de metadatos y contenido:

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

El patrón es consistente: intentar primero con el contenido traducido, recurrir al inglés como respaldo. Esto permite la traducción parcial — un usuario japonés ve títulos y descripciones traducidos incluso si el cuerpo completo del artículo permanece en inglés. El filtro | default() de Jinja2 codifica este patrón en un solo pipe:

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

Traducción de datos estáticos

El contenido estático como descripciones de proyectos y etiquetas de navegación se traduce mediante funciones auxiliares que mantienen la misma estructura de datos mientras intercambian las cadenas específicas del locale:

# i18n/data.py
def translate_projects(projects: list, locale: str) -> list:
    """Return projects with translated titles and descriptions."""
    if locale == "en":
        return projects
    translated = []
    for project in projects:
        t = get_translation(f"project.{project['slug']}.title", locale)
        d = get_translation(f"project.{project['slug']}.description", locale)
        translated.append({
            **project,
            "title": t or project["title"],
            "description": d or project["description"],
        })
    return translated

Este enfoque mantiene la capa de traducción separada de la capa de datos. Las rutas pasan la misma lista projects independientemente del locale. Las funciones de traducción envuelven los datos de forma transparente.

Sitemap con alternativas hreflang

El sitemap dinámico incluye todas las páginas en todos los locales con referencias cruzadas:

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

Esto produce 10 entradas de URL por página (una por locale), cada una con 11 enlaces alternativos (10 locales + x-default). Para un sitio con 50 páginas, el sitemap contiene 500 entradas de URL con 5.500 enlaces hreflang. El sitemap se genera dinámicamente y se almacena en caché durante una hora.


Patrones de Base de Datos

SQLAlchemy 2.0 Async

Para aplicaciones que necesitan una base de datos relacional, el soporte async de SQLAlchemy 2.0 se integra de forma limpia con 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

Inyección de Dependencias para Sesiones de Base de Datos

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

La dependencia get_db gestiona el ciclo de vida de la sesión: abre una sesión, la cede al manejador de ruta, confirma los cambios en caso de éxito y revierte en caso de excepción. Cada operación de base de datos utiliza consultas parametrizadas — nunca interpolación de cadenas.

Integración con Pydantic

Los modelos Pydantic validan la entrada en el límite de la API y serializan la salida para las plantillas:

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 valida tipos, formatos (correo electrónico, URL) y restricciones (longitud mínima/máxima) antes de que se ejecute el manejador de ruta. La entrada inválida devuelve una respuesta 422 automáticamente. Esto reemplaza las bibliotecas de validación de formularios del lado del cliente — el servidor valida, y HTMX intercambia el mensaje de éxito o la retroalimentación de error.

Migraciones con Alembic

Alembic gestiona los cambios en el esquema de la base de datos:

# 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

La función de autogeneración compara los modelos de SQLAlchemy con el esquema actual de la base de datos y genera scripts de migración. Estos scripts son archivos Python versionados que residen en el repositorio:

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

Las migraciones se ejecutan durante el despliegue (antes de que la aplicación inicie). Esto garantiza que el esquema de la base de datos coincida con el código de la aplicación. En blakecrosley.com, la mayoría de los datos residen en Cloudflare D1 (accedido vía HTTP), por lo que las migraciones de Alembic se aplican a la base de datos local SQLite o PostgreSQL utilizada para datos de sesión y analíticas.

El Patrón Cloudflare D1

blakecrosley.com utiliza Cloudflare D1 como base de datos remota accedida a través de un 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"]

Este patrón funciona para aplicaciones que necesitan una base de datos pero no quieren administrar un servidor de base de datos. D1 es SQLite en el edge de Cloudflare, accedido vía HTTP. El proxy Worker se encarga de la autenticación y la limitación de solicitudes. La contrapartida es la latencia: cada consulta es una solicitud HTTP (~50-100ms) frente a una conexión de base de datos local (~1-5ms). La caché en memoria al inicio mitiga esto para cargas de trabajo de lectura intensiva como las traducciones.


Seguridad

Middleware de Encabezados de Seguridad

blakecrosley.com implementa encabezados de seguridad reforzados mediante middleware personalizado:

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

La CSP incluye 'unsafe-inline' y 'unsafe-eval' porque Alpine.js los requiere para la evaluación de expresiones. La alternativa es la compilación compatible con CSP de Alpine.js, que tiene limitaciones.14 Todo lo demás está bloqueado: frame-ancestors previene el clickjacking, form-action restringe el envío de formularios al mismo origen, y upgrade-insecure-requests fuerza HTTPS.

Seguridad de Caché CDN con HTMX

El middleware de encabezados de seguridad agrega Vary: HX-Request a las respuestas 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)

Sin este encabezado, un CDN podría almacenar en caché una respuesta de fragmento HTMX y servirla como página completa a una solicitud que no sea HTMX (o viceversa). El encabezado Vary le indica al CDN que almacene entradas de caché separadas según el valor del encabezado HX-Request.11

Protección CSRF

Los formularios HTMX utilizan tokens CSRF firmados con HMAC sin estado:

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

El token se genera en la plantilla mediante un global de Jinja2 y se incluye en las solicitudes de formulario HTMX:

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

Los tokens sin estado eliminan el almacenamiento de sesiones del lado del servidor. La firma HMAC garantiza que el token fue generado por el servidor. La marca de tiempo previene ataques de repetición. hmac.compare_digest previene ataques de temporización.15

Sanitización HTML

El contenido generado por usuarios pasa por nh3 antes de renderizarse:

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

La biblioteca nh3 elimina etiquetas y atributos que no están en la lista permitida. Los enlaces reciben automáticamente rel="noopener noreferrer". Esta defensa es independiente de CSP — previene XSS almacenado en la capa de renderizado, mientras que CSP previene scripts inyectados en la capa del navegador. Defensa en profundidad.

Validación de Entrada

Los modelos Pydantic validan toda la entrada en el límite de la 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 devuelve 422 Unprocessable Entity para entrada inválida automáticamente. Combinado con consultas parametrizadas de base de datos (SQLAlchemy nunca interpola cadenas), esto previene la inyección SQL y garantiza la seguridad de tipos en los límites.


Rendimiento

Lighthouse 100/100/100/100

blakecrosley.com obtiene 100 en las cuatro categorías de Lighthouse: Performance, Accessibility, Best Practices y SEO. Puedes verificarlo en PageSpeed Insights.2

Las optimizaciones clave:

CSS Crítico

El CSS crítico (above-the-fold) se extrae e inserta en línea dentro de <head>. La hoja de estilos completa se carga de forma asíncrona:

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

El truco de media="print" le indica al navegador que la hoja de estilos no es necesaria para la renderización en pantalla, por lo que no bloquea el primer pintado. El handler onload la cambia a media="all" después de cargar. El fallback con <noscript> asegura que la hoja de estilos se cargue incluso sin JavaScript.16

Compresión GZip

app.add_middleware(GZipMiddleware, minimum_size=500)

Las respuestas de más de 500 bytes se comprimen. HTML comprime entre un 70-80%, reduciendo un documento de 15KB a 3-4KB.

Caché Inmutable de Archivos Estáticos

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

Los archivos estáticos con URLs de hash de contenido (?v=a3f8b2c1d0) se almacenan en caché durante un año con immutable. El hash cambia cuando el archivo cambia, forzando a los navegadores y CDNs a obtener la nueva versión.

Carga Diferida de Scripts

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

El atributo defer descarga los scripts en paralelo con el análisis de HTML, pero los ejecuta después de que el documento se haya analizado. Esto evita el bloqueo de renderizado sin la complejidad de la carga asíncrona y la gestión del orden de ejecución.

Optimización de Imágenes

Las imágenes usan WebP con srcset responsivo y dimensiones explícitas:

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>

Los atributos explícitos width y height previenen el Cumulative Layout Shift (CLS). El atributo loading="lazy" difiere las imágenes fuera de pantalla. WebP proporciona archivos entre un 25-35% más pequeños que JPEG con calidad equivalente.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)

El header Link con rel=preload le indica a Cloudflare que envíe una respuesta 103 Early Hints, permitiendo al navegador comenzar a obtener el CSS antes de que el servidor termine de generar la respuesta HTML.18

JavaScript Mínimo

La huella total de JavaScript:

Biblioteca Tamaño (minificado + gzipped)
HTMX ~14 KB
Alpine.js ~14 KB
JS específico de página 4-8 KB
Total 32-36 KB

Una aplicación típica de React envía entre 100-300 KB de JavaScript del framework antes del código de la aplicación.19 El enfoque sin build envía menos JavaScript porque hay menos JavaScript que enviar.


Despliegue

Railway

blakecrosley.com se despliega en Railway mediante 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

El builder Nixpacks de Railway detecta el proyecto Python a partir de requirements.txt, instala las dependencias y ejecuta el comando de inicio. No se necesita Dockerfile. El endpoint de health check asegura que la aplicación responda antes de recibir tráfico:

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

El Pipeline de Despliegue

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

Sin npm install. Sin npm run build. Sin compilación de webpack. Sin compilación de TypeScript. El único paso de instalación es pip install -r requirements.txt, que se almacena en caché entre despliegues.

Procfile

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

El Procfile proporciona una alternativa compatible con Heroku. Railway soporta tanto railway.toml como Procfile. La sintaxis ${PORT:-8000} usa el puerto proporcionado por la plataforma o por defecto el 8000 para desarrollo local.

Configuración de Uvicorn en Producción

Para despliegues con mayor tráfico, usa múltiples workers:

uvicorn app.main:app \
  --host 0.0.0.0 \
  --port ${PORT:-8000} \
  --workers 4 \
  --loop uvloop \
  --http httptools
  • --workers 4 ejecuta cuatro procesos worker (regla general: 2 * núcleos de CPU + 1)
  • --loop uvloop usa el event loop más rápido uvloop (reemplazo directo de asyncio)
  • --http httptools usa el parser HTTP más rápido httptools

Para desarrollo, --reload observa los cambios en archivos:

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

Alternativa con Docker

Para plataformas que requieren 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"]

La imagen base slim mantiene el contenedor pequeño. --no-cache-dir evita que pip almacene paquetes descargados en la capa de la imagen.

CDN de Cloudflare

blakecrosley.com usa Cloudflare para caché CDN, DNS y 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 — el navegador almacena en caché durante 5 minutos
  • s-maxage=3600 — el CDN almacena en caché durante 1 hora
  • stale-while-revalidate=86400 — sirve contenido obsoleto mientras revalida durante 24 horas

Los archivos estáticos obtienen max-age=31536000, immutable porque las URLs con hash de contenido garantizan la frescura.


Marco de Decisión

¿Necesitas Herramientas de Build?

Responde cuatro preguntas:

1. ¿Más de cinco desarrolladores comparten interfaces de JavaScript? Si la respuesta es sí, la verificación de tipos en tiempo de compilación de TypeScript previene errores de integración que las pruebas en tiempo de ejecución detectan demasiado tarde. Agrega un paso de build.

2. ¿Tu aplicación gestiona estado complejo del lado del cliente? Si el drag-and-drop, la colaboración en tiempo real o los datos offline-first son funciones centrales (no complementarias), un framework como React o Svelte justifica su complejidad. Agrega un paso de build.

3. ¿Múltiples productos consumen una biblioteca de componentes compartida? Si la respuesta es sí, esa biblioteca necesita empaquetado npm, versionado semántico y tree shaking. Agrega un paso de build.

4. ¿Dependes de bibliotecas del ecosistema npm que asumen un bundler? Si Radix, Framer Motion, TanStack Query o bibliotecas similares son fundamentales para el producto, un pipeline de build es obligatorio.

Si las cuatro respuestas son “no”, el enfoque sin build es viable. Si cualquier respuesta es “sí”, las herramientas de build resuelven un problema real. El error es agregar herramientas de build cuando las cuatro respuestas son “no” — resolviendo problemas que no tienes mientras creas sobrecarga de gestión de dependencias que sí tendrás.1

Comparación de Stacks

Categoría Sin Build (Esta Guía) React + Herramientas de Build
Ideal para Sitios de contenido, portafolios, herramientas internas, apps CRUD Productos SaaS, SPAs complejas, consumidores de design systems
Tamaño del equipo 1-5 desarrolladores 5-50+ desarrolladores
Gestión de estado Servidor (HTMX) + cliente (Alpine.js) Cliente (React state, Redux, Zustand)
Seguridad de tipos En tiempo de ejecución (Pydantic del lado del servidor) En tiempo de compilación (TypeScript)
Reutilización de componentes Jinja2 includes + macros Paquetes npm, bibliotecas compartidas
SEO Renderizado en servidor por defecto Requiere configuración de SSR/SSG
Piso de rendimiento Alto (JS mínimo, renderizado en servidor) Variable (sobrecarga del framework)
Techo de complejidad Menor (sin offline, sin estado rico del cliente) Mayor (cualquier interacción del cliente es posible)
Dependencias 15 paquetes de Python 300+ paquetes npm
Tiempo de build 0 segundos 15-60 segundos

Cuándo HTMX No Es la Opción Correcta

HTMX reemplaza el estado del cliente con viajes de ida y vuelta al servidor. Esto funciona hasta que la latencia importa:

  • Interfaces de drag-and-drop — un viaje de ida y vuelta de 200ms al servidor por cada evento de arrastre es inaceptable
  • Colaboración en tiempo real — el estado impulsado por WebSocket requiere resolución de conflictos del lado del cliente
  • Aplicaciones offline-first — sin servidor no hay HTMX
  • Animaciones complejas vinculadas al estado — Framer Motion y React Spring asumen un modelo de reconciliación de React
  • Aplicaciones Canvas/WebGL — el bucle de renderizado es inherentemente del lado del cliente

Para estos casos de uso, un framework del lado del cliente es la herramienta correcta. El enfoque sin build no intenta reemplazarlos.


Tarjeta de Referencia Rápida

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"

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

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

Propiedades personalizadas de 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; }
}

Encabezados de seguridad

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 de verificación para configuración del proyecto

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

Preguntas Frecuentes

¿HTMX está listo para producción en aplicaciones web reales?

Sí. HTMX ha sido estable desde 2020 y se usa en producción en múltiples industrias. Carson Gross, su creador, mantiene la compatibilidad retroactiva como principio fundamental de diseño — la documentación de HTMX establece que la biblioteca no romperá aplicaciones existentes dentro de una versión mayor.20 La biblioteca pesa 14KB minificada y comprimida con gzip, no tiene dependencias y sigue versionado semántico. blakecrosley.com ha ejecutado HTMX en producción durante tres años sin un solo bug relacionado con HTMX.

¿Puedo usar TypeScript sin un paso de compilación?

Parcialmente. Los archivos TypeScript pueden verificarse con tsc --noEmit sin generar archivos de salida, lo que proporciona verificación en tiempo de compilación como un linter. Sin embargo, los navegadores no pueden ejecutar archivos .ts directamente, por lo que aún se necesita un paso de compilación para servir TypeScript. La alternativa son las anotaciones de tipo JSDoc en archivos .js estándar, que TypeScript puede verificar sin compilación. Esto ofrece seguridad de tipos durante el desarrollo mientras se entrega JavaScript estándar.

¿Cómo se compara este enfoque con Astro o 11ty?

Astro y 11ty son generadores de sitios estáticos que producen HTML plano con mínimo JavaScript en el cliente, pero requieren un paso de compilación (Node.js, npm install, un comando de build). El enfoque sin compilación elimina ese paso — el servidor renderiza HTML en cada solicitud. La compensación: Astro/11ty producen páginas estáticas más rápidas (sin computación del servidor), mientras que FastAPI + HTMX maneja contenido dinámico de forma nativa (datos específicos del usuario, envío de formularios, actualizaciones en tiempo real) sin una capa API separada.

¿Qué hay del renderizado del lado del servidor (SSR) con React?

Next.js SSR y el enfoque FastAPI + HTMX comparten un objetivo: enviar HTML renderizado en el servidor al navegador. La diferencia está en lo que sucede después del renderizado inicial. Next.js hidrata la página con React, enviando el runtime del framework y el código de los componentes al cliente. FastAPI + HTMX no hidrata — el HTML es el resultado final. HTMX maneja las interacciones subsiguientes solicitando nuevos fragmentos de HTML al servidor. El resultado: FastAPI + HTMX envía 30-40KB de JavaScript en total frente a 100-300KB de una aplicación Next.js.19

¿Cómo manejo la validación de formularios con este stack?

Del lado del servidor. Pydantic valida la entrada cuando se envía el formulario. Si la validación falla, el servidor devuelve el formulario con mensajes de error. HTMX intercambia la respuesta en el 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
    })

El servidor valida, el servidor renderiza los estados de error y HTMX intercambia el resultado. No se necesita ninguna biblioteca de validación del lado del cliente. El atributo HTML required proporciona validación básica a nivel del navegador como primera línea de defensa.

¿Puedo agregar funciones en tiempo real (WebSockets)?

Sí. FastAPI tiene soporte integrado para 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 tiene una extensión de WebSocket (hx-ws) que conecta elementos a endpoints WebSocket:

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

Los mensajes del servidor se intercambian en el DOM usando los mismos mecanismos de targeting y swap que las respuestas HTTP. El servidor envía fragmentos de HTML a través del WebSocket, y HTMX los inserta.

¿Cómo maneja este stack el SEO?

El HTML renderizado en el servidor es inherentemente amigable para SEO porque los rastreadores reciben el contenido completo de la página sin ejecutar JavaScript. blakecrosley.com agrega varias capas de SEO:

  • Datos estructurados JSON-LD en <head> para cada página (esquemas Person, Article, WebSite, FAQPage)
  • Sitemap dinámico con alternativas hreflang para los 10 idiomas
  • Feed RSS en /blog/feed.xml
  • llms.txt en la raíz para descubrimiento por rastreadores de IA
  • URLs canónicas y etiquetas Open Graph en la plantilla base
  • HTML semántico: <article>, <section>, <main>, jerarquía de encabezados adecuada

No se necesita configuración de SSR. No hay getStaticProps. No hay ISR. El HTML se renderiza en cada solicitud — ese es el comportamiento predeterminado, no una optimización.

¿Cuál es la curva de aprendizaje comparada con React?

Para desarrolladores Python, la curva de aprendizaje es significativamente menor. Ya conoces el lenguaje. Los manejadores de rutas de FastAPI devuelven respuestas de plantilla — el mismo modelo mental que las vistas de Flask o Django. HTMX agrega un puñado de atributos HTML (hx-get, hx-target, hx-swap). Alpine.js agrega unos pocos más (x-data, x-show, @click). No hay JSX, no hay DOM virtual, no hay sistema de hooks, no hay biblioteca de gestión de estado ni configuración de herramientas de compilación que aprender.

La documentación de HTMX cabe en una sola página larga. La documentación de Alpine.js cabe en unas pocas páginas. La documentación de React abarca cientos de páginas cubriendo hooks, context, refs, effects, suspense, server components y streaming SSR.

Para desarrolladores de JavaScript/React, el cambio es conceptual más que sintáctico. La idea central es que el servidor posee el estado y el servidor renderiza el HTML. La gestión de estado del lado del cliente se convierte en manejo de rutas del lado del servidor. La obtención de datos del lado del cliente se convierte en atributos HTMX en elementos HTML. La sintaxis es más simple — el modelo mental requiere desaprender la suposición SPA de que el cliente controla el renderizado.


Registro de Cambios

Fecha Cambio
24 de marzo de 2026 Publicación inicial

Referencias


Esta guía cubre el sistema completo utilizado para construir blakecrosley.com. El No-Build Manifesto proporciona el argumento filosófico. La publicación Lighthouse Perfect Score documenta el recorrido de optimización de rendimiento. La publicación Vibe Coding vs. Engineering explora dónde encaja el desarrollo asistido por IA en este flujo de trabajo.


  1. Métricas de producción de blakecrosley.com a marzo de 2026. El sitio sirve 37 publicaciones de blog, 20 componentes interactivos de JavaScript, 20 secciones de guías y 10 traducciones de idiomas con 15 paquetes de Python y cero herramientas de compilación. Lista completa de dependencias: fastapi, uvicorn, starlette, pydantic, pydantic-settings, jinja2, markdown, pygments, beautifulsoup4, lxml, nh3, resend, python-multipart, httpx, analytics-941. Verificado desde requirements.txt

  2. Google PageSpeed Insights (pagespeed.web.dev) ejecuta auditorías Lighthouse contra cualquier URL pública. blakecrosley.com obtiene 100/100/100/100 (Rendimiento, Accesibilidad, Mejores Prácticas, SEO) a marzo de 2026. Los resultados son verificables públicamente. Consulta From 76 to 100: Achieving a Perfect Lighthouse Score para el recorrido completo de optimización. 

  3. Un npx create-next-app@latest limpio (Next.js 15, probado en febrero de 2026) instala 311 paquetes en node_modules/ con un total de 187 MB. Los proyectos en producción con dependencias adicionales tienden a ser mayores. Los proyectos individuales varían. Fuente: pruebas del autor, documentadas en The No-Build Manifesto

  4. La documentación de rendimiento de Next.js de Vercel recomienda optimizaciones específicas (optimización de imágenes, carga de fuentes, división de código) para alcanzar puntuaciones superiores a 90. Consulta nextjs.org/docs/app/building-your-application/optimizing. El rango de 70-90 refleja la configuración predeterminada antes de aplicar estas optimizaciones. 

  5. Lista completa de dependencias verificada desde el requirements.txt de blakecrosley.com a marzo de 2026. Cero paquetes son herramientas de compilación, compiladores o empaquetadores. 

  6. Basado en la experiencia del autor manteniendo proyectos Next.js (2021-2024), el ecosistema de JavaScript genera entre 15 y 25 PRs de Dependabot por mes para proyectos activos, la mayoría actualizando dependencias transitivas que el desarrollador nunca importó directamente. 

  7. Tim Berners-Lee articuló la compatibilidad retroactiva como un principio de diseño web: “un navegador debe ser retrocompatible.” Una página de 1996 se renderiza en Chrome 2026. Consulta w3.org/DesignIssues/Principles

  8. OWASP recomienda deshabilitar los endpoints de documentación de API en producción para reducir la superficie de ataque. El endpoint /openapi.json expone todas las definiciones de rutas, parámetros y modelos de respuesta. 

  9. Documentación de FastAPI sobre handlers async vs sync: fastapi.tiangolo.com/async/. Mezclar await con llamadas bloqueantes en funciones async agota el bucle de eventos. 

  10. nh3 es un sanitizador de HTML basado en Rust, el sucesor de la biblioteca Bleach. Es mantenido por el proyecto PyO3 y proporciona sanitización de HTML basada en listas de permitidos. Consulta github.com/messense/nh3

  11. El header Vary está definido en la RFC 9110, Sección 12.5.5. Instruye a las cachés a almacenar respuestas separadas según los valores del header de solicitud especificado. Sin Vary: HX-Request, un CDN podría servir un fragmento de HTMX como una respuesta de página completa. Consulta httpwg.org/specs/rfc9110.html#field.vary

  12. Las Custom Properties de CSS (Variables CSS) son compatibles con más del 97% de los navegadores globales. Se propagan en cascada, se heredan y responden a media queries en tiempo de ejecución, capacidades que las variables de preprocesadores no tienen. Fuente: caniuse.com/css-variables

  13. Documentación de hreflang de Google: developers.google.com/search/docs/specialty/international/localized-versions. El valor x-default designa la página de respaldo para usuarios cuyo idioma no está en la lista de hreflang. 

  14. Alpine.js requiere 'unsafe-eval' en la Content Security Policy para su motor de evaluación de expresiones. La compilación compatible con CSP (@alpinejs/csp) evita este requisito pero tiene limitaciones. Consulta alpinejs.dev/advanced/csp

  15. Los tokens CSRF basados en HMAC siguen el patrón “Signed Double-Submit Cookie” descrito en la hoja de referencia de prevención de CSRF de OWASP. hmac.compare_digest utiliza comparación en tiempo constante para prevenir ataques de canal lateral por temporización. Consulta cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html

  16. La técnica de carga asíncrona de CSS con media="print" está documentada por el equipo de web.dev. El navegador trata la hoja de estilos como no bloqueante para el renderizado porque está declarada para medios de impresión. El handler onload la actualiza a medios all después de la descarga. Consulta web.dev/articles/defer-non-critical-css

  17. WebP proporciona archivos entre un 25-35% más pequeños que JPEG con calidad visual equivalente. Estudio de WebP de Google: developers.google.com/speed/webp/docs/webp_study

  18. 103 Early Hints permite al servidor (o CDN) enviar una respuesta preliminar con indicaciones de precarga antes de que la respuesta final esté lista. Cloudflare admite Early Hints para headers Link con rel=preload. Consulta developer.chrome.com/blog/early-hints

  19. React 18 + ReactDOM pesa aproximadamente 42 KB minificado + gzipped. Con un enrutador, una biblioteca de gestión de estado y el runtime del framework de compilación, las aplicaciones típicas de React envían entre 100-300 KB de JavaScript del framework. Fuente: bundlephobia.com/package/[email protected]

  20. La política de versionado de HTMX y su compromiso de compatibilidad retroactiva están documentados en htmx.org/migration-guide-htmx-1/. Carson Gross ha declarado el principio de compatibilidad retroactiva en Hypermedia Systems (2023) de Gross, Stepinski y Cotter: hypermedia.systems

NORMAL fastapi-htmx.md EOF