FastAPI + HTMX: Full-Stack sem build
# FastAPI + HTMX: Full-Stack sem build
TL;DR: FastAPI + HTMX + Alpine.js + Jinja2 + CSS puro produz aplicações web prontas para produção com zero ferramentas de build, zero
node_modules/, e pontuações perfeitas no Lighthouse. Este guia cobre o sistema inteiro, da arquitetura ao deploy, usando blakecrosley.com como referência de produção que serve mais de 100 posts de blog, componentes JavaScript interativos, múltiplos guias abrangentes e traduções em nove idiomas sem um único bundler, compilador ou transpilador.1
A stack moderna de desenvolvimento web pressupõe que você precisa de React, webpack, TypeScript e um pipeline de build. Para uma grande categoria de aplicações — sites orientados a conteúdo, ferramentas internas, aplicações CRUD, sites de portfólio, plataformas de documentação — essa suposição está errada. A stack descrita neste guia elimina toda a toolchain de build do frontend enquanto produz sites que marcam 100/100/100/100 no Lighthouse.2
Isto não é advocacia. É uma medição. A arquitetura descrita aqui roda em produção, atende usuários reais em dez idiomas, e os números são verificáveis.
Principais conclusões
- HTML renderizado no servidor elimina três categorias inteiras de problemas: gerenciamento de estado no cliente, fronteiras de serialização JSON, e incompatibilidades de hidratação. HTMX faz com que as respostas do servidor sejam a saída final — sem etapa de renderização no cliente.
- Zero ferramentas de build significa zero falhas de build. Sem conflitos de dependências peer no
npm install, sem erros do compilador TypeScript em arquivos que você não tocou, sem PRs do Dependabot para dependências transitivas que você nunca importou. O pipeline de deploy égit push. - Alpine.js lida com o estado que existe apenas no cliente e que HTMX não consegue. Dropdowns, modais, toggles de navegação mobile e qualquer estado de UI que existe puramente no navegador pertencem ao Alpine.js. A fronteira é clara: se o estado precisa do servidor, use HTMX. Se não precisa, use Alpine.js.
- CSS puro com custom properties substitui Sass e Tailwind. Custom properties de CSS fazem cascade, herdam e respondem a media queries em tempo de execução. Variáveis de preprocessadores compilam para valores estáticos e desaparecem. O navegador lê custom properties diretamente — sem etapa de compilação.
- Essa abordagem tem limites claros. Não é adequada para grandes equipes compartilhando interfaces de componentes, produtos SaaS com estado complexo no cliente, e aplicações que dependem de bibliotecas do ecossistema npm. O framework de decisão na Seção 15 identifica esse limite com precisão.
- blakecrosley.com é a prova. Os padrões centrais deste guia (HTMX, Alpine.js, Jinja2, CSS puro) rodam em produção no blakecrosley.com. As seções sobre Bootstrap e SQLAlchemy cobrem padrões comuns do stack que não são usados neste site específico. Cada afirmação tem um caminho de arquivo, um bloco de configuração ou uma auditoria do Lighthouse que você pode verificar em PageSpeed Insights.2
Como usar este guia
Este é um guia de referência completo. Comece pelo ponto que se encaixa no seu nível de experiência:
| Experiência | Comece aqui | Depois explore |
|---|---|---|
| Desenvolvedor Python, novo em HTMX | A tese No-Build → Visão geral da arquitetura → HTMX em profundidade | Padrões Alpine.js, Segurança |
| Desenvolvedor React/Vue avaliando alternativas | A tese No-Build → Framework de decisão | Visão geral da arquitetura, Performance |
| Desenvolvedor FastAPI adicionando interatividade | HTMX em profundidade → Padrões Alpine.js | i18n e localização, Deploy |
| Desenvolvedor full-stack construindo do zero | Leia sequencialmente a partir de Visão geral da arquitetura | Cartão de referência rápida para uso contínuo |
Use Ctrl+F / Cmd+F para buscar padrões ou atributos específicos. O Cartão de referência rápida no final fornece um resumo fácil de consultar.
A tese No-Build
A tese é estreita e específica: para sites orientados a conteúdo com um desenvolvedor solo ou equipe pequena, ferramentas de build resolvem problemas que você não tem enquanto criam problemas que você tem.
Aqui estão as métricas reais do blakecrosley.com:
| Métrica | blakecrosley.com (No-Build) | Projeto Next.js típico3 |
|---|---|---|
| Dependências | 15 pacotes Python | 311+ pacotes npm |
| Arquivos de configuração de build | 0 | 5-8 (next.config, tsconfig, postcss, tailwind, etc.) |
Tamanho do node_modules/ |
Não existe | 187 MB base, 250-400 MB com adições |
| Tempo de instalação | pip install: 8 segundos |
npm install: 30-90 segundos |
| Etapa de build | Nenhuma | next build: 15-60 segundos |
| Pipeline de deploy | git push → no ar em ~40 segundos |
Instalar → build → deploy: 2-5 minutos |
| Lighthouse Performance | 100 | 70-90 sem otimização explícita4 |
Os 15 pacotes Python incluem FastAPI, Jinja2, Pydantic, uvicorn, nh3, e outros 10. Nenhum é uma ferramenta de build. Nenhum é um compilador. Nenhum é um bundler.5
O que você abre mão
Honestidade exige listar os custos reais:
Sem TypeScript. Todo arquivo .js é JavaScript puro. Erros de tipo são detectados por testes e análise de código, não por um compilador. Isso funciona para um desenvolvedor solo. Não funcionaria para uma equipe de 10 pessoas compartilhando interfaces de componentes.
Sem Hot Module Replacement. Alterações em CSS exigem um refresh manual no navegador. O hx-boost do HTMX torna a navegação rápida o suficiente para que refreshes completos sejam toleráveis, porém em ciclos intensos de iteração visual, HMR economiza tempo.
Sem Tree Shaking. Cada byte de JavaScript que você escreve é enviado ao navegador. A restrição força disciplina: arquivos pequenos e focados em vez de módulos utilitários grandes.
Sem bibliotecas de componentes npm. Sem Radix, sem shadcn/ui, sem Headless UI. Todo elemento interativo é construído manualmente ou usa os componentes nativos do Bootstrap 5.
Sem Design System Tokens via npm. O design system vive em custom properties de CSS. Não pode ser importado como pacote em outro projeto.
Essas concessões são aceitáveis para um site orientado a conteúdo com um a três desenvolvedores. Seriam inaceitáveis para um produto SaaS com uma equipe de engenharia de 15 pessoas. A Seção 15 fornece o framework de decisão.
O que você ganha
Zero falhas de build. Nenhum npm install pode falhar por conflitos de dependências peer. Nenhum next build pode falhar por um erro TypeScript em um arquivo que você não tocou.6
Debug com View Source. O JavaScript rodando no navegador é o JavaScript que você escreveu. Sem necessidade de source maps.
Startup local instantâneo. uvicorn app.main:app --reload inicia em menos de 2 segundos.
Waterfall de requisições concreto. Uma primeira visita carrega: um documento HTML (~15KB gzipado), um arquivo CSS (~8KB), HTMX (~14KB, em cache), Alpine.js (~14KB, em cache), e o JS interativo da página (~4-8KB). Total: 45-60KB na primeira visita.1
Frontend à prova do futuro. O código do lado do cliente usa HTML, CSS e JavaScript — padrões que mantêm compatibilidade retroativa há 30 anos.7 Sem migração do Webpack 4 → 5, sem depreciação do Create React App, sem migração do Next.js App Router.
Comparação de stacks
Como o stack no-build se compara a alternativas comuns em dimensões mensuráveis:
| Dimensão | FastAPI+HTMX (este guia) | Next.js (React) | Astro | 11ty |
|---|---|---|---|---|
| JS enviado ao navegador | 32-46KB (HTMX+Alpine) | 85-250KB+ (runtime React) | 0KB por padrão, islands sob demanda | 0KB por padrão |
| Etapa de build | Nenhuma | Obrigatória (webpack/turbopack) | Obrigatória (Vite) | Obrigatória (custom) |
| Arquivos de configuração | 0 | 5-8 (next.config, tsconfig, etc.) | 1-3 (astro.config, tsconfig) | 1-2 (.eleventy.js) |
| Pipeline de deploy | git push (40s) |
Instalar+build+deploy (2-5min) | Instalar+build+deploy (1-3min) | Instalar+build+deploy (1-2min) |
| Interatividade server-side | Nativa (HTMX) | Rotas API + fetch no cliente | Limitada (form actions) | Nenhuma (saída estática) |
| Gerenciamento de estado no cliente | Alpine.js (15KB) | React state/context/Redux | Framework islands | JS manual |
| Linguagem do backend | Python | JavaScript/TypeScript | JavaScript/TypeScript | JavaScript |
| Abordagem de i18n | Server-side (middleware) | next-intl ou pacote similar | @astrojs/i18n | Manual |
| Lighthouse Performance | 100 (medido) | 70-90 típico4 | 95-100 típico | 95-100 típico |
| Ideal para | Sites de conteúdo, CRUD, dashboards | SPA complexas, grandes equipes | Sites de conteúdo, marketing | Blogs estáticos, docs |
Astro e 11ty são os concorrentes mais próximos para sites de conteúdo. Ambos produzem excelente saída estática, mas exigem uma etapa de build e uma toolchain JavaScript. O stack FastAPI+HTMX troca performance de site estático por interatividade server-side (filtragem por categoria, manipulação de formulários, busca em tempo real) sem adicionar uma etapa de build. Se o seu site é puramente estático sem interações com servidor, Astro ou 11ty podem ser a melhor escolha.
Visão geral da arquitetura
Fluxo de requisições
Cada requisição segue um único caminho através de quatro camadas:
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 -------------------------------------------------------->|
Carregamentos de página completos retornam documentos HTML inteiros (template base + template da página). Requisições HTMX retornam fragmentos HTML (partials). O servidor decide o que renderizar com base no tipo de requisição. Alpine.js gerencia o estado exclusivo do cliente, que nunca chega ao servidor.
Papel de cada componente
| Componente | Papel | Escopo |
|---|---|---|
| FastAPI | Roteamento, lógica de negócios, acesso a dados, validação | Servidor |
| Jinja2 | Renderização de templates, herança, macros | Servidor |
| HTMX | Interatividade orientada pelo servidor (formulários, paginação, busca) | Cliente ↔ Servidor |
| Alpine.js | Estado exclusivo do cliente (dropdowns, modais, toggles) | Apenas cliente |
| Bootstrap 5 | Sistema de grid, classes utilitárias, layout responsivo | Cliente (CSS) |
| CSS puro | Propriedades customizadas, estilos de componentes, design tokens | Cliente (CSS) |
| Pydantic | Validação de request/response, configurações | Servidor |
Estrutura do projeto
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
A estrutura segue um único princípio: cada diretório contém um tipo de coisa. Rotas ficam em routes/. Templates ficam em templates/. Arquivos estáticos ficam em static/. Nenhuma etapa de build transforma um no outro.
Comparação com a arquitetura SPA
Em um projeto React + Next.js, a estrutura equivalente incluiria:
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
A arquitetura SPA exige coordenação em tempo de build entre esses diretórios. TypeScript compila .tsx para JavaScript. PostCSS processa diretivas Tailwind em CSS. Webpack (ou Turbopack) empacota a saída em chunks. Cada etapa pode falhar de forma independente.
A arquitetura sem build não exige coordenação. O template referencia um arquivo CSS. O arquivo CSS existe em static/css/. O navegador o carrega diretamente. Se você renomear um arquivo, a referência no template quebra em tempo de execução — não em tempo de build. Isso transfere os erros do tempo de compilação para o tempo de execução, o que é uma troca real. Para um desenvolvedor solo rodando uvicorn --reload durante o desenvolvimento, erros em tempo de execução aparecem imediatamente no navegador. Para uma equipe grande, erros em tempo de compilação capturados pelo TypeScript previnem uma categoria de bugs que erros em tempo de execução não conseguem.
Padrões do FastAPI
Configuração da aplicação
A aplicação é inicializada em main.py com ordenação explícita 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)
Três decisões de design importam aqui. Primeiro, docs_url=None e openapi_url=None desabilitam os endpoints de documentação automática da API. Um site de conteúdo voltado ao público não precisa de /docs ou /openapi.json expostos na internet.8 Segundo, a ordem dos middlewares importa — o logging de segurança executa primeiro (adicionado por último) para capturar toda requisição, incluindo aquelas rejeitadas pelo rate limiting. Terceiro, GZipMiddleware comprime todas as respostas acima de 500 bytes, o que normalmente reduz o tamanho de transferência do HTML em 70-80%.
Roteamento
As rotas se dividem em duas categorias: rotas de página retornam documentos HTML completos, e rotas de API retornam JSON ou fragmentos HTML.
# routes/pages.py — full HTML responses
from fastapi import APIRouter, Request
router = APIRouter()
@router.get("/about")
async def about(request: Request):
templates = request.app.state.templates
return templates.TemplateResponse("pages/about.html", {
"request": request,
"page_title": "About — Blake Crosley",
"page_description": "Designer, developer, dad.",
})
# routes/api.py — JSON or HTML fragment responses
@router.get("/api/quiz/{quiz_id}/step")
async def quiz_step(request: Request, quiz_id: str, answers: str = ""):
# Parse answers, compute next question or result
question = get_next_question(quiz_id, answers)
templates = request.app.state.templates
return templates.TemplateResponse("components/_quiz_step.html", {
"request": request,
"question": question,
"answers": answers,
"step": len(answers.split(",")) if answers else 0,
})
A distinção importa para o HTMX. Rotas de página completa retornam documentos que estendem base.html. Rotas de API retornam fragmentos HTML que o HTMX substitui em elementos DOM existentes. O mesmo mecanismo de templates Jinja2 renderiza ambos — sem camada API separada.
Injeção de dependências
O sistema Depends() do FastAPI oferece separação limpa entre handlers de rota e lógica compartilhada:
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,
})
Dependências se compõem. Uma dependência get_db pode depender de get_current_locale, que depende do request. O FastAPI resolve a cadeia automaticamente.
Configurações com Pydantic
A configuração usa BaseSettings do Pydantic com precedência de variáveis de ambiente:
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()
Variáveis de ambiente sobrescrevem os valores do arquivo .env. Em produção (Railway), os secrets são definidos como variáveis de ambiente. Localmente, um arquivo .env fornece os valores padrão. A classe Settings valida os tipos na inicialização — um campo obrigatório ausente falha imediatamente em vez de falhar em tempo de execução.
Padrões assíncronos
As rotas do FastAPI são assíncronas por padrão. Para operações vinculadas a I/O (consultas ao banco de dados, requisições HTTP, leitura de arquivos), o async evita bloquear o event loop:
@asynccontextmanager
async def lifespan(app: FastAPI):
# Load translations into memory cache at startup
async with httpx.AsyncClient() as client:
for locale in SUPPORTED_LOCALES:
resp = await client.post(f"{D1_URL}/query", ...)
TRANSLATIONS[locale] = resp.json()["results"]
yield
# Cleanup on shutdown (if needed)
app = FastAPI(lifespan=lifespan)
Operações vinculadas à CPU (renderização de Markdown, extração de CSS) podem usar funções síncronas. O FastAPI as executa automaticamente em um thread pool quando o handler de rota não é declarado como async:
# Sync function — FastAPI runs it in a thread pool
@router.get("/blog/{slug}")
def blog_post(slug: str):
post = load_post_by_slug(slug) # CPU-bound Markdown parsing
return templates.TemplateResponse(...)
A regra é: se a função aguarda I/O, torne-a async. Se ela faz trabalho de CPU, deixe-a síncrona. Não misture await com chamadas bloqueantes na mesma função.9
Templates Jinja2
Herança de templates
O sistema de herança do Jinja2 substitui a composição de componentes do React por um modelo mais simples. Um template base define o esqueleto da página. Templates filhos preenchem blocos nomeados:
<!-- 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 %}
A diretiva {% extends %} estabelece uma relação pai-filho. O template filho define apenas os blocos que precisa sobrescrever. Todo o resto — o <head>, o header, o footer, as tags de script — vem do base. Isso é composição por subtração, não por construção.
O global asset()
Arquivos estáticos usam versionamento por hash de conteúdo para invalidação de cache:
# 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}"
No template: {{ asset('css/styles.css') }} renderiza como /static/css/styles.css?v=a3f8b2c1d0. O hash muda quando o arquivo muda, invalidando o cache do CDN. Isso substitui a estratégia de nomes com [contenthash] do webpack por 30 linhas de Python computadas na inicialização.
Include para partials reutilizáveis
Componentes que se repetem em várias páginas usam {% 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>
O prefixo underscore (_language_switcher.html) é uma convenção que indica um partial — um fragmento de template que não deve ser renderizado de forma independente. Esse componente usa tanto Alpine.js (para o toggle do dropdown) quanto Jinja2 (para a lista de idiomas). A fronteira é clara: Alpine.js controla o estado abrir/fechar, Jinja2 controla os dados.
Macros para componentes reutilizáveis
Macros são as funções do Jinja2 — blocos de template reutilizáveis com 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 %}
Importe e use macros nos templates 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>
Macros substituem componentes React para padrões de apresentação. Elas aceitam parâmetros, suportam valores padrão e se compõem com outras macros. A diferença: macros renderizam uma vez no servidor e produzem HTML estático. Componentes React renderizam no cliente e mantêm estado. Para exibição de conteúdo, macros são a ferramenta certa.
Contexto de template e globals
Globals do Jinja2 são funções disponíveis em todos os templates sem necessidade de passagem explícita:
# 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
O global asset() gera URLs versionadas. O global csrf_token() gera tokens CSRF novos. O global analytics_script() injeta o snippet de rastreamento. Essas funções podem ser chamadas em qualquer template sem que o handler da rota precise passá-las explicitamente.
Para i18n, a configuração é mais elaborada — as funções de tradução precisam acessar o idioma da requisição atual:
# 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 função lê o idioma a partir da variável de contexto da requisição definida pelo middleware de localização. O template chama {{ _('ui.nav.about') }} e obtém a string traduzida para o idioma da requisição atual, sem nenhum parâmetro de idioma explícito.
Blocos condicionais
O sistema de blocos do Jinja2 suporta sobrescritas condicionais:
<!-- 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 %}
Posts de blog declaram suas dependências no frontmatter YAML (scripts: ["/static/js/boids.js"]). O template as inclui condicionalmente. Páginas que não precisam de scripts ou estilos extras não carregam nenhum — sem código morto, sem imports não utilizados.
Filtros personalizados
Filtros do Jinja2 transformam dados durante a renderização. O filtro sanitize previne XSS em conteúdo gerado por usuários:
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
Nos templates: {{ user_content | sanitize }}. A biblioteca nh3 é um sanitizador de HTML baseado em Rust — rápido e seguro. Ela remove qualquer tag ou atributo que não esteja na lista de permitidos, prevenindo XSS persistente mesmo que o conteúdo venha de uma fonte não confiável.10
HTMX em profundidade
HTMX transforma qualquer elemento HTML em algo capaz de emitir requisições HTTP e inserir a resposta no DOM. A sacada principal é arquitetural: HTML renderizado no servidor é a API. O servidor retorna a representação final. Sem renderização no cliente, sem serialização JSON, sem hidratação.
Atributos principais
| Atributo | Finalidade | Exemplo |
|---|---|---|
hx-get |
Emitir requisição GET | hx-get="/search?q=term" |
hx-post |
Emitir requisição POST | hx-post="/contact" |
hx-target |
Onde inserir a resposta | hx-target="#results" |
hx-swap |
Como inserir a resposta | hx-swap="innerHTML" (padrão), outerHTML, beforeend |
hx-trigger |
O que dispara a requisição | hx-trigger="click", keyup changed delay:300ms, load |
hx-indicator |
Elemento exibido durante a requisição | hx-indicator="#spinner" |
hx-push-url |
Atualizar a URL do navegador | hx-push-url="true" |
hx-replace-url |
Substituir URL sem entrada no histórico | hx-replace-url="true" |
Padrão 1: Quiz interativo (estado multi-etapas no servidor)
blakecrosley.com inclui um quiz interativo que guia os usuários pela seleção de ferramentas. Todo o estado do quiz fica no servidor — sem gerenciamento de estado no cliente:
<!-- _quiz_container.html — carregamento 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 pergunta -->
<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 clique em um botão envia as respostas acumuladas como parâmetro de consulta. O servidor calcula a próxima pergunta ou o resultado final com base no histórico de respostas. O estado se acumula na URL — sem cookies, sem sessões, sem JavaScript no cliente. O quiz avança por meio de swaps outerHTML: cada resposta substitui o elemento inteiro da etapa do quiz.
Padrão 2: Lista de posts paginada
A página de artigos usa HTMX para uma paginação fluida que atualiza a URL:
<!-- Link de paginação -->
<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>
Quatro atributos trabalhando juntos:
hx-getemite a requisição para a mesma URL dohref(aprimoramento progressivo — funciona sem JavaScript)hx-targetinsere a resposta no container#writing-contenthx-replace-url="true"atualiza a URL do navegador sem adicionar uma entrada no históricohx-indicatorexibe um spinner de carregamento durante a requisição
O servidor detecta requisições HTMX pelo header HX-Request e retorna apenas o fragmento da lista de posts em vez da página completa. Por isso o middleware de headers de segurança adiciona Vary: HX-Request — para que caches de CDN armazenem a página completa e o fragmento separadamente.11
Padrão 3: Busca com 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>
O atributo hx-trigger combina três modificadores:
keyupdispara ao soltar a teclachangeddispara apenas se o valor realmente mudou (evita requisições duplicadas por teclas modificadoras)delay:300msaplica debounce — aguarda 300ms após o último keyup antes de disparar
O servidor retorna um 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,
})
Sem estado no cliente. Sem biblioteca de debounce. Sem useEffect. O template renderiza os resultados, HTMX faz o swap no DOM, e o servidor é a única fonte de verdade.
Padrão 4: Swaps Out-of-Band (OOB)
Às vezes uma única ação do servidor precisa atualizar múltiplos elementos no DOM. O mecanismo de swap out-of-band do HTMX resolve isso sem orquestração no cliente:
<!-- O servidor retorna múltiplos elementos em uma única resposta -->
<!-- Alvo principal: swap normal via hx-target -->
<div id="cart-items">
<ul>
<li>Widget A — $29.99</li>
<li>Widget B — $14.99</li>
</ul>
</div>
<!-- Alvo OOB: swap independente 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>
O atributo hx-swap-oob="true" diz ao HTMX para encontrar o elemento pelo id em qualquer lugar do DOM e substituí-lo, independentemente do hx-target. Isso substitui o padrão “elevar o estado” do React — o servidor calcula todo o estado derivado e envia o HTML final de cada elemento em uma única resposta.
Um formulário de contato demonstra isso bem: enviar o formulário pode substituir o corpo do formulário por uma mensagem de sucesso e, ao mesmo tempo, atualizar um badge de notificação via OOB swap:
Padrão 5: Links com boost
HTMX pode aplicar “boost” em links de navegação padrão para usar AJAX em vez de carregamentos de página completos:
<nav hx-boost="true">
<a href="/about">About</a>
<a href="/writing">Writing</a>
<a href="/guides">Guides</a>
</nav>
Com hx-boost="true", clicar em um link busca a página via AJAX, faz o swap do conteúdo do <body> e atualiza a URL — sem recarregamento completo da página. O histórico do navegador funciona normalmente (botões voltar/avançar). Se o JavaScript falhar, os links funcionam como navegação padrão.
O benefício é a performance percebida: a navegação com boost parece instantânea porque o navegador não precisa re-analisar o CSS, re-avaliar scripts ou re-renderizar o layout. Apenas o conteúdo do <body> muda. Links com boost funcionam bem para elementos de navegação principal, o que faz as transições de página parecerem uma single-page application sem a arquitetura de SPA.
Padrão 6: Headers de requisição do HTMX
HTMX envia headers customizados em cada requisição:
| Header | Valor | Caso de uso |
|---|---|---|
HX-Request |
true |
Detectar requisições HTMX no servidor |
HX-Target |
ID do elemento | Saber qual elemento receberá a resposta |
HX-Trigger |
ID do elemento | Saber qual elemento disparou a requisição |
HX-Current-URL |
URL completa | Saber a página atual do usuário |
O servidor pode usar HX-Request para retornar respostas 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)
Esse padrão de resposta dual é central na arquitetura. Um carregamento de página completo retorna o documento inteiro (template base + conteúdo da página). Uma navegação HTMX retorna apenas o conteúdo alterado. O servidor decide, não o cliente.
Padrão 7: Aprimoramento progressivo
Todo link HTMX em blakecrosley.com inclui um atributo href padrão:
<a href="/writing?page=2"
hx-get="/writing?page=2"
hx-target="#writing-content"
hx-swap="innerHTML">
Next Page
</a>
Se o JavaScript não carregar, o href funciona como um link normal. Se o HTMX carregar, ele intercepta o clique e realiza um swap via AJAX. Isso é aprimoramento progressivo: o site funciona sem JavaScript, e o HTMX melhora a experiência quando disponível.
Padrão 8: Estados de carregamento
<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 adiciona a classe htmx-request ao elemento que disparou a requisição durante o processamento. O atributo hx-indicator aponta para um elemento que se torna visível durante a requisição. Estilize com CSS:
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
display: inline;
}
Sem gerenciamento de estado de carregamento. Sem useState(false). Sem setLoading(true). CSS cuida da visibilidade, HTMX cuida do toggle de classe.
Padrões do Alpine.js
O Alpine.js preenche a lacuna que o HTMX deixa: estado que existe apenas no cliente e nunca precisa ir ao servidor. Se o usuário clica em um dropdown e ele abre, esse estado existe apenas no navegador. O Alpine.js gerencia isso com atributos HTML.
A regra de fronteira
A fronteira entre HTMX e Alpine.js é precisa:
| Tipo de estado | Ferramenta | Exemplo |
|---|---|---|
| Precisa de dados do servidor | HTMX | Resultados de busca, validação de formulário, paginação |
| Existe apenas no navegador | Alpine.js | Abrir/fechar dropdown, toggle de menu mobile, visibilidade de modal |
| Combina ambos | Ambos | Seletor de idioma (toggle com Alpine.js, navegação com HTMX) |
Navegação mobile
O template base envolve todo o cabeçalho em um 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>
Padrões-chave do Alpine.js:
x-datadeclara o escopo do componente e o estado inicialx-showalterna a visibilidade com base no estado (usa CSSdisplay: none)x-cloakoculta o elemento até o Alpine.js inicializar (evita o flash de conteúdo sem estilo)@clickvincula handlers de clique com expressões:aria-expanded(atalho parax-bind:aria-expanded) define atributos dinamicamente@keydown.escape.windowescuta a tecla Escape globalmente para fechar painéis
Componente dropdown
O seletor de idioma usa Alpine.js para o estado de toggle com @click.away para fechar ao clicar fora:
<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>
O modificador @click.away fecha o dropdown ao clicar fora dele. O Alpine.js resolve isso com um único atributo — sem registro de event listeners, sem cleanup, sem gerenciamento de refs.
Quando usar Alpine.js vs. JavaScript puro
O Alpine.js é adequado quando:
- O estado é limitado a um único elemento DOM (dropdown, modal, toggle)
- As interações são binárias ou simples (abrir/fechar, mostrar/ocultar, alternar)
- Múltiplos elementos precisam reagir à mesma mudança de estado
- Atributos de acessibilidade devem permanecer sincronizados com a visibilidade
JavaScript puro é adequado quando:
- A interação envolve computação complexa (visualizações, simulações)
- O componente tem seu próprio loop de renderização (canvas, animação)
- Performance é crítica (o Alpine.js adiciona overhead por componente
x-data) - A lógica excede 20-30 linhas de expressões Alpine.js
O blakecrosley.com usa Alpine.js para navegação, troca de idioma e toggles de conteúdo. Os 20 componentes interativos do blog (simulação de boids, visualizador de código Hamming, etc.) usam JavaScript puro porque exigem renderização em canvas e máquinas de estado complexas.
Exemplo completo: filtragem por categoria em /writing
Esta seção acompanha um recurso real do código de produção por todas as camadas: rota, template, interação HTMX, segurança, cache e resultado renderizado. O recurso: abas de categoria na página de artigos que filtram posts do blog sem recarregar a página inteira.
A rota (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,
)
A verificação do header HX-Request é o padrão central: mesma rota, mesmos dados, template diferente. O HTMX recebe um fragmento. Navegadores recebem a página completa.
As abas de categoria (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 aba tem tanto href (funciona sem JavaScript) quanto hx-get (troca apenas a lista de posts). O hx-push-url atualiza a URL do navegador para que a visualização filtrada possa ser compartilhada e favoritada.
O partial (pages/writing/_post_list.html)
O partial renderiza de forma idêntica, seja incluído no carregamento da página ou trocado pelo HTMX:
{% for post in posts %}
<article class="post-card">
<a href="{{ locale_prefix() }}/blog/{{ post.meta.slug }}">
<h3>{{ post.meta.title }}</h3>
<p>{{ post.meta.description }}</p>
<time>{{ post.meta.date }}</time> · {{ post.reading_time }}m
</a>
</article>
{% endfor %}
Nenhuma marcação especial do HTMX no partial. Nenhuma lógica de renderização no cliente. O mesmo HTML funciona tanto para o carregamento inicial da página quanto para cada filtro subsequente.
Segurança
Os valores de categoria são validados contra CATEGORY_MAP (um dicionário no servidor) antes da filtragem. Categorias inválidas são ignoradas, não ecoadas de volta. Nenhuma entrada do usuário é interpolada em SQL ou HTML. O header CSP bloqueia scripts inline.
Cache
As respostas de categoria são dinâmicas (sem cache no CDN). Porém, os assets estáticos (CSS, HTMX, Alpine.js) possuem hash de conteúdo e são cacheados indefinidamente após o primeiro carregamento. Trocas de categoria subsequentes transferem apenas o partial HTML (~3-5KB) — sem CSS, sem JS, sem re-download de imagens.
O que isso demonstra
Um recurso, código real de produção, zero ferramentas de build. O servidor filtra e renderiza HTML. O HTMX troca a lista de posts. O Alpine.js não está envolvido (nenhum estado no cliente é necessário). A URL é atualizada para compartilhamento. Progressive enhancement: as abas funcionam como links comuns sem JavaScript. Total de JavaScript customizado para este recurso: zero linhas.
Extensões opcionais
As seções a seguir cobrem padrões que complementam o stack principal, mas não são usados no blakecrosley.com. Estão incluídas porque representam as adições mais comuns que equipes fazem ao adotar esta arquitetura.
Bootstrap 5 sem Sass
Nota: blakecrosley.com usa CSS puro com propriedades customizadas — sem Bootstrap. Esta seção aborda o Bootstrap 5 como uma opção para equipes que desejam um framework utilitário sem etapa de build. O CSS compilado do Bootstrap pode ser carregado de um CDN ou incluído na sua folha de estilos. Os padrões abaixo são genéricos e funcionam junto com a abordagem HTMX + Alpine.js descrita nas seções anteriores.
O Bootstrap 5 removeu a dependência do jQuery e suporta uso autônomo de CSS. Você não precisa de Sass, PostCSS ou qualquer ferramenta de build para usar o sistema de grid e as classes utilitárias do Bootstrap.
Self-hosting sem CDN
blakecrosley.com hospeda localmente todas as bibliotecas de terceiros:
<!-- 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>
O self-hosting elimina dependências externas, evita que quedas de CDN quebrem o site e permite cache imutável com URLs baseadas em hash de conteúdo. Baixe o CSS compilado do Bootstrap (não o código-fonte Sass) e coloque-o em static/css/vendor/.
Sistema de grid
O grid do Bootstrap funciona com classes HTML puras:
<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>
Sem mixins Sass. Sem @include make-col(). O CSS compilado já inclui as classes responsivas do grid. Para breakpoints personalizados além dos padrões do Bootstrap, escreva media queries em CSS puro.
Sobrescrevendo com CSS puro
Sobrescreva os padrões do Bootstrap com propriedades customizadas de CSS e seletores padrão:
/* 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;
}
Propriedades customizadas de CSS cascateiam pelo DOM, herdam de elementos pai e respondem a media queries em tempo de execução. Variáveis Sass são compiladas em valores estáticos e desaparecem. Essa distinção é importante para temas: uma única alteração em uma propriedade customizada pode atualizar todos os valores derivados sem recompilação.12
Classes utilitárias vs. CSS de componente
Use classes utilitárias do Bootstrap para espaçamento e layout pontuais. Use CSS de componente para padrões 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;
}
O princípio: utilitários do Bootstrap para mecânicas de layout (margin, padding, flexbox). CSS customizado para identidade visual (cores, tipografia, animações). Nunca misture classes utilitárias com estilização de componente para a mesma finalidade.
i18n e localização
blakecrosley.com serve conteúdo em 10 idiomas: inglês, japonês, coreano, chinês simplificado, chinês tradicional, alemão, francês, espanhol, polonês e português (brasileiro).
Roteamento de locale baseado em URL
O locale fica no caminho da URL: /about (inglês), /ja/about (japonês), /zh-Hans/about (chinês simplificado). O inglês é o padrão e não possui prefixo.
# i18n/config.py
SUPPORTED_LOCALES = [
"en", "zh-Hans", "zh-Hant", "fr", "de", "ja", "ko", "pl", "pt-BR", "es"
]
DEFAULT_LOCALE = "en"
O middleware de locale extrai o locale do caminho da 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
O middleware remove o prefixo do locale antes do roteamento. Isso significa que os handlers de rota não precisam de caminhos específicos por locale — /about atende tanto ao inglês (/about) quanto ao japonês (/ja/about) porque o middleware normaliza o caminho.
Funções de tradução nos templates
As variáveis globais do Jinja2 fornecem funções de tradução:
<!-- Template usage -->
<h3>{{ _('ui.footer.navigate') | default('Navigate') }}</h3>
<a href="{{ locale_prefix() }}/about">
{{ _('ui.nav.about') | default('About') }}
</a>
A função _() busca uma chave de tradução no cache em memória. O filtro | default() fornece o fallback em inglês caso a tradução esteja ausente. A função locale_prefix() retorna o prefixo de URL para o locale atual ("" para inglês, "/ja" para japonês).
Tags hreflang
Toda página inclui tags hreflang para todos os locales suportados:
<!-- Generated in base.html -->
{% for alt in alternate_urls(request.url.path) %}
<link rel="alternate" hreflang="{{ alt.hreflang }}" href="{{ alt.url }}">
{% endfor %}
Isso produz:
<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">
Os mecanismos de busca usam o hreflang para exibir a versão correta do idioma nos resultados de pesquisa. A entrada x-default aponta para a versão em inglês como fallback.13
Armazenamento de traduções e cache em memória
As traduções são armazenadas no Cloudflare D1 (SQLite na edge) e carregadas em um cache em memória através do handler 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)
O cache em memória evita consultas ao banco de dados em cada renderização de página. Atualizações de tradução exigem uma atualização do cache (disparada via endpoint administrativo ou um deploy). Essa arquitetura troca atualidade por performance — traduções mudam com pouca frequência, mas renderizações de página acontecem a cada requisição.
Monitoramento de saúde
blakecrosley.com inclui um endpoint de health check para i18n que monitora a cobertura de tradução 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
O limite de 99,5% de cobertura detecta traduções ausentes antes que os usuários encontrem strings não traduzidas. O endpoint de saúde se integra ao monitoramento do Railway para alertar quando a cobertura cai — por exemplo, após adicionar novas strings de UI que ainda não foram traduzidas.
Renderização de conteúdo com reconhecimento de locale
Posts de blog e guias suportam traduções por locale de metadados e conteúdo:
# 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 }}
O padrão é consistente: tente o conteúdo traduzido primeiro, use o inglês como fallback. Isso permite tradução parcial — um usuário japonês vê títulos e descrições traduzidos mesmo que o corpo completo do artigo permaneça em inglês. O filtro | default() do Jinja2 codifica esse padrão em um único pipe:
{{ translated.title if translated else post.meta.title }}
Tradução de dados de locale
Conteúdo estático como descrições de projetos e labels de navegação são traduzidos através de funções auxiliares que mantêm a mesma estrutura de dados enquanto substituem as strings específicas do 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
Essa abordagem mantém a camada de tradução separada da camada de dados. As rotas passam a mesma lista projects independentemente do locale. As funções de tradução envolvem os dados de forma transparente.
Sitemap com alternativas hreflang
O sitemap dinâmico inclui todas as páginas em todos os locales com referências 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}"/>'
)
Isso produz 10 entradas de URL por página (uma por locale), cada uma com 11 links alternativos (10 locales + x-default). Para um site com 50 páginas, o sitemap contém 500 entradas de URL com 5.500 links hreflang. O sitemap é gerado dinamicamente e armazenado em cache por uma hora.
Padrões de banco de dados
Nota: blakecrosley.com usa Cloudflare D1 (SQLite serverless) via HTTP para todos os dados persistentes, não SQLAlchemy. Esta seção cobre o padrão assíncrono padrão do SQLAlchemy para projetos FastAPI que precisam de um banco de dados relacional — a configuração de produção mais comum para essa stack.
SQLAlchemy 2.0 Async
Para aplicações que precisam de um banco de dados relacional, o suporte assíncrono do SQLAlchemy 2.0 se integra facilmente com 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
Injeção de dependência para sessões de banco de dados
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
})
A dependência get_db gerencia o ciclo de vida da sessão: ela abre uma sessão, a disponibiliza para o handler da rota, faz commit em caso de sucesso e rollback em caso de exceção. Todas as operações de banco de dados usam queries parametrizadas — nunca interpolação de strings.
Integração com Pydantic
Os modelos Pydantic validam a entrada na fronteira da API e serializam a saída para os templates:
from pydantic import BaseModel, EmailStr
class ContactForm(BaseModel):
name: str
email: EmailStr
message: str
@router.post("/contact")
async def submit_contact(request: Request, form: ContactForm):
# form.name, form.email, form.message are validated
await send_email(form)
return templates.TemplateResponse("components/_contact_success.html", {
"request": request
})
O Pydantic valida tipos, formatos (email, URL) e restrições (comprimento mínimo/máximo) antes do handler da rota ser executado. Entradas inválidas retornam uma resposta 422 automaticamente. Isso substitui bibliotecas de validação de formulários no lado do cliente — o servidor valida, e o HTMX insere a mensagem de sucesso ou o feedback de erro.
Migrações com Alembic
O Alembic gerencia alterações no esquema do banco de dados:
# 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
O recurso de autogeração compara os modelos SQLAlchemy com o esquema atual do banco de dados e gera scripts de migração. Esses scripts são arquivos Python versionados que ficam no repositório:
# 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")
As migrações são executadas durante o deploy (antes da aplicação iniciar). Isso garante que o esquema do banco de dados corresponda ao código da aplicação. No blakecrosley.com, a maioria dos dados fica no Cloudflare D1 (acessado via HTTP), então as migrações do Alembic se aplicam ao banco de dados SQLite local ou PostgreSQL usado para dados de sessão e analytics.
O padrão Cloudflare D1
O blakecrosley.com usa Cloudflare D1 como banco de dados remoto acessado através de um proxy Cloudflare Worker:
class D1Client:
"""HTTP client for Cloudflare D1 via Worker proxy."""
def __init__(self, worker_url: str, auth_secret: str):
self.worker_url = worker_url
self.auth_secret = auth_secret
async def fetch_all(self, sql: str, params: list = None) -> list[dict]:
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.worker_url}/query",
json={"sql": sql, "params": params or []},
headers={"Authorization": f"Bearer {self.auth_secret}"},
)
return response.json()["results"]
Esse padrão funciona para aplicações que precisam de um banco de dados mas não querem gerenciar um servidor de banco de dados. O D1 é SQLite na edge da Cloudflare, acessado via HTTP. O proxy Worker cuida da autenticação e do rate limiting. O trade-off é a latência: cada query é uma requisição HTTP (~50-100ms) contra uma conexão de banco de dados local (~1-5ms). O cache em memória na inicialização mitiga isso para cargas de trabalho com muita leitura, como traduções.
Segurança
Middleware de cabeçalhos de segurança
O blakecrosley.com implementa cabeçalhos de segurança reforçados via middleware customizado:
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
O CSP inclui 'unsafe-inline' e 'unsafe-eval' porque o Alpine.js precisa deles para avaliação de expressões. A alternativa é o build compatível com CSP do Alpine.js, que tem limitações.14 Todo o restante é bloqueado: frame-ancestors previne clickjacking, form-action restringe envios de formulários à mesma origem, e upgrade-insecure-requests força HTTPS.
Segurança de cache CDN com HTMX
O middleware de cabeçalhos de segurança adiciona Vary: HX-Request às respostas 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)
Sem esse cabeçalho, um CDN poderia cachear uma resposta de fragmento HTMX e servi-la como página completa para uma requisição não-HTMX (ou vice-versa). O cabeçalho Vary instrui o CDN a armazenar entradas de cache separadas com base no valor do cabeçalho HX-Request.11
Proteção CSRF
Os formulários HTMX usam tokens CSRF stateless assinados com 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)
O token é gerado no template via uma variável global do Jinja2 e incluído nas requisições de formulários HTMX:
<form hx-post="/contact" hx-target="#form-result">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- form fields -->
</form>
Tokens stateless eliminam o armazenamento de sessão no servidor. A assinatura HMAC garante que o token foi gerado pelo servidor. O timestamp previne ataques de replay. O hmac.compare_digest previne ataques de timing.15
Sanitização de HTML
Conteúdo gerado pelo usuário passa pelo nh3 antes da renderização:
templates.env.filters["sanitize"] = sanitize_html
# In templates: {{ content | sanitize }}
A biblioteca nh3 remove tags e atributos que não estão na allowlist. Links recebem automaticamente rel="noopener noreferrer". Essa defesa é independente do CSP — ela previne XSS armazenado na camada de renderização, enquanto o CSP previne scripts injetados na camada do navegador. Defesa em profundidade.
Validação de entrada
Os modelos Pydantic validam toda entrada na fronteira da 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)
O FastAPI retorna 422 Unprocessable Entity para entradas inválidas automaticamente. Combinado com queries parametrizadas no banco de dados (o SQLAlchemy nunca interpola strings), isso previne SQL injection e garante type safety nas fronteiras.
Desempenho
Lighthouse 100/100/100/100
blakecrosley.com alcança 100 em todas as quatro categorias do Lighthouse: Performance, Accessibility, Best Practices e SEO. Verifique em PageSpeed Insights.2
As principais otimizações:
Estratégia de carregamento de CSS
blakecrosley.com carrega CSS com uma única tag <link> e URLs com hash de conteúdo para cache imutável:
<link rel="stylesheet" href="{{ asset('css/styles.css') }}">
O helper asset() adiciona um hash de conteúdo (?v=a3b2c1d4) para que o navegador armazene o arquivo em cache indefinidamente até que o conteúdo mude. Sem extração de CSS crítico, sem truque de print-media, sem carregamento baseado em JavaScript. O arquivo CSS tem ~8KB gzipado — pequeno o suficiente para que a abordagem de requisição única alcance 100 no Lighthouse Performance sem ginásticas de otimização.
Compressão GZip
app.add_middleware(GZipMiddleware, minimum_size=500)
Respostas acima de 500 bytes são comprimidas. HTML comprime 70-80%, reduzindo um documento de 15KB para 3-4KB.
Cache imutável de assets 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"
Assets estáticos com URLs de hash de conteúdo (?v=a3f8b2c1d0) são armazenados em cache por um ano com immutable. O hash muda quando o arquivo muda, forçando navegadores e CDNs a buscar a nova versão.
Carregamento diferido 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>
O atributo defer baixa os scripts em paralelo com o parsing do HTML, mas os executa após o documento ser analisado. Isso evita o bloqueio de renderização sem a complexidade do carregamento assíncrono e do gerenciamento de ordem de execução.
Otimização de imagens
As imagens usam WebP com srcset responsivo e dimensões 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>
Os atributos explícitos width e height evitam o Cumulative Layout Shift (CLS). O atributo loading="lazy" adia imagens fora da tela. WebP oferece arquivos 25-35% menores que JPEG com qualidade 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)
O header Link com rel=preload instrui o Cloudflare a enviar uma resposta 103 Early Hints, permitindo que o navegador comece a buscar o CSS antes que o servidor termine de gerar a resposta HTML.17
JavaScript mínimo
O footprint total de JavaScript:
| Biblioteca | Tamanho (minificado + gzipado) |
|---|---|
| HTMX | ~14 KB |
| Alpine.js | ~14 KB |
| JS específico da página | 4-8 KB |
| Total | 32-36 KB |
Uma aplicação React típica envia 100-300 KB de JavaScript de framework antes do código da aplicação.18 A abordagem sem build envia menos JavaScript porque há menos JavaScript para enviar.
Deploy
Railway
blakecrosley.com faz deploy no Railway via git push:
# railway.toml
[build]
builder = "nixpacks"
[deploy]
startCommand = "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}"
healthcheckPath = "/health"
healthcheckTimeout = 300
restartPolicyType = "ON_FAILURE"
restartPolicyMaxRetries = 10
O builder Nixpacks do Railway detecta o projeto Python a partir do requirements.txt, instala as dependências e executa o comando de inicialização. Nenhum Dockerfile é necessário. O endpoint de health check garante que a aplicação esteja respondendo antes de receber tráfego:
@app.get("/health")
async def health():
return {"status": "healthy"}
O pipeline de deploy
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
Sem npm install. Sem npm run build. Sem compilação webpack. Sem compilação TypeScript. A única etapa de instalação é pip install -r requirements.txt, que é armazenada em cache entre deploys.
Procfile
web: uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}
O Procfile oferece uma alternativa compatível com Heroku. O Railway suporta tanto railway.toml quanto Procfile. A sintaxe ${PORT:-8000} usa a porta fornecida pela plataforma ou o padrão 8000 para desenvolvimento local.
Configuração de produção do Uvicorn
Para deploys com maior tráfego, use múltiplos workers:
uvicorn app.main:app \
--host 0.0.0.0 \
--port ${PORT:-8000} \
--workers 4 \
--loop uvloop \
--http httptools
--workers 4executa quatro processos worker (regra geral: 2 * núcleos de CPU + 1)--loop uvloopusa o event loop uvloop, mais rápido (substituto direto do asyncio)--http httptoolsusa o parser HTTP httptools, mais rápido
Para desenvolvimento, --reload monitora alterações nos arquivos:
uvicorn app.main:app --reload --port 8000
Alternativa com Docker
Para plataformas que exigem 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"]
A imagem base slim mantém o contêiner pequeno. --no-cache-dir evita que o pip armazene pacotes baixados na camada da imagem.
CDN Cloudflare
blakecrosley.com usa Cloudflare para CDN, DNS e 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— navegador armazena em cache por 5 minutoss-maxage=3600— CDN armazena em cache por 1 horastale-while-revalidate=86400— serve conteúdo obsoleto enquanto revalida por 24 horas
Assets estáticos recebem max-age=31536000, immutable porque URLs com hash de conteúdo garantem a atualidade.
Framework de decisão
Você precisa de ferramentas de build?
Responda quatro perguntas:
1. Mais de cinco desenvolvedores compartilham interfaces JavaScript? Se sim, a verificação de tipos em tempo de compilação do TypeScript previne bugs de integração que testes em runtime detectam tarde demais. Adicione uma etapa de build.
2. Sua aplicação gerencia estado complexo no lado do cliente? Se drag-and-drop, colaboração em tempo real ou dados offline-first são recursos essenciais (não apenas desejáveis), um framework como React ou Svelte justifica sua complexidade. Adicione uma etapa de build.
3. Múltiplos produtos consomem uma biblioteca de componentes compartilhada? Se sim, essa biblioteca precisa de empacotamento npm, versionamento semântico e tree shaking. Adicione uma etapa de build.
4. Você depende de bibliotecas do ecossistema npm que assumem um bundler? Se Radix, Framer Motion, TanStack Query ou bibliotecas similares são essenciais para o produto, um pipeline de build é obrigatório.
Se todas as quatro respostas forem “não”, a abordagem sem build é viável. Se qualquer resposta for “sim”, ferramentas de build resolvem um problema real. O erro é adicionar ferramentas de build quando todas as quatro respostas são “não” — resolvendo problemas que você não tem enquanto cria overhead de gerenciamento de dependências que você tem.1
Comparação de stacks
| Categoria | Sem build (este guia) | React + ferramentas de build |
|---|---|---|
| Melhor para | Sites de conteúdo, portfólios, ferramentas internas, apps CRUD | Produtos SaaS, SPAs complexas, consumidores de design systems |
| Tamanho da equipe | 1-5 desenvolvedores | 5-50+ desenvolvedores |
| Gerenciamento de estado | Servidor (HTMX) + cliente (Alpine.js) | Cliente (React state, Redux, Zustand) |
| Segurança de tipos | Runtime (Pydantic no servidor) | Tempo de compilação (TypeScript) |
| Reuso de componentes | Jinja2 includes + macros | Pacotes npm, bibliotecas compartilhadas |
| SEO | Renderizado no servidor por padrão | Requer configuração de SSR/SSG |
| Piso de performance | Alto (JS mínimo, renderizado no servidor) | Varia (overhead do framework) |
| Teto de complexidade | Menor (sem offline, sem estado rico no cliente) | Maior (qualquer interação no cliente é possível) |
| Dependências | 15 pacotes Python | 300+ pacotes npm |
| Tempo de build | 0 segundos | 15-60 segundos |
Quando HTMX é a escolha errada
HTMX substitui estado no cliente por round-trips ao servidor. Isso funciona até que a latência importe:
- Interfaces de drag-and-drop — 200ms de round-trip ao servidor por evento de arraste é inaceitável
- Colaboração em tempo real — estado orientado por WebSocket exige resolução de conflitos no lado do cliente
- Aplicações offline-first — sem servidor significa sem HTMX
- Animações complexas vinculadas a estado — Framer Motion e React Spring assumem um modelo de reconciliação React
- Aplicações Canvas/WebGL — o loop de renderização é inerentemente no lado do cliente
Para esses casos de uso, um framework no lado do cliente é a ferramenta certa. A abordagem sem build não tenta substituí-los.
Cartão de Referência 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 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 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 -->
Custom Properties 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; }
}
Headers de Segurança
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=()
Checklist de Configuração do Projeto
[ ] 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
Perguntas Frequentes
O HTMX está pronto para produção em aplicações web reais?
Sim. O HTMX é estável desde 2020 e é utilizado em produção em diversos setores. Carson Gross, o criador, mantém a compatibilidade retroativa como princípio fundamental de design — a documentação do HTMX afirma que a biblioteca não quebrará aplicações existentes dentro de uma versão major.19 A biblioteca tem 14KB minificados e comprimidos com gzip, não possui dependências e segue versionamento semântico. O blakecrosley.com roda HTMX em produção há três anos sem nenhum bug relacionado ao HTMX.
Posso usar TypeScript sem etapa de build?
Parcialmente. Arquivos TypeScript podem ser verificados com tsc --noEmit sem gerar arquivos de saída, oferecendo verificação em tempo de compilação como um linter. Porém, navegadores não conseguem executar arquivos .ts diretamente, então uma etapa de build ainda é necessária para servir TypeScript. A alternativa são anotações de tipo JSDoc em arquivos .js simples, que o TypeScript consegue verificar sem compilação. Isso proporciona segurança de tipos durante o desenvolvimento enquanto entrega JavaScript padrão.
Como essa abordagem se compara ao Astro ou 11ty?
Astro e 11ty são geradores de sites estáticos que produzem HTML puro com JavaScript mínimo no cliente, mas exigem uma etapa de build (Node.js, npm install, um comando de build). A abordagem no-build elimina essa etapa — o servidor renderiza HTML a cada requisição. O tradeoff: Astro/11ty produzem páginas estáticas mais rápidas (sem computação no servidor), enquanto FastAPI + HTMX lida com conteúdo dinâmico nativamente (dados específicos do usuário, envio de formulários, atualizações em tempo real) sem uma camada API separada.
E quanto ao server-side rendering (SSR) com React?
O SSR do Next.js e a abordagem FastAPI + HTMX compartilham um objetivo: enviar HTML renderizado pelo servidor ao navegador. A diferença está no que acontece após a renderização inicial. O Next.js hidrata a página com React, enviando o runtime do framework e o código dos componentes para o cliente. O FastAPI + HTMX não hidrata — o HTML é a saída final. O HTMX lida com interações subsequentes solicitando novos fragmentos de HTML ao servidor. O resultado: FastAPI + HTMX entrega 30-40KB de JavaScript no total contra 100-300KB de uma aplicação Next.js.18
Como faço validação de formulários com essa stack?
No servidor. O Pydantic valida a entrada quando o formulário é enviado. Se a validação falhar, o servidor retorna o formulário com mensagens de erro. O HTMX insere a resposta no DOM:
<form hx-post="/contact" hx-target="#form-container" hx-swap="outerHTML">
<input type="email" name="email" required>
<button type="submit">Send</button>
</form>
@router.post("/contact")
async def contact(request: Request, email: str = Form(...)):
if not validate_email(email):
return templates.TemplateResponse("components/_contact_form.html", {
"request": request,
"error": "Please enter a valid email address",
"email": email, # Preserve input
})
await send_email(email)
return templates.TemplateResponse("components/_contact_success.html", {
"request": request
})
O servidor valida, o servidor renderiza os estados de erro, e o HTMX insere o resultado. Nenhuma biblioteca de validação no cliente é necessária. O atributo HTML required oferece validação básica no nível do navegador como primeira linha de defesa.
Posso adicionar funcionalidades em tempo real (WebSockets)?
Sim. O FastAPI tem suporte nativo a 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))
O HTMX possui uma extensão WebSocket (hx-ws) que conecta elementos a endpoints WebSocket:
<!-- HTMX 2.x WebSocket extension syntax -->
<div hx-ext="ws" ws-connect="/ws/notifications">
<div id="notifications" ws-send></div>
</div>
Nota: O HTMX 1.x usava a sintaxe
hx-ws="connect:...". O HTMX 2.x moveu o suporte a WebSocket para uma extensão separada (htmx-ext-ws) com os atributosws-connectews-sendmostrados acima. Se estiver usando HTMX 1.x, a sintaxe antigahx-wsainda funciona.
As mensagens do servidor são inseridas no DOM usando os mesmos mecanismos de targeting e swap das respostas HTTP. O servidor envia fragmentos HTML pelo WebSocket, e o HTMX os insere.
Como essa stack lida com SEO?
HTML renderizado no servidor é inerentemente amigável para SEO porque os crawlers recebem o conteúdo completo da página sem executar JavaScript. O blakecrosley.com adiciona diversas camadas de SEO:
- Dados estruturados JSON-LD no
<head>de cada página (schemas Person, Article, WebSite, FAQPage) - Sitemap dinâmico com alternates hreflang para todos os 10 locales
- Feed RSS em
/blog/feed.xml llms.txtna raiz para descoberta por crawlers de IA- URLs canônicas e tags Open Graph no template base
- HTML semântico:
<article>,<section>,<main>, hierarquia correta de headings
Nenhuma configuração de SSR necessária. Nenhum getStaticProps. Nenhum ISR. O HTML é renderizado a cada requisição — esse é o comportamento padrão, não uma otimização.
Qual é a curva de aprendizado comparada ao React?
Para desenvolvedores Python, a curva de aprendizado é significativamente menor. Você já conhece a linguagem. Os route handlers do FastAPI retornam template responses — o mesmo modelo mental do Flask ou Django views. O HTMX adiciona um punhado de atributos HTML (hx-get, hx-target, hx-swap). O Alpine.js adiciona mais alguns (x-data, x-show, @click). Não há JSX, virtual DOM, sistema de hooks, biblioteca de gerenciamento de estado, nem configuração de ferramentas de build para aprender.
A documentação do HTMX cabe em uma única página longa. A documentação do Alpine.js cabe em algumas páginas. A documentação do React abrange centenas de páginas cobrindo hooks, context, refs, effects, suspense, server components e streaming SSR.
Para desenvolvedores JavaScript/React, a mudança é conceitual e não sintática. O insight central é que o servidor é dono do estado e o servidor renderiza o HTML. Gerenciamento de estado no cliente se torna tratamento de rotas no servidor. Busca de dados no cliente se torna atributos HTMX em elementos HTML. A sintaxe é mais simples — o modelo mental exige desaprender a premissa SPA de que o cliente é dono da renderização.
Changelog
| Data | Alteração |
|---|---|
| 24 de março de 2026 | Publicação inicial |
Referências
Este guia cobre o sistema completo usado para construir o blakecrosley.com. O No-Build Manifesto fornece o argumento filosófico. O post Lighthouse Perfect Score documenta a jornada de otimização de performance. O post Vibe Coding vs. Engineering explora onde o desenvolvimento assistido por IA se encaixa neste fluxo de trabalho.
-
Métricas de produção do blakecrosley.com em abril de 2026. O site serve mais de 100 posts de blog, componentes interativos de JavaScript, 9 guias abrangentes e traduções em 9 idiomas com dependências mínimas de Python e zero ferramentas de build. Verificado a partir do site ao vivo e do
requirements.txt. ↩↩↩ -
O Google PageSpeed Insights (pagespeed.web.dev) executa auditorias Lighthouse em qualquer URL pública. O blakecrosley.com pontua 100/100/100/100 (Performance, Acessibilidade, Boas Práticas, SEO) em março de 2026. Os resultados são publicamente verificáveis. Veja From 76 to 100: Achieving a Perfect Lighthouse Score para a jornada completa de otimização. ↩↩↩
-
Um
npx create-next-app@latestnovo (Next.js 15, testado em fevereiro de 2026) instala 311 pacotes emnode_modules/totalizando 187 MB. Projetos em produção com dependências adicionais tendem a ser maiores. Projetos individuais variam. Fonte: testes do autor, documentados em The No-Build Manifesto. ↩ -
A documentação de performance do Next.js da Vercel recomenda otimizações específicas (otimização de imagens, carregamento de fontes, code splitting) para atingir pontuações acima de 90. Veja nextjs.org/docs/app/building-your-application/optimizing. A faixa de 70-90 reflete as configurações padrão antes de aplicar essas otimizações. ↩↩
-
Lista completa de dependências verificada a partir do
requirements.txtdo blakecrosley.com em março de 2026. Zero pacotes são ferramentas de build, compiladores ou bundlers. ↩ -
Com base na experiência do autor mantendo projetos Next.js (2021-2024), o ecossistema JavaScript gera de 15 a 25 PRs do Dependabot por mês para projetos ativos, a maioria atualizando dependências transitivas que o desenvolvedor nunca importou diretamente. ↩
-
Tim Berners-Lee articulou a compatibilidade retroativa como um princípio de design da web: “um navegador deve ser retrocompatível.” Uma página de 1996 renderiza no Chrome de 2026. Veja w3.org/DesignIssues/Principles. ↩
-
O OWASP recomenda desabilitar os endpoints de documentação API em produção para reduzir a superfície de ataque. O endpoint
/openapi.jsonexpõe todas as definições de rotas, parâmetros e modelos de resposta. ↩ -
Documentação do FastAPI sobre handlers async vs sync: fastapi.tiangolo.com/async/. Misturar
awaitcom chamadas bloqueantes em funçõesasyncconsome o event loop. ↩ -
nh3 é um sanitizador HTML baseado em Rust, sucessor da biblioteca Bleach. É mantido pelo projeto PyO3 e fornece sanitização HTML baseada em allowlist. Veja github.com/messense/nh3. ↩
-
O header
Varyé definido na RFC 9110 Seção 12.5.5. Ele instrui caches a armazenar respostas separadas com base nos valores de header de requisição especificados. SemVary: HX-Request, um CDN poderia servir um fragmento HTMX como uma resposta de página completa. Veja httpwg.org/specs/rfc9110.html#field.vary. ↩↩ -
CSS Custom Properties (Variáveis CSS) são suportadas em mais de 97% dos navegadores globais. Elas cascateiam, herdam e respondem a media queries em tempo de execução — capacidades que variáveis de pré-processador não possuem. Fonte: caniuse.com/css-variables. ↩
-
Documentação do Google sobre hreflang: developers.google.com/search/docs/specialty/international/localized-versions. O valor
x-defaultdesigna a página de fallback para usuários cujo idioma não está na lista hreflang. ↩ -
Alpine.js requer
'unsafe-eval'na Content Security Policy para seu mecanismo de avaliação de expressões. O build compatível com CSP (@alpinejs/csp) evita esse requisito, mas possui limitações. Veja alpinejs.dev/advanced/csp. ↩ -
Tokens CSRF baseados em HMAC seguem o padrão “Signed Double-Submit Cookie” descrito no OWASP CSRF Prevention Cheat Sheet. O
hmac.compare_digestusa comparação em tempo constante para prevenir ataques de canal lateral por temporização. Veja cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html. ↩ -
WebP fornece arquivos 25-35% menores que JPEG com qualidade visual equivalente. Estudo do Google sobre WebP: developers.google.com/speed/webp/docs/webp_study. ↩
-
103 Early Hints permite que o servidor (ou CDN) envie uma resposta preliminar com dicas de preload antes que a resposta final esteja pronta. Cloudflare suporta Early Hints para headers
Linkcomrel=preload. Veja developer.chrome.com/blog/early-hints. ↩ -
React 18 + ReactDOM pesa aproximadamente 42 KB minificado + gzipped. Com um router, biblioteca de gerenciamento de estado e runtime de framework de build, aplicações React típicas enviam de 100 a 300 KB de JavaScript de framework. Fonte: bundlephobia.com/package/[email protected]. ↩↩
-
A política de versionamento e o compromisso de compatibilidade retroativa do HTMX estão documentados em htmx.org/migration-guide-htmx-1/. Carson Gross declarou o princípio de compatibilidade retroativa em Hypermedia Systems (2023) por Gross, Stepinski e Cotter: hypermedia.systems. ↩