FastAPI + HTMX : le full-stack sans build
# Créez des applications web de production sans React ni webpack : FastAPI, HTMX, Alpine.js, Jinja2, CSS pur, patterns Bootstrap, i18n, déploiement, SEO et performance.
En bref : FastAPI + HTMX + Alpine.js + Jinja2 + du CSS simple produisent des applications web de production sans aucun outil de build, sans aucun
node_modules/, avec des scores Lighthouse parfaits. Ce guide couvre tout le système, de l’architecture au déploiement, en utilisant blakecrosley.com comme référence en production : 210 articles de blog, des composants JavaScript interactifs, 11 guides fondamentaux, 48 études de design, ainsi que l’anglais et 9 langues traduites, sans le moindre bundler, compilateur ou transpilateur.1
Le stack moderne de développement web part du principe que vous avez besoin de React, webpack, TypeScript et d’un pipeline de build. Pour une grande catégorie d’applications — sites axés sur le contenu, outils internes, applications CRUD, sites portfolio, plateformes de documentation — cette hypothèse est fausse. Le stack décrit dans ce guide élimine toute la chaîne d’outils de build frontend, tout en produisant des sites qui obtiennent 100/100/100/100 sur Lighthouse.2
Ce n’est pas du militantisme. C’est une mesure. L’architecture décrite ici fonctionne en production, sert de vrais utilisateurs dans dix langues, et les chiffres sont vérifiables.
Points clés
- Le rendu HTML côté serveur élimine trois catégories entières de problèmes : la gestion d’état côté client, les frontières de sérialisation JSON et les incohérences d’hydratation. HTMX fait des réponses du serveur la sortie finale — pas d’étape de rendu côté client.
- Zéro outil de build signifie zéro échec de build. Pas de conflits de dépendances entre pairs lors de
npm install, pas d’erreurs du compilateur TypeScript dans des fichiers que vous n’avez pas touchés, pas de 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 qui existe uniquement dans le navigateur relèvent d’Alpine.js. La frontière est claire : si l’état a besoin du serveur, utilisez HTMX. Sinon, utilisez Alpine.js.
- Le CSS pur avec propriétés personnalisées remplace Sass et Tailwind. Les propriétés personnalisées CSS se propagent en cascade, s’héritent et répondent aux media queries à l’exécution. Les variables de préprocesseur se compilent en valeurs statiques et disparaissent. Le navigateur lit directement les propriétés personnalisées — sans étape de compilation.
- Cette approche a des limites claires. Elle est inadaptée aux grandes équipes qui partagent des interfaces de composants, aux produits SaaS dotés d’un état client complexe, et aux applications qui dépendent des bibliothèques de l’écosystème npm. Le cadre de décision de la section 15 identifie précisément cette frontière.
- blakecrosley.com en est la preuve. Les patterns fondamentaux de ce guide (HTMX, Alpine.js, Jinja2, CSS pur) tournent en production sur blakecrosley.com. Les sections Bootstrap et SQLAlchemy couvrent des patterns standards de la stack qui ne sont pas utilisés sur ce site précis. Chaque affirmation s’appuie sur un chemin de fichier, un bloc de configuration ou un audit Lighthouse que vous pouvez vérifier vous-même sur PageSpeed Insights.2
Comment utiliser ce guide
Il s’agit d’une référence exhaustive. Commencez là où votre niveau d’expérience correspond :
| Expérience | Commencez ici | Puis explorez |
|---|---|---|
| Développeur Python, nouveau venu sur HTMX | La thèse no-build → Vue d’ensemble de l’architecture → HTMX en profondeur | Patterns Alpine.js, Sécurité |
| Développeur React/Vue évaluant des alternatives | La thèse no-build → Cadre de décision | Vue d’ensemble de l’architecture, Performance |
| Développeur FastAPI ajoutant de l’interactivité | HTMX en profondeur → Patterns Alpine.js | i18n et localisation, Déploiement |
| Développeur full-stack partant de zéro | Lisez de manière séquentielle à partir de Vue d’ensemble de l’architecture | Carte de référence rapide pour un usage continu |
Utilisez Ctrl+F / Cmd+F pour rechercher des patterns ou des attributs spécifiques. La Carte de référence rapide à la fin fournit un résumé parcourable.
La thèse no-build
La thèse est étroite et précise : pour les sites pilotés par le 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 en créant que vous avez.
Voici les véritables métriques de blakecrosley.com :
| Métrique | blakecrosley.com (no-build) | Projet Next.js typique3 |
|---|---|---|
| Dépendances | 17 paquets Python | 311+ paquets npm |
| Fichiers de configuration de 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 |
Install → build → deploy : 2-5 minutes |
| Performance Lighthouse | 100 | 70-90 sans optimisation explicite4 |
Les 17 paquets Python incluent FastAPI, Jinja2, Pydantic, uvicorn, nh3, et 12 autres. Aucun n’est un outil de build. Aucun n’est un compilateur. Aucun n’est un bundler.5
Ce à quoi vous renoncez
L’honnêteté impose de lister les véritables coûts :
Pas de TypeScript. Chaque fichier .js est du JavaScript vanilla. Les erreurs de type sont attrapées par les tests et l’analyse de code, pas par un compilateur. Cela fonctionne pour un développeur solo. Cela ne fonctionnerait pas 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 d’HTMX rend la navigation suffisamment rapide pour que les rafraîchissements complets soient 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 livré au navigateur. Cette contrainte impose de 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 design tokens issus de npm. Le système de design vit dans les propriétés personnalisées CSS. Il ne peut pas être importé comme paquet dans un autre projet.
Ces compromis sont acceptables pour un site piloté par le 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 de décision.
Ce que vous gagnez
Zéro échec de build. Aucun npm install ne peut échouer à cause de conflits de dépendances entre pairs. Aucun next build ne peut échouer à cause d’une erreur TypeScript dans un fichier que vous n’avez pas touché.6
Debug via View Source. Le JavaScript qui s’exécute dans le navigateur est le JavaScript que vous avez écrit. Pas besoin de source maps.
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és), un fichier CSS (~8 Ko), HTMX (~16 Ko, mis en cache), Alpine.js (~15 Ko, mis en cache) et le JS interactif de la page (~4-8 Ko). Total : environ 55-65 Ko à la première visite.1
Frontend pérenne. Le code côté client utilise HTML, CSS et JavaScript — des standards qui ont maintenu une rétrocompatibilité pendant 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.
Comparaison de stacks
Comment la stack no-build se compare aux alternatives courantes sur des dimensions mesurables :
| Dimension | FastAPI+HTMX (ce guide) | Next.js (React) | Astro | 11ty |
|---|---|---|---|---|
| JS livré au navigateur | 35-40 Ko (HTMX+Alpine+petits scripts de page) | 85-250 Ko+ (runtime React) | 0 Ko par défaut, îlots opt-in | 0 Ko par défaut |
| Étape de build | Aucune | Requise (webpack/turbopack) | Requise (Vite) | Requise (personnalisée) |
| Fichiers de configuration | 0 | 5-8 (next.config, tsconfig, etc.) | 1-3 (astro.config, tsconfig) | 1-2 (.eleventy.js) |
| Pipeline de déploiement | git push (40 s) |
Install+build+deploy (2-5 min) | Install+build+deploy (1-3 min) | Install+build+deploy (1-2 min) |
| Interactivité côté serveur | Native (HTMX) | Routes API + fetch client | Limitée (actions de formulaire) | Aucune (sortie statique) |
| Gestion d’état client | Alpine.js (15 Ko) | State/context/Redux React | Îlots de framework | JS manuel |
| Langage backend | Python | JavaScript/TypeScript | JavaScript/TypeScript | JavaScript |
| Approche i18n | Côté serveur (middleware) | next-intl ou paquet similaire | @astrojs/i18n | Manuelle |
| Performance Lighthouse | 100 (mesurée) | 70-90 typique4 | 95-100 typique | 95-100 typique |
| Idéal pour | Sites de contenu, CRUD, tableaux de bord | SPA complexes, grandes équipes | Sites de contenu, marketing | Blogs statiques, docs |
Astro et 11ty sont les concurrents les plus proches pour les sites de contenu. Tous deux produisent une excellente sortie statique mais nécessitent une étape de build et une chaîne d’outils JavaScript. La stack FastAPI+HTMX échange la performance des sites statiques contre une interactivité côté serveur (filtrage par catégorie, gestion de formulaires, recherche en temps réel) sans ajouter d’étape de build. Si votre site est purement statique sans interactions serveur, Astro ou 11ty seront peut-être le meilleur choix.
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 afficher en fonction du type de requête. Alpine.js gère l’état côté client qui ne touche jamais 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 pur | 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
Cette structure repose sur un principe unique : chaque répertoire contient un seul type d’élément. Les routes se trouvent dans routes/. Les templates dans templates/. Les ressources statiques 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 le résultat 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. Ce 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 de la compilation vers l’exécution, ce qui constitue un véritable compromis. Pour un développeur solo utilisant 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.
FastAPI Patterns
Configuration de l’application
L’application s’initialise dans main.py avec un ordre 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 comptent ici. D’abord, docs_url=None et openapi_url=None désactivent les endpoints automatiques de documentation API. Un site de contenu public n’a pas besoin d’exposer /docs ou /openapi.json sur internet.8 Ensuite, l’ordre des middlewares compte : le journal de sécurité s’exécute en premier (ajouté en dernier) afin de capturer chaque requête, y compris celles rejetées par la limitation de débit. Enfin, GZipMiddleware compresse toutes les réponses de plus de 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 des fragments 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,
})
La distinction est importante pour HTMX. Les routes de pages complètes renvoient des documents qui étendent base.html. Les routes API renvoient des fragments HTML que HTMX remplace dans des éléments DOM existants. Le même moteur de templates Jinja2 rend les deux : pas de couche API séparée.
Injection de dépendances
Le système Depends() de FastAPI fournit une séparation nette 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. Une dépendance get_db peut dépendre de get_current_locale, qui dépend elle-même de la requête. FastAPI résout automatiquement la chaîne.
Paramètres Pydantic
La configuration utilise le 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 remplacent 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 échoue immédiatement plutôt qu’à l’exécution.
Patterns async
Les routes FastAPI sont async par défaut. Pour les opérations liées aux I/O (requêtes de base de données, requêtes HTTP, lectures de fichiers), async évite de bloquer l’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 est désormais le seul chemin de démarrage/arrêt. Starlette a atteint sa première version stable, 1.0, en mars 2026 (1.3.1 au 12 juin) et a supprimé les hooks on_event, on_startup et on_shutdown, longtemps dépréciés : lifespan (ci-dessus) est le seul mécanisme, et @app.route() / @app.websocket_route() ont cédé la place à Route / WebSocketRoute dans la liste routes. FastAPI 0.137.0 (14 juin 2026) fixe Starlette sur la branche 1.x et refactorise les éléments internes de son propre routeur : router.routes n’est plus une liste plate d’objets APIRoute, mais un arbre de nœuds intermédiaires. Traitez-la donc comme un détail interne plutôt que comme quelque chose à parcourir. L’avantage, c’est que les routes ajoutées à un routeur après include_router() sont désormais reflétées en direct, et qu’un sous-routeur peut être inclus avant que ses routes ne soient définies.24 Rien de tout cela ne change les patterns de ce guide : il utilise lifespan et la déclaration de routes standard partout. Toutefois, si vous maintenez des outils qui parcourent router.routes, ou si vous exécutez encore d’anciens gestionnaires @app.on_event, 0.137.0 / Starlette 1.0 introduisent des ruptures. FastAPI 0.137.2 (18 juin 2026) ajoute ensuite iter_route_contexts(), la méthode prise en charge pour énumérer les routes maintenant que router.routes est interne. FastAPI 0.138.0 (20 juin 2026) ajoute ensuite app.frontend("/", directory="dist") / router.frontend(...) pour servir un frontend statique compilé : utile si vous livrez un build SPA séparé, mais orthogonal à l’approche sans build et rendue côté serveur de ce guide (cela monte un dossier dist/ plutôt que de rendre du HTML sur le serveur).25
Les opérations liées au CPU (rendu Markdown, extraction CSS) peuvent utiliser des fonctions synchrones. FastAPI les exécute automatiquement dans un thread pool 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 : si la fonction attend des I/O, rendez-la async. Si elle effectue du travail CPU, laissez-la synchrone. Ne mélangez pas 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 avec 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 de la base. C’est une composition par soustraction plutôt que par construction.
Le global asset()
Les ressources statiques utilisent le 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 lorsque le fichier est modifié, invalidant ainsi le cache CDN. Cela remplace la stratégie de noms de fichiers [contenthash] de webpack par 30 lignes de Python calculées au démarrage.
Include pour les partiels réutilisables
Les composants qui se répètent 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 partiel — un fragment 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 locales). 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 modèles 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 globals
Les globals 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 à la volée. 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 locale 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 locale 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 locale de la requête en cours, sans aucun paramètre de locale 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 embarquent 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 prévient 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ûr. Elle supprime toute balise ou tout attribut absent de la liste autorisée, empêchant ainsi 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 fondamentaux
| 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 dans l’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’un outil. L’intégralité de l’état du quiz réside sur le serveur — aucune gestion d’état côté client :
<!-- _quiz_container.html — chargement initial -->
<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 — chaque 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 sous forme de paramètre de requête. Le serveur calcule la question suivante ou le résultat final en fonction 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 via des swaps outerHTML : chaque réponse remplace l’intégralité de l’élément d’étape du quiz.
Pattern 2 : Liste d’articles 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 travaillent 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 temporisation
<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 la touchechangedne se déclenche que si la valeur a réellement changé (évite les requêtes dupliquées dues aux touches de modification)delay:300msapplique une temporisation — 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 temporisation. Aucun useEffect. Le template rend les résultats, HTMX les injecte, et le serveur reste l’unique source de vérité.
Pattern 4 : Swaps hors bande (OOB)
Parfois, une seule action serveur doit mettre à jour plusieurs éléments du DOM. Le mécanisme de swap hors bande de HTMX gère cela 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 trouver 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 React « remonter l’état » — le serveur calcule tous les états dérivés et envoie le HTML final pour chaque élément en une seule réponse.
Un formulaire de contact illustre bien ce mécanisme : la soumission du formulaire pourrait remplacer le corps du formulaire par un message de succès tout en mettant 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", cliquer 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 ré-analyser le CSS, de réévaluer les scripts ni de recalculer la mise en page. Seul le contenu du <body> change. Les liens boostés conviennent parfaitement aux éléments de navigation principale, ce qui confère aux transitions de page l’apparence 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’utilisation |
|---|---|---|
HX-Request |
true |
Détecter les requêtes HTMX côté serveur |
HX-Target |
ID de l’élément | Savoir quel élément recevra la réponse |
HX-Trigger |
ID de l’élément | Savoir quel élément 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 de page complet renvoie le document entier (template de base + contenu de la 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 se charge, 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 8 : É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. Le style se gère en 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). CSS gère la visibilité, HTMX gère le basculement de classe.
Patterns Alpine.js
Alpine.js comble le vide laissé par HTMX : l’état côté client qui n’a jamais besoin de communiquer avec le serveur. Lorsqu’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é de modale |
| 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 de l’en-tête 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 clés :
x-datadéclare la portée du composant et l’état initialx-showbascule la visibilité selon 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 menu déroulant
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 menu déroulant 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 plutôt que du JavaScript natif
Alpine.js est approprié lorsque :
- L’état est limité à un seul élément DOM (menu déroulant, 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é
Le JavaScript natif est approprié lorsque :
- 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 du rendu canvas et des machines à états complexes.
Exemple de bout en bout : filtrage par catégorie sur /writing
Cette section retrace une fonctionnalité réelle du code de production à travers chaque couche : route, template, interaction HTMX, sécurité, mise en cache et rendu final. La fonctionnalité : des onglets de catégories sur la page d’écriture qui filtrent les articles de blog sans rechargement complet de la page.
La route (app/routes/pages.py:508)
async def writing_listing(request: Request, page: int = 1, category: str | None = None):
"""Writing page — blog posts and external publications."""
templates = get_templates(request)
markdown_posts = load_all_posts(published_only=True)
all_posts = CUSTOM_BLOG_POSTS + markdown_posts
# Filter by category if specified
if category and category in CATEGORY_MAP:
display_name = CATEGORY_MAP[category]
all_posts = [
p for p in all_posts
if _get_post_category(p).lower() == display_name.lower()
]
# Pagination
total_pages = max(1, (len(all_posts) + POSTS_PER_PAGE - 1) // POSTS_PER_PAGE)
page = max(1, min(page, total_pages))
paginated = all_posts[(page - 1) * POSTS_PER_PAGE : page * POSTS_PER_PAGE]
template_context = {
"request": request,
"posts": paginated,
"categories": categories,
"current_category": category,
"current_page": page,
"total_pages": total_pages,
# ... SEO: canonical, prev/next URLs
}
# HTMX partial: return just the post list fragment
if request.headers.get("HX-Request"):
return templates.TemplateResponse(
"pages/writing/_post_list.html",
template_context,
)
# Full page for direct navigation
return templates.TemplateResponse(
"pages/writing/index.html",
template_context,
)
La vérification de l’en-tête HX-Request constitue le pattern central : même route, mêmes données, template différent. HTMX reçoit un fragment. Les navigateurs reçoivent la page complète.
Les onglets de catégorie (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>
Chaque onglet possède à la fois un href (fonctionne sans JavaScript) et un hx-get (remplace uniquement la liste d’articles). hx-push-url met à jour l’URL du navigateur afin que la vue filtrée soit partageable et mémorisable dans les favoris.
Le partial (pages/writing/_post_list.html)
Le partial s’affiche de manière identique, qu’il soit inclus au chargement de la page ou injecté par 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 %}
Aucun balisage HTMX spécifique dans le partial. Aucune logique de rendu côté client. Le même HTML fonctionne pour le chargement initial de la page et pour chaque filtre ultérieur.
Sécurité
Les valeurs de catégorie sont validées par rapport à CATEGORY_MAP (un dictionnaire côté serveur) avant le filtrage. Les catégories invalides sont ignorées, jamais renvoyées en écho. Aucune entrée utilisateur n’est interpolée dans du SQL ou du HTML. L’en-tête CSP bloque les scripts en ligne.
Mise en cache
Les réponses de catégorie sont dynamiques (pas de cache CDN). En revanche, les ressources statiques (CSS, HTMX, Alpine.js) sont hashées par contenu et mises en cache indéfiniment après le premier chargement. Les changements de catégorie suivants ne transfèrent que le partial HTML (~3-5 Ko) — ni CSS, ni JS, ni images ne sont re-téléchargés.
Ce que cela démontre
Une fonctionnalité, du vrai code de production, zéro outil de build. Le serveur filtre et génère le HTML. HTMX remplace la liste d’articles. Alpine.js n’intervient pas (aucun état client nécessaire). L’URL se met à jour pour le partage. Amélioration progressive : les onglets fonctionnent comme de simples liens sans JavaScript. Total de JavaScript personnalisé pour cette fonctionnalité : zéro ligne.
Extensions optionnelles
Les sections suivantes couvrent des patterns qui complètent la stack principale mais ne sont pas utilisés sur blakecrosley.com. Ils sont inclus car ils représentent les ajouts les plus courants que les équipes adoptent avec cette architecture.
Bootstrap 5 sans Sass
Remarque : blakecrosley.com utilise du CSS brut avec des propriétés personnalisées — pas de Bootstrap. Cette section présente Bootstrap 5 comme option pour les équipes souhaitant un framework utilitaire sans étape de build. Le CSS compilé de Bootstrap peut être chargé depuis un CDN ou intégré à votre feuille de styles. Les patterns ci-dessous sont génériques et fonctionnent avec l’approche HTMX + Alpine.js décrite dans les sections précédentes.
Bootstrap 5 a abandonné jQuery comme dépendance et prend en charge l’utilisation autonome du CSS. Ni Sass, ni PostCSS, ni aucun outil de build ne sont nécessaires 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 URLs à 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 de grille responsive. Pour des breakpoints personnalisés au-delà des valeurs par défaut de Bootstrap, écrivez de simples media queries en CSS.
Surcharges en CSS brut
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, elles, se compilent en valeurs statiques et disparaissent. Cette distinction est cruciale pour la gestion des thèmes : modifier une seule propriété personnalisée peut mettre à jour chaque valeur dérivée sans recompilation.12
Classes utilitaires vs. CSS de composants
Utilisez les classes utilitaires Bootstrap pour les espacements et mises en page ponctuels. Utilisez le CSS de composants 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 composants pour une même responsabilité.
i18n et localisation
blakecrosley.com propose du contenu en 10 langues : anglais, japonais, coréen, chinois simplifié, chinois traditionnel, allemand, français, espagnol, polonais et portugais (brésilien).
Routage de locale basé sur l’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 depuis le 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 aussi bien l’anglais (/about) que le japonais (/ja/about), car 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 manquante. 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 hreflang pour afficher la bonne version linguistique dans les résultats. L’entrée x-default pointe vers la version anglaise comme solution de repli.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 via le gestionnaire 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)
Le cache mémoire évite les requêtes à la 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 sacrifie la fraîcheur au profit de la performance — les traductions changent rarement, tandis que les rendus de page surviennent à chaque requête.
Surveillance de la santé
blakecrosley.com inclut un endpoint de vérification de santé i18n qui surveille la couverture de traduction 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 % détecte 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 lorsque la couverture diminue — par exemple, après l’ajout de nouvelles chaînes d’interface qui n’ont pas encore été traduites.
Rendu de contenu adapté à 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 : privilégier le contenu traduit, puis se rabattre sur l’anglais. Cela permet 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 de locale
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 substituant les chaînes spécifiques à la 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 de 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.
Patterns de base de données
Note : blakecrosley.com utilise Cloudflare D1 (SQLite serverless) via HTTP pour toutes les données persistantes, et non SQLAlchemy. Cette section couvre le pattern async SQLAlchemy standard pour les projets FastAPI qui nécessitent une base de données relationnelle — la configuration de production la plus courante pour cette stack.
SQLAlchemy 2.0 Async
Pour les applications qui nécessitent une base de données relationnelle, la prise en charge async de SQLAlchemy 2.0 s’intègre proprement 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
Note d’installation (SQLAlchemy 2.0.50+) : depuis la version 2.0.50, la dépendance greenlet de la stack async ne s’installe plus par défaut. Utilisez l’extra asyncio pour qu’elle soit incluse, sans quoi le premier await exécuté sur l’engine échouera avec une erreur indiquant l’absence de greenlet :23
pip install "sqlalchemy[asyncio]" aiosqlite
SQLAlchemy 2.0.50 exige également Python 3.10+ (3.7–3.9 ne sont plus prises en charge) et ajoute des wheels free-threaded (3.13t).23
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(request: Request, user_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(404, "User not found")
return templates.TemplateResponse("pages/user.html", {
"request": request, "user": user
})
La dépendance get_db gère le cycle de vie de la session : elle ouvre une session, la fournit au gestionnaire de route, commit 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îne.
Intégration 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(request: Request, form: ContactForm):
# form.name, form.email, form.message are validated
await send_email(form)
return templates.TemplateResponse("components/_contact_success.html", {
"request": request
})
Pydantic valide les types, les formats (e-mail, URL) et les contraintes (longueur minimale/maximale) avant l’exécution du gestionnaire de route. Une entrée invalide renvoie automatiquement une réponse 422. Cela remplace les bibliothèques de validation de formulaire côté client : le serveur valide, puis HTMX insère soit le message de succès, soit le retour d’erreur.
Migrations avec Alembic
Alembic gère les changements de 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é autogenerate compare les modèles SQLAlchemy au schéma actuel de la base de données et génère des scripts de migration. Ces scripts sont des fichiers Python versionnés qui vivent 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 pendant le déploiement (avant le démarrage de l’application). Cela garantit que le schéma de 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), donc les migrations Alembic s’appliquent à la base de données SQLite locale ou PostgreSQL utilisée pour les données de session et l’analytics.
Le pattern Cloudflare D1
blakecrosley.com utilise Cloudflare D1 comme base de données distante accessible via un 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"]
Ce pattern 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 à l’edge de Cloudflare, accessible via HTTP. Le proxy Worker gère l’authentification et la limitation de débit. Le compromis porte sur la latence : chaque requête est une requête HTTP (~50-100ms), contre une connexion à une base de données locale (~1-5ms). Le cache en mémoire au démarrage atténue cet effet pour les charges de travail à forte lecture, 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 limites.14 Toutes les autres fonctionnalités sont verrouillées : frame-ancestors empêche le clickjacking, form-action limite les envois 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 une réponse de fragment HTMX et la servir comme page complète à une requête non HTMX (ou l’inverse). L’en-tête Vary indique au CDN de stocker des entrées de cache distinctes selon la valeur de l’en-tête HX-Request.11
Protection CSRF
Les formulaires HTMX utilisent des tokens CSRF sans état, 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 token est généré dans le template via une 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 tokens sans état éliminent le stockage de session côté serveur. La signature HMAC garantit que le token a été généré par le serveur. L’horodatage empêche les attaques par rejeu. hmac.compare_digest empêche les attaques temporelles.15
Nettoyage de 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 l’allowlist. Les liens reçoivent automatiquement rel="noopener noreferrer". Cette défense est indépendante de la CSP : elle empêche le XSS stocké au niveau du rendu, tandis que la CSP empêche 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 422 Unprocessable Entity pour les entrées invalides. Associé aux requêtes de base de données paramétrées (SQLAlchemy n’interpole jamais de chaînes), cela empêche l’injection SQL et garantit la sécurité des types aux frontières.
Performance
Lighthouse 100/100/100/100
blakecrosley.com obtient 100 dans les quatre catégories Lighthouse : Performance, Accessibilité, Bonnes pratiques et SEO. Vérifiez avec PageSpeed Insights.2
Les optimisations clés :
Stratégie de chargement de CSS
blakecrosley.com charge CSS avec une seule balise <link> et des URL avec hash de contenu pour une mise en cache immuable :
<link rel="stylesheet" href="{{ asset('css/styles.css') }}">
Le helper asset() ajoute un hash de contenu (?v=a3b2c1d4) afin que le navigateur mette le fichier en cache indéfiniment, jusqu’à ce que le contenu change. Pas d’extraction critique de CSS, pas d’astuce print-media, pas de chargement basé sur JavaScript. Le fichier CSS fait environ 8 Ko gzipé : assez petit pour que l’approche à requête unique obtienne 100 en Performance Lighthouse sans gymnastique d’optimisation.
Compression GZip
app.add_middleware(GZipMiddleware, minimum_size=500)
Les réponses de plus de 500 octets sont compressées. HTML se compresse de 70 à 80 %, réduisant un document de 15 Ko à 3-4 Ko.
Mise en 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 à hash de contenu (?v=a3f8b2c1d0) sont mises en cache pendant un an avec immutable. Le hash change quand le fichier change, ce qui force les navigateurs et les 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 de l’analyse de HTML, mais les exécute après l’analyse du document. Cela évite de bloquer le rendu sans la complexité du chargement async et de la gestion de l’ordre d’exécution.
Optimisation des images
Les images utilisent WebP avec un srcset responsive 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 empêchent le Cumulative Layout Shift (CLS). L’attribut loading="lazy" diffère les images hors écran. WebP fournit des fichiers 25 à 35 % plus petits que JPEG à qualité équivalente.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)
L’en-tête Link avec rel=preload indique à Cloudflare d’envoyer une réponse 103 Early Hints, ce qui permet au navigateur de commencer à récupérer CSS avant que le serveur ait fini de générer la réponse HTML.17
JavaScript minimal
L’empreinte totale de JavaScript :
| Bibliothèque | Taille (minifiée + gzipée) |
|---|---|
| HTMX | ~16 KB |
| Alpine.js | ~15 KB |
| JS spécifique à la page | 4-8 KB |
| Total | 35-39 KB |
Une application React typique livre 100 à 300 Ko de JavaScript de framework avant le code applicatif.18 L’approche sans build livre moins de JavaScript, parce qu’il y a moins de JavaScript à livrer.
Déploiement
Railway
blakecrosley.com est déployé sur 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
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 fichier Docker n’est requis. L’endpoint de health check garantit 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, mise en cache entre les déploiements.
Procfile
web: uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}
Le Procfile fournit une alternative compatible Heroku. Railway prend en charge railway.toml comme Procfile. La syntaxe ${PORT:-8000} utilise le port fourni par la plateforme ou 8000 par défaut pour le développement local.
Configuration de production Uvicorn
Pour les déploiements à trafic plus élevé, 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 d’événements uvloop, plus rapide (remplacement direct d’asyncio)--http httptoolsutilise le parseur HTTP httptools, plus rapide
En développement, --reload surveille les changements 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 garde le conteneur léger. --no-cache-dir empêche pip de stocker les paquets téléchargés dans la couche de l’image.
CDN Cloudflare
blakecrosley.com utilise Cloudflare pour la mise en 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— cache navigateur pendant 5 minutess-maxage=3600— cache CDN pendant 1 heurestale-while-revalidate=86400— servir du contenu obsolète pendant la revalidation pendant 24 heures
Les ressources statiques reçoivent max-age=31536000, immutable, car les URL avec hash 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 évite des 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 drag-and-drop, la collaboration en temps réel ou les données offline-first sont des fonctionnalités centrales (et non de simples plus), 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 a besoin d’un packaging npm, d’un versionnage sémantique et de tree shaking. Ajoutez une étape de build.
4. Dépendez-vous de bibliothèques de l’écosystème npm qui 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 obligatoire.
Si les quatre réponses sont « non », l’approche sans build est viable. Si une réponse est « oui », les outils de build résolvent un vrai problème. L’erreur consiste à ajouter des outils de build lorsque les quatre réponses sont « non » : résoudre des problèmes que vous n’avez pas tout en créant une charge de gestion des dépendances inutile.1
Comparaison des stacks
| Catégorie | Sans build (ce guide) | React + outils de build |
|---|---|---|
| Idéal pour | Sites de contenu, portfolios, outils internes, apps CRUD | Produits SaaS, SPA complexes, consommateurs de design system |
| 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é de type | À l’exécution (Pydantic côté serveur) | À la compilation (TypeScript) |
| Réutilisation des composants | Includes + macros Jinja2 | Paquets npm, bibliothèques partagées |
| SEO | Rendu serveur par défaut | Nécessite une configuration SSR/SSG |
| Niveau de performance minimal | Élevé (JS minimal, rendu serveur) | Variable (surcoût du framework) |
| Plafond de complexité | Plus bas (pas d’offline, pas d’état client riche) | Plus élevé (toute interaction client possible) |
| Dépendances | 17 paquets Python | 300+ paquets npm |
| Temps de build | 0 seconde | 15-60 secondes |
Quand HTMX n’est pas adapté
HTMX remplace l’état client par des allers-retours serveur. Cela fonctionne jusqu’à ce que la latence compte :
- Interfaces drag-and-drop — un aller-retour serveur de 200 ms par événement de glisser est inacceptable
- Collaboration en temps réel — l’état piloté par WebSocket exige une résolution des 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 supposent un 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 le bon outil. L’approche sans build ne tente pas de les remplacer.
Fiche 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=()
Checklist de configuration 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 plusieurs secteurs. Carson Gross, son créateur, maintient la compatibilité ascendante comme principe de conception central : la documentation de HTMX indique que la bibliothèque ne cassera pas les applications existantes au sein d’une version majeure.19 La bibliothèque pèse environ 16 Ko minifiée et gzippée, n’a aucune dépendance et suit le versionnement sémantique. blakecrosley.com utilise HTMX en production depuis trois ans sans aucun bug lié à HTMX.20
Puis-je 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, ce qui fournit une vérification à la compilation comme un linter. Toutefois, les navigateurs ne peuvent pas exécuter directement les fichiers .ts, donc une étape de build reste nécessaire pour servir TypeScript. L’alternative consiste à utiliser des annotations de type JSDoc dans des fichiers .js simples, que TypeScript peut vérifier sans compilation. Cela apporte une sécurité 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 simple 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 supprime cette étape : le serveur rend le HTML à chaque requête. Le compromis : Astro/11ty produisent des pages statiques plus rapides (aucun calcul serveur), tandis que FastAPI + HTMX gère nativement le contenu dynamique (données propres à l’utilisateur, envois 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 ?
Next.js SSR et l’approche FastAPI + HTMX partagent un objectif : envoyer au navigateur du HTML rendu côté serveur. La différence se situe après le rendu initial. Next.js hydrate la page avec React, en envoyant au client le runtime du framework et le code des composants. FastAPI + HTMX n’hydrate pas : le HTML est la sortie finale. HTMX gère les interactions suivantes en demandant de nouveaux fragments HTML au serveur. Résultat : FastAPI + HTMX envoie environ 35 à 40 Ko de JavaScript au total, contre 100 à 300 Ko pour une application Next.js.18
Comment gérer la validation de formulaires avec cette stack ?
Côté serveur. Pydantic valide les données lorsque le formulaire est envoyé. Si la validation échoue, le serveur renvoie le formulaire avec des messages d’erreur. HTMX remplace 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 rend les états d’erreur, et HTMX remplace le résultat. Aucune bibliothèque de validation côté client n’est nécessaire. L’attribut HTML required fournit une validation de base au niveau du navigateur comme première ligne de défense.
Puis-je ajouter des fonctionnalités en temps réel (WebSockets) ?
Oui. FastAPI intègre la prise en charge de 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 dispose d’une extension WebSocket (hx-ws) qui connecte des éléments à des endpoints WebSocket :
<!-- HTMX 2.x WebSocket extension syntax -->
<div hx-ext="ws" ws-connect="/ws/notifications">
<div id="notifications" ws-send></div>
</div>
Remarque : HTMX 1.x utilisait la syntaxe
hx-ws="connect:...". HTMX 2.x a déplacé la prise en charge de WebSocket vers une extension séparée (htmx-ext-ws) avec les attributsws-connectetws-sendindiqués ci-dessus. Si vous utilisez HTMX 1.x, l’ancienne syntaxehx-wsfonctionne toujours.Branche bêta HTMX 4.0 : htmx 4.0.0-beta4 est désormais sur le tag npm
nextet dans la documentation 4.0, tandis que le guide de démarrage rapide de htmx.org et le tag npmlatestrestent sur 2.0.10. Ce guide cible toujours HTMX 2.x, qui reste la version recommandée pour le travail en production jusqu’à la stabilisation de 4.0 ; la migration 2.x -> 4.x est un saut générationnel, pas une version corrective 2.x. Le modèle de versionnement de big-skies-software saute les versions majeures impaires, donc 4.0 est l’étape suivante après 2.x.2122À suivre dans la documentation 4.0. Deux ajouts méritent particulièrement une revue sécurité et architecture avant la GA de 4.0 : la nouvelle extension
hx-liveintroduit des expressions réactives au DOM qui sont réévaluées lorsque l’état référencé change, et la nouvelle extensionhx-nonceconditionne le traitement des attributs htmx à des nonces CSP. Le guide de migration 4.0 déplace également plusieurs concepts de configuration, restaure ou modifie certains comportements liés aux événements et à l’historique, et retire du cœur certains helpers JavaScript. Traitez 4.0 comme un projet de migration, pas comme un patch 2.x prêt à déposer.21
Les messages du serveur sont remplacés dans le DOM avec les mêmes mécanismes de ciblage et de swap 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 crawlers 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 alternatives hreflang pour les 10 locales
- Flux RSS sur
/blog/feed.xml llms.txtà la racine pour la découvrabilité par les crawlers IA- URL canoniques et balises Open Graph dans le template de base
- HTML sémantique :
<article>,<section>,<main>, hiérarchie de titres correcte
Aucune configuration SSR n’est 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). Il n’y a 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 de HTMX tient sur une seule longue page. Celle de Alpine.js tient sur quelques pages. La documentation de React couvre des centaines de pages sur les hooks, le contexte, les refs, les effets, suspense, les composants serveur et le SSR en streaming.
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 que le serveur rend le HTML. La gestion de l’état côté client devient une 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 l’hypothèse SPA selon laquelle le client possède le rendu.
Journal des modifications
| Date | Modification |
|---|---|
| 2026-06-22 | FastAPI 0.138.0 + 0.137.2. 0.138.0 (20 juin) ajoute app.frontend("/", directory="dist") / router.frontend(...) pour servir un frontend statique compilé (sortie SPA dist/) — orthogonal à la thèse de ce guide, à savoir un rendu côté serveur sans build, et signalé comme contraste dans la section Async Patterns. 0.137.2 (18 juin) ajoute iter_route_contexts() comme méthode prise en charge pour énumérer les routes maintenant que router.routes est interne (depuis 0.137.0). Les deux versions ajoutent des fonctionnalités, sans 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) restent inchangés. |
| 2026-06-16 | FastAPI 0.137.0/0.137.1 + Starlette 1.0→1.3.1. FastAPI 0.137.0 (14 juin) remanie les internes du routeur : router.routes est désormais un arbre interne, et non plus une liste plate de APIRoute (breaking pour tout code qui l’itère), tout en permettant les routes ajoutées après include_router() et les nouveaux hooks APIRouter.matches()/.handle() ; 0.137.1 (15 juin) corrige le typage de APIRoute et les routeurs sans préfixe avec chemin vide. Starlette a livré sa première version stable 1.0 (22 mars) et en est maintenant à 1.3.1 (12 juin), supprimant les hooks dépréciés on_event/on_startup/on_shutdown et les décorateurs @app.route()/@app.websocket_route() — lifespan et Route/WebSocketRoute sont les seules voies possibles ; FastAPI 0.137.0 épingle Starlette 1.3.1. Ajout d’une note sur lifespan/router à la section Async Patterns. SQLAlchemy 2.0.51 (15 juin) ne contient que des corrections de bugs. |
| 2026-06-08 | Changement d’installation async dans SQLAlchemy 2.0.50. Depuis SQLAlchemy 2.0.50, la dépendance greenlet de la stack async ne s’installe plus par défaut — installez l’extra sqlalchemy[asyncio] (sinon le premier await contre le moteur échoue avec une erreur indiquant l’absence de greenlet). 2.0.50 exige également Python 3.10+ (3.7 à 3.9 abandonnés) et ajoute des wheels free-threaded 3.13t. Ajout d’une note d’installation à la section SQLAlchemy 2.0 Async. Aucun changement dans le corps pour le reste de la stack : la dernière version de FastAPI reste 0.136.3 (2026-05-23, aucune version de juin), htmx stable reste 2.0.10 (4.0.0-beta4 « The Fetchening » est en beta avec une cible stable vers début 2027, ce n’est pas encore une recommandation de production), Alpine.js 3.15.12, Bootstrap 5.3.x inchangé. Recommandation de production inchangée : HTMX 2.x jusqu’à la stabilisation de 4.0.23 |
| 2026-05-24 | Contrôle de maintenance : l’inventaire local du contenu affiche toujours 210 articles de blog, 11 guides principaux, 48 études de design et 10 locales prises en charge, dont l’anglais. La dernière version de FastAPI est 0.136.3 (2026-05-23) ; le seul remaniement côté application signalé dans les notes de version concerne une gestion plus stricte des en-têtes avec underscores lorsque convert_underscores=True, et 0.136.2 valide les champs Server-Sent Event pour éviter les données d’événement cassées. htmx stable reste 2.0.10 tandis que npm next et la documentation 4.0 pointent désormais vers 4.0.0-beta4 ; la dernière version de SQLAlchemy 2.0 est 2.0.50 ; la dernière version de Pydantic reste 2.13.4. La recommandation de production reste inchangée : utilisez HTMX 2.x jusqu’à la stabilisation de 4.0.122 |
| 2026-05-18 | Actualisation de l’inventaire du site : l’inventaire local du contenu affiche désormais 210 articles de blog, 11 guides principaux, 48 études de design et 10 locales prises en charge, dont l’anglais. La dernière version de FastAPI reste 0.136.1 ; htmx stable reste 2.0.10 avec npm next sur 4.0.0-beta3 ; la dernière version npm de Alpine.js reste 3.15.12. La recommandation de production reste inchangée : utilisez HTMX 2.x jusqu’à la stabilisation de 4.0.12021 |
| 2026-05-15 | Contrôle de maintenance : la dernière version de FastAPI reste 0.136.1 ; cet environnement de site local importe FastAPI 0.128.0 et Starlette 0.50.0 ; htmx stable reste 2.0.10 et npm next est maintenant 4.0.0-beta3 ; la dernière version npm de Alpine.js est 3.15.12 ; la dernière version de Bootstrap est 5.3.8 ; la dernière version de SQLAlchemy 2.0 est 2.0.49 ; la dernière version de Pydantic est 2.13.4. Recommandation de production inchangée : utilisez HTMX 2.x jusqu’à la stabilisation de 4.0.2021 |
| 2026-05-09 | Suivi de htmx 4.0.0-beta3 (8 mai 2026) : htmx 4.0.0-beta3 est disponible sur le tag npm next et dans la documentation 4.0, tandis que npm latest reste 2.0.10. Points importants à suivre avant la GA : nouvelle extension hx-live (expressions réactives au DOM), nouvelle extension hx-nonce (protection CSP par nonce pour les attributs htmx) et changements du guide de migration concernant la configuration, l’historique, les événements et les helpers JavaScript du cœur. Recommandation de production inchangée : htmx 2.x reste le dernier tag npm et la version recommandée jusqu’à la GA de 4.0.21 |
| 2026-05-07 | Contrôle de maintenance : la dernière version de FastAPI reste 0.136.1 ; htmx stable est 2.0.10 et v4 reste en beta avec une cible à l’été 2026 ; la dernière version npm de Alpine.js est 3.15.12 ; la dernière version de Bootstrap est 5.3.8 ; la dernière version de SQLAlchemy 2.0 est 2.0.49 ; la dernière version de Pydantic est 2.13.4. Les métriques locales du site ont été actualisées à 182 articles de blog, 11 guides, dix locales prises en charge et 17 exigences Python. Consignes de migration inchangées : utilisez HTMX 2.x en production jusqu’à la stabilisation de 4.0.20 |
| 2026-04-25 | FastAPI 0.136.1 (23 avril 2026) : nettoyage des dépréciations Pydantic v2 (aucun changement de comportement pour le code applicatif). Chronologie HTMX 4.0 suivie : htmx 4.0.0-beta1 (6 avr.) et 4.0.0-beta2 (14 avr.) ont été livrés. Consignes de migration inchangées — htmx 2.x reste sur le tag npm latest jusqu’à la stabilisation de 4.0 ; les correctifs de sécurité continuent, aucune pression de mise à niveau. Principaux changements 4.0 à prendre en compte dès maintenant dans la conception : (1) fetch() remplace XMLHttpRequest comme infrastructure ajax centrale, (2) l’héritage des attributs devient explicite par défaut, (3) la prise en charge de l’historique émet une requête réseau pour restaurer le contenu (pas de snapshot DOM local). FastAPI 0.135.4 (16 avril) a supprimé le décorateur de poisson d’avril @app.vibe() arrivé dans 0.135.3. |
| 2026-04-16 | Ajout d’une sensibilisation à HTMX 4.0-beta (référence anticipée). Mention de la prise en charge par FastAPI 0.136.0 des builds free-threaded Python 3.14t. Fonctionnalités Pydantic 2.13.x (factories par défaut pour attributs privés avec accès aux données de modèle validées, espace de noms pydantic.v1 vers 1.10.26 avec prise en charge de 3.14). Correctifs Alpine.js 3.15.11 : modificateur x-anchor.noflip, avertissement x-for pour plusieurs éléments racines, correction d’une régression morph de $refs. |
| 2026-03-24 | Publication initiale |
Références
Ce guide couvre le système complet utilisé pour créer blakecrosley.com. Le No-Build Manifesto fournit l’argument philosophique. L’article Score Lighthouse parfait 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 au 18 mai 2026. Le site compte 210 articles de blog, des composants JavaScript interactifs, 11 guides principaux, 48 études de design, l’anglais plus 9 locales traduites, des dépendances Python minimales et aucun outil de build. Vérifié à partir de l’inventaire local du contenu, de
app/i18n/config.pyet derequirements.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 mars 2026. Les résultats sont vérifiables publiquement. Consultez De 76 à 100 : obtenir un score Lighthouse parfait pour le parcours complet d’optimisation. ↩↩↩
-
Un
npx create-next-app@latestfrais (Next.js 15, testé en février 2026) installe 311 packages dansnode_modules/, pour un total de 187 Mo. Les projets de production avec des dépendances supplémentaires ont tendance à être plus volumineux. Les projets individuels varient. Source : tests de l’auteur, documentés dans The No-Build Manifesto. ↩ -
La documentation de performance Next.js de Vercel recommande des optimisations spécifiques (optimisation des images, chargement des polices, découpage du code) pour atteindre des scores supérieurs à 90. Consultez nextjs.org/docs/app/building-your-application/optimizing. La plage 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 à partir du
requirements.txtde blakecrosley.com en mai 2026. Le fichier contient actuellement 17 entrées d’exigences Python et aucun outil de build, compilateur ni bundler. ↩ -
D’après 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 : « a browser should be backwards-compatible. » Une page de 1996 s’affiche dans Chrome 2026. Consultez w3.org/DesignIssues/Principles. ↩
-
OWASP recommande de désactiver les endpoints de documentation API en production afin de réduire la surface d’attaque. L’endpoint
/openapi.jsonexpose toutes les définitions de routes, les paramètres et les modèles de réponse. ↩ -
Documentation FastAPI sur les gestionnaires async et sync : fastapi.tiangolo.com/async/. Mélanger
awaitavec des appels bloquants dans des fonctionsasyncaffame la boucle d’événements. ↩ -
nh3 est un assainisseur HTML basé sur Rust, successeur de la bibliothèque Bleach. Il est maintenu par le projet PyO3 et fournit un assainissement HTML fondé sur une liste d’autorisation. Consultez github.com/messense/nh3. ↩
-
L’en-tête
Varyest défini dans la section 12.5.5 de la RFC 9110. Il indique aux caches de stocker des réponses séparées selon les 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. Consultez 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 mondiaux. Elles se propagent en cascade, héritent et réagissent aux media queries à l’exécution — des capacités absentes des variables de préprocesseur. Source : caniuse.com/css-variables. ↩
-
Documentation hreflang de Google : 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 exigence, mais comporte des limitations. Consultez alpinejs.dev/advanced/csp. ↩ -
Les tokens CSRF fondés sur HMAC suivent le modèle « Signed Double-Submit Cookie » décrit dans l’OWASP CSRF Prevention Cheat Sheet.
hmac.compare_digestutilise une comparaison en temps constant pour prévenir les attaques par canal auxiliaire temporel. Consultez cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html. ↩ -
WebP produit des fichiers 25 à 35 % plus petits que JPEG à qualité visuelle équivalente. Étude WebP de Google : developers.google.com/speed/webp/docs/webp_study. ↩
-
103 Early Hints permet au serveur (ou au CDN) d’envoyer une réponse préliminaire avec des indications de preload avant que la réponse finale soit prête. Cloudflare prend en charge Early Hints pour les en-têtes
Linkavecrel=preload. Consultez developer.chrome.com/blog/early-hints. ↩ -
React 18 + ReactDOM pèse environ 42 Ko minifié + gzip. 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 versioning 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) de Gross, Stepinski et Cotter : hypermedia.systems. ↩
-
Vérification de maintenance du 15 mai 2026. FastAPI PyPI et les notes de publication listent 0.136.1 ; la vérification d’import local a retourné FastAPI 0.128.0 et Starlette 0.50.0 pour cet environnement de site ; htmx.org liste 2.0.10 dans le démarrage rapide ;
npm view htmx.org version dist-tagsa retournélatest=2.0.10etnext=4.0.0-beta3;npm view alpinejs versionetnpm view @alpinejs/csp versionont retourné3.15.12; le blog officiel de Bootstrap et les métadonnées du package npm listent 5.3.8 ; SQLAlchemy PyPI et la documentation listent 2.0.49 ; Pydantic PyPI liste 2.13.4. ↩↩↩↩ -
Les métadonnées du package htmx 4.0.0-beta3 indiquaient une publication le 8 mai 2026 et npm
nextpointait vers4.0.0-beta3; npmlatestrestait à 2.0.10. La documentation 4.0 sur four.htmx.org affichait[email protected], l’index des extensions 4.0 listaithx-liveethx-nonce, et le guide de migration 4.0 documentait des changements de migration à examiner avant de faire passer des applications de production hors de la branche 2.x. Remplacé pour le suivi de la ligne la plus récente par 22. ↩↩↩↩↩ -
Vérification de maintenance du 24 mai 2026. Les commandes d’inventaire local ont retourné 210 articles de blog Markdown, 11 fichiers de guides de premier niveau et 48 fichiers d’études de design. Les notes de publication de FastAPI listent 0.136.3 le 2026-05-23 avec une gestion plus stricte des en-têtes à underscore lorsque
convert_underscores=True; 0.136.2 valide les champs Server-Sent Event.python3 -m pip index versions fastapia retourné la dernière version0.136.3;python3 -m pip index versions sqlalchemya retourné la dernière version2.0.50;python3 -m pip index versions pydantica retourné la dernière version2.13.4.npm view htmx.org dist-tags version time.modified --jsona retournélatest=2.0.10,next=4.0.0-beta4ettime.modified=2026-05-22T15:56:21.948Z; la documentation d’installation de four.htmx.org affiche[email protected]. ↩↩↩ -
Changelog SQLAlchemy 2.0.50 et article de publication, publiés le 2026-05-24. La dépendance asyncio
greenletne s’installe plus par défaut ; la cible d’installationsqlalchemy[asyncio]est désormais requise pour l’inclure. 2.0.50 abandonne également Python 3.7/3.8/3.9 (désormais 3.10+), ajoute des wheels Python free-threaded et ajoute un paramètre de cadre de fenêtreover(..., exclude=...). Dernière version vérifiée sur PyPI au 2026-06-08. htmx 4.0.0-beta4 (« The Fetchening », 2026-05-22) reste en bêta avec une cible stable début 2027 ; FastAPI 0.136.3 (2026-05-23), Alpine.js 3.15.12 et Bootstrap 5.3.x sont inchangés sur cette période. ↩↩↩ -
Notes de publication de FastAPI : 0.137.0 (2026-06-14) refactorise les internes du routeur, de sorte que
router.routesn’est plus une liste plate d’objetsAPIRoute, mais un arbre d’objets intermédiaires (à traiter comme interne) ; elle permet aussi d’ajouter des routes aprèsinclude_router(), y compris un sous-routeur avant que ses routes soient définies, évite de copier les routes et ajouteAPIRouter.matches()/.handle(); elle épingle Starlette 1.3.1. 0.137.1 (2026-06-15) corrige le typage APIRoute et un chemin vide dans un routeur sans préfixe. Notes de publication de Starlette : 1.0.0 (2026-03-22), sa première version stable en environ 8 ans, a suppriméon_startup/on_shutdown/on_event()et les décorateurs@app.route()/@app.websocket_route()(utilisezlifespanetRoute/WebSocketRoute) ; la dernière version est 1.3.1 (2026-06-12). SQLAlchemy 2.0.51 (changelog, 2026-06-15) ne contient que des corrections de bugs, sans impact sur async ni sur l’installation. Vérifié via PyPI et les notes de publication officielles le 2026-06-16. ↩ -
Notes de publication de FastAPI : 0.138.0 (2026-06-20) ajoute
app.frontend("/", directory="dist")etrouter.frontend("/", directory="dist")pour servir un frontend statique déjà buildé (PR #15800 ; documentation Frontend) — une fonctionnalité de service de SPA statiquedist/, pas un modèle avec rendu serveur ; aucun changement cassant. 0.137.2 (2026-06-18) ajouteiter_route_contexts()pour les usages avancés qui parcouraient auparavantrouter.routes(interne depuis 0.137.0) ; aucun changement cassant. Aucune version plus récente que 0.138.0 au 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 sont tous inchangés. Vérifié via PyPI et les notes de publication officielles le 2026-06-22. ↩