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

FastAPI + HTMX: El full stack sin compilación

# Crea aplicaciones web de producción sin React ni webpack: FastAPI, HTMX, Alpine.js, Jinja2, CSS puro, patrones de Bootstrap, i18n, despliegue, SEO y rendimiento.

author: words: 14174 read_time: 66m updated: 2026-08-17 02:27
$ less fastapi-htmx.md

Resumen rápido: FastAPI + HTMX + Alpine.js + Jinja2 + CSS simple permite crear aplicaciones web de producción sin herramientas de compilación, sin node_modules/ y con puntuaciones perfectas en Lighthouse. Esta guía cubre todo el sistema, desde la arquitectura hasta el despliegue, usando blakecrosley.com como referencia de producción que sirve 210 publicaciones de blog, componentes interactivos de JavaScript, 11 guías principales, 48 estudios de diseño e inglés más 9 configuraciones regionales traducidas sin un solo empaquetador, compilador ni transpilador.1

El stack moderno de desarrollo web asume que necesitas React, webpack, TypeScript y un pipeline de compilación. Para una gran categoría de aplicaciones —sitios basados en contenido, herramientas internas, aplicaciones CRUD, sitios de portafolio, plataformas de documentación— esa suposición es incorrecta. El stack descrito en esta guía elimina toda la cadena de herramientas de compilación frontend y, al mismo tiempo, produce sitios que obtienen 100/100/100/100 en Lighthouse.2

Esto no es defensa de una idea. Es una medición. La arquitectura descrita aquí se ejecuta en producción, sirve a usuarios reales en diez idiomas y los números se pueden verificar.


Puntos clave

  • El HTML renderizado en el servidor elimina tres categorías completas de problemas: gestión del estado del cliente, límites de serialización JSON y desajustes de hidratación. HTMX convierte las respuestas del servidor en el resultado final — sin paso de renderizado en el cliente.
  • Cero herramientas de compilación significa cero fallos de compilación. Sin conflictos de dependencias entre pares en npm install, sin errores del compilador TypeScript en archivos que no tocaste, sin PRs de Dependabot por dependencias transitivas que nunca importaste. La canalización de despliegue es git push.
  • Alpine.js gestiona el estado exclusivo del cliente que HTMX no puede manejar. Menús desplegables, modales, alternancias de navegación móvil y cualquier estado de UI que existe únicamente en el navegador pertenecen a Alpine.js. El límite es claro: si el estado necesita el servidor, usa HTMX. Si no lo necesita, usa Alpine.js.
  • CSS plano con propiedades personalizadas reemplaza a Sass y Tailwind. Las propiedades personalizadas de CSS se propagan en cascada, se heredan y responden a media queries en tiempo de ejecución. Las variables de los preprocesadores se compilan a valores estáticos y desaparecen. El navegador lee las propiedades personalizadas directamente — sin paso de compilación.
  • Este enfoque tiene límites claros. Es incorrecto para equipos grandes que comparten interfaces de componentes, productos SaaS con estado complejo del lado del cliente y aplicaciones que dependen de bibliotecas del ecosistema npm. El marco de decisión en la Sección 15 identifica el límite con precisión.
  • blakecrosley.com es la prueba. Los patrones centrales de esta guía (HTMX, Alpine.js, Jinja2, CSS plano) se ejecutan en producción en blakecrosley.com. Las secciones de Bootstrap y SQLAlchemy cubren patrones estándar para el stack que no se usan en este sitio específico. 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 exhaustiva. Empieza donde mejor se ajuste tu nivel de experiencia:

Experiencia Empieza aquí Después explora
Desarrollador Python, nuevo en HTMX La tesis sin buildVisión general de la arquitecturaHTMX a fondo Patrones de Alpine.js, Seguridad
Desarrollador de React/Vue evaluando alternativas La tesis sin buildMarco de decisión Visión general de la arquitectura, Rendimiento
Desarrollador FastAPI que añade interactividad HTMX a fondoPatrones de Alpine.js i18n y localización, Despliegue
Desarrollador full-stack que construye 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 ofrece un resumen escaneable.


La tesis sin build

La tesis es estrecha y específica: para sitios orientados a contenido con un desarrollador en solitario o un equipo pequeño, las herramientas de compilación resuelven problemas que no tienes y, al mismo tiempo, crean problemas que sí tendrás.

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

Métrica blakecrosley.com (sin build) Proyecto típico de Next.js3
Dependencias 17 paquetes de Python Más de 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 de base, 250-400 MB con añadidos
Tiempo de instalación pip install: 8 segundos npm install: 30-90 segundos
Paso de build Ninguno next build: 15-60 segundos
Canalización de despliegue git push → en vivo en ~40 segundos Instalar → compilar → desplegar: 2-5 minutos
Rendimiento de Lighthouse 100 70-90 sin optimización explícita4

Los 17 paquetes de Python incluyen FastAPI, Jinja2, Pydantic, uvicorn, nh3 y otros 12. Ninguno es una herramienta de compilación. Ninguno es un compilador. Ninguno es un empaquetador.5

A qué renuncias

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 en solitario. No funcionaría para un equipo de 10 personas que comparten interfaces de componentes.

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

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

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 sistema de diseño desde npm. El sistema de diseño vive en propiedades personalizadas de CSS. No se puede importar como un 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 entre pares. Ningún next build puede fallar por un error de TypeScript en un archivo que no tocaste.6

Depura con Ver código fuente. El JavaScript que se ejecuta en el navegador es el JavaScript que escribiste. No se requieren 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 (~15 KB comprimido con gzip), un archivo CSS (~8 KB), HTMX (~16 KB, en caché), Alpine.js (~15 KB, en caché) y el JS interactivo de la página (~4-8 KB). Total: aproximadamente 55-65 KB 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 a 5, sin obsolescencia de Create React App, sin migración al App Router de Next.js.

Comparación de stacks

Cómo se compara el stack sin build con alternativas comunes en dimensiones medibles:

Dimensión FastAPI+HTMX (esta guía) Next.js (React) Astro 11ty
JS enviado al navegador 35-40 KB (HTMX+Alpine+pequeños scripts de página) 85-250 KB+ (runtime de React) 0 KB por defecto, islas opcionales 0 KB por defecto
Paso de build Ninguno Obligatorio (webpack/turbopack) Obligatorio (Vite) Obligatorio (personalizado)
Archivos de configuración 0 5-8 (next.config, tsconfig, etc.) 1-3 (astro.config, tsconfig) 1-2 (.eleventy.js)
Canalización de despliegue git push (40 s) Instalar+compilar+desplegar (2-5 min) Instalar+compilar+desplegar (1-3 min) Instalar+compilar+desplegar (1-2 min)
Interactividad del lado del servidor Nativa (HTMX) Rutas de API + fetch del cliente Limitada (acciones de formulario) Ninguna (salida estática)
Gestión del estado del cliente Alpine.js (15 KB) Estado/contexto de React/Redux Islas del framework JS manual
Lenguaje del backend Python JavaScript/TypeScript JavaScript/TypeScript JavaScript
Enfoque de i18n Lado del servidor (middleware) next-intl o paquete similar @astrojs/i18n Manual
Rendimiento de Lighthouse 100 (medido) 70-90 típico4 95-100 típico 95-100 típico
Mejor para Sitios de contenido, CRUD, dashboards SPA complejas, equipos grandes Sitios de contenido, marketing Blogs estáticos, documentación

Astro y 11ty son los competidores más cercanos para sitios de contenido. Ambos producen una excelente salida estática, pero requieren un paso de build y una cadena de herramientas de JavaScript. El stack FastAPI+HTMX intercambia el rendimiento de los sitios estáticos por interactividad del lado del servidor (filtrado por categorías, manejo de formularios, búsqueda en tiempo real) sin añadir un paso de build. Si tu sitio es puramente estático sin interacciones con el servidor, Astro o 11ty pueden ser la mejor opción.


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, alternadores) Solo cliente
Bootstrap 5 Sistema de grilla, clases utilitarias, diseño responsivo Cliente (CSS)
CSS simple 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 cosa. Las rutas viven en routes/. Las plantillas viven en templates/. Los archivos estáticos viven en static/. Ningún paso de compilación transforma uno en otro.

Contraste con la arquitectura SPA

En un proyecto 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 .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 manera 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 se rompe en tiempo de ejecución, no en tiempo de compilación. Esto traslada los errores del tiempo de compilación al tiempo de ejecución, lo cual es una compensación genuina. 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 bugs que los errores en tiempo de ejecución no pueden.


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)

