FastAPI + HTMX : le full-stack sans compilation
# FastAPI + HTMX : le full-stack sans compilation
TL;DR : FastAPI + HTMX + Alpine.js + Bootstrap 5 + Jinja2 + du CSS simple produit des applications web prêtes pour la production sans aucun outil de build, sans
node_modules/, et avec des scores Lighthouse parfaits. Ce guide couvre l’ensemble du système, de l’architecture au déploiement, en s’appuyant sur blakecrosley.com comme référence de production — un site qui sert 37 articles de blog, 20 composants JavaScript interactifs, 20 guides et des traductions en dix langues, le tout sans le moindre bundler, compilateur ou transpileur.1
L’écosystème moderne du développement web part du principe que vous avez besoin de React, webpack, TypeScript et d’un pipeline de build. Pour une large catégorie d’applications — sites orientés contenu, outils internes, applications CRUD, portfolios, plateformes de documentation — cette hypothèse est fausse. L’architecture décrite dans ce guide élimine entièrement la chaîne d’outils de build frontend tout en produisant des sites qui obtiennent 100/100/100/100 sur Lighthouse.2
Il ne s’agit pas d’un plaidoyer, mais d’un constat mesurable. L’architecture présentée ici tourne en production, sert de vrais utilisateurs dans dix langues, et les résultats sont vérifiables.
Points clés
- Le rendu côté serveur avec HTML élimine trois catégories entières de problèmes : la gestion de l’état côté client, les frontières de sérialisation JSON, et les incohérences d’hydratation. HTMX fait des réponses serveur le résultat final — aucune étape de rendu côté client.
- Zéro outil de build signifie zéro échec de build. Aucun conflit de dépendances pair avec
npm install, aucune erreur du compilateur TypeScript dans des fichiers que vous n’avez pas modifiés, aucune PR Dependabot pour des dépendances transitives que vous n’avez jamais importées. Le pipeline de déploiement se résume àgit push. - Alpine.js gère l’état purement client que HTMX ne peut pas gérer. Les menus déroulants, les modales, les bascules de navigation mobile et tout état d’interface existant exclusivement dans le navigateur relèvent de Alpine.js. La frontière est nette : si l’état nécessite le serveur, utilisez HTMX. Sinon, utilisez Alpine.js.
- Du CSS simple avec des propriétés personnalisées remplace Sass et Tailwind. Les propriétés personnalisées CSS cascadent, héritent et répondent aux media queries à l’exécution. Les variables de préprocesseur compilent en valeurs statiques et disparaissent. Le navigateur lit directement les propriétés personnalisées — aucune étape de compilation.
- Cette approche a des limites claires. Elle ne convient pas aux grandes équipes partageant des interfaces de composants, aux produits SaaS avec un état côté client complexe, ni aux applications dépendant de bibliothèques de l’écosystème npm. Le cadre décisionnel de la Section 15 identifie ces limites avec précision.
- blakecrosley.com en est la preuve. Chaque patron présenté dans ce guide fonctionne en production. Chaque affirmation est accompagnée d’un chemin de fichier, d’un bloc de configuration ou d’un audit Lighthouse que vous pouvez vérifier vous-même sur PageSpeed Insights.2
Comment utiliser ce guide
Ce guide est une référence complète. Commencez là où votre niveau d’expérience vous situe :
| Expérience | Commencez ici | Puis explorez |
|---|---|---|
| Développeur Python, nouveau sur HTMX | La thèse No-Build → Vue d’ensemble de l’architecture → HTMX en profondeur | Patrons Alpine.js, Sécurité |
| Développeur React/Vue évaluant les alternatives | La thèse No-Build → Cadre décisionnel | Vue d’ensemble de l’architecture, Performance |
| Développeur FastAPI ajoutant de l’interactivité | HTMX en profondeur → Patrons Alpine.js | i18n et localisation, Déploiement |
| Développeur full-stack partant de zéro | Lecture séquentielle depuis Vue d’ensemble de l’architecture | Carte de référence rapide pour un usage continu |
Utilisez Ctrl+F / Cmd+F pour rechercher des patrons ou attributs spécifiques. La Carte de référence rapide en fin de guide fournit un résumé synthétique.
La thèse No-Build
La thèse est étroite et spécifique : pour les sites orientés contenu avec un développeur solo ou une petite équipe, les outils de build résolvent des problèmes que vous n’avez pas tout en créant des problèmes que vous avez.
Voici les métriques réelles de blakecrosley.com :
| Métrique | blakecrosley.com (No-Build) | Projet Next.js typique3 |
|---|---|---|
| Dépendances | 15 packages Python | 311+ packages npm |
| Fichiers de configuration build | 0 | 5-8 (next.config, tsconfig, postcss, tailwind, etc.) |
Taille de node_modules/ |
N’existe pas | 187 Mo de base, 250-400 Mo avec ajouts |
| Temps d’installation | pip install : 8 secondes |
npm install : 30-90 secondes |
| Étape de build | Aucune | next build : 15-60 secondes |
| Pipeline de déploiement | git push → en ligne en ~40 secondes |
Installation → build → déploiement : 2-5 minutes |
| Lighthouse Performance | 100 | 70-90 sans optimisation explicite4 |
Les 15 packages Python incluent FastAPI, Jinja2, Pydantic, uvicorn, nh3, et 10 autres. Aucun n’est un outil de build. Aucun n’est un compilateur. Aucun n’est un bundler.5
Ce que vous abandonnez
L’honnêteté exige de lister les coûts réels :
Pas de TypeScript. Chaque fichier .js est du JavaScript vanilla. Les erreurs de type sont détectées par les tests et l’analyse de code, pas par un compilateur. Cela fonctionne pour un développeur solo. Ce ne serait pas le cas pour une équipe de 10 personnes partageant des interfaces de composants.
Pas de Hot Module Replacement. Les modifications CSS nécessitent un rafraîchissement manuel du navigateur. Le hx-boost de HTMX rend la navigation suffisamment rapide pour que les rafraîchissements complets restent tolérables, mais lors de cycles d’itération visuelle serrés, le HMR fait gagner du temps.
Pas de Tree Shaking. Chaque octet de JavaScript que vous écrivez est envoyé au navigateur. Cette contrainte impose la discipline : des fichiers petits et ciblés plutôt que de gros modules utilitaires.
Pas de bibliothèques de composants npm. Pas de Radix, pas de shadcn/ui, pas de Headless UI. Chaque élément interactif est construit à la main ou utilise les composants intégrés de Bootstrap 5.
Pas de tokens de design system via npm. Le design system réside dans les propriétés personnalisées CSS. Il ne peut pas être importé comme package dans un autre projet.
Ces compromis sont acceptables pour un site orienté contenu avec un à trois développeurs. Ils seraient inacceptables pour un produit SaaS avec une équipe d’ingénierie de 15 personnes. La Section 15 fournit le cadre décisionnel.
Ce que vous gagnez
Zéro échec de build. Aucun npm install ne peut échouer à cause de conflits de dépendances pair. Aucun next build ne peut échouer à cause d’une erreur TypeScript dans un fichier que vous n’avez pas modifié.6
Débogage via « Afficher la source ». Le JavaScript exécuté dans le navigateur est le JavaScript que vous avez écrit. Aucun source map nécessaire.
Démarrage local instantané. uvicorn app.main:app --reload démarre en moins de 2 secondes.
Cascade de requêtes concrète. Une première visite charge : un document HTML (~15 Ko gzippé), un fichier CSS (~8 Ko), HTMX (~14 Ko, mis en cache), Alpine.js (~14 Ko, mis en cache), et le JS interactif de la page (~4-8 Ko). Total : 45-60 Ko à la première visite.1
Un frontend pérenne. Le code côté client utilise HTML, CSS et JavaScript — des standards qui maintiennent leur rétrocompatibilité depuis 30 ans.7 Pas de migration Webpack 4 → 5, pas de dépréciation de Create React App, pas de migration vers le App Router de Next.js.
Vue d’ensemble de l’architecture
Flux des requêtes
Chaque requête suit un chemin unique à travers quatre couches :
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 -------------------------------------------------------->|
Les chargements de page complets renvoient des documents HTML entiers (template de base + template de page). Les requêtes HTMX renvoient des fragments HTML (partiels). Le serveur décide quoi rendre en fonction du type de requête. Alpine.js gère l’état côté client qui ne communique jamais avec le serveur.
Rôles des composants
| Composant | Rôle | Portée |
|---|---|---|
| FastAPI | Routage, logique métier, accès aux données, validation | Serveur |
| Jinja2 | Rendu des templates, héritage, macros | Serveur |
| HTMX | Interactivité pilotée par le serveur (formulaires, pagination, recherche) | Client ↔ Serveur |
| Alpine.js | État côté client uniquement (menus déroulants, modales, bascules) | Client uniquement |
| Bootstrap 5 | Système de grille, classes utilitaires, mise en page responsive | Client (CSS) |
| CSS simple | Propriétés personnalisées, styles de composants, design tokens | Client (CSS) |
| Pydantic | Validation des requêtes/réponses, paramètres | Serveur |
Structure du projet
app/
├── main.py # FastAPI app, middleware, templates
├── config.py # Pydantic settings management
├── routes/
│ ├── pages.py # Page routes (HTML responses)
│ └── api.py # API routes (JSON/HTML fragment responses)
├── content.py # Markdown loading, blog post parsing
├── security/
│ ├── headers.py # CSP, HSTS, security headers middleware
│ ├── csrf.py # HMAC-signed CSRF tokens
│ ├── rate_limit.py # 3-tier rate limiting
│ └── logging.py # Security event logging
├── i18n/
│ ├── config.py # Supported locales, mappings
│ ├── middleware.py # URL-based locale detection
│ ├── jinja.py # Translation functions for templates
│ └── d1_client.py # Cloudflare D1 translation storage
├── cache_assets.py # Content-hash asset versioning
└── templates/
├── base.html # Base layout with Alpine.js state
├── components/ # Reusable partials (_language_switcher.html, etc.)
└── pages/ # Page templates (home.html, about.html, etc.)
content/
├── blog/ # Markdown blog posts with YAML frontmatter
└── guides/ # Multi-section guide markdown
static/
├── css/ # Plain CSS (no preprocessors)
├── js/ # Vanilla JavaScript (no bundlers)
│ └── vendor/ # Self-hosted HTMX, Alpine.js
└── images/ # Optimized images with WebP srcset
La structure suit un principe unique : chaque répertoire contient un seul type d’élément. Les routes se trouvent dans routes/. Les templates se trouvent dans templates/. Les ressources statiques se trouvent dans static/. Aucune étape de build ne transforme l’un en l’autre.
Comparaison avec l’architecture SPA
Dans un projet React + Next.js, la structure équivalente inclurait :
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
L’architecture SPA nécessite une coordination au moment du build entre ces répertoires. TypeScript compile les fichiers .tsx en JavaScript. PostCSS transforme les directives Tailwind en CSS. Webpack (ou Turbopack) regroupe la sortie en chunks. Chaque étape peut échouer indépendamment.
L’architecture sans build ne nécessite aucune coordination. Le template référence un fichier CSS. Le fichier CSS existe dans static/css/. Le navigateur le charge directement. Si vous renommez un fichier, la référence dans le template échoue au moment de l’exécution — pas au moment du build. Cela déplace les erreurs du temps de compilation vers le temps d’exécution, ce qui constitue un véritable compromis. Pour un développeur solo exécutant uvicorn --reload pendant le développement, les erreurs d’exécution apparaissent immédiatement dans le navigateur. Pour une grande équipe, les erreurs de compilation détectées par TypeScript préviennent une catégorie de bugs que les erreurs d’exécution ne peuvent pas intercepter.
Patterns FastAPI
Configuration de l’application
L’application s’initialise dans main.py avec un ordonnancement explicite des middlewares :
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)
Trois décisions de conception méritent attention ici. Premièrement, docs_url=None et openapi_url=None désactivent les endpoints de documentation API générés automatiquement. Un site de contenu public n’a pas besoin d’exposer /docs ou /openapi.json sur internet.8 Deuxièmement, l’ordre des middlewares compte — la journalisation de sécurité s’exécute en premier (ajoutée en dernier) afin de capturer chaque requête, y compris celles rejetées par le limiteur de débit. Troisièmement, GZipMiddleware compresse toutes les réponses dépassant 500 octets, ce qui réduit généralement la taille de transfert HTML de 70 à 80 %.
Routage
Les routes se divisent en deux catégories : les routes de pages renvoient des documents HTML complets, et les routes API renvoient du JSON ou des fragments 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,
})
Cette distinction est essentielle pour HTMX. Les routes de pages renvoient des documents qui étendent base.html. Les routes API renvoient des fragments HTML que HTMX injecte dans les éléments DOM existants. Le même moteur de templates Jinja2 produit les deux — aucune couche API séparée n’est nécessaire.
Injection de dépendances
Le système Depends() de FastAPI assure une séparation propre entre les gestionnaires de routes et la logique partagée :
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,
})
Les dépendances se composent entre elles. Une dépendance get_db peut dépendre de get_current_locale, qui elle-même dépend de la requête. FastAPI résout la chaîne automatiquement.
Paramètres Pydantic
La configuration repose sur BaseSettings de Pydantic avec priorité aux variables d’environnement :
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()
Les variables d’environnement prennent le pas sur les valeurs du fichier .env. En production (Railway), les secrets sont définis comme variables d’environnement. En local, un fichier .env fournit les valeurs par défaut. La classe Settings valide les types au démarrage — un champ obligatoire manquant provoque une erreur immédiate plutôt qu’à l’exécution.
Patterns async
Les routes FastAPI sont async par défaut. Pour les opérations liées aux entrées/sorties (requêtes en base de données, requêtes HTTP, lectures de fichiers), l’approche async évite de bloquer la boucle d’événements :
@app.on_event("startup")
async def startup_load_translations():
"""Load translations from D1 into memory at startup."""
client = init_d1_client(
worker_url=settings.D1_WORKER_URL,
auth_secret=settings.D1_AUTH_SECRET,
)
if not client.is_configured:
logger.warning("i18n: D1 not configured, translations use defaults")
return
cache = await load_translations(client)
logger.info(f"i18n: Loaded {cache.stats['key_count']} keys")
Les opérations intensives en CPU (rendu Markdown, extraction CSS) peuvent utiliser des fonctions synchrones. FastAPI les exécute automatiquement dans un pool de threads lorsque le gestionnaire de route n’est pas déclaré async :
# Sync function — FastAPI runs it in a thread pool
@router.get("/blog/{slug}")
def blog_post(slug: str):
post = load_post_by_slug(slug) # CPU-bound Markdown parsing
return templates.TemplateResponse(...)
La règle est simple : si la fonction attend des entrées/sorties, déclarez-la async. Si elle effectue du traitement CPU, laissez-la synchrone. Ne mélangez jamais await avec des appels bloquants dans la même fonction.9
Templates Jinja2
Héritage de templates
Le système d’héritage de Jinja2 remplace la composition par composants de React par un modèle plus simple. Un template de base définit le squelette de la page. Les templates enfants remplissent des blocs nommés :
<!-- base.html — the skeleton -->
<!DOCTYPE html>
<html lang="{{ lang_attr() }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ page_title | default("Blake Crosley") }}</title>
<meta name="description" content="{{ page_description | default('...') }}">
<!-- CSS — single file, no preprocessor -->
<link rel="stylesheet" href="{{ asset('css/styles.css') }}">
<!-- JSON-LD structured data -->
<script type="application/ld+json">
{ "@context": "https://schema.org", "@graph": [...] }
</script>
{% block head %}{% endblock %}
</head>
<body>
<header class="header">...</header>
<main id="main" role="main">
{% block content %}{% endblock %}
</main>
<footer class="footer">...</footer>
<!-- Scripts deferred for performance -->
<script defer src="{{ asset('js/vendor/alpine.min.js') }}"></script>
<script defer src="{{ asset('js/vendor/htmx.min.js') }}"></script>
<script defer src="{{ asset('js/main.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
<!-- pages/about.html — fills the blocks -->
{% extends "base.html" %}
{% block head %}
<script type="application/ld+json">
{ "@type": "AboutPage", "name": "About Blake Crosley", ... }
</script>
{% endblock %}
{% block content %}
<section class="hero">
<h1>About</h1>
<p>Designer, developer, dad.</p>
</section>
{% endblock %}
La directive {% extends %} établit une relation parent-enfant. Le template enfant ne définit que les blocs qu’il souhaite remplacer. Tout le reste — le <head>, l’en-tête, le pied de page, les balises script — provient du template de base. Il s’agit d’une composition par soustraction plutôt que par construction.
Le global asset()
Les ressources statiques utilisent un versionnage par hash de contenu pour l’invalidation du 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}"
Dans le template : {{ asset('css/styles.css') }} produit /static/css/styles.css?v=a3f8b2c1d0. Le hash change dès que le fichier est modifié, invalidant ainsi le cache CDN. Cela remplace la stratégie de nommage [contenthash] de webpack par 30 lignes de Python calculées au démarrage.
Include pour les fragments réutilisables
Les composants répétés sur plusieurs pages utilisent {% 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>
Le préfixe underscore (_language_switcher.html) est une convention indiquant un fragment — un morceau de template qui n’est pas destiné à être rendu de manière autonome. Ce composant utilise à la fois Alpine.js (pour le basculement du menu déroulant) et Jinja2 (pour la liste des langues). La frontière est nette : Alpine.js gère l’état ouvert/fermé, Jinja2 gère les données.
Macros pour les composants réutilisables
Les macros sont les fonctions de Jinja2 — des blocs de template réutilisables avec des paramètres :
<!-- 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 %}
Importez et utilisez les macros dans les templates de page :
{% 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>
Les macros remplacent les composants React pour les motifs de présentation. Elles acceptent des paramètres, prennent en charge les valeurs par défaut et se composent avec d’autres macros. La différence : les macros s’exécutent une seule fois côté serveur et produisent du HTML statique. Les composants React s’exécutent côté client et maintiennent un état. Pour l’affichage de contenu, les macros sont l’outil approprié.
Contexte de template et globaux
Les globaux Jinja2 sont des fonctions disponibles dans chaque template sans passage explicite :
# 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
Le global asset() génère des URL versionnées. Le global csrf_token() génère des jetons CSRF à chaque appel. Le global analytics_script() injecte le snippet de suivi. Ces fonctions sont appelables dans n’importe quel template sans que le gestionnaire de route ne les transmette explicitement.
Pour l’i18n, la mise en place est plus élaborée — les fonctions de traduction ont besoin d’accéder à la langue de la requête en cours :
# 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
Chaque fonction lit la langue depuis la variable de contexte de requête définie par le middleware de locale. Le template appelle {{ _('ui.nav.about') }} et obtient la chaîne traduite pour la langue de la requête en cours, sans aucun paramètre de langue explicite.
Blocs conditionnels
Le système de blocs de Jinja2 prend en charge les remplacements conditionnels :
<!-- 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 %}
Les articles de blog déclarent leurs dépendances dans le frontmatter YAML (scripts: ["/static/js/boids.js"]). Le template les inclut de manière conditionnelle. Les pages qui n’ont pas besoin de scripts ou de styles supplémentaires n’en chargent aucun — pas de code mort, pas d’imports inutilisés.
Filtres personnalisés
Les filtres Jinja2 transforment les données lors du rendu. Le filtre sanitize empêche les attaques XSS dans le contenu généré par les utilisateurs :
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
Dans les templates : {{ user_content | sanitize }}. La bibliothèque nh3 est un assainisseur HTML écrit en Rust — rapide et sécurisé. Elle supprime toute balise ou attribut absent de la liste autorisée, empêchant les attaques XSS stockées même si le contenu provient d’une source non fiable.10
Plongée dans HTMX
HTMX permet à n’importe quel élément HTML d’émettre des requêtes HTTP et d’injecter la réponse dans le DOM. L’idée architecturale clé est la suivante : le HTML rendu côté serveur constitue l’API. Le serveur renvoie la représentation finale. Pas de rendu côté client, pas de sérialisation JSON, pas d’hydratation.
Attributs principaux
| Attribut | Rôle | Exemple |
|---|---|---|
hx-get |
Émettre une requête GET | hx-get="/search?q=term" |
hx-post |
Émettre une requête POST | hx-post="/contact" |
hx-target |
Où placer la réponse | hx-target="#results" |
hx-swap |
Comment insérer la réponse | hx-swap="innerHTML" (par défaut), outerHTML, beforeend |
hx-trigger |
Ce qui déclenche la requête | hx-trigger="click", keyup changed delay:300ms, load |
hx-indicator |
Élément à afficher pendant la requête | hx-indicator="#spinner" |
hx-push-url |
Mettre à jour l’URL du navigateur | hx-push-url="true" |
hx-replace-url |
Remplacer l’URL sans entrée d’historique | hx-replace-url="true" |
Pattern 1 : Quiz interactif (état serveur multi-étapes)
blakecrosley.com intègre un quiz interactif qui guide les utilisateurs dans le choix d’outils. L’intégralité de l’état du quiz réside sur le serveur — aucune gestion d’état côté client :
<!-- _quiz_container.html — initial load -->
<div hx-get="/api/quiz/claude-vs-codex/step?answers="
hx-trigger="load"
hx-swap="innerHTML"
id="quiz-wrapper">
<p>Loading quiz...</p>
</div>
<!-- _quiz_step.html — each question -->
<div class="quiz-step" id="quiz-container">
<p>Question {{ step }} of {{ total }}</p>
<h3>{{ question.question }}</h3>
<div class="quiz-step__options">
{% for opt in question.options %}
<button class="quiz-step__btn"
hx-get="/api/quiz/claude-vs-codex/step?answers={{ answers }},{{ opt.value }}"
hx-target="#quiz-container"
hx-swap="outerHTML">
{{ opt.label }}
</button>
{% endfor %}
</div>
</div>
Chaque clic sur un bouton envoie les réponses accumulées en paramètre de requête. Le serveur calcule la question suivante ou le résultat final à partir de l’historique des réponses. L’état s’accumule dans l’URL — ni cookies, ni sessions, ni JavaScript côté client. Le quiz progresse grâce à des swaps outerHTML : chaque réponse remplace l’intégralité de l’élément d’étape du quiz.
Pattern 2 : Liste de blog paginée
La page d’écriture utilise HTMX pour une pagination fluide qui met à jour l’URL :
<!-- Pagination link -->
<a href="/writing?page=2&category=Engineering"
hx-get="/writing?page=2&category=Engineering"
hx-target="#writing-content"
hx-swap="innerHTML"
hx-replace-url="true"
hx-indicator="#writing-loading"
aria-label="Go to page 2">
2
</a>
Quatre attributs fonctionnent de concert :
hx-getémet la requête vers la même URL que lehref(amélioration progressive — fonctionne sans JavaScript)hx-targetplace la réponse dans le conteneur#writing-contenthx-replace-url="true"met à jour l’URL du navigateur sans ajouter d’entrée dans l’historiquehx-indicatoraffiche un indicateur de chargement pendant la requête
Le serveur détecte les requêtes HTMX via l’en-tête HX-Request et renvoie uniquement le fragment de la liste d’articles au lieu de la page complète. C’est pourquoi le middleware des en-têtes de sécurité ajoute Vary: HX-Request — afin que les caches CDN stockent séparément la page complète et le fragment.11
Pattern 3 : Recherche avec 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>
L’attribut hx-trigger combine trois modificateurs :
keyupse déclenche au relâchement de touchechangedne se déclenche que si la valeur a réellement changé (évite les requêtes en double dues aux touches de modification)delay:300msapplique un debounce — attend 300 ms après le dernier keyup avant de déclencher la requête
Le serveur renvoie un fragment HTML rendu :
@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,
})
Aucun état côté client. Aucune bibliothèque de debounce. Aucun useEffect. Le template génère les résultats, HTMX les injecte dans le DOM, et le serveur reste l’unique source de vérité.
Pattern 4 : Swaps hors bande (OOB)
Il arrive qu’une seule action serveur doive mettre à jour plusieurs éléments du DOM. Le mécanisme de swap hors bande d’HTMX gère ce cas sans orchestration côté client :
<!-- Server returns multiple elements in one response -->
<!-- Primary target: swapped normally via hx-target -->
<div id="cart-items">
<ul>
<li>Widget A — $29.99</li>
<li>Widget B — $14.99</li>
</ul>
</div>
<!-- OOB target: swapped independently via hx-swap-oob -->
<span id="cart-count" hx-swap-oob="true">2 items</span>
<span id="cart-total" hx-swap-oob="true">$44.98</span>
L’attribut hx-swap-oob="true" indique à HTMX de rechercher l’élément par son id n’importe où dans le DOM et de le remplacer, indépendamment du hx-target. Cela remplace le pattern « remonter l’état » de React — le serveur calcule tous les états dérivés et envoie le HTML final pour chaque élément en une seule réponse.
Sur blakecrosley.com, ce pattern apparaît dans le formulaire de contact : la soumission du formulaire remplace le corps du formulaire par un message de succès et met simultanément à jour un badge de notification via un swap OOB.
Pattern 5 : Liens boostés
HTMX peut « booster » les liens de navigation standard pour utiliser AJAX au lieu de chargements de page complets :
<nav hx-boost="true">
<a href="/about">About</a>
<a href="/writing">Writing</a>
<a href="/guides">Guides</a>
</nav>
Avec hx-boost="true", un clic sur un lien récupère la page via AJAX, remplace le contenu du <body> et met à jour l’URL — sans rechargement complet de la page. L’historique du navigateur fonctionne normalement (boutons précédent/suivant). Si JavaScript échoue, les liens fonctionnent comme une navigation standard.
L’avantage réside dans la performance perçue : la navigation boostée semble instantanée car le navigateur n’a pas besoin de re-parser le CSS, de réévaluer les scripts ou de recalculer la mise en page. Seul le contenu du <body> change. blakecrosley.com utilise les liens boostés dans la navigation principale, ce qui donne aux transitions de page l’impression d’une application monopage sans l’architecture SPA.
Pattern 6 : En-têtes de requête HTMX
HTMX envoie des en-têtes personnalisés avec chaque requête :
| En-tête | Valeur | Cas d’usage |
|---|---|---|
HX-Request |
true |
Détecter les requêtes HTMX côté serveur |
HX-Target |
ID de l’élément | Identifier l’élément qui recevra la réponse |
HX-Trigger |
ID de l’élément | Identifier l’élément qui a déclenché la requête |
HX-Current-URL |
URL complète | Connaître la page actuelle de l’utilisateur |
Le serveur peut utiliser HX-Request pour renvoyer des réponses différentes :
@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)
Ce pattern de double réponse est au cœur de l’architecture. Un chargement complet de page renvoie le document entier (template de base + contenu de page). Une navigation HTMX renvoie uniquement le contenu modifié. C’est le serveur qui décide, pas le client.
Pattern 7 : Amélioration progressive
Chaque lien HTMX sur blakecrosley.com inclut un attribut href standard :
<a href="/writing?page=2"
hx-get="/writing?page=2"
hx-target="#writing-content"
hx-swap="innerHTML">
Next Page
</a>
Si JavaScript ne parvient pas à se charger, le href fonctionne comme un lien classique. Si HTMX est chargé, il intercepte le clic et effectue un swap AJAX. C’est l’amélioration progressive : le site fonctionne sans JavaScript, et HTMX enrichit l’expérience lorsqu’il est disponible.
Pattern 5 : États de chargement
<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 ajoute la classe htmx-request à l’élément déclencheur pendant les requêtes. L’attribut hx-indicator pointe vers un élément qui devient visible pendant la requête. Stylisez-le avec du CSS :
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
display: inline;
}
Aucune gestion d’état de chargement. Aucun useState(false). Aucun setLoading(true). Le CSS gère la visibilité, HTMX gère le basculement de classe.
Patterns Alpine.js
Alpine.js comble le vide que HTMX laisse : l’état côté client qui n’a jamais besoin de communiquer avec le serveur. Quand un utilisateur clique sur un menu déroulant et que celui-ci s’ouvre, cet état n’existe que dans le navigateur. Alpine.js le gère avec des attributs HTML.
La règle de frontière
La frontière entre HTMX et Alpine.js est précise :
| Type d’état | Outil | Exemple |
|---|---|---|
| Nécessite des données serveur | HTMX | Résultats de recherche, validation de formulaire, pagination |
| N’existe que dans le navigateur | Alpine.js | Ouverture/fermeture de menu déroulant, bascule du menu mobile, visibilité des modales |
| Combine les deux | Les deux | Sélecteur de langue (bascule Alpine.js, navigation de type HTMX) |
Navigation mobile
Le template de base enveloppe l’ensemble du header dans un composant 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>
Patterns Alpine.js essentiels :
x-datadéclare la portée du composant et l’état initialx-showbascule la visibilité en fonction de l’état (utilise CSSdisplay: none)x-cloakmasque l’élément jusqu’à l’initialisation d’Alpine.js (évite le flash de contenu non stylisé)@clicklie des gestionnaires de clic avec des expressions:aria-expanded(raccourci pourx-bind:aria-expanded) définit dynamiquement les attributs@keydown.escape.windowécoute la touche Échap globalement pour fermer les panneaux
Composant dropdown
Le sélecteur de langue utilise Alpine.js pour l’état de bascule avec @click.away pour la fermeture au clic extérieur :
<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>
Le modificateur @click.away ferme le dropdown lors d’un clic à l’extérieur. Alpine.js gère cela avec un seul attribut — pas d’enregistrement d’écouteur d’événements, pas de nettoyage, pas de gestion de références.
Quand utiliser Alpine.js vs. JavaScript natif
Alpine.js est approprié quand :
- L’état est limité à un seul élément DOM (dropdown, modale, bascule)
- Les interactions sont binaires ou simples (ouvrir/fermer, afficher/masquer, basculer)
- Plusieurs éléments doivent réagir au même changement d’état
- Les attributs d’accessibilité doivent rester synchronisés avec la visibilité
JavaScript natif est approprié quand :
- L’interaction implique des calculs complexes (visualisations, simulations)
- Le composant possède sa propre boucle de rendu (canvas, animation)
- La performance est critique (Alpine.js ajoute une surcharge par composant
x-data) - La logique dépasse 20-30 lignes d’expressions Alpine.js
blakecrosley.com utilise Alpine.js pour la navigation, le changement de langue et les bascules de contenu. Les 20 composants interactifs du blog (simulation de boids, visualiseur de code de Hamming, etc.) utilisent du JavaScript natif car ils nécessitent un rendu canvas et des machines à états complexes.
Bootstrap 5 sans Sass
Bootstrap 5 a abandonné jQuery comme dépendance et prend en charge l’utilisation autonome de CSS. Vous n’avez besoin ni de Sass, ni de PostCSS, ni d’aucun outil de build pour utiliser le système de grille et les classes utilitaires de Bootstrap.
Auto-hébergement sans CDN
blakecrosley.com auto-héberge toutes les bibliothèques tierces :
<!-- 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>
L’auto-hébergement élimine les dépendances externes, empêche les pannes de CDN de casser le site et permet un cache immuable avec des URL à empreinte de contenu. Téléchargez le CSS compilé de Bootstrap (pas les sources Sass) et placez-le dans static/css/vendor/.
Système de grille
La grille Bootstrap fonctionne avec de simples classes HTML :
<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>
Pas de mixins Sass. Pas de @include make-col(). Le CSS compilé inclut les classes responsives de la grille. Pour des breakpoints personnalisés au-delà des valeurs par défaut de Bootstrap, écrivez de simples media queries en CSS.
Surcharges en CSS pur
Surchargez les valeurs par défaut de Bootstrap avec des propriétés personnalisées CSS et des sélecteurs standard :
/* 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;
}
Les propriétés personnalisées CSS se propagent dans le DOM, héritent des éléments parents et réagissent aux media queries à l’exécution. Les variables Sass se compilent en valeurs statiques et disparaissent. Cette distinction compte pour la thématisation : un seul changement de propriété personnalisée peut mettre à jour chaque valeur dérivée sans recompilation.12
Classes utilitaires vs. CSS de composant
Utilisez les classes utilitaires Bootstrap pour l’espacement et la mise en page ponctuels. Utilisez le CSS de composant pour les patterns récurrents :
<!-- 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;
}
Le principe : les utilitaires Bootstrap pour la mécanique de mise en page (marges, padding, flexbox). Le CSS personnalisé pour l’identité visuelle (couleurs, typographie, animations). Ne mélangez jamais classes utilitaires et styles de composant pour la même préoccupation.
i18n et localisation
blakecrosley.com diffuse du contenu en 10 langues : anglais, japonais, coréen, chinois simplifié, chinois traditionnel, allemand, français, espagnol, polonais et portugais (brésilien).
Routage des locales par URL
La locale figure dans le chemin de l’URL : /about (anglais), /ja/about (japonais), /zh-Hans/about (chinois simplifié). L’anglais est la langue par défaut et ne comporte aucun préfixe.
# i18n/config.py
SUPPORTED_LOCALES = [
"en", "zh-Hans", "zh-Hant", "fr", "de", "ja", "ko", "pl", "pt-BR", "es"
]
DEFAULT_LOCALE = "en"
Le middleware de locale extrait la locale à partir du chemin de l’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
Le middleware supprime le préfixe de locale avant la résolution des routes. Les gestionnaires de routes n’ont donc pas besoin de chemins spécifiques à chaque locale — /about gère à la fois l’anglais (/about) et le japonais (/ja/about) puisque le middleware normalise le chemin.
Fonctions de traduction dans les templates
Les variables globales Jinja2 fournissent des fonctions de traduction :
<!-- Template usage -->
<h3>{{ _('ui.footer.navigate') | default('Navigate') }}</h3>
<a href="{{ locale_prefix() }}/about">
{{ _('ui.nav.about') | default('About') }}
</a>
La fonction _() recherche une clé de traduction dans le cache mémoire. Le filtre | default() fournit le texte anglais de secours si la traduction est absente. La fonction locale_prefix() renvoie le préfixe d’URL pour la locale courante ("" pour l’anglais, "/ja" pour le japonais).
Balises hreflang
Chaque page inclut des balises hreflang pour toutes les locales prises en charge :
<!-- Generated in base.html -->
{% for alt in alternate_urls(request.url.path) %}
<link rel="alternate" hreflang="{{ alt.hreflang }}" href="{{ alt.url }}">
{% endfor %}
Ce qui produit :
<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">
Les moteurs de recherche utilisent les balises hreflang pour afficher la bonne version linguistique dans les résultats. L’entrée x-default pointe vers la version anglaise en tant que version de secours.13
Stockage des traductions et cache mémoire
Les traductions sont stockées dans Cloudflare D1 (SQLite en périphérie) et chargées dans un cache mémoire au démarrage :
@app.on_event("startup")
async def startup_load_translations():
client = init_d1_client(worker_url=settings.D1_WORKER_URL,
auth_secret=settings.D1_AUTH_SECRET)
cache = await load_translations(client)
logger.info(f"i18n: Loaded {cache.stats['key_count']} keys")
Le cache mémoire évite les requêtes en base de données à chaque rendu de page. Les mises à jour de traduction nécessitent un rafraîchissement du cache (déclenché via un endpoint d’administration ou un déploiement). Cette architecture privilégie la performance au détriment de la fraîcheur des données — les traductions changent rarement, tandis que les rendus de page se produisent à chaque requête.
Surveillance de la santé
blakecrosley.com intègre un endpoint de vérification de santé i18n qui surveille la couverture des traductions par 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
Le seuil de couverture à 99,5 % permet de détecter les traductions manquantes avant que les utilisateurs ne rencontrent des chaînes non traduites. L’endpoint de santé s’intègre au système de surveillance de Railway pour alerter en cas de baisse de couverture — par exemple, après l’ajout de nouvelles chaînes d’interface qui n’ont pas encore été traduites.
Rendu de contenu selon la locale
Les articles de blog et les guides prennent en charge les traductions par locale des métadonnées et du contenu :
# 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 }}
Le principe est constant : essayer d’abord le contenu traduit, puis se rabattre sur l’anglais. Cela autorise une traduction partielle — un utilisateur japonais voit les titres et descriptions traduits même si le corps complet de l’article reste en anglais. Le filtre Jinja2 | default() encode ce schéma en un seul pipe :
{{ translated.title if translated else post.meta.title }}
Traduction des données localisées
Le contenu statique comme les descriptions de projets et les libellés de navigation est traduit via des fonctions utilitaires qui conservent la même structure de données tout en y injectant les chaînes propres à chaque 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
Cette approche maintient la couche de traduction séparée de la couche de données. Les routes transmettent la même liste projects quelle que soit la locale. Les fonctions de traduction enveloppent les données de manière transparente.
Sitemap avec alternates hreflang
Le sitemap dynamique inclut toutes les pages dans toutes les locales avec des références croisées :
@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}"/>'
)
Cela produit 10 entrées d’URL par page (une par locale), chacune avec 11 liens alternatifs (10 locales + x-default). Pour un site comportant 50 pages, le sitemap contient 500 entrées d’URL avec 5 500 liens hreflang. Le sitemap est généré dynamiquement et mis en cache pendant une heure.
Modèles de base de données
SQLAlchemy 2.0 asynchrone
Pour les applications nécessitant une base de données relationnelle, le support asynchrone de SQLAlchemy 2.0 s’intègre parfaitement avec 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
Injection de dépendances pour les sessions de base de données
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with async_session() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
@router.get("/users/{user_id}")
async def get_user(user_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(404, "User not found")
return templates.TemplateResponse("pages/user.html", {
"request": request, "user": user
})
La dépendance get_db gère le cycle de vie de la session : elle ouvre une session, la transmet au gestionnaire de route via yield, valide la transaction en cas de succès et effectue un rollback en cas d’exception. Chaque opération de base de données utilise des requêtes paramétrées — jamais d’interpolation de chaînes.
Intégration de Pydantic
Les modèles Pydantic valident les entrées à la frontière API et sérialisent les sorties pour les templates :
from pydantic import BaseModel, EmailStr
class ContactForm(BaseModel):
name: str
email: EmailStr
message: str
@router.post("/contact")
async def submit_contact(form: ContactForm):
# form.name, form.email, form.message are validated
await send_email(form)
return templates.TemplateResponse("components/_contact_success.html", {
"request": request
})
Pydantic valide les types, les formats (e-mail, URL) et les contraintes (longueur minimale/maximale) avant l’exécution du gestionnaire de route. Les entrées invalides renvoient automatiquement une réponse 422. Cela remplace les bibliothèques de validation de formulaires côté client — le serveur valide, et HTMX injecte soit le message de succès, soit le retour d’erreur.
Migrations avec Alembic
Alembic gère les modifications du schéma de base de données :
# Generate a migration from model changes
alembic revision --autogenerate -m "add user preferences table"
# Apply migrations
alembic upgrade head
# Roll back one migration
alembic downgrade -1
La fonctionnalité d’auto-génération compare les modèles SQLAlchemy au schéma actuel de la base de données et produit des scripts de migration. Ces scripts sont des fichiers Python versionnés qui résident dans le dépôt :
# 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")
Les migrations s’exécutent lors du déploiement (avant le démarrage de l’application). Cela garantit que le schéma de la base de données correspond au code de l’application. Pour blakecrosley.com, la plupart des données résident dans Cloudflare D1 (accessible via HTTP), les migrations Alembic s’appliquent donc à la base de données SQLite ou PostgreSQL locale utilisée pour les données de session et les statistiques.
Le modèle Cloudflare D1
blakecrosley.com utilise Cloudflare D1 comme base de données distante accessible via un Worker proxy Cloudflare :
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"]
Ce modèle convient aux applications qui ont besoin d’une base de données sans vouloir gérer un serveur de base de données. D1 est SQLite en périphérie de Cloudflare, accessible via HTTP. Le Worker proxy gère l’authentification et la limitation de débit. Le compromis est la latence : chaque requête est une requête HTTP (~50-100 ms) contre une connexion locale (~1-5 ms). Le cache en mémoire au démarrage atténue ce problème pour les charges de travail en lecture intensive comme les traductions.
Sécurité
Middleware d’en-têtes de sécurité
blakecrosley.com implémente des en-têtes de sécurité renforcés via un middleware personnalisé :
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
CSP_DIRECTIVES = {
"default-src": "'self'",
"script-src": "'self' 'unsafe-inline' 'unsafe-eval'",
"style-src": "'self' 'unsafe-inline'",
"img-src": "'self' data: https:",
"connect-src": "'self'",
"frame-ancestors": "'self'",
"base-uri": "'self'",
"form-action": "'self'",
"upgrade-insecure-requests": "",
}
async def dispatch(self, request, call_next):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "SAMEORIGIN"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Strict-Transport-Security"] = (
"max-age=31536000; includeSubDomains"
)
response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
response.headers["Content-Security-Policy"] = self.csp
response.headers["Permissions-Policy"] = self.PERMISSIONS_POLICY
return response
La CSP inclut 'unsafe-inline' et 'unsafe-eval' car Alpine.js en a besoin pour l’évaluation des expressions. L’alternative est le build compatible CSP de Alpine.js, qui comporte des limitations.14 Toutes les autres fonctionnalités sont verrouillées : frame-ancestors empêche le clickjacking, form-action restreint les soumissions de formulaires à la même origine, et upgrade-insecure-requests force HTTPS.
Sécurité du cache CDN avec HTMX
Le middleware d’en-têtes de sécurité ajoute Vary: HX-Request aux réponses 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)
Sans cet en-tête, un CDN pourrait mettre en cache un fragment de réponse HTMX et le servir comme page complète à une requête non-HTMX (ou inversement). L’en-tête Vary indique au CDN de stocker des entrées de cache distinctes en fonction de la valeur de l’en-tête HX-Request.11
Protection CSRF
Les formulaires HTMX utilisent des jetons CSRF stateless signés par 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)
Le jeton est généré dans le template via une variable globale Jinja2 et inclus dans les requêtes de formulaire HTMX :
<form hx-post="/contact" hx-target="#form-result">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- form fields -->
</form>
Les jetons stateless éliminent le stockage de session côté serveur. La signature HMAC garantit que le jeton a été généré par le serveur. L’horodatage empêche les attaques par rejeu. hmac.compare_digest protège contre les attaques temporelles.15
Assainissement HTML
Le contenu généré par les utilisateurs passe par nh3 avant le rendu :
templates.env.filters["sanitize"] = sanitize_html
# In templates: {{ content | sanitize }}
La bibliothèque nh3 supprime les balises et attributs absents de la liste autorisée. Les liens reçoivent automatiquement rel="noopener noreferrer". Cette défense est indépendante de la CSP — elle prévient le XSS stocké au niveau du rendu, tandis que la CSP bloque les scripts injectés au niveau du navigateur. Défense en profondeur.
Validation des entrées
Les modèles Pydantic valident toutes les entrées à la frontière 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 renvoie automatiquement une erreur 422 Unprocessable Entity pour les entrées invalides. Combiné aux requêtes paramétrées de la base de données (SQLAlchemy n’interpole jamais de chaînes), cela prévient l’injection SQL et assure la sécurité des types aux frontières.
Performance
Lighthouse 100/100/100/100
blakecrosley.com obtient un score de 100 dans les quatre catégories Lighthouse : Performance, Accessibility, Best Practices et SEO. Vérifiez par vous-même sur PageSpeed Insights.2
Les optimisations clés :
CSS critique
Le CSS critique (au-dessus de la ligne de flottaison) est extrait et intégré directement dans <head>. La feuille de style complète se charge de manière asynchrone :
<!-- Critical CSS inlined for instant first paint -->
<style>{% include "components/_critical.css" %}</style>
<!-- Full CSS loads async — doesn't block render -->
<link rel="stylesheet" href="/static/css/styles.css"
media="print" onload="this.media='all'">
<noscript>
<link rel="stylesheet" href="/static/css/styles.css">
</noscript>
L’astuce media="print" indique au navigateur que la feuille de style n’est pas nécessaire pour le rendu à l’écran, ce qui évite de bloquer le premier affichage. Le gestionnaire onload bascule vers media="all" après le chargement. Le fallback <noscript> garantit le chargement de la feuille de style même sans JavaScript.16
Compression GZip
app.add_middleware(GZipMiddleware, minimum_size=500)
Les réponses dépassant 500 octets sont compressées. Le HTML se compresse à 70-80 %, réduisant un document de 15 Ko à 3-4 Ko.
Cache immuable des ressources statiques
# 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"
Les ressources statiques avec des URL à empreinte de contenu (?v=a3f8b2c1d0) sont mises en cache pour un an avec immutable. L’empreinte change lorsque le fichier est modifié, forçant les navigateurs et CDN à récupérer la nouvelle version.
Chargement différé des 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>
L’attribut defer télécharge les scripts en parallèle avec l’analyse du HTML, mais les exécute une fois le document analysé. Cela évite le blocage du rendu sans la complexité du chargement asynchrone et de la gestion de l’ordre d’exécution.
Optimisation des images
Les images utilisent le format WebP avec des srcset adaptatifs et des dimensions explicites :
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>
Les attributs explicites width et height préviennent le décalage cumulatif de mise en page (Cumulative Layout Shift, CLS). L’attribut loading="lazy" diffère le chargement des images hors écran. Le WebP offre des fichiers 25-35 % plus légers que le JPEG à qualité équivalente.17
Early Hints
# In main.py
app.state.preload_links = [
f'<{make_asset_url(_asset_map, "css/styles.css")}>; rel=preload; as=style',
]
# In security headers middleware
if "text/html" in content_type:
preload_links = getattr(request.app.state, "preload_links", [])
if preload_links:
response.headers["Link"] = ", ".join(preload_links)
L’en-tête Link avec rel=preload indique à Cloudflare d’envoyer une réponse 103 Early Hints, permettant au navigateur de commencer à récupérer le CSS avant que le serveur ait fini de générer la réponse HTML.18
JavaScript minimal
L’empreinte totale en JavaScript :
| Bibliothèque | Taille (minifiée + gzippée) |
|---|---|
| HTMX | ~14 Ko |
| Alpine.js | ~14 Ko |
| JS spécifique aux pages | 4-8 Ko |
| Total | 32-36 Ko |
Une application React typique embarque 100-300 Ko de JavaScript de framework avant même le code applicatif.19 L’approche sans build embarque moins de JavaScript car il y a tout simplement moins de JavaScript à embarquer.
Déploiement
Railway
blakecrosley.com se déploie sur Railway via un simple 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
Le builder Nixpacks de Railway détecte le projet Python à partir de requirements.txt, installe les dépendances et exécute la commande de démarrage. Aucun Dockerfile n’est nécessaire. Le point de terminaison de vérification de santé s’assure que l’application répond avant de recevoir du trafic :
@app.get("/health")
async def health():
return {"status": "healthy"}
Le pipeline de déploiement
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
Pas de npm install. Pas de npm run build. Pas de compilation webpack. Pas de compilation TypeScript. La seule étape d’installation est pip install -r requirements.txt, qui est mise en cache entre les déploiements.
Procfile
web: uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}
Le Procfile offre une alternative compatible Heroku. Railway prend en charge à la fois railway.toml et Procfile. La syntaxe ${PORT:-8000} utilise le port fourni par la plateforme ou se rabat sur 8000 pour le développement local.
Configuration uvicorn en production
Pour les déploiements à fort trafic, utilisez plusieurs workers :
uvicorn app.main:app \
--host 0.0.0.0 \
--port ${PORT:-8000} \
--workers 4 \
--loop uvloop \
--http httptools
--workers 4lance quatre processus worker (règle générale : 2 × cœurs CPU + 1)--loop uvlooputilise la boucle événementielle uvloop, plus rapide (remplacement direct d’asyncio)--http httptoolsutilise le parseur HTTP httptools, plus performant
Pour le développement, --reload surveille les modifications de fichiers :
uvicorn app.main:app --reload --port 8000
Alternative Docker
Pour les plateformes qui exigent 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"]
L’image de base slim maintient un conteneur léger. --no-cache-dir empêche pip de stocker les paquets téléchargés dans la couche d’image.
CDN Cloudflare
blakecrosley.com utilise Cloudflare pour le cache CDN, le DNS et les 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— le navigateur met en cache pendant 5 minutess-maxage=3600— le CDN met en cache pendant 1 heurestale-while-revalidate=86400— sert le contenu périmé pendant la revalidation sur 24 heures
Les ressources statiques reçoivent max-age=31536000, immutable car les URL à empreinte de contenu garantissent la fraîcheur.
Cadre de décision
Avez-vous besoin d’outils de build ?
Répondez à quatre questions :
1. Plus de cinq développeurs partagent-ils des interfaces JavaScript ? Si oui, la vérification de types à la compilation de TypeScript prévient les bugs d’intégration que les tests à l’exécution détectent trop tard. Ajoutez une étape de build.
2. Votre application gère-t-elle un état côté client complexe ? Si le glisser-déposer, la collaboration en temps réel ou les données offline-first constituent des fonctionnalités essentielles (et non de simples ajouts), un framework comme React ou Svelte justifie sa complexité. Ajoutez une étape de build.
3. Plusieurs produits consomment-ils une bibliothèque de composants partagée ? Si oui, cette bibliothèque nécessite un packaging npm, un versionnement sémantique et du tree shaking. Ajoutez une étape de build.
4. Dépendez-vous de bibliothèques de l’écosystème npm qui présupposent un bundler ? Si Radix, Framer Motion, TanStack Query ou des bibliothèques similaires sont au cœur du produit, un pipeline de build est indispensable.
Si les quatre réponses sont « non », l’approche sans build est viable. Si l’une des réponses est « oui », les outils de build résolvent un vrai problème. L’erreur consiste à ajouter des outils de build quand les quatre réponses sont « non » — résoudre des problèmes inexistants tout en créant une charge de gestion des dépendances superflue.1
Comparaison des stacks
| Catégorie | Sans build (ce guide) | React + outils de build |
|---|---|---|
| Idéal pour | Sites de contenu, portfolios, outils internes, applications CRUD | Produits SaaS, SPA complexes, consommateurs de design systems |
| Taille de l’équipe | 1-5 développeurs | 5-50+ développeurs |
| Gestion de l’état | Serveur (HTMX) + client (Alpine.js) | Client (état React, Redux, Zustand) |
| Sécurité des types | À l’exécution (Pydantic côté serveur) | À la compilation (TypeScript) |
| Réutilisation des composants | Includes et macros Jinja2 | Packages npm, bibliothèques partagées |
| SEO | Rendu serveur par défaut | Nécessite une configuration SSR/SSG |
| Plancher de performance | Élevé (JS minimal, rendu serveur) | Variable (surcoût du framework) |
| Plafond de complexité | Plus bas (pas d’offline, pas d’état client riche) | Plus haut (toute interaction client possible) |
| Dépendances | 15 packages Python | 300+ packages npm |
| Temps de build | 0 seconde | 15-60 secondes |
Quand HTMX n’est pas adapté
HTMX remplace l’état côté client par des allers-retours serveur. Cela fonctionne tant que la latence n’est pas critique :
- Interfaces glisser-déposer — un aller-retour serveur de 200 ms par événement de glissement est inacceptable
- Collaboration en temps réel — un état piloté par WebSocket nécessite une résolution de conflits côté client
- Applications offline-first — pas de serveur signifie pas de HTMX
- Animations complexes liées à l’état — Framer Motion et React Spring reposent sur le modèle de réconciliation React
- Applications Canvas/WebGL — la boucle de rendu est intrinsèquement côté client
Pour ces cas d’usage, un framework côté client est l’outil approprié. L’approche sans build ne prétend pas les remplacer.
Carte de référence rapide
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"
Attributs 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) -->
Attributs 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 -->
Propriétés personnalisées 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; }
}
En-têtes de sécurité
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=()
Liste de vérification pour la mise en place du projet
[ ] 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-il prêt pour la production dans de vraies applications web ?
Oui. HTMX est stable depuis 2020 et utilisé en production dans de nombreux secteurs. Carson Gross, son créateur, considère la rétrocompatibilité comme un principe fondamental — la documentation d’HTMX indique que la bibliothèque ne cassera pas les applications existantes au sein d’une version majeure.20 La bibliothèque pèse 14 Ko minifiée et compressée en gzip, n’a aucune dépendance et suit le versionnage sémantique. blakecrosley.com utilise HTMX en production depuis trois ans sans aucun bogue lié à HTMX.
Peut-on utiliser TypeScript sans étape de build ?
Partiellement. Les fichiers TypeScript peuvent être vérifiés avec tsc --noEmit sans générer de fichiers de sortie, offrant ainsi une vérification au moment de la compilation en guise de linter. Toutefois, les navigateurs ne peuvent pas exécuter directement les fichiers .ts, une étape de build reste donc nécessaire pour servir du TypeScript. L’alternative consiste à utiliser les annotations de type JSDoc dans des fichiers .js standard, que TypeScript peut vérifier sans compilation. Vous obtenez ainsi la sûreté de typage pendant le développement tout en livrant du JavaScript standard.
Comment cette approche se compare-t-elle à Astro ou 11ty ?
Astro et 11ty sont des générateurs de sites statiques qui produisent du HTML pur avec un minimum de JavaScript côté client, mais ils nécessitent une étape de build (Node.js, npm install, une commande de build). L’approche sans build élimine cette étape — le serveur génère le HTML à chaque requête. Le compromis : Astro/11ty produisent des pages statiques plus rapides (aucun calcul côté serveur), tandis que FastAPI + HTMX gère nativement le contenu dynamique (données spécifiques à l’utilisateur, soumissions de formulaires, mises à jour en temps réel) sans couche API séparée.
Qu’en est-il du rendu côté serveur (SSR) avec React ?
Le SSR de Next.js et l’approche FastAPI + HTMX partagent un même objectif : envoyer du HTML rendu côté serveur au navigateur. La différence réside dans ce qui se passe après le rendu initial. Next.js hydrate la page avec React, envoyant le runtime du framework et le code des composants au client. FastAPI + HTMX n’hydrate pas — le HTML est le résultat final. HTMX gère les interactions suivantes en demandant de nouveaux fragments HTML au serveur. Résultat : FastAPI + HTMX envoie 30-40 Ko de JavaScript au total contre 100-300 Ko pour une application Next.js.19
Comment gérer la validation de formulaire avec cette stack ?
Côté serveur. Pydantic valide les données lorsque le formulaire est soumis. Si la validation échoue, le serveur renvoie le formulaire avec les messages d’erreur. HTMX injecte la réponse dans le 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
})
Le serveur valide, le serveur affiche les états d’erreur, et HTMX injecte le résultat. Aucune bibliothèque de validation côté client n’est nécessaire. L’attribut HTML required fournit une validation basique au niveau du navigateur en première ligne de défense.
Peut-on ajouter des fonctionnalités en temps réel (WebSockets) ?
Oui. FastAPI intègre nativement la prise en charge des WebSockets :
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 dispose d’une extension WebSocket (hx-ws) qui connecte les éléments aux points de terminaison WebSocket :
<div hx-ws="connect:/ws/notifications">
<div id="notifications" hx-ws="send"></div>
</div>
Les messages du serveur sont injectés dans le DOM selon les mêmes mécanismes de ciblage et d’insertion que les réponses HTTP. Le serveur envoie des fragments HTML via le WebSocket, et HTMX les insère.
Comment cette stack gère-t-elle le SEO ?
Le HTML rendu côté serveur est intrinsèquement favorable au SEO, car les robots d’indexation reçoivent le contenu complet de la page sans exécuter de JavaScript. blakecrosley.com ajoute plusieurs couches SEO :
- Données structurées JSON-LD dans
<head>pour chaque page (schémas Person, Article, WebSite, FAQPage) - Sitemap dynamique avec alternates hreflang pour les 10 locales
- Flux RSS à
/blog/feed.xml llms.txtà la racine pour la découvrabilité par les robots IA- URL canoniques et balises Open Graph dans le template de base
- HTML sémantique :
<article>,<section>,<main>, hiérarchie correcte des titres
Aucune configuration SSR nécessaire. Pas de getStaticProps. Pas d’ISR. Le HTML est rendu à chaque requête — c’est le comportement par défaut, pas une optimisation.
Quelle est la courbe d’apprentissage par rapport à React ?
Pour les développeurs Python, la courbe d’apprentissage est nettement plus faible. Vous connaissez déjà le langage. Les gestionnaires de routes de FastAPI renvoient des réponses de template — le même modèle mental que les vues Flask ou Django. HTMX ajoute une poignée d’attributs HTML (hx-get, hx-target, hx-swap). Alpine.js en ajoute quelques autres (x-data, x-show, @click). Pas de JSX, pas de DOM virtuel, pas de système de hooks, pas de bibliothèque de gestion d’état, et aucune configuration d’outil de build à apprendre.
La documentation d’HTMX tient sur une seule longue page. Celle d’Alpine.js tient sur quelques pages. La documentation de React s’étend sur des centaines de pages couvrant les hooks, le contexte, les refs, les effets, suspense, les composants serveur et le streaming SSR.
Pour les développeurs JavaScript/React, le changement est conceptuel plutôt que syntaxique. L’idée centrale est que le serveur possède l’état et le serveur effectue le rendu du HTML. La gestion d’état côté client devient la gestion de routes côté serveur. La récupération de données côté client devient des attributs HTMX sur des éléments HTML. La syntaxe est plus simple — le modèle mental exige de désapprendre le postulat SPA selon lequel le client contrôle le rendu.
Journal des modifications
| Date | Modification |
|---|---|
| 24 mars 2026 | Publication initiale |
Références
Ce guide couvre le système complet utilisé pour construire blakecrosley.com. Le No-Build Manifesto présente l’argumentaire philosophique. L’article Lighthouse Perfect Score documente le parcours d’optimisation des performances. L’article Vibe Coding vs. Engineering explore la place du développement assisté par IA dans ce workflow.
-
Métriques de production de blakecrosley.com en date de mars 2026. Le site propose 37 articles de blog, 20 composants JavaScript interactifs, 20 sections de guides et 10 traductions linguistiques avec 15 packages Python et aucun outil de build. Liste complète des dépendances : fastapi, uvicorn, starlette, pydantic, pydantic-settings, jinja2, markdown, pygments, beautifulsoup4, lxml, nh3, resend, python-multipart, httpx, analytics-941. Vérifié depuis
requirements.txt. ↩↩↩ -
Google PageSpeed Insights (pagespeed.web.dev) exécute des audits Lighthouse sur n’importe quelle URL publique. blakecrosley.com obtient 100/100/100/100 (Performance, Accessibilité, Bonnes pratiques, SEO) en date de mars 2026. Les résultats sont vérifiables publiquement. Consultez From 76 to 100: Achieving a Perfect Lighthouse Score pour le parcours complet d’optimisation. ↩↩↩
-
Un
npx create-next-app@latesttout neuf (Next.js 15, testé en février 2026) installe 311 packages dansnode_modules/pour un total de 187 Mo. Les projets en production avec des dépendances supplémentaires tendent vers davantage. Les résultats varient selon les projets. Source : tests de l’auteur, documentés dans The No-Build Manifesto. ↩ -
La documentation de Vercel sur les performances de Next.js recommande des optimisations spécifiques (optimisation des images, chargement des polices, découpage du code) pour atteindre des scores supérieurs à 90. Voir nextjs.org/docs/app/building-your-application/optimizing. La fourchette 70-90 reflète les paramètres par défaut avant l’application de ces optimisations. ↩
-
Liste complète des dépendances vérifiée depuis le
requirements.txtde blakecrosley.com en date de mars 2026. Aucun package n’est un outil de build, un compilateur ou un bundler. ↩ -
Basé sur l’expérience de l’auteur dans la maintenance de projets Next.js (2021-2024), l’écosystème JavaScript génère 15 à 25 PR Dependabot par mois pour les projets actifs, la plupart mettant à jour des dépendances transitives que le développeur n’a jamais importées directement. ↩
-
Tim Berners-Lee a formulé la rétrocompatibilité comme principe de conception du web : « un navigateur devrait être rétrocompatible ». Une page de 1996 s’affiche dans Chrome 2026. Voir w3.org/DesignIssues/Principles. ↩
-
L’OWASP recommande de désactiver les endpoints de documentation API en production afin de réduire la surface d’attaque. L’endpoint
/openapi.jsonexpose l’ensemble des définitions de routes, paramètres et modèles de réponse. ↩ -
Documentation FastAPI sur les handlers async vs sync : fastapi.tiangolo.com/async/. Mélanger
awaitavec des appels bloquants dans des fonctionsasyncaffame la boucle d’événements. ↩ -
nh3 est un outil de nettoyage HTML écrit en Rust, successeur de la bibliothèque Bleach. Il est maintenu par le projet PyO3 et fournit une sanitisation HTML basée sur des listes blanches. Voir github.com/messense/nh3. ↩
-
L’en-tête
Varyest défini dans la RFC 9110, Section 12.5.5. Il indique aux caches de stocker des réponses distinctes en fonction des valeurs d’en-tête de requête spécifiées. SansVary: HX-Request, un CDN pourrait servir un fragment HTMX comme réponse de page complète. Voir httpwg.org/specs/rfc9110.html#field.vary. ↩↩ -
Les propriétés personnalisées CSS (variables CSS) sont prises en charge par plus de 97 % des navigateurs à l’échelle mondiale. Elles cascadent, héritent et répondent aux media queries à l’exécution — des capacités dont les variables de préprocesseur sont dépourvues. Source : caniuse.com/css-variables. ↩
-
Documentation Google sur hreflang : developers.google.com/search/docs/specialty/international/localized-versions. La valeur
x-defaultdésigne la page de repli pour les utilisateurs dont la langue ne figure pas dans la liste hreflang. ↩ -
Alpine.js nécessite
'unsafe-eval'dans la Content Security Policy pour son moteur d’évaluation d’expressions. Le build compatible CSP (@alpinejs/csp) évite cette contrainte mais comporte des limitations. Voir alpinejs.dev/advanced/csp. ↩ -
Les jetons CSRF basés sur HMAC suivent le pattern « Signed Double-Submit Cookie » décrit dans la fiche OWASP de prévention CSRF.
hmac.compare_digestutilise une comparaison en temps constant pour prévenir les attaques par canal auxiliaire temporel. Voir cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html. ↩ -
La technique de chargement asynchrone CSS via
media="print"est documentée par l’équipe web.dev. Le navigateur traite la feuille de style comme non bloquante pour le rendu car elle est déclarée pour le média print. Le handleronloadla bascule vers le médiaallune fois le téléchargement terminé. Voir web.dev/articles/defer-non-critical-css. ↩ -
Le format WebP offre des fichiers 25 à 35 % plus légers que le JPEG à qualité visuelle équivalente. Étude Google sur WebP : developers.google.com/speed/webp/docs/webp_study. ↩
-
Le 103 Early Hints permet au serveur (ou au CDN) d’envoyer une réponse préliminaire contenant des indications de préchargement avant que la réponse finale ne soit prête. Cloudflare prend en charge les Early Hints pour les en-têtes
Linkavecrel=preload. Voir developer.chrome.com/blog/early-hints. ↩ -
React 18 + ReactDOM pèse environ 42 Ko minifié + gzippé. Avec un routeur, une bibliothèque de gestion d’état et le runtime d’un framework de build, les applications React typiques embarquent 100 à 300 Ko de JavaScript de framework. Source : bundlephobia.com/package/[email protected]. ↩↩
-
La politique de versionnement et l’engagement de rétrocompatibilité de HTMX sont documentés sur htmx.org/migration-guide-htmx-1/. Carson Gross a énoncé le principe de rétrocompatibilité dans Hypermedia Systems (2023) par Gross, Stepinski et Cotter : hypermedia.systems. ↩