FastAPI + HTMX: o full-stack sem build
# Crie apps web de produção sem React ou webpack: FastAPI, HTMX, Alpine.js, Jinja2, CSS puro, padrões Bootstrap, i18n, deploy, SEO e performance.
Resumo: FastAPI + HTMX + Alpine.js + Jinja2 + CSS puro produz aplicações web de 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 210 posts de blog, componentes interativos de JavaScript, 11 guias principais, 48 estudos de design e inglês mais 9 locales traduzidos sem um único bundler, compilador ou transpilador.1
A stack moderna de desenvolvimento web parte do princípio de que você precisa de React, webpack, TypeScript e um pipeline de build. Para uma grande categoria de aplicações — sites orientados por conteúdo, ferramentas internas, aplicações CRUD, sites de portfólio, plataformas de documentação — essa premissa está errada. A stack descrita neste guia elimina toda a cadeia de ferramentas de build do frontend enquanto produz sites que alcançam 100/100/100/100 no Lighthouse.2
Isto não é defesa de uma ideia. É 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 torna as respostas do servidor a saída final — sem etapa de renderização no cliente.
- Zero ferramentas de build significa zero falhas de build. Nenhum conflito de peer dependencies do
npm install, nenhum erro do compilador TypeScript em arquivos que você não tocou, nenhum PR do Dependabot para dependências transitivas que você nunca importou. O pipeline de deploy égit push. - Alpine.js lida com o estado puramente do cliente que HTMX não consegue. Dropdowns, modais, alternadores 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. As custom properties do CSS cascateiam, herdam 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ê as custom properties diretamente — sem etapa de compilação.
- Esta abordagem tem fronteiras claras. Ela é inadequada para grandes equipes que compartilham 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 a fronteira com precisão.
- blakecrosley.com é a prova. Os padrões centrais deste guia (HTMX, Alpine.js, Jinja2, CSS puro) rodam em produção em blakecrosley.com. As seções de Bootstrap e SQLAlchemy cobrem padrões padrão para o stack que não são usados neste site específico. Toda afirmação tem um caminho de arquivo, um bloco de configuração ou uma auditoria do Lighthouse que você mesmo pode verificar em PageSpeed Insights.2
Como usar este guia
Esta é uma referência abrangente. Comece onde seu nível de experiência se encaixa:
| 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 de 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 de Alpine.js | i18n e localização, Deploy |
| Desenvolvedor full-stack começando 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 ao final fornece um resumo escaneável.
A tese no-build
A tese é estreita e específica: para sites orientados a conteúdo com um desenvolvedor solo ou uma 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 de blakecrosley.com:
| Métrica | blakecrosley.com (No-Build) | Projeto Next.js típico3 |
|---|---|---|
| Dependências | 17 pacotes Python | 311+ pacotes npm |
| Arquivos de configuração de build | 0 | 5-8 (next.config, tsconfig, postcss, tailwind, etc.) |
Tamanho de node_modules/ |
Não existe | 187 MB de 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 |
Install → build → deploy: 2-5 minutos |
| Lighthouse Performance | 100 | 70-90 sem otimização explícita4 |
Os 17 pacotes Python incluem FastAPI, Jinja2, Pydantic, uvicorn, nh3 e outros 12. 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 compartilhando interfaces de componentes.
Sem Hot Module Replacement. Mudanças de 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 apertados, o HMR economiza tempo.
Sem Tree Shaking. Cada byte de JavaScript que você escreve vai para o navegador. A restrição força disciplina: arquivos pequenos e focados em vez de grandes módulos utilitários.
Sem bibliotecas de componentes do npm. Sem Radix, sem shadcn/ui, sem Headless UI. Todo elemento interativo é construído à mão ou usa os componentes embutidos do Bootstrap 5.
Sem tokens de design system do npm. O design system vive em custom properties do CSS. Ele não pode ser importado como um pacote em outro projeto.
Esses tradeoffs são aceitáveis para um site orientado a conteúdo com um a três desenvolvedores. Eles 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 devido a conflitos de peer dependencies. Nenhum next build pode falhar devido a um erro de 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 source maps necessários.
Inicialização local instantânea. uvicorn app.main:app --reload inicia em menos de 2 segundos.
Cascata de requisições concreta. Uma primeira visita carrega: um documento HTML (~15KB gzipped), um arquivo CSS (~8KB), HTMX (~16KB, em cache), Alpine.js (~15KB, em cache) e o JS interativo da página (~4-8KB). Total: aproximadamente 55-65KB na primeira visita.1
Frontend à prova de futuro. O código do lado do cliente usa HTML, CSS e JavaScript — padrões que mantiveram compatibilidade retroativa por 30 anos.7 Sem migração de Webpack 4 → 5, sem deprecação do Create React App, sem migração do App Router do Next.js.
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 | 35-40KB (HTMX+Alpine+pequenos scripts de página) | 85-250KB+ (runtime do React) | 0KB por padrão, islands opt-in | 0KB por padrão |
| Etapa de build | Nenhuma | Obrigatória (webpack/turbopack) | Obrigatória (Vite) | Obrigatória (customizada) |
| 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) |
Install+build+deploy (2-5min) | Install+build+deploy (1-3min) | Install+build+deploy (1-2min) |
| Interatividade no servidor | Nativa (HTMX) | Rotas API + fetch no cliente | Limitada (form actions) | Nenhuma (saída estática) |
| Gerenciamento de estado no cliente | Alpine.js (15KB) | state/context/Redux do React | Islands do framework | JS manual |
| Linguagem de backend | Python | JavaScript/TypeScript | JavaScript/TypeScript | JavaScript |
| Abordagem de i18n | Lado do servidor (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 |
| Melhor para | Sites de conteúdo, CRUD, dashboards | SPA complexa, equipes grandes | 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 a performance de site estático por interatividade no servidor (filtragem de categorias, manipulação de formulários, busca em tempo real) sem adicionar uma etapa de build. Se seu site é puramente estático sem interações com o 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.
FastAPI Patterns
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 desativam os endpoints automáticos de documentação do API. Um site de conteúdo público não precisa expor /docs ou /openapi.json à internet.8 Segundo, a ordem dos middlewares importa — o log de segurança executa primeiro (adicionado por último), então captura todas as requisições, inclusive as rejeitadas por rate limiting. Terceiro, GZipMiddleware compacta todas as respostas acima de 500 bytes, o que normalmente reduz o tamanho de transferência de HTML em 70-80%.
Roteamento
As rotas se dividem em duas categorias: rotas de página retornam documentos HTML completos, e rotas API retornam fragmentos JSON ou 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 HTMX. Rotas de página completas retornam documentos que estendem base.html. Rotas API retornam fragmentos HTML que o HTMX troca dentro de elementos existentes do DOM. O mesmo mecanismo de templates Jinja2 renderiza ambos — sem uma camada API separada.
Injeção de dependências
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,
})
As dependências são componíveis. Uma dependência get_db pode depender de get_current_locale, que depende da requisição. 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 valores do arquivo .env. Em produção (Railway), secrets são definidos como variáveis de ambiente. Localmente, um arquivo .env fornece os padrões. A classe Settings valida os tipos na inicialização — um campo obrigatório ausente falha cedo em vez de falhar em runtime.
Padrões async
As rotas do FastAPI são async por padrão. Para operações limitadas por I/O (consultas ao banco de dados, requisições HTTP, leituras de arquivo), 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)
Lifespan agora é o único caminho de startup/shutdown. O Starlette chegou à sua primeira versão estável, 1.0, em março de 2026 (1.3.1 em 12 de junho) e removeu os hooks há muito depreciados on_event, on_startup e on_shutdown — lifespan (acima) é o único mecanismo, e @app.route() / @app.websocket_route() deram lugar a Route / WebSocketRoute na lista routes. O FastAPI 0.137.0 (14 de junho de 2026) fixa o Starlette na linha 1.x e refatora os próprios componentes internos do roteador: router.routes não é mais uma lista plana de objetos APIRoute, mas uma árvore de nós intermediários, então trate isso como um detalhe interno em vez de algo para iterar. O lado positivo é que rotas adicionadas a um roteador depois de include_router() agora são refletidas ao vivo, e um sub-roteador pode ser incluído antes de suas rotas serem definidas.24 Nada disso muda os padrões deste guia — ele usa lifespan e declaração padrão de rotas do começo ao fim — mas se você mantém ferramentas que percorrem router.routes, ou ainda executa handlers legados com @app.on_event, 0.137.0 / Starlette 1.0 trazem breaking changes. O FastAPI 0.137.2 (18 de junho de 2026) complementa isso com iter_route_contexts(), a forma suportada de enumerar rotas agora que router.routes é interno. O FastAPI 0.138.0 (20 de junho de 2026) então adiciona app.frontend("/", directory="dist") / router.frontend(...) para servir um frontend estático já buildado — útil se você publica um build de SPA separado, mas ortogonal à abordagem deste guia, sem build e renderizada no servidor (ele monta um diretório dist/ em vez de renderizar HTML no servidor).25
Operações limitadas por 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 da 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
Observação: blakecrosley.com usa Cloudflare D1 (SQLite serverless) via HTTP para todos os dados persistentes, não SQLAlchemy. Esta seção aborda o padrão async do SQLAlchemy para projetos FastAPI que precisam de um banco de dados relacional — a configuração de produção mais comum para esta stack.
SQLAlchemy 2.0 async
Para aplicações que precisam de um banco de dados relacional, o suporte async do SQLAlchemy 2.0 se integra bem ao 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
Observação de instalação (SQLAlchemy 2.0.50+): a partir da versão 2.0.50, a dependência greenlet da stack async não é mais instalada por padrão. Use o extra asyncio para que ela seja incluída, ou o primeiro await contra o engine falhará com um erro de greenlet ausente:23
pip install "sqlalchemy[asyncio]" aiosqlite
O SQLAlchemy 2.0.50 também exige Python 3.10+ (3.7–3.9 foram removidos) e adiciona wheels para free-threaded (3.13t).23
Dependency injection 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 entrega ao route handler, 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
Modelos Pydantic validam a entrada na fronteira do API e serializam a saída para 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 que o route handler 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 troca pelo aviso de sucesso ou pelo feedback de erro.
Migrations com Alembic
Alembic gerencia mudanças no schema 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 autogenerate compara os modelos SQLAlchemy com o schema atual do banco de dados e gera scripts de migration. Esses scripts são arquivos versionados Python que vivem 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 migrations rodam durante o deployment (antes de a aplicação iniciar). Isso garante que o schema do banco de dados corresponda ao código da aplicação. Para blakecrosley.com, a maior parte dos dados vive no Cloudflare D1 (acessado via HTTP), então as migrations do Alembic se aplicam ao banco de dados local SQLite ou PostgreSQL usado para dados de sessão e analytics.
O padrão Cloudflare D1
blakecrosley.com usa Cloudflare D1 como um 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 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), em vez de uma conexão local com o banco de dados (~1-5ms). O cache em memória na inicialização reduz esse impacto em workloads com muitas leituras, como traduções.
Segurança
Middleware de headers de segurança
blakecrosley.com implementa headers 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
A CSP inclui 'unsafe-inline' e 'unsafe-eval' porque Alpine.js exige isso para avaliação de expressões. A alternativa é o build compatível com CSP do Alpine.js, que tem limitações.14 Todos os outros recursos ficam bloqueados: frame-ancestors impede clickjacking, form-action restringe envios de formulários à mesma origem, e upgrade-insecure-requests força HTTPS.
Segurança de cache em CDN com HTMX
O middleware de headers de segurança adiciona Vary: HX-Request às respostas do 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 header, uma CDN poderia armazenar em cache uma resposta de fragmento do HTMX e servi-la como página completa para uma requisição que não é do HTMX (ou o contrário). O header Vary informa à CDN para armazenar entradas de cache separadas com base no valor do header HX-Request.11
Proteção contra CSRF
Formulários do HTMX usam tokens CSRF sem estado 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 um global do Jinja2 e incluído nas requisições de formulário do HTMX:
<form hx-post="/contact" hx-target="#form-result">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- form fields -->
</form>
Tokens sem estado eliminam o armazenamento de sessões no servidor. A assinatura HMAC garante que o token foi gerado pelo servidor. O timestamp impede ataques de repetição. hmac.compare_digest impede ataques de timing.15
Sanitização de HTML
Conteúdo gerado por usuários 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 da CSP: ela impede XSS armazenado na camada de renderização, enquanto a CSP impede scripts injetados na camada do navegador. Defesa em profundidade.
Validação de entrada
Modelos Pydantic validam toda entrada na fronteira do 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 automaticamente 422 Unprocessable Entity para entradas inválidas. Combinado com queries parametrizadas no banco de dados (SQLAlchemy nunca interpola strings), isso impede SQL injection e garante segurança de tipos nas fronteiras.
Performance
Lighthouse 100/100/100/100
blakecrosley.com pontua 100 nas quatro categorias do Lighthouse: Performance, Accessibility, Best Practices e SEO. Verifique no 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 cerca de 8 KB com gzip, pequeno o suficiente para que a abordagem de uma única requisição marque 100 em Lighthouse Performance sem malabarismos de otimização.
Compressão GZip
app.add_middleware(GZipMiddleware, minimum_size=500)
Respostas acima de 500 bytes são compactadas. HTML compacta 70-80%, reduzindo um documento de 15 KB para 3-4 KB.
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 com 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 adiado 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 scripts em paralelo com o parsing de HTML, mas os executa depois que o documento é analisado. Isso evita bloqueio de renderização sem a complexidade de carregamento async e gerenciamento de ordem de execução.
Otimização de imagens
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>
Atributos explícitos de width e height impedem Cumulative Layout Shift (CLS). O atributo loading="lazy" adia imagens fora da tela. WebP gera 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 informa ao Cloudflare para 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
A pegada total de JavaScript:
| Biblioteca | Tamanho (minificado + gzip) |
|---|---|
| HTMX | ~16 KB |
| Alpine.js | ~15 KB |
| JS específico da página | 4-8 KB |
| Total | 35-39 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.
Deployment
Railway
blakecrosley.com faz deploy para 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 de requirements.txt, instala as dependências e executa o comando de inicialização. Nenhum arquivo Docker é 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 webpack. Sem compilação TypeScript. A única etapa de instalação é pip install -r requirements.txt, que fica em cache entre deployments.
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 oferece suporte tanto a railway.toml quanto a Procfile. A sintaxe ${PORT:-8000} usa a porta fornecida pela plataforma ou assume 8000 como padrão para desenvolvimento local.
Configuração de produção do Uvicorn
Para deployments com tráfego mais alto, 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 para asyncio)--http httptoolsusa o parser HTTP httptools mais rápido
Para desenvolvimento, --reload monitora alterações em 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 de 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 mantém cache por 5 minutoss-maxage=3600— a CDN mantém cache por 1 horastale-while-revalidate=86400— serve conteúdo expirado enquanto revalida por 24 horas
Assets estáticos recebem max-age=31536000, immutable porque URLs com hash de conteúdo garantem atualização.
Framework de decisão
Você precisa de ferramentas de build?
Responda a quatro perguntas:
1. Mais de cinco desenvolvedores compartilham interfaces JavaScript? Se sim, a verificação de tipos em tempo de compilação do TypeScript evita bugs de integração que os 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 forem recursos centrais (não apenas interessantes), um framework como React ou Svelte justifica sua complexidade. Adicione uma etapa de build.
3. Vários 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 forem centrais 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 sobrecarga de gerenciamento de dependências.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 system |
| Tamanho da equipe | 1-5 desenvolvedores | 5-50+ desenvolvedores |
| Gerenciamento de estado | Servidor (HTMX) + cliente (Alpine.js) | Cliente (estado do React, Redux, Zustand) |
| Segurança de tipos | Runtime (Pydantic no lado do servidor) | Tempo de compilação (TypeScript) |
| Reuso de componentes | Includes + macros do Jinja2 | Pacotes npm, bibliotecas compartilhadas |
| SEO | Renderizado no servidor por padrão | Exige configuração SSR/SSG |
| Piso de performance | Alto (JS mínimo, renderizado no servidor) | Varia (sobrecarga do framework) |
| Teto de complexidade | Mais baixo (sem offline, sem estado rico no cliente) | Mais alto (qualquer interação no cliente é possível) |
| Dependências | 17 pacotes Python | 300+ pacotes npm |
| Tempo de build | 0 segundos | 15-60 segundos |
Quando HTMX é a escolha errada
HTMX substitui estado no cliente por idas e voltas ao servidor. Isso funciona até a latência importar:
- Interfaces drag-and-drop — uma ida e volta ao servidor de 200 ms 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 do lado do cliente
Para esses casos de uso, um framework do 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 -->
Propriedades customizadas 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; }
}
Cabeçalhos 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
FAQ
HTMX está pronto para produção em aplicações web reais?
Sim. HTMX é estável desde 2020 e é usado em produção em vários setores. Carson Gross, o criador, mantém compatibilidade retroativa como um 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.19 A biblioteca tem cerca de 16 KB minificada e gzipada, não tem dependências e segue versionamento semântico. blakecrosley.com roda HTMX em produção há três anos com zero bugs relacionados ao HTMX.20
Posso usar TypeScript sem uma etapa de build?
Parcialmente. Arquivos TypeScript podem ser verificados por tipo 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 é usar anotações de tipo JSDoc em arquivos .js comuns, que o TypeScript consegue verificar sem compilação. Isso oferece segurança de tipos durante o desenvolvimento enquanto entrega JavaScript padrão.
Como esta abordagem se compara ao Astro ou ao 11ty?
Astro e 11ty são geradores de sites estáticos que produzem HTML simples com o mínimo de JavaScript no cliente, mas exigem uma etapa de build (Node.js, npm install, um comando de build). A abordagem sem 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 nativamente com conteúdo dinâmico (dados específicos do usuário, envios de formulário, atualizações em tempo real) sem uma camada API separada.
E quanto à renderização no servidor (SSR) com React?
Next.js SSR e a abordagem FastAPI + HTMX têm um objetivo em comum: enviar HTML renderizado no servidor para o navegador. A diferença é o que acontece depois da renderização inicial. 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. HTMX lida com interações posteriores solicitando novos fragmentos HTML ao servidor. O resultado: FastAPI + HTMX envia aproximadamente 35-40 KB de JavaScript no total, contra 100-300 KB em uma aplicação Next.js.18
Como lido com validação de formulários com esta stack?
No servidor. Pydantic valida a entrada quando o formulário é enviado. Se a validação falhar, o servidor retorna o formulário com mensagens de erro. HTMX troca 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 HTMX troca o resultado. Não é necessária nenhuma biblioteca de validação no cliente. O atributo HTML required oferece validação básica no navegador como primeira linha de defesa.
Posso adicionar recursos em tempo real (WebSockets)?
Sim. FastAPI tem suporte integrado 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))
HTMX tem 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>
Observação: HTMX 1.x usava a sintaxe
hx-ws="connect:...". HTMX 2.x moveu o suporte a WebSocket para uma extensão separada (htmx-ext-ws) com os atributosws-connectews-sendmostrados acima. Se você estiver usando HTMX 1.x, a sintaxe antigahx-wsainda funciona.Trilha beta do HTMX 4.0: htmx 4.0.0-beta4 agora está na tag
nextdo npm e na documentação 4.0, enquanto o quick start do htmx.org e a taglatestdo npm continuam na 2.0.10. Este guia ainda mira em HTMX 2.x, que continua sendo a versão recomendada para trabalho em produção até que a 4.0 esteja estável; a migração de 2.x para 4.x é um salto geracional, não uma release pontual de 2.x. O padrão de versionamento da big-skies-software pula versões major ímpares, então 4.0 é o próximo passo depois da 2.x.2122Vale acompanhar na documentação 4.0. Duas adições se destacam para revisão de segurança e arquitetura antes do GA da 4.0: a nova extensão
hx-liveintroduz expressões reativas ao DOM que são reavaliadas quando o estado referenciado muda, e a nova extensãohx-noncecontrola o processamento de atributos htmx por trás de nonces CSP. O guia de migração 4.0 também move vários conceitos de configuração, restaura ou altera alguns comportamentos de eventos/histórico e remove alguns helpers JavaScript do core. Trate a 4.0 como um projeto de migração, não como um patch 2.x plug-and-play.21
As mensagens do servidor são trocadas no DOM usando a mesma mecânica de alvo e troca das respostas HTTP. O servidor envia fragmentos HTML pelo WebSocket, e HTMX os insere.
Como esta stack lida com SEO?
HTML renderizado no servidor é naturalmente amigável para SEO porque crawlers recebem o conteúdo completo da página sem executar JavaScript. blakecrosley.com adiciona várias camadas de SEO:
- Dados estruturados JSON-LD em
<head>para todas as páginas (schemas Person, Article, WebSite, FAQPage) - Sitemap dinâmico com alternativas 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. Sem getStaticProps. Sem ISR. O HTML é renderizado a cada requisição — esse é o comportamento padrão, não uma otimização.
Qual é a curva de aprendizado em comparação com React?
Para desenvolvedores Python, a curva de aprendizado é significativamente menor. Você já conhece a linguagem. Os route handlers do FastAPI retornam respostas de template — o mesmo modelo mental de views do Flask ou Django. HTMX adiciona alguns atributos HTML (hx-get, hx-target, hx-swap). 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 ferramenta 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 se estende por centenas de páginas cobrindo hooks, context, refs, effects, suspense, server components e streaming SSR.
Para desenvolvedores JavaScript/React, a mudança é mais conceitual do que sintática. A ideia central é que o servidor é dono do estado e o servidor renderiza o HTML. Gerenciamento de estado no cliente vira manipulação de rotas no servidor. Busca de dados no cliente vira atributos HTMX em elementos HTML. A sintaxe é mais simples — o modelo mental exige desaprender a suposição de SPA de que o cliente é dono da renderização.
Changelog
| Data | Mudança |
|---|---|
| 2026-06-22 | FastAPI 0.138.0 + 0.137.2. 0.138.0 (20 de junho) adiciona app.frontend("/", directory="dist") / router.frontend(...) para servir um frontend estático já compilado (saída dist/ de SPA) — algo separado da tese deste guia de renderização no servidor sem build, mencionado como contraste na seção Async Patterns. 0.137.2 (18 de junho) adiciona iter_route_contexts() como a forma suportada de enumerar rotas agora que router.routes é interno (desde 0.137.0). Ambas são adições de recursos, sem breaking changes; Starlette (1.3.1), Pydantic (2.13.4), HTMX (2.0.10), Alpine.js (3.15.12), Bootstrap (5.3.8), SQLAlchemy (2.0.51) continuam sem mudanças. |
| 2026-06-16 | FastAPI 0.137.0/0.137.1 + Starlette 1.0→1.3.1. FastAPI 0.137.0 (14 de junho) refatora os componentes internos do router: router.routes agora é uma árvore interna, não uma lista plana de APIRoute (breaking change para qualquer coisa que itere sobre ela), ao mesmo tempo que permite rotas adicionadas depois de include_router() e novos hooks APIRouter.matches()/.handle(); 0.137.1 (15 de junho) corrige a tipagem de APIRoute e routers sem prefixo com caminho vazio. Starlette lançou sua primeira versão estável 1.0 (22 de março) e agora está na 1.3.1 (12 de junho), removendo os hooks obsoletos on_event/on_startup/on_shutdown e os decoradores @app.route()/@app.websocket_route() — lifespan e Route/WebSocketRoute são os únicos caminhos; FastAPI 0.137.0 fixa Starlette em 1.3.1. Uma observação sobre lifespan/router foi adicionada à seção Async Patterns. SQLAlchemy 2.0.51 (15 de junho) traz apenas correções de bugs. |
| 2026-06-08 | Mudança na instalação async do SQLAlchemy 2.0.50. A partir do SQLAlchemy 2.0.50, a dependência greenlet da stack async não é mais instalada por padrão — instale o extra sqlalchemy[asyncio] (ou o primeiro await contra o engine falha com um erro de greenlet ausente). A versão 2.0.50 também exige Python 3.10+ (3.7–3.9 foram removidos) e adiciona wheels para 3.13t free-threaded. Uma observação de instalação foi adicionada à seção SQLAlchemy 2.0 Async. Sem mudança no corpo para o restante da stack: a versão mais recente de FastAPI ainda é 0.136.3 (2026-05-23, sem release em junho), htmx estável continua em 2.0.10 (4.0.0-beta4 “The Fetchening” está em beta com meta de versão estável por volta do início de 2027, ainda não é recomendação para produção), Alpine.js 3.15.12, Bootstrap 5.3.x sem mudanças. Recomendação de produção sem mudanças: HTMX 2.x até a 4.0 ficar estável.23 |
| 2026-05-24 | Verificação de manutenção: o inventário local de conteúdo ainda mostra 210 posts de blog, 11 guias principais, 48 estudos de design e 10 locales suportados, incluindo inglês. A versão mais recente de FastAPI é 0.136.3 (2026-05-23); a única refatoração voltada para apps destacada nas notas de release é um tratamento mais rigoroso de headers com underscore quando convert_underscores=True, e 0.136.2 valida campos de Server-Sent Event para evitar dados de evento quebrados. htmx estável continua em 2.0.10, enquanto npm next e a documentação 4.0 agora apontam para 4.0.0-beta4; a versão mais recente do SQLAlchemy 2.0 é 2.0.50; a versão mais recente do Pydantic continua em 2.13.4. A recomendação de produção continua sem mudanças: use HTMX 2.x até a 4.0 ficar estável.122 |
| 2026-05-18 | Atualização do inventário do site: o inventário local de conteúdo agora mostra 210 posts de blog, 11 guias principais, 48 estudos de design e 10 locales suportados, incluindo inglês. A versão mais recente de FastAPI continua em 0.136.1; htmx estável continua em 2.0.10, com npm next em 4.0.0-beta3; a versão mais recente de Alpine.js no npm continua em 3.15.12. A recomendação de produção continua sem mudanças: use HTMX 2.x até a 4.0 ficar estável.12021 |
| 2026-05-15 | Verificação de manutenção: a versão mais recente de FastAPI continua em 0.136.1; este ambiente local do site importa FastAPI 0.128.0 e Starlette 0.50.0; htmx estável continua em 2.0.10 e npm next agora está em 4.0.0-beta3; a versão mais recente de Alpine.js no npm é 3.15.12; a versão mais recente do Bootstrap é 5.3.8; a versão mais recente do SQLAlchemy 2.0 é 2.0.49; a versão mais recente do Pydantic é 2.13.4. Recomendação de produção sem mudanças: use HTMX 2.x até a 4.0 ficar estável.2021 |
| 2026-05-09 | Acompanhamento do htmx 4.0.0-beta3 (8 de maio de 2026): htmx 4.0.0-beta3 está disponível na tag npm next e na documentação 4.0, enquanto npm latest continua em 2.0.10. Destaques que vale acompanhar antes do GA: nova extensão hx-live (expressões reativas ao DOM), nova extensão hx-nonce (proteção de nonce CSP para atributos htmx) e mudanças no guia de migração para configuração, histórico, eventos e helpers principais de JavaScript. Recomendação de produção sem mudanças: htmx 2.x continua sendo a tag npm mais recente e a versão recomendada até o GA da 4.0.21 |
| 2026-05-07 | Verificação de manutenção: a versão mais recente de FastAPI continua em 0.136.1; htmx estável é 2.0.10 e v4 continua em beta com meta para o verão de 2026; a versão mais recente de Alpine.js no npm é 3.15.12; a versão mais recente do Bootstrap é 5.3.8; a versão mais recente do SQLAlchemy 2.0 é 2.0.49; a versão mais recente do Pydantic é 2.13.4. As métricas locais do site foram atualizadas para 182 posts de blog, 11 guias, dez locales suportados e 17 requisitos de Python. Orientação de migração sem mudanças: use HTMX 2.x em produção até a 4.0 ficar estável.20 |
| 2026-04-25 | FastAPI 0.136.1 (23 de abril de 2026): limpeza de depreciações do Pydantic v2 (sem mudanças de comportamento para código de app). Linha do tempo do HTMX 4.0 acompanhada: htmx 4.0.0-beta1 (6 de abril) e 4.0.0-beta2 (14 de abril) foram lançados. Orientação de migração sem mudanças — htmx 2.x permanece na tag npm latest até a 4.0 ficar estável; correções de segurança continuam, sem pressão para upgrade. Principais mudanças da 4.0 que já vale considerar no design: (1) fetch() substitui XMLHttpRequest como infraestrutura ajax central, (2) a herança de atributos passa a ser explícita por padrão, (3) o suporte a histórico faz uma requisição de rede para conteúdo restaurado (sem snapshot local do DOM). FastAPI 0.135.4 (16 de abril) removeu o decorador de Primeiro de Abril @app.vibe() que entrou na 0.135.3. |
| 2026-04-16 | Adicionada consciência sobre HTMX 4.0-beta (referência futura). Observado o suporte do FastAPI 0.136.0 a builds free-threaded do Python 3.14t. Recursos do Pydantic 2.13.x (factories padrão de atributos privados com acesso a dados de modelo validados, namespace pydantic.v1 para 1.10.26 com suporte a 3.14). Correções do Alpine.js 3.15.11: modificador x-anchor.noflip, aviso de vários elementos raiz em x-for, correção de regressão de morph em $refs. |
| 2026-03-24 | Publicação inicial |
Referências
Este guia cobre o sistema completo usado para criar blakecrosley.com. O manifesto No-Build apresenta o argumento filosófico. O post Pontuação Lighthouse perfeita documenta a jornada de otimização de performance. O post Vibe Coding vs. Engineering explora onde o desenvolvimento assistido por AI se encaixa nesse workflow.
-
Métricas de produção do blakecrosley.com em 18 de maio de 2026. O site tem 210 posts de blog, componentes interativos JavaScript, 11 guias principais, 48 estudos de design, inglês mais 9 locales traduzidos, dependências Python mínimas e zero ferramentas de build. Verificado a partir do inventário local de conteúdo,
app/i18n/config.pyerequirements.txt. ↩↩↩↩↩ -
Google PageSpeed Insights (pagespeed.web.dev) executa auditorias Lighthouse em qualquer URL pública. blakecrosley.com pontua 100/100/100/100 (Performance, Acessibilidade, Boas práticas, SEO) desde março de 2026. Os resultados podem ser verificados publicamente. Veja De 76 a 100: alcançando uma pontuação Lighthouse perfeita 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 de produção com dependências adicionais tendem a ficar maiores. Projetos individuais variam. Fonte: testes do autor, documentados em O manifesto No-Build. ↩ -
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 alcançar pontuações acima de 90. Veja nextjs.org/docs/app/building-your-application/optimizing. A faixa de 70 a 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 maio de 2026. O arquivo atualmente tem 17 entradas de requisitos Python e zero 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 compatibilidade retroativa como um princípio de design da web: “a browser should be backwards-compatible.” Uma página de 1996 renderiza no Chrome 2026. Veja w3.org/DesignIssues/Principles. ↩
-
OWASP recomenda desativar 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çõesasyncesgota o event loop. ↩ -
nh3 é um sanitizer HTML baseado em Rust, sucessor da biblioteca Bleach. Ele é mantido pelo projeto PyO3 e oferece sanitização HTML baseada em allowlist. Veja github.com/messense/nh3. ↩
-
O header
Varyé definido na Seção 12.5.5 da RFC 9110. Ele instrui caches a armazenarem respostas separadas com base nos valores especificados dos headers da requisição. SemVary: HX-Request, um CDN poderia servir um fragmento HTMX como resposta de página completa. Veja httpwg.org/specs/rfc9110.html#field.vary. ↩↩ -
CSS Custom Properties (CSS Variables) têm suporte em mais de 97% dos navegadores globais. Elas fazem cascade, são herdadas e respondem a media queries em runtime — capacidades que variáveis de preprocessador não têm. Fonte: caniuse.com/css-variables. ↩
-
Documentação hreflang do Google: developers.google.com/search/docs/specialty/international/localized-versions. O valor
x-defaultdesigna a página fallback para usuários cujo idioma não está na lista hreflang. ↩ -
Alpine.js exige
'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 tem 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.
hmac.compare_digestusa comparação em tempo constante para evitar ataques de canal lateral por timing. Veja cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html. ↩ -
WebP gera arquivos 25-35% menores que JPEG com qualidade visual equivalente. Estudo WebP do Google: 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 oferece suporte a Early Hints para headers
Linkcomrel=preload. Veja developer.chrome.com/blog/early-hints. ↩ -
React 18 + ReactDOM pesa aproximadamente 42 KB minificado + gzipado. Com um router, uma biblioteca de gerenciamento de estado e o runtime de um framework de build, aplicações React típicas enviam 100-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 afirmou o princípio de compatibilidade retroativa em Hypermedia Systems (2023), de Gross, Stepinski e Cotter: hypermedia.systems. ↩
-
Verificação de manutenção de 15 de maio de 2026. FastAPI PyPI e notas de release listam 0.136.1; a verificação de import local retornou FastAPI 0.128.0 e Starlette 0.50.0 para este ambiente do site; htmx.org lista 2.0.10 no quick start;
npm view htmx.org version dist-tagsretornoulatest=2.0.10enext=4.0.0-beta3;npm view alpinejs versionenpm view @alpinejs/csp versionretornaram3.15.12; o blog oficial do Bootstrap e os metadados do pacote npm listam 5.3.8; SQLAlchemy PyPI e docs listam 2.0.49; Pydantic PyPI lista 2.13.4. ↩↩↩↩ -
Os metadados do pacote htmx 4.0.0-beta3 listavam publicação em 8 de maio de 2026, e o
nextdo npm apontava para4.0.0-beta3; olatestdo npm permanecia 2.0.10. A documentação 4.0 em four.htmx.org mostrava[email protected], o índice de extensões 4.0 listavahx-liveehx-nonce, e o guia de migração 4.0 documentava mudanças de migração que devem ser revisadas antes de mover apps de produção para fora da linha 2.x. Substituído para acompanhamento da linha mais recente por 22. ↩↩↩↩↩ -
Verificação de manutenção de 24 de maio de 2026. Comandos de inventário local retornaram 210 posts de blog em Markdown, 11 arquivos de guia de nível superior e 48 arquivos de estudos de design. As notas de release do FastAPI listam 0.136.3 em 2026-05-23 com tratamento mais rigoroso de headers com underscore quando
convert_underscores=True; 0.136.2 valida campos Server-Sent Event.python3 -m pip index versions fastapiretornou a versão mais recente0.136.3;python3 -m pip index versions sqlalchemyretornou a versão mais recente2.0.50;python3 -m pip index versions pydanticretornou a versão mais recente2.13.4.npm view htmx.org dist-tags version time.modified --jsonretornoulatest=2.0.10,next=4.0.0-beta4etime.modified=2026-05-22T15:56:21.948Z; a documentação de instalação do four.htmx.org mostra[email protected]. ↩↩↩ -
Changelog do SQLAlchemy 2.0.50 e post de release, lançados em 2026-05-24. A dependência asyncio
greenletnão é mais instalada por padrão; o alvo de instalaçãosqlalchemy[asyncio]agora é necessário para incluí-la. A versão 2.0.50 também remove suporte a Python 3.7/3.8/3.9 (agora 3.10+), adiciona wheels Python free-threaded e adiciona um parâmetro de janelaover(..., exclude=...). Última versão verificada no PyPI em 2026-06-08. htmx 4.0.0-beta4 (“The Fetchening”, 2026-05-22) continua em beta, com meta estável no início de 2027; FastAPI 0.136.3 (2026-05-23), Alpine.js 3.15.12 e Bootstrap 5.3.x permanecem inalterados nessa janela. ↩↩↩ -
Notas de release do FastAPI: 0.137.0 (2026-06-14) refatora os internos do router, então
router.routesnão é mais uma lista plana de objetosAPIRoute, mas uma árvore de objetos intermediários (trate como interno); também permite adicionar rotas depois deinclude_router(), incluir um sub-router antes de suas rotas serem definidas, evita copiar rotas e adicionaAPIRouter.matches()/.handle(); fixa Starlette 1.3.1. 0.137.1 (2026-06-15) corrige a tipagem de APIRoute e um path vazio em um router sem prefixo. Notas de release do Starlette: 1.0.0 (2026-03-22), sua primeira release estável em cerca de 8 anos, removeuon_startup/on_shutdown/on_event()e os decorators@app.route()/@app.websocket_route()(uselifespaneRoute/WebSocketRoute); a versão mais recente é 1.3.1 (2026-06-12). SQLAlchemy 2.0.51 (changelog, 2026-06-15) traz apenas correções de bugs, sem impacto em async ou instalação. Verificado via PyPI e notas de release oficiais em 2026-06-16. ↩ -
Notas de release do FastAPI: 0.138.0 (2026-06-20) adiciona
app.frontend("/", directory="dist")erouter.frontend("/", directory="dist")para servir um frontend estático já compilado (PR #15800; docs de Frontend) — um recurso estático para servir SPA emdist/, não um padrão server-rendered; sem breaking change. 0.137.2 (2026-06-18) adicionaiter_route_contexts()para uso avançado que antes percorriarouter.routes(interno desde 0.137.0); sem breaking change. Nenhuma release mais recente que 0.138.0 em 2026-06-22. Starlette 1.3.1, Pydantic 2.13.4, Uvicorn 0.49.0, SQLAlchemy 2.0.51, HTMX 2.0.10, Alpine.js 3.15.12, Bootstrap 5.3.8 todos inalterados. Verificado via PyPI e notas de release oficiais em 2026-06-22. ↩