FastAPI + HTMX: Full-Stack sem Build
# FastAPI + HTMX: Full-Stack sem Build
Resumo: FastAPI + HTMX + Alpine.js + Bootstrap 5 + 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 todo o sistema, da arquitetura ao deploy, usando blakecrosley.com como referência de produção que serve 37 posts de blog, 20 componentes JavaScript interativos, 20 guias e traduções em dez idiomas sem um único bundler, compilador ou transpilador.1
A stack moderna de desenvolvimento web assume 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 e ainda assim produz sites que alcançam 100/100/100/100 no Lighthouse.2
Isso não é apologia. É uma constataçã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 lado do cliente.
- Zero ferramentas de build significa zero falhas de build. Sem conflitos de dependências com
npm install, sem erros do compilador TypeScript em arquivos que você não alterou, sem PRs do Dependabot para dependências transitivas que você nunca importou. O pipeline de deploy égit push. - Alpine.js cuida do estado exclusivo do cliente 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 do CSS fazem cascade, herdam valores e respondem a media queries em tempo de execução. Variáveis de pré-processador 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 equipes grandes 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 essa fronteira com precisão.
- blakecrosley.com é a prova. Cada padrão deste guia roda em produção. Cada afirmação tem um caminho de arquivo, um bloco de configuração ou uma auditoria Lighthouse que você pode verificar em PageSpeed Insights.2
Como usar este guia
Este é um guia de referência abrangente. 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 consulta contínua |
Use Ctrl+F / Cmd+F para buscar padrões ou atributos específicos. O Cartão de referência rápida no final oferece um resumo fácil de consultar.
A tese No-Build
A tese é restrita 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 no CSS exigem um refresh manual do navegador. O hx-boost do HTMX torna a navegação rápida o suficiente para que refreshes completos sejam toleráveis, mas em ciclos de iteração visual intensos, 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. Cada 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 do CSS. Não pode ser importado como pacote em outro projeto.
Esses tradeoffs 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 apresenta o framework de decisão.
O que você ganha
Zero falhas de build. Nenhum npm install pode falhar por conflitos de dependências. Nenhum next build pode falhar por um erro TypeScript em um arquivo que você não alterou.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 gzipped), 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 de 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 de Webpack 4 → 5, sem deprecação do Create React App, sem migração para o App Router do Next.js.
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ócio, acesso a dados, validação | Servidor |
| Jinja2 | Renderização de templates, herança, macros | Servidor |
| HTMX | Interatividade dirigida 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 as diretivas do Tailwind em CSS. Webpack (ou Turbopack) empacota a saída em chunks. Cada etapa pode falhar independentemente.
A arquitetura sem build não exige coordenação alguma. 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 requisiçã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 genuína. 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 detectados 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 são importantes aqui. Primeiro, docs_url=None e openapi_url=None desabilitam os endpoints de documentação automática da API. Um site de conteúdo público não precisa ter /docs ou /openapi.json expostos na internet.8 Segundo, a ordem dos middlewares importa — o logging de segurança é executado primeiro (adicionado por último) para capturar todas as requisições, incluindo aquelas rejeitadas pelo rate limiting. Terceiro, o 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,
})
Essa distinção é importante para o HTMX. Rotas de página completa retornam documentos que estendem base.html. Rotas de API retornam fragmentos HTML que o HTMX troca dentro de elementos DOM existentes. O mesmo motor de templates Jinja2 renderiza ambos — sem uma camada API separada.
Injeção de dependência
O sistema Depends() do FastAPI oferece uma 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 da request. O FastAPI resolve a cadeia automaticamente.
Configurações com Pydantic
A configuração usa o 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 async
As rotas do FastAPI são async por padrão. Para operações I/O-bound (consultas ao banco de dados, requisições HTTP, leitura de arquivos), o async evita o bloqueio do event loop:
@app.on_event("startup")
async def startup_load_translations():
"""Load translations from D1 into memory at startup."""
client = init_d1_client(
worker_url=settings.D1_WORKER_URL,
auth_secret=settings.D1_AUTH_SECRET,
)
if not client.is_configured:
logger.warning("i18n: D1 not configured, translations use defaults")
return
cache = await load_translations(client)
logger.info(f"i18n: Loaded {cache.stats['key_count']} keys")
Operações CPU-bound (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 faz await de I/O, declare-a como 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 em vez de 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 com underscore (_language_switcher.html) é uma convenção que indica um partial — um fragmento de template que não deve ser renderizado de forma independente. Este componente usa tanto Alpine.js (para o toggle do dropdown) quanto Jinja2 (para a lista de locales). A fronteira é clara: Alpine.js controla o estado de 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 única 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 precisar passá-las explicitamente:
# 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 atualizados. 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 de acesso ao locale 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 locale a partir da variável de contexto da requisição definida pelo middleware de locale. O template chama {{ _('ui.nav.about') }} e obtém a string traduzida para o locale da requisição atual sem nenhum parâmetro de locale 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 do 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 desnecessários.
Filtros personalizados
Filtros do Jinja2 transformam dados durante a renderização. O filtro sanitize previne XSS em conteúdo gerado pelo usuário:
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 todas as tags ou atributos que não estejam na lista de permissões, prevenindo XSS armazenado mesmo que o conteúdo venha de uma fonte não confiável.10
HTMX em Profundidade
HTMX transforma qualquer elemento HTML em um emissor de requisições HTTP, inserindo a resposta diretamente no DOM. O ponto-chave é arquitetural: o HTML renderizado pelo 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 a URL sem criar entrada no histórico | hx-replace-url="true" |
Padrão 1: Quiz Interativo (Estado Multi-Etapa no Servidor)
blakecrosley.com inclui um quiz interativo que guia os usuários na escolha de ferramentas. Todo o estado do quiz reside no servidor — sem gerenciamento de estado no cliente:
<!-- _quiz_container.html — initial load -->
<div hx-get="/api/quiz/claude-vs-codex/step?answers="
hx-trigger="load"
hx-swap="innerHTML"
id="quiz-wrapper">
<p>Loading quiz...</p>
</div>
<!-- _quiz_step.html — each question -->
<div class="quiz-step" id="quiz-container">
<p>Question {{ step }} of {{ total }}</p>
<h3>{{ question.question }}</h3>
<div class="quiz-step__options">
{% for opt in question.options %}
<button class="quiz-step__btn"
hx-get="/api/quiz/claude-vs-codex/step?answers={{ answers }},{{ opt.value }}"
hx-target="#quiz-container"
hx-swap="outerHTML">
{{ opt.label }}
</button>
{% endfor %}
</div>
</div>
Cada clique no 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 Blog Paginada
A página de publicações usa HTMX para paginação fluida com atualização da 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>
Quatro atributos trabalhando juntos:
hx-getemite a requisição para a mesma URL dohref(aprimoramento progressivo — funciona sem JavaScript)hx-targetinsere a resposta no contêiner#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 os insere no DOM, e o servidor é a única fonte de verdade.
Padrão 4: Swaps Out-of-Band (OOB)
Às vezes, uma única ação no servidor precisa atualizar múltiplos elementos do DOM. O mecanismo de swap out-of-band do HTMX resolve isso sem orquestração no 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>
O atributo hx-swap-oob="true" instrui o HTMX a localizar 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.
No blakecrosley.com, esse padrão aparece no formulário de contato: ao enviar o formulário, o corpo é substituído por uma mensagem de sucesso e, simultaneamente, um badge de notificação é atualizado 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 completos de página:
<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, substitui o 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, reavaliar scripts ou re-renderizar o layout. Apenas o conteúdo do <body> muda. blakecrosley.com usa links com boost na navegação principal, o que faz as transições de página parecerem uma single-page application sem a arquitetura SPA.
Padrão 6: Headers de Requisição do HTMX
HTMX envia headers personalizados 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 dupla é central na arquitetura. Um carregamento completo de página 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
Cada link HTMX no 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 aprimora a experiência quando disponível.
Padrão 5: 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. O atributo hx-indicator aponta para um elemento que fica 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 da alternância de classe.
Padrões do Alpine.js
Alpine.js preenche a lacuna que HTMX deixa: estado apenas no cliente que nunca precisa se comunicar com o servidor. Se o usuário clica em um dropdown e ele abre, esse estado existe apenas no navegador. 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 do menu mobile, visibilidade de modal |
| Combina ambos | Ambos | Seletor de idioma (toggle com Alpine.js, navegação via HTMX) |
Navegação mobile
O template base envolve todo o header 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 principais do Alpine.js:
x-datadeclara o escopo do componente e o estado inicialx-showalterna a visibilidade com base no estado (usadisplay: nonedo CSS)x-cloakoculta o elemento até que Alpine.js inicialize (evita flash de conteúdo sem estilo)@clickvincula handlers de clique com expressões:aria-expanded(abreviação dex-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 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. Alpine.js lida com isso usando um único atributo — sem registro de event listener, sem cleanup, sem gerenciamento de ref.
Quando usar Alpine.js vs. JavaScript puro
Alpine.js é apropriado quando:
- O estado está 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 é apropriado 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 (Alpine.js adiciona overhead por componente
x-data) - A lógica excede 20-30 linhas de expressões Alpine.js
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.
Bootstrap 5 sem Sass
Bootstrap 5 removeu jQuery como dependência e suporta uso independente 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>
Hospedar localmente elimina dependências externas, evita que falhas 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 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>
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 custom properties de CSS e seletores comuns:
/* 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;
}
Custom properties de CSS se propagam pelo DOM, herdam de elementos pai e respondem a media queries em tempo de execução. Variáveis Sass compilam para valores estáticos e desaparecem. Essa distinção importa para temas: uma única mudança em uma custom property 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ânica de layout (margin, padding, flexbox). CSS personalizado para identidade visual (cores, tipografia, animações). Nunca misture classes utilitárias com estilização de componente para a mesma responsabilidade.
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 tem 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 de locale antes do matching de rotas. Isso significa que os handlers de rota não precisam de caminhos específicos por locale — /about lida tanto com inglês (/about) quanto japonês (/ja/about) porque o middleware normaliza o caminho.
Funções de Tradução nos Templates
Os globals 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 hreflang para servir 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 na inicialização:
@app.on_event("startup")
async def startup_load_translations():
client = init_d1_client(worker_url=settings.D1_WORKER_URL,
auth_secret=settings.D1_AUTH_SECRET)
cache = await load_translations(client)
logger.info(f"i18n: Loaded {cache.stats['key_count']} keys")
O cache em memória evita consultas ao banco de dados a cada renderização de página. Atualizações de tradução exigem uma atualização do cache (acionada via um endpoint administrativo ou um deploy). Essa arquitetura troca frescor 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 de 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, faça fallback para inglês. 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 rótulos 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 cacheado por uma hora.
Padrões de banco de dados
SQLAlchemy 2.0 Async
Para aplicações que precisam de um banco de dados relacional, o suporte async do SQLAlchemy 2.0 se integra perfeitamente 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(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. Toda operação de banco de dados usa 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(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) e restrições (tamanho mínimo/máximo) antes que o handler da rota seja executado. Entradas inválidas retornam automaticamente uma resposta 422. Isso substitui bibliotecas de validação de formulário no lado do cliente — o servidor valida, e HTMX insere a mensagem de sucesso ou o feedback de erro.
Migrações com Alembic
Alembic gerencia as 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 reside no Cloudflare D1 (acessado via HTTP), então as migrações do Alembic se aplicam ao banco de dados SQLite ou PostgreSQL local 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 por meio 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. D1 é SQLite na borda 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) versus uma conexão local com o banco de dados (~1-5ms). O cache em memória na inicialização mitiga isso para cargas de trabalho com muitas leituras, 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 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
O CSP inclui 'unsafe-inline' e 'unsafe-eval' porque Alpine.js precisa deles para avaliação de expressões. A alternativa é o build compatível com CSP do Alpine.js, que possui limitações.14 Todo o restante é bloqueado: frame-ancestors previne clickjacking, form-action restringe o envio 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 servir 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
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 por meio de uma global Jinja2 e incluído nas requisições de formulário 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. 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 lista de permissões. 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
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)
FastAPI retorna 422 Unprocessable Entity para entradas inválidas automaticamente. Combinado com queries parametrizadas no banco de dados (SQLAlchemy nunca interpola strings), isso previne SQL injection e garante segurança de tipos 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:
CSS crítico
O CSS crítico (acima da dobra) é extraído e inserido inline no <head>. A folha de estilos completa carrega de forma assíncrona:
<!-- Critical CSS inlined for instant first paint -->
<style>{% include "components/_critical.css" %}</style>
<!-- Full CSS loads async — doesn't block render -->
<link rel="stylesheet" href="/static/css/styles.css"
media="print" onload="this.media='all'">
<noscript>
<link rel="stylesheet" href="/static/css/styles.css">
</noscript>
O truque do media="print" diz ao navegador que a folha de estilos não é necessária para renderização em tela, então ela não bloqueia a primeira pintura. O handler onload altera para media="all" após o carregamento. O fallback <noscript> garante que a folha de estilos carregue mesmo sem JavaScript.16
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 content-hash (?v=a3f8b2c1d0) são cacheados 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 faz o download dos scripts em paralelo com o parsing do HTML, mas os executa após o documento ser parseado. Isso evita o bloqueio da renderização sem a complexidade do carregamento async 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 o carregamento de imagens fora da tela. WebP proporciona arquivos 25-35% menores que JPEG com qualidade equivalente.17
Early Hints
# In main.py
app.state.preload_links = [
f'<{make_asset_url(_asset_map, "css/styles.css")}>; rel=preload; as=style',
]
# In security headers middleware
if "text/html" in content_type:
preload_links = getattr(request.app.state, "preload_links", [])
if preload_links:
response.headers["Link"] = ", ".join(preload_links)
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 do servidor terminar de gerar a resposta HTML.18
JavaScript mínimo
O footprint total de JavaScript:
| Biblioteca | Tamanho (minificado + gzipped) |
|---|---|
| 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 do framework antes do código da aplicação.19 A abordagem no-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 responsiva 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 do webpack. Sem compilação do TypeScript. A única etapa de instalação é pip install -r requirements.txt, que fica em cache entre os 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 container pequeno. --no-cache-dir impede que o pip armazene pacotes baixados na camada da imagem.
CDN Cloudflare
blakecrosley.com usa Cloudflare para cache via 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— o navegador cacheia por 5 minutoss-maxage=3600— o CDN cacheia por 1 horastale-while-revalidate=86400— serve conteúdo desatualizado enquanto revalida por 24 horas
Assets estáticos recebem max-age=31536000, immutable porque URLs com content-hash garantem a atualização.
Framework de decisão
Você precisa de build tools?
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 diferenciais), 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 semelhantes são essenciais para o produto, um pipeline de build é obrigatório.
Se todas as quatro respostas forem “não”, a abordagem no-build é viável. Se qualquer resposta for “sim”, build tools resolvem um problema real. O erro é adicionar build tools 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ê não precisa.1
Comparação de stacks
| Categoria | No-Build (este guia) | React + Build Tools |
|---|---|---|
| Ideal para | Sites de conteúdo, portfólios, ferramentas internas, apps CRUD | Produtos SaaS, SPAs complexas, consumidores de design system |
| 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) |
| Reutilização de componentes | Includes e macros do Jinja2 | Pacotes npm, bibliotecas compartilhadas |
| SEO | Renderizado no servidor por padrão | Requer configuração de SSR/SSG |
| Piso de desempenho | Alto (JS mínimo, renderizado no servidor) | Variável (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 não é a escolha certa
HTMX substitui estado do cliente por round-trips ao servidor. Isso funciona até que a latência importa:
- 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 do 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 no-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 é usado em produção em diversos setores. Carson Gross, o criador, mantém a compatibilidade retroativa como princípio central de design — a documentação do HTMX afirma que a biblioteca não quebrará aplicações existentes dentro de uma versão major.20 A biblioteca tem 14KB minificada e comprimida com gzip, não possui dependências e segue versionamento semântico. O blakecrosley.com roda HTMX em produção há três anos com zero bugs relacionados ao HTMX.
Posso usar TypeScript sem um build step?
Parcialmente. Arquivos TypeScript podem ser verificados com tsc --noEmit sem gerar arquivos de saída, proporcionando verificação em tempo de compilação como um linter. Porém, navegadores não conseguem executar arquivos .ts diretamente, então um build step ainda é necessário para servir TypeScript. A alternativa são anotações de tipo com JSDoc em arquivos .js simples, que o TypeScript pode verificar sem compilação. Isso proporciona type safety 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 um build step (Node.js, npm install, um comando de build). A abordagem no-build elimina esse passo — 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 no servidor para o 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. FastAPI + HTMX não hidrata — o HTML é a saída final. O HTMX lida com interações subsequentes solicitando novos fragmentos de HTML do servidor. O resultado: FastAPI + HTMX envia 30-40KB de JavaScript no total contra 100-300KB de uma aplicação Next.js.19
Como faço a 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 faz o swap da 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 faz o swap do resultado. Nenhuma biblioteca de validação no cliente é necessária. O atributo HTML required fornece validação básica no nível do navegador como primeira linha de defesa.
Posso adicionar recursos 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 tem uma extensão WebSocket (hx-ws) que conecta elementos a endpoints WebSocket:
<div hx-ws="connect:/ws/notifications">
<div id="notifications" hx-ws="send"></div>
</div>
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 várias 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 adequada 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, nem virtual DOM, nem sistema de hooks, nem biblioteca de gerenciamento de estado e nem configuração de build tools 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.
Registro de Alterações
| 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 apresenta 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 nesse fluxo de trabalho.
-
Métricas de produção do blakecrosley.com em março de 2026. O site serve 37 posts de blog, 20 componentes interativos de JavaScript, 20 seções de guias e 10 traduções de idiomas com 15 pacotes Python e zero ferramentas de build. Lista completa de dependências: fastapi, uvicorn, starlette, pydantic, pydantic-settings, jinja2, markdown, pygments, beautifulsoup4, lxml, nh3, resend, python-multipart, httpx, analytics-941. Verificado a partir do
requirements.txt. ↩↩↩ -
O Google PageSpeed Insights (pagespeed.web.dev) executa auditorias Lighthouse em qualquer URL pública. O blakecrosley.com obtém 100/100/100/100 (Performance, Acessibilidade, Boas Práticas, SEO) em março de 2026. Os resultados são verificáveis publicamente. 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 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 do 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çõesasyncesgota o event loop. ↩ -
nh3 é um sanitizador de HTML baseado em Rust, sucessor da biblioteca Bleach. É mantido pelo projeto PyO3 e fornece sanitização de HTML baseada em allowlist. Veja github.com/messense/nh3. ↩
-
O header
Varyé definido na RFC 9110 Seção 12.5.5. Ele instrui os 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é-processadores não possuem. Fonte: caniuse.com/css-variables. ↩
-
Documentação de hreflang do Google: 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 de hreflang. ↩ -
O 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. ↩ -
A técnica de carregamento assíncrono de CSS com
media="print"é documentada pela equipe do web.dev. O navegador trata a folha de estilos como não bloqueante para renderização porque é declarada para mídia de impressão. O handleronloada atualiza para mídiaallapós o download. Veja web.dev/articles/defer-non-critical-css. ↩ -
O 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. ↩
-
O 103 Early Hints permite que o servidor (ou CDN) envie uma resposta preliminar com dicas de preload antes que a resposta final esteja pronta. O Cloudflare suporta Early Hints para headers
Linkcomrel=preload. Veja developer.chrome.com/blog/early-hints. ↩ -
React 18 + ReactDOM pesa aproximadamente 42 KB minificado + gzipado. Com um roteador, 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. ↩