Aquí importan tres decisiones de diseño. Primero, docs_url=None y openapi_url=None desactivan los endpoints automáticos de documentación de API. Un sitio de contenido orientado al público no necesita tener /docs ni /openapi.json expuestos en internet.8 Segundo, el orden del middleware importa: el registro de seguridad se ejecuta primero (se añade al final), por lo que captura cada solicitud, incluidas las rechazadas por la limitación de frecuencia. Tercero, GZipMiddleware comprime las respuestas de más de 500 bytes, lo que normalmente reduce el tamaño de transferencia de HTML entre un 70 y un 80 %. Desde Starlette 1.5.0 ya no comprime todo: una lista de exclusión predeterminada ahora omite las cargas útiles ya comprimidas y binarias (archivos gzip y zip, PNG, JPEG, WebP, GIF, AVIF, audio/*, video/*, fuentes WOFF y WOFF2, y text/event-stream), que es lo que quieres: volver a comprimir un PNG consume CPU para hacerlo apenas más grande. Ten en cuenta que la lista excluye deliberadamente image/*, por lo que image/svg+xml todavía se comprime. Puedes reemplazarla con el parámetro exclusivo de palabras clave exclude_content_types.28

Enrutamiento

Las rutas se dividen en dos categorías: las rutas de página devuelven documentos completos de HTML, y las rutas de API devuelven fragmentos de JSON o 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 completas devuelven documentos que extienden base.html. Las rutas de API devuelven fragmentos de HTML que HTMX intercambia en elementos DOM existentes. El mismo motor de plantillas de Jinja2 renderiza ambos; no existe una capa de API separada.

Inyección de dependencias

El sistema Depends() de FastAPI ofrece una separación clara 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. Una dependencia get_db puede depender de get_current_locale, que depende de la solicitud. FastAPI resuelve la cadena automáticamente.

Configuración de Pydantic

La configuración usa 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 reemplazan los valores del archivo .env. En producción (Railway), los secretos se configuran como variables de entorno. De forma local, un archivo .env proporciona valores predeterminados. La clase Settings valida los tipos al inicio: si falta un campo obligatorio, falla de inmediato en lugar de hacerlo durante la ejecución.

Patrones async

Las rutas de FastAPI son async de forma predeterminada. Para operaciones vinculadas a E/S (consultas de base de datos, solicitudes HTTP, lecturas de archivos), async evita bloquear el bucle de eventos:

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

app = FastAPI(lifespan=lifespan)

Lifespan ahora es la única vía de inicio y apagado. Starlette alcanzó su primera versión estable, 1.0, en marzo de 2026 (1.6.0 al 8 de agosto) y eliminó los hooks on_event, on_startup y on_shutdown, obsoletos desde hace tiempo: lifespan (arriba) es el único mecanismo, y @app.route() / @app.websocket_route() dieron paso a Route / WebSocketRoute en la lista routes. FastAPI 0.137.0 (14 de junio de 2026) refactoriza sus propios componentes internos del router: router.routes ya no es una lista plana de objetos APIRoute, sino un árbol de nodos intermedios, así que debes tratarlo como un detalle interno en vez de algo que recorrer. La ventaja es que las rutas añadidas a un router después de include_router() ahora se reflejan en tiempo real, y un subrouter puede incluirse antes de definir sus rutas. FastAPI en sí no fija Starlette a la línea 1.x: su requisito de ejecución es un límite inferior sin más, starlette>=0.46.0, desde 0.136.3 y no ha cambiado hasta 0.140.7; no hay límite superior, y Starlette 0.4x todavía lo cumple. Los números de versión 1.x en las notas de lanzamiento de 0.137.0 son actualizaciones de dependabot al archivo de bloqueo de pruebas del propio repositorio, no una restricción de ejecución para tu aplicación.24 Nada de esto cambia los patrones de esta guía: utiliza lifespan y la declaración estándar de rutas en todo momento; pero si mantienes herramientas que recorren router.routes, o aún ejecutas manejadores heredados @app.on_event, 0.137.0 / Starlette 1.0 son incompatibles. FastAPI 0.137.2 (18 de junio de 2026) continúa con iter_route_contexts(), la forma admitida de enumerar rutas ahora que router.routes es interno. FastAPI 0.138.0 (20 de junio de 2026) añade después app.frontend("/", directory="dist") / router.frontend(...) para servir un frontend estático compilado: es útil si distribuyes una compilación SPA independiente, pero no tiene relación con el enfoque sin compilación y renderizado en servidor de esta guía (monta un directorio dist/ en vez de renderizar HTML en el servidor).25 FastAPI 0.139.0 (1 de julio de 2026) lo amplía con soporte de dependencias en app.frontend() —por ejemplo, autenticación automática mediante cookies para el frontend servido—, llevando la misma infraestructura Depends() que usas en rutas de API al montaje de frontend estático.26 FastAPI 0.141.0 (29 de julio de 2026) añade app.frontend(check_dir="auto"), que evita que fastapi dev falle cuando el directorio de compilación todavía no existe: el caso habitual en el que inicias el servidor antes de ejecutar la compilación del frontend. FastAPI 0.141.1, publicada el mismo día, corrige que las dependencias de app.frontend() descartaran silenciosamente tareas en segundo plano y encabezados de respuesta: una dependencia que configuraba una cookie o programaba una BackgroundTask perdía ese trabajo en el montaje de frontend, aunque funcionaba normalmente en las rutas de API. Si adoptaste el soporte de dependencias de 0.139.0, 0.141.1 es la versión que hace que se comporte como el resto de la aplicación.29

FastAPI 0.140.0 pone fin a una regresión de memoria presente en todas las versiones desde noviembre de 2025: actualiza. La versión del 24 de julio de 2026 es un único refactor con un efecto desproporcionado. Dependant, el objeto interno que FastAPI crea para cada nodo del grafo de dependencias de cada ruta, había acumulado atributos functools.cached_property desde 0.121.0 (3 de noviembre de 2025): diez de ellos en 0.139.2. Una propiedad en caché necesita un __dict__ por instancia para escribir su resultado, por lo que el costo se multiplicaba en cada nodo de cada grafo de la aplicación. PR #16049 saca esa lógica de la clase y la lleva a funciones auxiliares de nivel de módulo (_get_cache_key(), _get_oauth_scopes(), _uses_scopes()), y declara Dependant como @dataclass(slots=True), dejándolo como un contenedor de datos puro. La propia ejecución de CodSpeed de FastAPI en el PR fusionado sitúa el benchmark de memoria test_dependency_graph en 17.5 MB → 1.1 MB, una reducción de ×16; el informe que impulsó el trabajo describía un servicio de producción que se mantenía por debajo de aproximadamente 400 MB en 0.120.4 y alcanzaba OOM en 0.121.3. Todas las versiones que esta guía ha recomendado desde entonces —0.137.x, 0.138.0, 0.139.2— lo incluían. Si tu aplicación tiene un árbol de dependencias profundo o amplio (Depends() anidados, esquemas de seguridad, muchos routers incluidos), 0.140.0 ofrece una mejora gratuita de memoria sin requerir cambios en el código de la aplicación.27

0.140.0 fue el primer paso, no toda la solución: fija 0.140.7 o una versión posterior. Tres días después de ese lanzamiento, el 27 de julio de 2026, FastAPI publicó siete versiones más en cinco horas y media: de 0.140.1 a 0.140.7; todas ellas refactorizaciones de la misma infraestructura de dependencias. El trabajo se divide en dos partes. Primero, el árbol de dependencias plano: FastAPI solía crear y conservar una copia aplanada del grafo de dependencias de cada ruta, y 0.140.2 deja de retenerla, mientras que 0.140.3, 0.140.5, 0.140.6 y 0.140.7 eliminan los lugares restantes que volvían a crear una: la generación de OpenAPI, los campos del cuerpo, los parámetros de solicitud y OpenAPI de nuevo. 0.140.4 elimina la contabilidad que rastreaba dependencias repetidas que nadie leía. Segundo, y el único cambio con un umbral visible: 0.140.1 aumenta la lru_cache de los auxiliares de clasificación de llamadas en fastapi/dependencies/models.py de 1,024 a 4,096 entradas, tras una constante denominada _CALLABLE_CLASSIFICATION_CACHE_SIZE, porque los usuarios reportaron que las aplicaciones con más de 1,024 dependencias distintas saturaban la caché. Nada de esto cambia una ruta de API que llamas, así que actualizar es solo cambiar la versión. Vale la pena mencionar claramente dos advertencias: la cadencia significa que la línea todavía está en movimiento, así que lee las notas de lanzamiento en vez de asumir que 0.140.7 es el final; y FastAPI añadió los benchmarks de dependencias de OpenAPI que miden este trabajo en la misma ventana (PR #16075), de modo que los números publicados cubren las últimas versiones y no todo el arco de siete versiones.30

Las operaciones vinculadas a CPU (renderizado de Markdown, extracción de CSS) pueden usar funciones síncronas. FastAPI las ejecuta automáticamente en un grupo de hilos cuando el manejador de ruta no se declara 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: si la función espera E/S, 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 sobreescribir. Todo lo demás — el <head>, el header, el footer, 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 de archivo [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 de guion bajo (_language_switcher.html) es una convención que indica un parcial — un fragmento de plantilla que no está pensado para renderizarse de forma independiente. Este componente usa tanto Alpine.js (para el toggle del desplegable) como Jinja2 (para la lista de idiomas). La frontera es clara: Alpine.js gestiona el estado de abrir/cerrar, Jinja2 gestiona los datos.

Macros para componentes reutilizables

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

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

Contexto de plantilla 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 nuevos. El global analytics_script() inyecta el snippet de rastreo. Estas funciones se pueden invocar 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 idioma 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 idioma desde la variable de contexto de la solicitud, establecida por el middleware de idioma. La plantilla llama a {{ _('ui.nav.about') }} y obtiene la cadena traducida para el idioma de la solicitud actual sin necesidad de ningún parámetro de idioma explícito.

Bloques condicionales

El sistema de bloques de Jinja2 soporta sobreescrituras 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 %}

Las entradas del blog declaran sus dependencias en el frontmatter de 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


HTMX a Fondo

HTMX permite que cualquier elemento HTML sea capaz de emitir solicitudes HTTP e intercambiar la respuesta en el DOM. La 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 Emitir solicitud GET hx-get="/search?q=term"
hx-post Emitir 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" (predeterminado), outerHTML, beforeend
hx-trigger Qué dispara 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 reside 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:

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

Cuatro atributos trabajando en conjunto:

  1. hx-get emite 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 spinner de carga durante la solicitud

El servidor detecta las solicitudes HTMX mediante el encabezado HX-Request y devuelve únicamente 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 dispara al soltar una tecla
  • changed se dispara 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 intercambia en el DOM, 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 maneja esto sin orquestación del lado del cliente:

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

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 “elevar el estado” de React — el servidor calcula todo el estado derivado y envía el HTML final para cada elemento en una sola respuesta.

Un formulario de contacto lo demuestra bien: enviar el formulario podría reemplazar el cuerpo del formulario con un mensaje de éxito y simultáneamente actualizar una insignia de notificación mediante un intercambio OOB:

Patrón 5: Enlaces Potenciados

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

<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 vía AJAX, se intercambia el contenido del <body> y se actualiza la URL — sin una recarga completa de página. El historial del navegador funciona normalmente (botones adelante/atrás). 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>. Los enlaces potenciados funcionan bien para elementos de navegación principal, lo que hace que las transiciones de página se sientan como una aplicación de página única sin la arquitectura SPA.

Patrón 6: Encabezados de Solicitud de 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 disparó 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 en la arquitectura. Una carga completa de página devuelve el documento completo (plantilla base + contenido de la página). Una navegación HTMX devuelve únicamente 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 8: 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 dispara la solicitud durante las peticiones. El atributo hx-indicator apunta a un elemento que se vuelve visible durante la solicitud. Dale estilo 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 solo existe en el cliente y que nunca necesita comunicarse con el servidor. Si el usuario hace clic en un desplegable y este se abre, ese estado existe únicamente en el navegador. Alpine.js lo gestiona con atributos HTML.

La regla de límites

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 desplegable, menú móvil, visibilidad de modal
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 su 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 de desplegable

El selector de idioma usa Alpine.js para el estado de alternancia con @click.away para cerrar 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 maneja esto con un solo atributo — sin registro de event listeners, sin limpieza, sin gestión de refs.

Cuándo usar Alpine.js vs. JavaScript puro

Alpine.js es apropiado cuando:

  • El estado tiene alcance limitado 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álculos complejos (visualizaciones, simulaciones)
  • El componente tiene su propio ciclo de renderizado (canvas, animación)
  • El rendimiento es crítico (Alpine.js agrega overhead por cada componente x-data)
  • La lógica supera las 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.


Ejemplo de extremo a extremo: filtrado por categorías en /writing

Esta sección traza una función real del código en producción a través de cada capa: ruta, plantilla, interacción con HTMX, seguridad, caché y resultado renderizado. La función: pestañas de categorías en la página de escritura que filtran las publicaciones del blog sin recargar la página completa.

La ruta (app/routes/pages.py:508)

async def writing_listing(request: Request, page: int = 1, category: str | None = None):
    """Writing page — blog posts and external publications."""
    templates = get_templates(request)
    markdown_posts = load_all_posts(published_only=True)
    all_posts = CUSTOM_BLOG_POSTS + markdown_posts

    # Filter by category if specified
    if category and category in CATEGORY_MAP:
        display_name = CATEGORY_MAP[category]
        all_posts = [
            p for p in all_posts
            if _get_post_category(p).lower() == display_name.lower()
        ]

    # Pagination
    total_pages = max(1, (len(all_posts) + POSTS_PER_PAGE - 1) // POSTS_PER_PAGE)
    page = max(1, min(page, total_pages))
    paginated = all_posts[(page - 1) * POSTS_PER_PAGE : page * POSTS_PER_PAGE]

    template_context = {
        "request": request,
        "posts": paginated,
        "categories": categories,
        "current_category": category,
        "current_page": page,
        "total_pages": total_pages,
        # ... SEO: canonical, prev/next URLs
    }

    # HTMX partial: return just the post list fragment
    if request.headers.get("HX-Request"):
        return templates.TemplateResponse(
            "pages/writing/_post_list.html",
            template_context,
        )

    # Full page for direct navigation
    return templates.TemplateResponse(
        "pages/writing/index.html",
        template_context,
    )

La verificación del encabezado HX-Request es el patrón central: misma ruta, mismos datos, plantilla diferente. HTMX recibe un fragmento. Los navegadores reciben la página completa.

Las pestañas de categorías (HTMX)

<!-- Category filter tabs -->
<nav class="writing-categories">
  <a href="/writing"
     hx-get="/writing"
     hx-target="#post-list"
     hx-push-url="true"
     class="category-tab {% if not current_category %}active{% endif %}">
    All ({{ total_posts }})
  </a>
  {% for cat in categories %}
  <a href="/writing?category={{ cat.slug }}"
     hx-get="/writing?category={{ cat.slug }}"
     hx-target="#post-list"
     hx-push-url="true"
     class="category-tab {% if current_category == cat.slug %}active{% endif %}">
    {{ cat.name }} ({{ cat.count }})
  </a>
  {% endfor %}
</nav>

<div id="post-list">
  {% include "pages/writing/_post_list.html" %}
</div>

Cada pestaña tiene tanto href (funciona sin JavaScript) como hx-get (intercambia solo la lista de publicaciones). hx-push-url actualiza la URL del navegador para que la vista filtrada sea compartible y se pueda guardar en marcadores.

El parcial (pages/writing/_post_list.html)

El parcial se renderiza de forma idéntica tanto cuando se incluye en la carga inicial de la página como cuando HTMX lo intercambia:

{% for post in posts %}
<article class="post-card">
  <a href="{{ locale_prefix() }}/blog/{{ post.meta.slug }}">
    <h3>{{ post.meta.title }}</h3>
    <p>{{ post.meta.description }}</p>
    <time>{{ post.meta.date }}</time> · {{ post.reading_time }}m
  </a>
</article>
{% endfor %}

Sin marcado especial de HTMX en el parcial. Sin lógica de renderizado en el cliente. La misma HTML funciona tanto para la carga inicial de la página como para cada filtro posterior.

Seguridad

Los valores de categoría se validan contra CATEGORY_MAP (un diccionario del lado del servidor) antes de filtrar. Las categorías inválidas se ignoran, no se devuelven como eco. Ninguna entrada del usuario se interpola en SQL ni en HTML. El encabezado CSP bloquea scripts inline.

Caché

Las respuestas de categorías son dinámicas (sin caché en CDN). Pero los recursos estáticos (CSS, HTMX, Alpine.js) tienen hash de contenido y se cachean indefinidamente después de la primera carga. Los cambios de categoría posteriores transfieren únicamente el parcial HTML (~3-5KB) — sin CSS, sin JS, sin imágenes que volver a descargar.

Lo que esto demuestra

Una función, código real de producción, cero herramientas de compilación. El servidor filtra y renderiza HTML. HTMX intercambia la lista de publicaciones. Alpine.js no interviene (no se necesita estado en el cliente). La URL se actualiza para poder compartirla. Mejora progresiva: las pestañas funcionan como enlaces simples sin JavaScript. Total de JavaScript personalizado para esta función: cero líneas.


Extensiones opcionales

Las siguientes secciones cubren patrones que complementan el stack principal pero que no se usan en blakecrosley.com. Se incluyen porque representan las adiciones más comunes que los equipos realizan al adoptar esta arquitectura.


Bootstrap 5 sin Sass

Nota: blakecrosley.com usa CSS plano con propiedades personalizadas — sin Bootstrap. Esta sección cubre Bootstrap 5 como opción para equipos que quieren un framework de utilidades sin un paso de compilación. El CSS compilado de Bootstrap se puede cargar desde un CDN o incluir en tu hoja de estilos. Los patrones a continuación son genéricos y funcionan junto con el enfoque de HTMX + Alpine.js descrito en secciones anteriores.

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

Autoalojamiento sin CDN

blakecrosley.com autoaloja todas las bibliotecas de terceros:

<!-- 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 autoalojamiento 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 incluye las clases responsivas de la grilla. Para breakpoints personalizados más allá de los valores predeterminados de Bootstrap, escribe media queries de CSS plano.

Sobrescrituras con CSS plano

Sobrescribe los valores predeterminados 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 de utilidad vs. CSS de componentes

Usa las clases de utilidad de Bootstrap para espaciado y diseño 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: utilidades de Bootstrap para la mecánica del diseño (margen, relleno, flexbox). CSS personalizado para la identidad visual (colores, tipografía, animaciones). Nunca mezcles clases de utilidad 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 gestiona tanto el inglés (/about) como el 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 usan hreflang para mostrar la versión en el idioma correcto 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 mediante el manejador lifespan:

@asynccontextmanager
async def lifespan(app: FastAPI):
    # Load translations into memory at startup
    for locale in SUPPORTED_LOCALES:
        data = await fetch_translations(locale)
        TRANSLATIONS[locale] = data
    yield

app = FastAPI(lifespan=lifespan)

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 para i18n que monitorea la cobertura de traducción 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 soportan 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 de locale

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

Nota: blakecrosley.com usa Cloudflare D1 (SQLite serverless) vía HTTP para todos los datos persistentes, no SQLAlchemy. Esta sección cubre el patrón async estándar de SQLAlchemy para proyectos FastAPI que necesitan una base de datos relacional: la configuración de producción más común para este stack.

SQLAlchemy 2.0 Async

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

Nota de instalación (SQLAlchemy 2.0.50+): a partir de la versión 2.0.50, la dependencia greenlet del stack async ya no se instala de forma predeterminada. Usa el extra asyncio para que se incluya, o el primer await contra el engine fallará con un error de greenlet faltante:23

pip install "sqlalchemy[asyncio]" aiosqlite

SQLAlchemy 2.0.50 también requiere Python 3.10+ (se eliminó el soporte para 3.7–3.9) y agrega wheels free-threaded (3.13t).23

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

La dependencia get_db administra el ciclo de vida de la sesión: abre una sesión, la cede al route handler, confirma los cambios si todo sale bien y revierte la transacción si hay una excepción. Cada operación de base de datos usa consultas parametrizadas; nunca interpolación de strings.

Integración con Pydantic

Los modelos de Pydantic validan la entrada en el límite de 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(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 valida tipos, formatos (email, URL) y restricciones (longitud mínima/máxima) antes de que se ejecute el route handler. Una entrada inválida devuelve automáticamente una respuesta 422. Esto reemplaza las bibliotecas de validación de formularios del lado del cliente: el servidor valida, y HTMX inserta el mensaje de éxito o la retroalimentación del error.

Migraciones con Alembic

Alembic administra 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 autogenerate 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 viven 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. Para blakecrosley.com, la mayoría de los datos vive en Cloudflare D1 (accedido vía HTTP), por lo que las migraciones de Alembic aplican a la base de datos local SQLite o PostgreSQL usada para datos de sesión y analytics.

El patrón Cloudflare D1

blakecrosley.com usa Cloudflare D1 como una base de datos remota a la que se accede mediante un proxy de 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, con acceso vía HTTP. El proxy Worker maneja la autenticación y la limitación de tasa. El costo es la latencia: cada consulta es una solicitud HTTP (~50-100ms), frente a una conexión a base de datos local (~1-5ms). La caché en memoria al inicio mitiga esto para cargas de trabajo con muchas lecturas, como las traducciones.


Seguridad

Limita el tamaño del cuerpo de las solicitudes

Starlette 1.6.0 agregó max_body_size, el control que le faltaba a esta pila: sin él, un cliente puede transmitir un cuerpo sin límite a tu app y hacer que la memoria se convierta en el punto de fallo. Configúralo en Starlette, Router, Mount o una Route individual, o envuelve cualquier app ASGI en RequestBodyLimitMiddleware. Las rutas anidadas pueden aumentar o reducir el límite global de la app, de modo que un endpoint de carga puede ser permisivo mientras todo lo demás permanece restringido.

app = FastAPI(lifespan=lifespan)
app.router.max_body_size = 2 * 1024 * 1024  # 2 MB default for the whole app

El límite cuenta los bytes realmente recibidos del servidor ASGI, incluidos los datos de archivos multipart, y trata Content-Length solo como una comprobación de fallo rápido: un encabezado ausente o con un valor menor al real no puede evadirlo. El valor predeterminado es None, lo que significa sin límite, así que debes activarlo de forma explícita.28

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 evaluar expresiones. La alternativa es la compilación compatible con CSP de Alpine.js, que tiene limitaciones.14 Todas las demás funciones quedan restringidas: frame-ancestors evita el clickjacking, form-action limita los envíos de formularios al mismo origen y upgrade-insecure-requests fuerza HTTPS.

Seguridad de la caché CDN con HTMX

El middleware de encabezados de seguridad agrega Vary: HX-Request a las respuestas de 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, una CDN podría almacenar en caché una respuesta de fragmento de HTMX y servirla como página completa a una solicitud que no sea de HTMX (o viceversa). El encabezado Vary indica a la CDN que almacene entradas de caché separadas según el valor del encabezado HX-Request.11

Protección CSRF

Los formularios de HTMX usan tokens CSRF sin estado firmados con HMAC:

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

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

El token se genera en la plantilla mediante un global de Jinja2 y se incluye en las solicitudes de formularios de 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 evita ataques de repetición. hmac.compare_digest evita ataques de temporización.15

Sanitización de 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 las etiquetas y atributos que no estén en la lista permitida. Los enlaces reciben automáticamente rel="noopener noreferrer". Esta defensa es independiente de la CSP: evita XSS almacenado en la capa de renderizado, mientras que la CSP evita 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 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 automáticamente un 422 Unprocessable Entity para entradas no válidas. Junto con las consultas parametrizadas a la base de datos (SQLAlchemy nunca interpola cadenas), esto evita 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. Verifícalo en PageSpeed Insights.2

Las optimizaciones clave:

Estrategia de carga de CSS

blakecrosley.com carga CSS con una única etiqueta <link> y URL con hash de contenido para caché inmutable:

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

El helper asset() añade un hash de contenido (?v=a3b2c1d4) para que el navegador almacene el archivo en caché indefinidamente hasta que cambie el contenido. Sin extracción de CSS crítico, sin truco de print-media, sin carga basada en JavaScript. El archivo de CSS pesa aproximadamente 8 KB comprimido con gzip; es lo bastante pequeño para que el enfoque de una sola solicitud obtenga 100 en Lighthouse Performance sin maniobras de optimización.

Compresión GZip

app.add_middleware(GZipMiddleware, minimum_size=500)

Las respuestas de más de 500 bytes se comprimen, excepto los tipos de contenido excluidos de forma predeterminada que Starlette 1.5.0 introdujo (archivos, imágenes, audio, video, fuentes, SSE). HTML se comprime entre un 70 y un 80 %, lo que reduce un documento de 15 KB a 3-4 KB.28

Caché inmutable de activos 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 activos estáticos con URL con hash de contenido (?v=a3f8b2c1d0) se almacenan en caché durante un año con immutable. El hash cambia cuando el archivo cambia, lo que obliga a los navegadores y CDN a obtener la versión nueva.

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 se analice el documento. Esto evita bloquear el 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 evitan Cumulative Layout Shift (CLS). El atributo loading="lazy" difiere las imágenes fuera de pantalla. WebP proporciona archivos entre un 25 y un 35 % más pequeños que JPEG con una calidad equivalente.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)

El encabezado Link con rel=preload indica a Cloudflare que envíe una respuesta 103 Early Hints, lo que permite que el navegador comience a obtener CSS antes de que el servidor termine de generar la respuesta de HTML.17

JavaScript mínimo

La huella total de JavaScript:

Biblioteca Tamaño (minificado + comprimido con gzip)
HTMX ~16 KB
Alpine.js ~15 KB
JS específico de la página 4-8 KB
Total 35-39 KB

Una aplicación React típica envía entre 100 y 300 KB de JavaScript del framework antes del código de la aplicación.18 El enfoque sin compilación 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 constructor Nixpacks de Railway detecta el proyecto Python a partir de requirements.txt, instala las dependencias y ejecuta el comando de inicio. No se requiere ningún archivo Docker. El endpoint de comprobación de estado garantiza que la aplicación responda antes de comenzar a recibir tráfico:

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

Proceso 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 con 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 ofrece una alternativa compatible con Heroku. Railway admite tanto railway.toml como Procfile. La sintaxis ${PORT:-8000} utiliza el puerto proporcionado por la plataforma o, para el desarrollo local, el valor predeterminado 8000.

Configuración de Uvicorn para producción

Para despliegues con mayor tráfico, usa varios 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 utiliza el bucle de eventos uvloop, que es más rápido (reemplazo directo de asyncio)
  • --http httptools utiliza el analizador HTTP httptools, que es más rápido

Cada worker es un proceso independiente que mantiene su propia copia de la aplicación, por lo que la memoria por proceso se multiplica por la cantidad de workers. Es precisamente aquí donde resulta beneficiosa la corrección del grafo de dependencias de FastAPI 0.140.0: en una aplicación con muchas dependencias, cuatro workers en 0.139.2 pagan cuatro veces la antigua sobrecarga de Dependant.27

Para el desarrollo, --reload supervisa los cambios en los 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 pequeño el contenedor. --no-cache-dir evita que pip almacene los paquetes descargados en la capa de la imagen.

CDN de Cloudflare

blakecrosley.com utiliza Cloudflare para el almacenamiento en caché mediante 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 el contenido en caché durante 5 minutos
  • s-maxage=3600 — el CDN almacena el contenido en caché durante 1 hora
  • stale-while-revalidate=86400 — entrega contenido desactualizado mientras se revalida durante 24 horas

Los recursos estáticos reciben max-age=31536000, immutable porque las URL con hashes de contenido garantizan que siempre estén actualizados.


Marco de decisión

¿Necesitas herramientas de compilación?

Responde cuatro preguntas:

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

2. ¿Tu aplicación gestiona un estado complejo del lado del cliente? Si las funciones de arrastrar y soltar, la colaboración en tiempo real o los datos offline-first son esenciales —y no simples extras—, un framework como React o Svelte justifica su complejidad. Agrega un paso de compilación.

3. ¿Varios productos utilizan una biblioteca de componentes compartida? Si la respuesta es sí, esa biblioteca necesita empaquetado con npm, versionado semántico y tree shaking. Agrega un paso de compilación.

4. ¿Dependes de bibliotecas del ecosistema npm que presuponen el uso de un bundler? Si Radix, Framer Motion, TanStack Query u otras bibliotecas similares son esenciales para el producto, es obligatorio contar con un proceso de compilación.

Si las cuatro respuestas son «no», el enfoque sin compilación es viable. Si alguna respuesta es «sí», las herramientas de compilación resuelven un problema real. El error consiste en agregarlas cuando las cuatro respuestas son «no»: intentas resolver problemas que no tienes y, al hacerlo, introduces una sobrecarga de gestión de dependencias que antes no existía.1

Comparación de stacks

Categoría Sin compilación (esta guía) React + herramientas de compilación
Ideal para Sitios de contenido, portafolios, herramientas internas y aplicaciones CRUD Productos SaaS, SPA complejas y consumidores de sistemas de diseño
Tamaño del equipo 1-5 desarrolladores 5-50+ desarrolladores
Gestión del estado Servidor (HTMX) + cliente (Alpine.js) Cliente (estado de React, Redux, Zustand)
Seguridad de tipos Tiempo de ejecución (Pydantic del lado del servidor) Tiempo de compilación (TypeScript)
Reutilización de componentes Inclusiones + macros de Jinja2 Paquetes npm y bibliotecas compartidas
SEO Renderizado en el servidor de forma predeterminada Requiere configuración de SSR/SSG
Rendimiento mínimo Alto (JS mínimo y renderizado en el servidor) Variable (sobrecarga del framework)
Límite de complejidad Menor (sin funcionamiento offline ni estado complejo del lado del cliente) Mayor (permite cualquier interacción del lado del cliente)
Dependencias 17 paquetes de Python Más de 300 paquetes npm
Tiempo de compilación 0 segundos 15-60 segundos

Cuándo HTMX no es adecuado

HTMX reemplaza el estado del cliente por intercambios de ida y vuelta con el servidor. Esto funciona hasta que la latencia se vuelve importante:

  • Interfaces de arrastrar y soltar — un intercambio de ida y vuelta con el servidor de 200 ms por cada evento de arrastre es inaceptable
  • Colaboración en tiempo real — el estado controlado por WebSocket requiere resolver conflictos del lado del cliente
  • Aplicaciones offline-first — sin servidor, no hay HTMX
  • Animaciones complejas vinculadas al estado — Framer Motion y React Spring presuponen un modelo de reconciliación de React
  • Aplicaciones Canvas/WebGL — el bucle de renderizado se ejecuta inherentemente del lado del cliente

Para estos casos de uso, un framework del lado del cliente es la herramienta adecuada. El enfoque sin compilación no pretende 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 configurar el 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

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

Sí. HTMX es estable desde 2020 y se utiliza en producción en múltiples sectores. Carson Gross, su creador, mantiene la compatibilidad con versiones anteriores como principio fundamental de diseño: la documentación de HTMX afirma que la biblioteca no hará que las aplicaciones existentes dejen de funcionar dentro de una versión principal.19 La biblioteca ocupa alrededor de 16 KB minificada y comprimida con gzip, no tiene dependencias y utiliza versionado semántico. blakecrosley.com ha utilizado HTMX en producción durante tres años sin ningún error relacionado con HTMX.20

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

Parcialmente. Puedes comprobar los tipos de los archivos de TypeScript con tsc --noEmit sin generar archivos de salida, lo que permite realizar verificaciones en tiempo de compilación como si se tratara de un linter. No obstante, los navegadores no pueden ejecutar archivos .ts directamente, por lo que sigue siendo necesario un paso de compilación para servir TypeScript. La alternativa consiste en usar anotaciones de tipo JSDoc en archivos .js estándar, que TypeScript puede comprobar sin compilarlos. Así obtienes seguridad de tipos durante el desarrollo y distribuyes 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 estándar con una cantidad mínima de JavaScript en el cliente, pero requieren un paso de compilación (Node.js, npm install y un comando de compilación). El enfoque sin compilación elimina ese paso: el servidor renderiza HTML con cada solicitud. La contrapartida es que Astro y 11ty producen páginas estáticas más rápidas, ya que no requieren procesamiento del servidor, mientras que FastAPI + HTMX gestiona contenido dinámico de forma nativa —como datos específicos del usuario, envíos de formularios y actualizaciones en tiempo real— sin una capa de API independiente.

¿Qué ocurre con el renderizado del lado del servidor (SSR) mediante React?

El SSR de Next.js y el enfoque de FastAPI + HTMX comparten un objetivo: enviar HTML renderizado en el servidor al navegador. La diferencia radica en lo que sucede después del renderizado inicial. Next.js hidrata la página con React y envía al cliente el entorno de ejecución del framework y el código de los componentes. FastAPI + HTMX no realiza hidratación: el HTML es el resultado final. HTMX gestiona las interacciones posteriores solicitando al servidor nuevos fragmentos de HTML. El resultado es que FastAPI + HTMX envía aproximadamente entre 35 y 40 KB de JavaScript en total, frente a los 100-300 KB de una aplicación Next.js.18

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

En el servidor. Pydantic valida los datos de entrada cuando se envía el formulario. Si la validación falla, el servidor devuelve el formulario con mensajes de error. HTMX sustituye el contenido del DOM por la respuesta:

<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, renderiza los estados de error y HTMX sustituye el contenido por el resultado. No se necesita ninguna biblioteca de validación del lado del cliente. El atributo required de HTML proporciona una validación básica en el navegador como primera línea de defensa.

¿Puedo agregar funciones en tiempo real (WebSockets)?

Sí. FastAPI incluye compatibilidad con 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 con endpoints de WebSocket:

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

Nota: HTMX 1.x utilizaba la sintaxis hx-ws="connect:...". En HTMX 2.x, la compatibilidad con WebSocket pasó a una extensión independiente (htmx-ext-ws) con los atributos ws-connect y ws-send que se muestran arriba. Si utilizas HTMX 1.x, la sintaxis anterior de hx-ws sigue funcionando.

Versión beta de HTMX 4.0: htmx 4.0.0-beta6 ya está disponible bajo la etiqueta next de npm, junto con la documentación de la versión 4.0 (beta6 publicada el 23 de julio de 2026), mientras que la guía de inicio rápido de htmx.org y la etiqueta latest de npm permanecen en la versión 2.0.10. Esta guía aún está dirigida a HTMX 2.x, que sigue siendo la versión recomendada para entornos de producción hasta que 4.0 sea estable; la migración de 2.x a 4.x supone un salto generacional, no una actualización menor de 2.x. El esquema de versiones de big-skies-software omite las versiones principales impares, por lo que 4.0 es el siguiente paso después de 2.x.2122

Aspectos de la documentación de 4.0 que conviene seguir de cerca. Hay dos incorporaciones que destacan de cara a la revisión de seguridad y arquitectura antes del lanzamiento general de 4.0: la nueva extensión hx-live introduce expresiones reactivas al DOM que se vuelven a evaluar cuando cambia el estado al que hacen referencia, mientras que la nueva extensión hx-nonce condiciona el procesamiento de atributos de htmx al uso de nonces de CSP. La guía de migración a 4.0 también reubica varios conceptos de configuración, restablece o modifica ciertos comportamientos relacionados con eventos e historial, y elimina del núcleo algunas funciones auxiliares de JavaScript. Considera 4.0 como un proyecto de migración, no como un parche de 2.x que puedas instalar directamente.21

Los mensajes del servidor se incorporan al DOM mediante los mismos mecanismos de selección de destino y sustitución que las respuestas HTTP. El servidor envía fragmentos de HTML a través de WebSocket, y HTMX los inserta.

¿Cómo gestiona este stack el SEO?

El HTML renderizado en el servidor es compatible con SEO por naturaleza, ya que los rastreadores reciben todo el contenido 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 y FAQPage)
  • Sitemap dinámico con alternativas hreflang para las 10 configuraciones regionales
  • Feed RSS en /blog/feed.xml
  • llms.txt en la raíz para facilitar que los rastreadores de IA descubran el sitio
  • URL canónicas y etiquetas Open Graph en la plantilla base
  • HTML semántico: <article>, <section>, <main> y una jerarquía adecuada de encabezados

No se necesita configurar SSR. No hay getStaticProps. No hay ISR. El HTML se renderiza con cada solicitud: ese es el comportamiento predeterminado, no una optimización.

¿Cómo se compara la curva de aprendizaje con la de React?

Para quienes desarrollan con Python, la curva de aprendizaje es considerablemente menor. Ya conoces el lenguaje. Los manejadores de rutas de FastAPI devuelven respuestas de plantilla, siguiendo el mismo modelo mental que las vistas de Flask o Django. HTMX agrega unos cuantos atributos de HTML (hx-get, hx-target, hx-swap). Alpine.js incorpora algunos más (x-data, x-show, @click). No tienes que aprender JSX, un DOM virtual, un sistema de hooks, una biblioteca de gestión de estado ni la configuración de herramientas de compilación.

La documentación de HTMX cabe en una sola página extensa. La de Alpine.js ocupa unas cuantas páginas. La documentación de React abarca cientos de páginas sobre hooks, contexto, refs, efectos, suspense, componentes del servidor y SSR por streaming.

Para quienes desarrollan con JavaScript/React, el cambio es conceptual más que sintáctico. La idea central es que el servidor controla el estado y renderiza el HTML. La gestión de estado del lado del cliente se convierte en el manejo de rutas en el servidor. La obtención de datos en el cliente se transforma en atributos de HTMX dentro de elementos de HTML. La sintaxis es más sencilla, pero el modelo mental exige abandonar la suposición propia de las SPA de que el cliente controla el renderizado.


Registro de cambios

Fecha Cambio Fuente
2026-08-16 Starlette 1.3.1 → 1.6.0, y uno de sus cambios modifica silenciosamente el propio fragmento de GZip de esta guía. Cambio de comportamiento: Starlette 1.5.0 (8 de agosto) amplió DEFAULT_EXCLUDED_CONTENT_TYPES mucho más allá de text/event-stream para cubrir archivos gzip/zip, PNG, JPEG, WebP, GIF, AVIF, audio/*, video/* y fuentes WOFF/WOFF2, por lo que ya no se comprimen de forma predeterminada; image/* se excluye deliberadamente, lo que deja image/svg+xml susceptible de compresión. Un nuevo argumento de palabra clave exclusivo, exclude_content_types, reemplaza la lista, la coincidencia no distingue mayúsculas de minúsculas y reasignar la constante del módulo en tiempo de ejecución ya no surte efecto. El pin en tiempo de ejecución de FastAPI es starlette>=0.46.0 sin límite superior, por lo que una instalación nueva obtiene 1.6.0 y el cambio llega al fragmento de esta guía sin que el lector haga nada — se corrigieron ambos pasajes sobre GZip. La versión 1.5.0 también omite respuestas parciales con estado 206 y vacía por cada fragmento transmitido; la 1.4.0 (5 de agosto) trasladó a un hilo de trabajo los fragmentos gzip de 128 KiB o más mediante thread_minimum_size, para que las compresiones grandes dejen de bloquear el bucle de eventos. Nueva capacidad: 1.6.0 (8 de agosto) agregó max_body_size en Starlette/Router/Mount/Route, además de RequestBodyLimitMiddleware — una nueva subsección de Seguridad lo cubre, ya que esta guía no incluía por completo límites para el cuerpo de las solicitudes. También se señaló que encode/starlette ahora redirige a Kludex/starlette, reflejando el cambio de Uvicorn. Solo para el registro de cambios: Uvicorn 0.52.0–0.52.3 (implementación experimental de HTTP/1.1 zttp basada en Zig que el propio lanzamiento dice no poner delante de tráfico de producción; se mantiene el consejo de la guía sobre --http httptools), Alpine.js 3.16.0/3.16.1, SQLAlchemy 2.0.52 (soporte para Python 3.15; corrección de desalineación de columnas de resultado de ORM UPDATE con synchronize_session="fetch"). Verificado sin cambios: FastAPI 0.141.1, HTMX 2.0.10 latest / 4.0.0-beta6 next, Bootstrap 5.3.8, Jinja2 3.1.6, Pydantic 2.13.4 estable. Cero avisos de seguridad nuevos en las nueve dependencias durante el período. 28
2026-07-29 FastAPI 0.141.0 + 0.141.1 (ambas el 29 de julio). 0.141.0 agrega app.frontend(check_dir="auto") para que fastapi dev ya no falle cuando falta el directorio de compilación — el caso habitual de iniciar el servidor antes de ejecutar la compilación del frontend. 0.141.1, horas después, corrige que las dependencias de app.frontend() descartaran tareas en segundo plano y encabezados de respuesta; una dependencia que establecía una cookie o programaba una BackgroundTask perdía ese trabajo en el montaje del frontend, aunque se comportaba correctamente en rutas de API, por lo que esto cierra una brecha real en el soporte de dependencias agregado en 0.139.0. Ambas se incorporan a la narrativa existente de app.frontend() en lugar de crear una nueva sección, ya que el enfoque renderizado en servidor de esta guía no monta un directorio dist/. 0.141.1 también documenta FASTAPI_ENV en la guía de FastAPI CLI (solo documentación, sin cambios en el contenido). 29
2026-07-27 FastAPI lanzó 0.140.1 hasta 0.140.7 en cinco horas y media el 27 de julio — siete lanzamientos, todos refactorizaciones de la maquinaria de dependencias que inició 0.140.0. Dos líneas de trabajo: la copia aplanada del grafo de dependencias que FastAPI construía y retenía ya desapareció (0.140.2), junto con cada sitio restante que reconstruía una — generación de OpenAPI (0.140.3, 0.140.7), campos del cuerpo (0.140.5), parámetros de solicitud (0.140.6) — y 0.140.4 elimina la contabilidad de seguimiento de repeticiones que no se leía. El único cambio con un umbral visible es 0.140.1: el lru_cache de los asistentes de clasificación de llamadas en fastapi/dependencies/models.py pasa de 1.024 a 4.096 entradas (denominado _CALLABLE_CLASSIFICATION_CACHE_SIZE), tras informes de aplicaciones que superaban 1.024 dependencias distintas y provocaban una rotación continua de la caché. Sin cambios en API; la recomendación del párrafo sobre memoria de dependencias pasa de 0.140.0 a 0.140.7 o posterior, con una nota de que la línea sigue evolucionando y de que los benchmarks de dependencias de OpenAPI (PR #16075) solo llegaron en el último lanzamiento de la serie. 30
2026-07-25 FastAPI 0.140.0 (24 de julio, 21:16 UTC) corrige una regresión de memoria del sistema de dependencias que estaba presente desde 0.121.0 (3 de noviembre de 2025). PR #16049 elimina diez atributos functools.cached_property de Dependant, los traslada a asistentes de nivel de módulo y convierte la clase en @dataclass(slots=True); la ejecución oficial de CodSpeed en el PR fusionado informa que el benchmark de memoria test_dependency_graph pasa de 17,5 MB → 1,1 MB (×16), y el informe original describía OOM en producción con 0.121.3, mientras 0.120.4 se mantenía por debajo de ~400 MB. Se agregó un pasaje sobre 0.140.0 a Patrones async y una línea sobre memoria de workers a Configuración de Uvicorn para producción. También se corrigió un error preexistente: la guía afirmaba que 0.137.0 «fija Starlette en la línea 1.x» — no es así. El requisito de tiempo de ejecución de FastAPI es starlette>=0.46.0 (un mínimo sin límite superior, aún satisfecho por Starlette 0.4x) de manera uniforme en 0.136.3, 0.137.0, 0.138.0, 0.139.2 y 0.140.0; los números 1.x en las notas de 0.137.0 son actualizaciones de dependabot al archivo de bloqueo de pruebas del repositorio (PR #15722 solo toca uv.lock). Se corrigieron tanto la afirmación del contenido como 24. Dos no-cambios señalados: los elementos internos de Dependant ahora son incompatibles para herramientas (oauth_scopes, cache_key, _uses_scopes, _is_security_scheme desaparecen como atributos, sustituidos por las funciones de módulo _get_oauth_scopes() / _get_cache_key() / _uses_scopes(), y slots=True impide el monkey-patching de instancias) — API interno no documentado al que esta guía nunca hace referencia, de la misma categoría que el cambio de router.routes en 0.137.0; y la documentación oficial de FastAPI ahora usa de forma predeterminada proyectos uv en lugar de pip/venv en 30 archivos, incluidos README, index.md, virtual-environments.md y las páginas de Docker/despliegue (PR #16032, fusionado el 21 de julio). El cambio de documentación es cosmético para tu código, pero la guía enseña pip install -r requirements.txt en todo momento y ahora difiere de la puerta de entrada de upstream — una decisión editorial futura, deliberadamente no tomada en esta revisión. 27
2026-07-24 htmx 4.0.0-beta6 sustituye beta5 como la etiqueta npm next (publicada el 23 de julio de 2026; lanzamiento de GitHub el mismo día). Elementos principales de la beta: nueva extensión hx-multipart (respuestas multipart/mixed/multipart/parallel transmitidas con encabezados de acción HX-* por parte), restauración del desplazamiento del historial mediante la Navigation API con una alternativa para Firefox, cambio de nombre de evento interno de la beta htmx:swap:finallyhtmx:finally:swap, los eventos del encabezado de respuesta HX-Trigger ahora se activan después del swap, métodos de solicitud personalizados y una reescritura de hx-ws con reenvío de protocols. La recomendación no cambia — producción se mantiene en HTMX 2.x (latest = 2.0.10) hasta 4.0 GA; el cambio de nombre es incompatible solo dentro de la línea beta 4.0. Se actualizaron la nota de la versión beta y 21. Se verificó que FastAPI 0.139.2, Uvicorn 0.51.0, Alpine.js 3.15.12, Starlette 1.3.1, Jinja2 3.1.6 siguen sin cambios; cero avisos de seguridad en el período.
2026-07-17 FastAPI 0.139.1 + 0.139.2 (16 de julio): corrección de rutas con puntos para las alternativas de app.frontend() (/users/john.doe, PR #16011) y construcción segura para hilos de las rutas del router en pruebas con hilos paralelos (PR #16013) — sin cambios de API orientados a la aplicación. Uvicorn 0.49.0 → 0.51.0: la implementación heredada de websockets está obsoleta y auto ahora usa de forma predeterminada websockets-sansio (0.50.0), la implementación predeterminada requiere websockets>=13.0 (0.50.2), y 0.51.0 (8 de julio) agrega reinicios de workers con SIGHUP y solapamiento para recargas con tiempo de inactividad cercano a cero; el repositorio ahora vive en Kludex/uvicorn. Se verificó que HTMX (2.0.10 / 4.0.0-beta5 next), Alpine.js 3.15.12, Starlette 1.3.1, Pydantic 2.13.4, SQLAlchemy 2.0.51 y Bootstrap 5.3.8 siguen sin cambios; cero avisos de seguridad en el período.
2026-07-07 htmx 4.0.0-beta5 ahora es la etiqueta npm next (publicada el 26 de junio de 2026), sustituyendo beta4; se actualizan para coincidir la nota de seguimiento de HTMX 4.0 beta y [^22]. La recomendación no cambia — el trabajo de producción se mantiene en HTMX 2.x (latest = 2.0.10) hasta 4.0 GA. Verificado con las etiquetas dist de npm de htmx.org.
2026-07-02 FastAPI 0.139.0 (1 de julio). app.frontend() ahora admite dependencias — por ejemplo, autenticación automática por cookies para el frontend servido (PR #15908) — y extiende el montaje de frontend estático de 0.138.0 con la maquinaria estándar Depends(); sigue siendo ortogonal a la tesis de renderizado en servidor de esta guía, indicada en el mismo párrafo de contraste. Sin más cambios en el stack: HTMX 2.0.10, Alpine.js 3.15.12, Bootstrap 5.3.8 y SQLAlchemy 2.0.51 sin cambios. 26
2026-06-22 FastAPI 0.138.0 + 0.137.2. 0.138.0 (20 de junio) agrega app.frontend("/", directory="dist") / router.frontend(...) para servir un frontend estático compilado (salida SPA dist/) — ortogonal a la tesis de esta guía de renderizado en servidor sin compilación, señalado como contraste en la sección Patrones async. 0.137.2 (18 de junio) agrega iter_route_contexts() como la forma compatible de enumerar rutas ahora que router.routes es interno (desde 0.137.0). Ambas son adiciones de funciones, sin cambios incompatibles; Starlette (1.3.1), Pydantic (2.13.4), HTMX (2.0.10), Alpine.js (3.15.12), Bootstrap (5.3.8), SQLAlchemy (2.0.51) permanecen sin cambios. 25
2026-06-16 FastAPI 0.137.0/0.137.1 + Starlette 1.0→1.3.1. FastAPI 0.137.0 (14 de junio) refactoriza los elementos internos del router: router.routes ahora es un árbol interno, no una lista plana de APIRoute (incompatible para cualquier elemento que la itere), a la vez que permite rutas agregadas después de include_router() y nuevos hooks APIRouter.matches()/.handle(); 0.137.1 (15 de junio) corrige el tipado de APIRoute y routers sin prefijo con rutas vacías. Starlette lanzó su primer 1.0 estable (22 de marzo) y ahora está en 1.3.1 (12 de junio), eliminando los hooks obsoletos on_event/on_startup/on_shutdown y los decoradores @app.route()/@app.websocket_route()lifespan y Route/WebSocketRoute son las únicas vías. (Esta entrada indicó originalmente que FastAPI 0.137.0 fija Starlette 1.3.1 — corregido el 2026-07-25: no lo hace; el requisito de tiempo de ejecución es starlette>=0.46.0 sin límite superior.) Se agregó una nota sobre lifespan/router a la sección Patrones async. SQLAlchemy 2.0.51 (15 de junio) solo incluye correcciones de errores. 24
2026-06-08 Cambio de instalación async de SQLAlchemy 2.0.50. Desde SQLAlchemy 2.0.50, la dependencia greenlet del stack async ya no se instala de forma predeterminada — instala el extra sqlalchemy[asyncio] (o el primer await contra el motor fallará con un error de greenlet faltante). 2.0.50 también requiere Python 3.10+ (se eliminaron 3.7–3.9) y agrega wheels para 3.13t con free-threaded. Se agregó una nota de instalación a la sección SQLAlchemy 2.0 Async. Sin cambios de contenido para el resto del stack: la versión más reciente de FastAPI sigue siendo 0.136.3 (2026-05-23, sin lanzamiento en junio), htmx estable sigue en 2.0.10 (4.0.0-beta4 «The Fetchening» está en beta con un objetivo estable de ~inicios de 2027, aún no es una recomendación para producción), Alpine.js 3.15.12 y Bootstrap 5.3.x sin cambios. La recomendación para producción no cambia: HTMX 2.x hasta que 4.0 sea estable.23
2026-05-24 Revisión de mantenimiento: el inventario de contenido local aún muestra 210 publicaciones de blog, 11 guías principales, 48 estudios de diseño y 10 idiomas compatibles, incluido el inglés. La versión más reciente de FastAPI es 0.136.3 (2026-05-23); la única refactorización orientada a la aplicación destacada en las notas de lanzamiento es un manejo más estricto de encabezados con guion bajo cuando convert_underscores=True, y 0.136.2 valida los campos de Server-Sent Event para evitar datos de eventos dañados. htmx estable sigue en 2.0.10, mientras npm next y la documentación 4.0 ahora apuntan a 4.0.0-beta4; la versión más reciente de SQLAlchemy 2.0 es 2.0.50; la más reciente de Pydantic sigue siendo 2.13.4. La recomendación para producción no cambia: usa HTMX 2.x hasta que 4.0 sea estable.122
2026-05-18 Actualización del inventario del sitio: el inventario de contenido local ahora muestra 210 publicaciones de blog, 11 guías principales, 48 estudios de diseño y 10 idiomas compatibles, incluido el inglés. La versión más reciente de FastAPI sigue siendo 0.136.1; htmx estable sigue en 2.0.10 con npm next en 4.0.0-beta3; la versión más reciente de npm de Alpine.js sigue siendo 3.15.12. La recomendación para producción no cambia: usa HTMX 2.x hasta que 4.0 sea estable.12021
2026-05-15 Revisión de mantenimiento: la versión más reciente de FastAPI sigue siendo 0.136.1; este entorno local del sitio importa FastAPI 0.128.0 y Starlette 0.50.0; htmx estable sigue en 2.0.10 y npm next ahora es 4.0.0-beta3; la versión más reciente de npm de Alpine.js es 3.15.12; la versión más reciente de Bootstrap es 5.3.8; la versión más reciente de SQLAlchemy 2.0 es 2.0.49; la versión más reciente de Pydantic es 2.13.4. La recomendación para producción no cambia: usa HTMX 2.x hasta que 4.0 sea estable.2021
2026-05-09 Seguimiento de htmx 4.0.0-beta3 (8 de mayo de 2026): htmx 4.0.0-beta3 está disponible en la etiqueta npm next y en la documentación 4.0, mientras npm latest sigue siendo 2.0.10. Aspectos destacados que conviene seguir antes de GA: nueva extensión hx-live (expresiones reactivas al DOM), nueva extensión hx-nonce (protección de nonce CSP para atributos htmx) y cambios en la guía de migración para configuración, historial, eventos y asistentes principales de JavaScript. La recomendación para producción no cambia: htmx 2.x sigue siendo la última etiqueta npm y la versión recomendada hasta 4.0 GA.21
2026-05-07 Revisión de mantenimiento: la versión más reciente de FastAPI sigue siendo 0.136.1; htmx estable es 2.0.10 y v4 sigue en beta con un objetivo para el verano de 2026; la versión más reciente de npm de Alpine.js es 3.15.12; la versión más reciente de Bootstrap es 5.3.8; la versión más reciente de SQLAlchemy 2.0 es 2.0.49; la versión más reciente de Pydantic es 2.13.4. Se actualizaron las métricas locales del sitio a 182 publicaciones de blog, 11 guías, diez idiomas compatibles y 17 requisitos de Python. La guía de migración no cambia: usa HTMX 2.x para producción hasta que 4.0 sea estable.20
2026-04-25 FastAPI 0.136.1 (23 de abril de 2026): limpieza de obsolescencias de Pydantic v2 (sin cambios de comportamiento para el código de la aplicación). Se siguió el cronograma de HTMX 4.0: se lanzaron htmx 4.0.0-beta1 (6 de abril) y 4.0.0-beta2 (14 de abril). La guía de migración no cambia — htmx 2.x se mantiene en la última etiqueta npm hasta que 4.0 sea estable; continúan las correcciones de seguridad, sin presión para actualizar. Cambios importantes de 4.0 que vale la pena tener presentes desde ahora: (1) fetch() sustituye a XMLHttpRequest como infraestructura ajax central, (2) la herencia de atributos pasa a ser explícita de forma predeterminada, (3) el soporte de historial emite una solicitud de red para el contenido restaurado (sin instantánea local del DOM). FastAPI 0.135.4 (16 de abril) eliminó el decorador de April Fool’s @app.vibe() que llegó en 0.135.3.
2026-04-16 Se agregó conocimiento de HTMX 4.0-beta (referencia anticipada). Se señaló el soporte de FastAPI 0.136.0 para compilaciones free-threaded de Python 3.14t. Funciones de Pydantic 2.13.x (fábricas predeterminadas de atributos privados con acceso a datos validados del modelo, espacio de nombres pydantic.v1 a 1.10.26 con soporte para 3.14). Correcciones de Alpine.js 3.15.11: modificador x-anchor.noflip, advertencia de múltiples elementos raíz para x-for, corrección de regresión de morph en $refs.
2026-03-24 Publicación inicial

Referencias


Esta guía cubre el sistema completo utilizado para crear blakecrosley.com. The No-Build Manifesto presenta el argumento filosófico. La publicación Lighthouse Perfect Score documenta el proceso de optimización del rendimiento. La publicación Vibe Coding vs. Engineering explora cómo encaja el desarrollo asistido por IA en este flujo de trabajo.


  1. Métricas de producción de blakecrosley.com al 18 de mayo de 2026. El sitio tiene 210 publicaciones de blog, componentes interactivos JavaScript, 11 guías principales, 48 estudios de diseño, inglés más 9 idiomas traducidos, dependencias Python mínimas y cero herramientas de compilación. Verificado a partir del inventario local de contenido, app/i18n/config.py y requirements.txt

  2. Google PageSpeed Insights (pagespeed.web.dev) ejecuta auditorías de Lighthouse en cualquier URL pública. blakecrosley.com obtiene 100/100/100/100 (Rendimiento, Accesibilidad, Prácticas recomendadas, SEO) desde marzo de 2026. Los resultados se pueden verificar públicamente. Consulta De 76 a 100: cómo lograr una puntuación perfecta en Lighthouse para conocer todo el proceso de optimización. 

  3. Una instalación nueva de npx create-next-app@latest (Next.js 15, probado en febrero de 2026) instala 311 paquetes en node_modules/, con un total de 187 MB. Los proyectos de producción con dependencias adicionales suelen alcanzar cifras mayores. Cada proyecto varía. 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 y división de código) para lograr puntuaciones superiores a 90. Consulta nextjs.org/docs/app/building-your-application/optimizing. El rango de 70 a 90 refleja la configuración predeterminada antes de aplicar estas optimizaciones. 

  5. Lista completa de dependencias verificada en requirements.txt de blakecrosley.com al mes de mayo de 2026. El archivo actualmente tiene 17 entradas de requisitos Python y cero herramientas de compilación, compiladores o empaquetadores. 

  6. Según la experiencia del autor manteniendo proyectos de Next.js (2021-2024), el ecosistema JavaScript genera entre 15 y 25 PR de Dependabot al mes para proyectos activos; la mayoría actualiza dependencias transitivas que el desarrollador nunca importó directamente. 

  7. Tim Berners-Lee articuló la compatibilidad retroactiva como un principio de diseño web: “a browser should be backwards-compatible.” Una página de 1996 se renderiza en Chrome 2026. Consulta w3.org/DesignIssues/Principles

  8. OWASP recomienda deshabilitar los endpoints de documentación 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 controladores async frente a sync: fastapi.tiangolo.com/async/. Mezclar await con llamadas bloqueantes en funciones async bloquea el event loop. 

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

  11. El encabezado Vary se define en la sección 12.5.5 de RFC 9110. Indica a las cachés que almacenen respuestas separadas según los valores especificados de los encabezados de solicitud. Sin Vary: HX-Request, una CDN podría entregar un fragmento HTMX como respuesta de página completa. Consulta httpwg.org/specs/rfc9110.html#field.vary

  12. Las Custom Properties de CSS (Variables de CSS) son compatibles con más del 97 % de los navegadores globales. Cascaden, 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 OWASP CSRF Prevention Cheat Sheet. hmac.compare_digest utiliza una comparación de 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. WebP ofrece archivos entre un 25 y un 35 % más pequeños que JPEG con una calidad visual equivalente. Estudio de WebP de Google: developers.google.com/speed/webp/docs/webp_study

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

  18. React 18 + ReactDOM pesan aproximadamente 42 KB minificados y comprimidos con gzip. Con un router, una biblioteca de gestión de estado y el runtime de un framework de compilación, las aplicaciones típicas de React envían entre 100 y 300 KB de JavaScript del framework. Fuente: bundlephobia.com/package/react-dom@18.2.0

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

  20. Comprobación de mantenimiento del 15 de mayo de 2026. FastAPI PyPI y las notas de lanzamiento enumeran la versión 0.136.1; la verificación de importación local devolvió FastAPI 0.128.0 y Starlette 0.50.0 para el entorno de este sitio; htmx.org enumera 2.0.10 en el inicio rápido; npm view htmx.org version dist-tags devolvió latest=2.0.10 y next=4.0.0-beta3; npm view alpinejs version y npm view @alpinejs/csp version devolvieron 3.15.12; el blog oficial de Bootstrap y los metadatos del paquete npm enumeran 5.3.8; SQLAlchemy PyPI y la documentación enumeran 2.0.49; Pydantic PyPI enumera 2.13.4. 

  21. htmx 4.0.0-beta6 es la etiqueta npm next actual (publicada el 23 de julio de 2026; la línea beta pasó de beta3 el 8 de mayo de 2026 → beta4 → beta5 → beta6), mientras que npm latest sigue siendo 2.0.10. La documentación de 4.0 en four.htmx.org sigue la compilación next, el índice de extensiones 4.0 enumera hx-live y hx-nonce, y la guía de migración 4.0 documenta los cambios de migración que debes revisar antes de mover aplicaciones de producción fuera de 2.x. Verificado con las etiquetas de distribución npm de htmx.org el 24 de julio de 2026. 

  22. Comprobación de mantenimiento del 24 de mayo de 2026. Los comandos del inventario local devolvieron 210 publicaciones de blog en Markdown, 11 archivos de guías de nivel superior y 48 archivos de estudios de diseño. Las notas de lanzamiento de FastAPI enumeran 0.136.3 el 23-05-2026 con un manejo más estricto de encabezados con guion bajo cuando convert_underscores=True; 0.136.2 valida campos de Server-Sent Event. python3 -m pip index versions fastapi devolvió como última versión 0.136.3; python3 -m pip index versions sqlalchemy devolvió como última versión 2.0.50; python3 -m pip index versions pydantic devolvió como última versión 2.13.4. npm view htmx.org dist-tags version time.modified --json devolvió latest=2.0.10, next=4.0.0-beta4 y time.modified=2026-05-22T15:56:21.948Z; la documentación de instalación de four.htmx.org muestra htmx.org@4.0.0-beta4

  23. Registro de cambios de SQLAlchemy 2.0.50 y blog de lanzamiento, publicado el 24-05-2026. La dependencia asyncio greenlet ya no se instala de forma predeterminada; ahora se requiere el objetivo de instalación sqlalchemy[asyncio] para incluirla. 2.0.50 también deja de admitir Python 3.7/3.8/3.9 (ahora 3.10+), añade wheels Python sin GIL y añade un parámetro de marco de ventana over(..., exclude=...). Última versión verificada en PyPI al 08-06-2026. htmx 4.0.0-beta4 (“The Fetchening”, 22-05-2026) sigue en beta con un objetivo estable a principios de 2027; FastAPI 0.136.3 (23-05-2026), Alpine.js 3.15.12 y Bootstrap 5.3.x no han cambiado durante este período. 

  24. Notas de lanzamiento de FastAPI: 0.137.0 (14-06-2026) refactoriza las partes internas del router, de modo que router.routes ya no es una lista plana de objetos APIRoute, sino un árbol de objetos intermedios (considéralo interno); también permite añadir rutas después de include_router(), incluido un sub-router antes de que se definan sus rutas, evita copiar rutas y añade APIRouter.matches()/.handle(). No fija Starlette en 1.x: el requisito de runtime de FastAPI es starlette>=0.46.0, un mínimo sin límite superior, idéntico en 0.136.3, 0.137.0, 0.138.0, 0.139.2 y 0.140.0, verificado con los metadatos requires_dist en la API JSON API de PyPI el 25-07-2026. La línea “bump starlette from 1.1.0 to 1.2.1” (PR #15722) en las notas de 0.137.0 es una actualización de dependabot en Internal que solo afecta al archivo de bloqueo de pruebas uv.lock del repositorio. (Sí existía anteriormente un límite superior: 0.120.4 y 0.121.0 incluían starlette<0.50.0,>=0.40.0, pero se eliminó en 0.136.3.) Corrección aplicada el 25-07-2026; la redacción anterior de esta nota al pie y la afirmación en el cuerpo eran incorrectas. 0.137.1 (15-06-2026) corrige el tipado de APIRoute y una ruta vacía en un router sin prefijo. Notas de lanzamiento de Starlette: 1.0.0 (22-03-2026), su primera versión estable en aproximadamente 8 años, eliminó on_startup/on_shutdown/on_event() y los decoradores @app.route()/@app.websocket_route() (usa lifespan y Route/WebSocketRoute); la última versión es 1.3.1 (12-06-2026). SQLAlchemy 2.0.51 (registro de cambios, 15-06-2026) solo contiene correcciones de errores, sin impacto en async ni en la instalación. Verificado mediante PyPI y las notas de lanzamiento oficiales el 16-06-2026. 

  25. Notas de lanzamiento de FastAPI: 0.138.0 (20-06-2026) añade app.frontend("/", directory="dist") y router.frontend("/", directory="dist") para servir un frontend estático compilado (PR #15800; documentación de Frontend): una función estática para servir SPA desde dist/, no un patrón renderizado en el servidor; sin cambios incompatibles. 0.137.2 (18-06-2026) añade iter_route_contexts() para uso avanzado que antes recorría router.routes (interno desde 0.137.0); sin cambios incompatibles. No hubo una versión más reciente que 0.138.0 al 22-06-2026. Starlette 1.3.1, Pydantic 2.13.4, Uvicorn 0.49.0, SQLAlchemy 2.0.51, HTMX 2.0.10, Alpine.js 3.15.12 y Bootstrap 5.3.8 no presentan cambios. Verificado mediante PyPI y las notas de lanzamiento oficiales el 22-06-2026. 

  26. Notas de lanzamiento de FastAPI 0.139.0, 1 de julio de 2026: “Support dependencies in app.frontend(), e.g. for automatic cookie authentication for the frontend” (PR #15908). El resto de la versión consiste en traducciones, documentación y actualizaciones de dependencias; sin cambios incompatibles. Verificación de la sesión actual el 2 de julio de 2026 (PST): 0.139.0 es la versión más reciente en la página de lanzamientos de GitHub. 

  27. Notas de lanzamiento de FastAPI 0.140.0, publicada el 24 de julio de 2026 a las 21:16 UTC (PyPI upload_time_iso_8601 2026-07-24T21:16:42Z). La única entrada de refactorización es “⚡️ Reduce memory usage in dependencies. PR #16049” (fusionada el 2026-07-24T21:07:52Z). La regresión fue introducida por el PR #14262 (fusionado el 03-11-2025), incluido en 0.121.0 el mismo día, que añadió functools.cached_property a Dependant.cache_key; en 0.139.2 la clase contenía diez definiciones de @cached_property. En 0.140.0, fastapi/dependencies/models.py declara @dataclass(slots=True) class Dependant con la lógica trasladada a _get_cache_key(), _get_oauth_scopes(), _uses_scopes() y _is_security_scheme() a nivel de módulo; código fuente verificado en la etiqueta 0.140.0. El bot CodSpeed del PR fusionado informa que el benchmark de memoria test_dependency_graph pasó de 17.5 MB (base) a 1.1 MB (head), “improve performance by ×16”; 0.140.0 también añade un benchmark de memoria en CI (PR #16046) para evitar que vuelva a producirse una regresión. El informe original es la discusión #14742, donde 0.120.4 se mantuvo por debajo de ~400 MB y 0.121.3 produjo OOM en producción. Nota para autores de herramientas: Dependant.oauth_scopes, .cache_key, ._uses_scopes y ._is_security_scheme ya no existen como atributos, y slots=True impide modificar instancias mediante monkey-patching: una API interna no documentada que esta guía no utiliza, en la misma categoría que el cambio de router.routes de 0.137.0. Todos los datos se volvieron a verificar con PyPI, la API de GitHub y el código fuente etiquetado el 25-07-2026. 

  28. Versiones de Starlette 1.4.0 (05-08-2026), 1.5.0 (08-08-2026) y 1.6.0 (08-08-2026). 1.5.0 se titula “This release is all about giving GZipMiddleware some love” y enumera “Add exclude_content_types parameter to GZipMiddleware”, “Flush GZip output for each streamed chunk”, “Skip compression of partial responses in GZipMiddleware” y “Expand default excluded content types in GZipMiddleware”. La tupla de exclusión y la firma se leyeron directamente de starlette/middleware/gzip.py: DEFAULT_EXCLUDED_CONTENT_TYPES = application/gzip, application/x-gzip, application/zip, audio/, font/woff, font/woff2, image/avif, image/gif, image/jpeg, image/png, image/webp, text/event-stream, video/; y def __init__(self, app, minimum_size=500, compresslevel=9, thread_minimum_size=128*1024, *, exclude_content_types=DEFAULT_EXCLUDED_CONTENT_TYPES). 1.6.0 añade max_body_size en Starlette/Router/Mount/Route, además de RequestBodyLimitMiddleware. Todo se obtuvo y verificó el 16-08-2026. 

  29. FastAPI 0.141.0 (29-07-2026, 14:47 UTC) añadió app.frontend(check_dir="auto") para el desarrollo local con fastapi dev (PR #16102). FastAPI 0.141.1 (29-07-2026, 17:17 UTC) corrigió la compatibilidad con tareas en segundo plano y encabezados provenientes de dependencias en app.frontend() (PR #16105) y documentó FASTAPI_ENV en la guía de CLI de FastAPI (PR #16104). Ambos por @tiangolo. PyPI confirmó 0.141.1 como la última versión el 29-07-2026. 

  30. Versiones de FastAPI 0.140.1 a 0.140.7, todas publicadas el 27-07-2026 entre las 12:07 y las 17:34 UTC (PyPI upload_time_iso_8601: 0.140.1 12:07:51Z, 0.140.2 14:15:38Z, 0.140.3 15:30:52Z, 0.140.4 15:46:49Z, 0.140.5 16:02:53Z, 0.140.6 16:31:48Z, 0.140.7 17:34:47Z). El cuerpo de cada versión contiene una sola entrada de Refactors: 0.140.1 “Update the lru_cache limit for dependencies to account for large apps” (PR #16062); 0.140.2 “Stop retaining flat dependency trees” (PR #16065); 0.140.3 “Avoid repeated dependency flattening in OpenAPI” (PR #16067); 0.140.4 “Skip unused dependency repeat bookkeeping” (PR #16069); 0.140.5 “Avoid flattening dependencies for body fields” (PR #16071); 0.140.6 “Avoid flattening dependencies for request parameters, mainly for OpenAPI” (PR #16073); 0.140.7 “Avoid flattening dependencies for OpenAPI” (PR #16076). La cifra de la caché proviene del diff de #16062, que reemplaza tres decoradores @lru_cache(maxsize=1024) en fastapi/dependencies/models.py por @lru_cache(maxsize=_CALLABLE_CLASSIFICATION_CACHE_SIZE) y actualiza tests/test_dependency_models.py para afirmar que cache_info.maxsize == 4096; el cuerpo del PR afirma: “Some users reported a number of dependencies larger than 1024, this should account for larger apps.” 0.140.2 también añade un benchmark de memoria (PR #16064) y 0.140.7 añade benchmarks de dependencias de OpenAPI (PR #16075), por lo que la cobertura de benchmarks es posterior a la mayor parte de la serie. Verificado con la API de lanzamientos de GitHub, los diffs de los PR y PyPI el 27-07-2026; 0.140.7 era la última versión al momento de redactar. 

NORMAL fastapi-htmx.md EOF