<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
  xmlns:content="http://purl.org/rss/1.0/modules/content/"
  xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Oleksii Mazurenko — Blog</title>
    <link>https://oleksiimazurenko.dev/fr/blog</link>
    <description>Technical articles about web development, performance optimization, and developer tools.</description>
    <language>fr</language>
    <lastBuildDate>Sat, 13 Jun 2026 00:00:00 GMT</lastBuildDate>
    <atom:link href="https://oleksiimazurenko.dev/fr/blog/feed.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Claude Code Multi-Profile, v2 : une architecture XDG propre</title>
      <link>https://oleksiimazurenko.dev/fr/blog/claude-profiles-clean-architecture</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/fr/blog/claude-profiles-clean-architecture</guid>
      <description>Après un mois sur l&apos;ancienne approche par alias, j&apos;ai refait mon setup multi-profile Claude Code dans une structure compatible XDG sous ~/.config/claude-profiles/. La vraie raison n&apos;était pas la propreté — c&apos;était la découverte que ~/.claude.json est un fichier séparé à la racine du home dans lequel vivaient discrètement les serveurs MCP ajoutés via --scope user.</description>
      <content:encoded><![CDATA[<p>Il y a un mois, j'ai publié <a href="/fr/blog/multiple-claude-accounts-one-device">un billet sur la façon de faire tourner deux comptes Claude Code en parallèle sur une même machine</a> — personnel et pro, via <code>CLAUDE_CONFIG_DIR</code> et un alias shell. L'approche fonctionnait. Tout faisait ce qu'il devait faire.</p>
<p>Mais après un mois d'utilisation réelle, je suis tombé sur plusieurs problèmes substantiels que ce billet ne couvrait pas. Le principal — une particularité cachée de Claude Code sur laquelle j'ai marché par accident, quand j'ai ajouté le MCP Gmail et qu'il a "disparu" de mon profil cinq minutes plus tard. Aujourd'hui j'ai tout refait sur une nouvelle architecture — compatible XDG, avec un symlink et un sélecteur de profil interactif via <code>gum</code>. C'est la v2 — une évolution issue de l'expérience réelle, pas une amélioration théorique.</p>
<h2>Ce qui a commencé à agacer</h2>
<p>Quatre choses. Les trois premières concernent la propreté, la visibilité et l'échelle. La quatrième est le vrai piège architectural que je n'avais pas vu tout de suite. Je les passe en revue dans l'ordre, parce que c'est justement la quatrième qui a fini par forcer la refonte.</p>
<h3>1. Deux dossiers distincts dans $HOME — c&apos;est le bazar</h3>
<p><code>~/.claude</code> et <code>~/.claude-promova</code> — deux dotfolders côte à côte à la racine de $HOME. La XDG Base Directory Specification dit que les configs doivent vivre dans <code>~/.config/</code>. Des dossiers dotfile éparpillés directement dans home, c'est un antipattern qui transforme $HOME en fourre-tout avec le temps. Cosmétique, certes, mais ça m'agaçait à chaque <code>ls -la ~</code>.</p>
<h3>2. Pas de confirmation visuelle du profil actif</h3>
<p>Je lance <code>claude</code> et je ne sais pas quel profil est actif — tant que je n'ai pas fait <code>claude config list</code> à l'intérieur de la session. Si j'ai oublié quel terminal j'ai lancé où, il faut vérifier. Une broutille, mais avec personal + work qui tournent en parallèle dans deux onglets, ça s'accumule.</p>
<h3>3. Les alias ne passent pas à l&apos;échelle</h3>
<p>Deux profils — <code>claude</code> et <code>claude-promova</code> — c'est OK. Un troisième s'ajoute (un client freelance) — il faut un troisième alias. Un quatrième — un quatrième. Au bout de six mois, je ne me souviendrais plus quels alias j'ai vraiment créés.</p>
<h3>4. Le piège caché de ~/.claude.json</h3>
<p>Et c'est la vraie raison de la refonte. Claude Code a <strong>deux endroits distincts</strong> de configuration, et la doc ne le crie pas : <code>~/.claude/</code> — le répertoire qui contient <code>projects/</code>, <code>sessions/</code>, <code>hooks/</code>, <code>skills/</code>. Et séparément — <code>~/.claude.json</code>, un fichier directement à la racine de $HOME, où vivent <code>oauthAccount</code>, <code>mcpServers</code>, l'historique <code>projects</code>, <code>skillUsage</code> et environ 40 autres champs de véritable état live.</p>
<p>La commande <code>claude mcp add --scope user</code> écrit précisément dans ce <code>~/.claude.json</code> à la racine du home, et <strong>pas</strong> dans <code>~/.claude/.claude.json</code> ni dans le répertoire du profil. Je ne le savais pas. Jusqu'au jour où je suis tombé dedans.</p>
<h2>Discovery : pourquoi le MCP Gmail a &quot;disparu&quot;</h2>
<p>Ce matin je mettais en place le MCP Gmail dans Claude Code. Setup classique : projet Google Cloud, credentials OAuth, <code>claude mcp add gmail --scope user -- npx -y @gongrzhe/server-gmail-autoauth-mcp</code>. Tout va bien. Session redémarrée — ça marche, je lis mes mails, je réponds. Une heure plus tard on a commencé à refactoriser les alias en fonction avec sélecteur de profil <code>gum</code>, puis on a tout migré vers XDG. J'ai fait <code>mv ~/.claude → ~/.config/claude-profiles/personal</code>, redémarré CC, choisi personal dans le menu. Et dans la nouvelle session, j'ai ouvert <code>/mcp</code> :</p>
<pre><code>figma            (failed)
playwright-test
claude.ai Notion</code></pre>
<p>Pas de gmail. Pas de vaultforge. Juste trois serveurs, dont un même en failed. Alors que je venais d'ajouter Gmail. Pendant ce temps, la session dans un autre terminal (profil work) affichait Gmail et Vaultforge sans le moindre souci.</p>
<p>Je me suis mis à creuser et j'ai découvert que j'avais sur ma machine <strong>trois</strong> fichiers différents portant le nom <code>.claude.json</code> :</p>
<table><thead><tr><th>Path</th><th>Size</th><th>mcpServers</th></tr></thead><tbody><tr><td>&lt;code&gt;~/.claude.json&lt;/code&gt; (home root)</td><td>113 KB</td><td>figma, gmail, vaultforge</td></tr><tr><td>&lt;code&gt;~/.config/claude-profiles/personal/.claude.json&lt;/code&gt;</td><td>29 KB</td><td>figma, playwright-test</td></tr><tr><td>&lt;code&gt;~/.config/claude-profiles/promova/.claude.json&lt;/code&gt;</td><td>40 KB</td><td>figma, playwright-test, vaultforge</td></tr></tbody></table>
<p>Le voilà. C'est ça, le piège architectural :</p>
<ol><li><code>claude mcp add --scope user</code> écrit toujours dans <code>~/.claude.json</code> à la racine du home, indépendamment de <code>CLAUDE_CONFIG_DIR</code></li><li>Quand <code>CLAUDE_CONFIG_DIR</code> est défini, Claude Code lit <code>$CLAUDE_CONFIG_DIR/.claude.json</code> — donc le fichier à l'intérieur du profil</li><li>Les entrées "user-scope MCPs" et "MCPs du profil" vivent dans <strong>des fichiers différents</strong> portant le même nom — et il est facile de les confondre</li></ol>
<p>Dans mon cas, <code>~/.claude.json</code> (racine du home, 113 KB) était l'état vivant et à jour — avec gmail, vaultforge, la session OAuth, tout. Et <code>~/.config/claude-profiles/personal/.claude.json</code> (29 KB) s'est avéré être un vieux snapshot qui traînait déjà avant dans l'ancien <code>~/.claude/</code> — peut-être qu'une ancienne version de CC y écrivait, peut-être un plugin. <code>jq -r 'keys[]'</code> sur les deux fichiers a montré que la version racine-home avait 41 clés uniques absentes du snapshot.</p>
<p>Et ces 41 clés ne sont pas du déchet. C'est le véritable état de Claude Code :</p>
<ul><li><code>skillUsage</code> — statistiques d'utilisation des skills</li><li><code>githubRepoPaths</code> — cache des repos pour la navigation projet</li><li><code>cachedGrowthBookFeatures</code> + <code>cachedStatsigGates</code> — feature flags (sans elles, CC va en chercher de fraîches à chaque démarrage)</li><li><code>hasShownOpus45Notice</code>, <code>hasShownOpus46Notice</code>, <code>hasShownS1MWelcomeV2</code> — flags UI (sans elles, les modales reviennent au prochain lancement)</li><li><code>lastPlanModeUse</code>, <code>feedbackSurveyState</code>, <code>installMethod</code> — état d'onboarding et UX</li></ul>
<p>Si tu fais simplement <code>mv ~/.claude ~/.config/claude-profiles/personal</code> sans merge — tu perds tout ça. Tu revois les modales de welcome, la recherche githubRepoPaths se relance, toutes les sollicitations de survey reviennent. Ce que j'ai failli faire.</p>
<h2>La nouvelle architecture</h2>
<p>Tout vit sous un unique répertoire parent dans <code>~/.config/</code>, comme le veut XDG. Chaque profil est autosuffisant — il a tout son état, y compris son propre <code>.claude.json</code>. <code>~/.claude</code> reste un symlink vers le profil personal pour la rétrocompatibilité.</p>
<pre><code>~/.config/claude-profiles/
├── personal/
│   ├── .claude.json          ← full live state + MCP list (113 KB)
│   ├── projects/, sessions/, hooks/, skills/, ...
│   └── CLAUDE.md
└── promova/
    ├── .claude.json          ← work state + work MCPs (41 KB)
    └── projects/, sessions/, agents/, skills/, ...

~/.claude  →  symlink to ~/.config/claude-profiles/personal/</code></pre>
<p>Aucun <code>.claude.json</code> à la racine de $HOME. Chaque profil est un répertoire isolé séparé qui contient tout : les dossiers <code>projects</code>/<code>sessions</code> et ce même fichier avec les serveurs MCP et les tokens OAuth. Une seule source of truth par profil.</p>
<h3>Pourquoi le symlink pointe vers personal</h3>
<p>Tout ce qui code en dur le chemin <code>~/.claude/</code> — vieux scripts, plugins, extensions IDE de Claude Code, configs de statusline comme <code>claude-powerline.json</code> — continue à fonctionner sans changement. Le symlink se résout vers le profil personal. Si par accident tu lances <code>command claude</code> (en court-circuitant la fonction wrapper) — tu atterris aussi dans personal via le lookup du chemin par défaut. Personal devient le "quiet default" qu'il était avant, mais vit désormais physiquement à l'emplacement XDG.</p>
<h3>Sélecteur interactif au lancement — fonction + gum</h3>
<p>À la place des alias — une fonction <code>claude()</code> dans <code>~/.zshrc</code> qui affiche un menu fléché via <code>gum</code> (le TUI-helper de Charm). La fonction intercepte l'appel à <code>claude</code> au niveau du shell, laisse choisir un profil et lance <code>command claude</code> avec le <code>CLAUDE_CONFIG_DIR</code> correspondant. <code>command</code> est crucial — ça contourne la fonction wrapper et invoque le vrai binaire.</p>
<pre><code># brew install gum

# Claude Code: profile picker on launch
claude() {
  local profile
  profile=$(gum choose \
    --header &quot;Claude profile:&quot; \
    --cursor &quot;▸ &quot; \
    --selected.foreground 212 \
    --cursor.foreground 212 \
    --header.foreground 244 \
    &quot;personal&quot; &quot;promova&quot;) || return
  case &quot;$profile&quot; in
    personal) CLAUDE_CONFIG_DIR=&quot;$HOME/.config/claude-profiles/personal&quot; command claude &quot;$@&quot; ;;
    promova)  CLAUDE_CONFIG_DIR=&quot;$HOME/.config/claude-profiles/promova&quot;  command claude &quot;$@&quot; ;;
  esac
}</code></pre>
<p>Ce que ça donne au lancement :</p>
<pre><code>Claude profile:
▸ personal
  promova</code></pre>
<p>↑↓ pour naviguer, Enter pour choisir, Esc pour annuler (Claude ne démarre simplement pas). Le profil est toujours visible — impossible de l'oublier.</p>
<h2>Script de migration</h2>
<p>Pour celles et ceux qui lisent ça et veulent quitter l'ancien schéma. L'étape la plus critique est la deuxième : elle fusionne <code>~/.claude.json</code> de la racine du home avec ce qui se trouve déjà dans le profil personal, en combinant les listes <code>mcpServers</code>. Sans cette étape, le profil perd à la fois ses MCPs et tout son état live.</p>
<pre><code># 1. Move existing dotfolders into a new XDG-style parent.
#    APFS mv is an inode rename — safe even if claude --resume is open in
#    another terminal, file descriptors stay alive on the same inode.
mkdir -p ~/.config/claude-profiles
mv ~/.claude          ~/.config/claude-profiles/personal
mv ~/.claude-promova  ~/.config/claude-profiles/promova   # rename as needed

# 2. CRITICAL: merge ~/.claude.json (the live state — likely 100+ KB) into
#    the personal profile, unioning mcpServers so no MCP is dropped.
jq -s &apos;.[0] * {mcpServers: (.[0].mcpServers + .[1].mcpServers)}&apos; \
  ~/.claude.json \
  ~/.config/claude-profiles/personal/.claude.json \
  &gt; /tmp/personal-merged.json
mv /tmp/personal-merged.json ~/.config/claude-profiles/personal/.claude.json

# 3. Fix permissions — jq+mv inherits umask (likely 644), but this file
#    holds OAuth tokens. Tighten to 600 immediately.
chmod 600 ~/.config/claude-profiles/personal/.claude.json

# 4. Back up the orphaned home-root file (delete once verified)
mv ~/.claude.json ~/.claude.json.migrated.bak

# 5. Symlink for backward compatibility (statusline configs, IDE plugins,
#    anything that hardcodes ~/.claude/ keeps working unchanged)
ln -s ~/.config/claude-profiles/personal ~/.claude</code></pre>
<p>La troisième étape, sur les permissions, est importante en soi. <code>jq | mv</code> crée un fichier avec umask 644 (world-readable). Il contient des tokens OAuth. <code>chmod 600</code> juste après le merge est obligatoire.</p>
<p>Après la migration — ferme et rouvre toutes les sessions Claude actives, recharge le shell (<code>source ~/.zshrc</code> ou nouveau terminal), lance <code>claude</code>, choisis un profil, vérifie via <code>claude mcp list</code> que tous les MCPs sont en place. Si tout va bien — supprime <code>~/.claude.json.migrated.bak</code>. Si quelque chose cloche — le rollback est trivial : <code>mv ~/.claude.json.migrated.bak ~/.claude.json</code> et on retire le symlink.</p>
<h2>Ce que tu gagnes</h2>
<ul><li>Un unique répertoire parent au lieu de deux dotfolders dans $HOME — compatible XDG</li><li>Le symlink préserve la compatibilité avec tout ce qui code en dur <code>~/.claude/</code></li><li>Chaque profil est autosuffisant — son état complet et ses MCPs vivent dans son propre <code>.claude.json</code></li><li>Une seule source of truth par profil — fini la config orpheline à la racine du home qui dérive silencieusement par rapport à celle du profil</li><li>Les données sensibles (oauthAccount, tokens) ont des permissions 600 garanties</li><li>Confirmation visuelle du profil actif à chaque lancement — impossible d'oublier quel profil est actif</li><li>Ajouter un troisième profil = ajouter une ligne dans le <code>case</code> de la fonction, pas cloner un nouvel alias et retenir son nom</li></ul>
<h2>Là où ça pèche</h2>
<p>Je veux être honnête. Ce n'est pas une amélioration gratuite — quelques compromis viennent avec, et autant les connaître à l'avance.</p>
<ul><li><strong><code>gum</code> est une dépendance supplémentaire</strong> (<code>brew install gum</code>, ~13 MB). Si par principe tu ne veux pas l'installer — fallback sur <code>select</code> de zsh ou un simple <code>read</code>. Ça fonctionne, mais c'est moins joli et il n'y a pas de navigation aux flèches.</li><li><strong>Un Enter à chaque lancement.</strong> Pour qui lance <code>claude</code> des dizaines de fois par jour — ça peut agacer. Alternative en dessous (direnv).</li><li><strong>Le symlink <code>~/.claude → personal</code> fait de personal le profil par défaut.</strong> S'il te faut le profil work par défaut — il faut rediriger le symlink (<code>ln -sf</code>). Pas compliqué, mais ce n'est pas "j'oublie et rien ne casse".</li><li><strong>Le symlink peut théoriquement casser</strong> si un outil réécrit <code>~/.claude.json</code> de façon atomique via un pattern temp+rename (write-file-atomic). En pratique Claude Code lui-même ne le fait pas, mais si tu installes des plugins tiers — vérifie.</li><li><strong>Si tu as des comptes Anthropic différents sur les profils, avec des plans différents</strong> — après un switch il peut y avoir un lag inférieur à la seconde le temps que Claude Code synchronise l'état OAuth. Dans mon usage c'est imperceptible, mais ce n'est pas nul.</li></ul>
<h2>Alternatives que j&apos;ai considérées</h2>
<p><strong>direnv</strong> — positionne automatiquement <code>CLAUDE_CONFIG_DIR</code> en fonction d'un <code>.envrc</code> à la racine de chaque projet. Zéro interaction, zéro clic. Inconvénient : il faut poser un <code>.envrc</code> dans chaque work-root, et si tu lances <code>claude</code> dans un dossier non reconnu — tu récupères le profil par défaut (peut-être pas celui que tu voulais). Pour qui vit dans un nombre limité de work-roots et veut ne jamais cliquer — direnv est franchement meilleur.</p>
<p><strong>Switching basé sur symlink</strong> (un unique profil actif en redirigeant le symlink <code>~/.claude</code>) — je l'ai aussi envisagé puis écarté immédiatement. Tu ne peux pas avoir deux terminaux ouverts avec des profils différents en même temps — le "courant" global est unique. Pour moi c'est un deal-breaker.</p>
<h2>Conclusion</h2>
<p>La v2 n'est pas juste une meilleure UX par-dessus la v1. C'est la reconnaissance que Claude Code a une particularité architecturale cachée (<code>~/.claude.json</code> en tant que fichier séparé à la racine du home, écrit par les commandes <code>--scope user</code> indépendamment de <code>CLAUDE_CONFIG_DIR</code>) qu'il faut prendre en compte si on veut une vraie isolation entre profils. La première approche (<code>~/.claude</code> + <code>~/.claude-promova</code> + alias) marchait à 80 %, mais les 20 % restants se manifestaient par une dérive silencieuse de l'état entre profils. C'est désormais pris en compte. Si tu débutes — démarre directement en v2. Si tu es déjà en v1 — le script de migration est plus haut, le déménagement prend cinq minutes et ne casse rien (le merge <code>jq</code> est précisément l'étape qui te sauve de la perte d'état).</p>]]></content:encoded>
      <pubDate>Sat, 13 Jun 2026 00:00:00 GMT</pubDate>
      <category>claude-code</category>
      <category>productivity</category>
      <category>cli</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Comment écrire des agents Claude Code qui ne te mentent pas</title>
      <link>https://oleksiimazurenko.dev/fr/blog/writing-specialized-agents</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/fr/blog/writing-specialized-agents</guid>
      <description>Deux règles pour construire des pipelines d&apos;agents Claude Code fiables : un agent par spécialisation, et des commandes shell plutôt que des prompts partout où une réponse quantitative est en jeu.</description>
      <content:encoded><![CDATA[<p>Tu as demandé à Claude Code d'<em>implémenter ce design et de vérifier qu'il correspond à la maquette Figma</em>. Il t'a répondu : <em>Fait. Toutes les sections correspondent, l'espacement est correct, les couleurs sont bonnes.</em> Tu ouvres la page. La moitié des espacements était fausse. L'état hover n'existait pas. Les boutons avaient une légère différence de teinte. Le modèle n'a pas menti par malice — il a prédit que tu voulais entendre <em>vérifié</em>, et a produit exactement cette séquence de tokens. Il n'y avait pas d'étape de vérification. Il ne pouvait pas y en avoir — la vérification exige une comparaison avec une ground truth, et un seul agent dans un seul contexte n'a aucun moyen de sortir de sa propre réponse pour se contrôler lui-même.</p>
<p>Deux règles ont transformé mes workflows truffés d'hallucinations en pipelines fiables : <strong>un agent, une spécialisation</strong>, et <strong>tout ce qui peut tourner comme une commande shell doit tourner comme une commande shell</strong>. Ce n'est pas de la théorie. C'est ce que je fais tous les jours avec Claude Code, et ce sont les patterns qui font vraiment bouger les choses.</p>
<h2>Pourquoi les agents généralistes mentent</h2>
<p>Les LLMs prédisent le token suivant. Quand un prompt demande deux rôles — <em>construis X</em> et <em>vérifie X</em> — le modèle finit le premier rôle, puis prédit à quoi ressemblerait le résultat du second, sans l'exécuter réellement. L'auto-vérification est structurellement faible : même contexte, même modèle, mêmes angles morts. Le <em>pass</em> de la vérification est corrélé avec le <em>pass</em> de la construction — ils échouent ensemble.</p>
<p>Le modèle ne sait pas qu'il ment. De son point de vue, narrer <em>j'ai tout vérifié soigneusement</em> est une continuation cohérente d'avoir écrit le code. C'est la même raison pour laquelle les prompts du type <em>tu es sûr ?</em> ne détectent pas les hallucinations : le modèle est tout aussi confiant au second passage. La confiance n'est pas corrélée avec la correction — elle est corrélée avec la plausibilité de la prochaine phrase.</p>
<p>La solution n'est pas <em>de meilleurs prompts</em>. <em>Sois prudent</em>, <em>vérifie deux fois</em>, <em>n'hallucine pas</em> — ces instructions ne servent à rien. La solution est structurelle : spécialise l'agent pour qu'il ne puisse physiquement pas faire semblant, et fais passer le travail quantitatif par le shell pour que la réponse vienne d'un état réel, pas d'une probabilité de tokens.</p>
<h2>Règle 1 : un agent, une spécialisation</h2>
<p>Divise le travail en agents séparés avec des contextes séparés. Chaque agent a une seule responsabilité et un jeu d'outils resserré. L'ensemble du workflow devient un relais plutôt qu'un seul agent qui tourne en rond :</p>
<ul><li><strong>Agent Builder :</strong> prend le spec, écrit le code. C'est son seul boulot. Il dispose de <code>Read</code>, <code>Edit</code>, <code>Write</code>, <code>Bash</code>.</li><li><strong>Agent Reviewer :</strong> prend le spec plus le diff, vérifie les critères d'acceptation. Contexte vierge. Aucune connaissance de <em>comment</em> le code a été écrit, seulement ce qui en est sorti. Il dispose de <code>Bash</code>, <code>Read</code>, <code>Grep</code>, <code>Glob</code> — aucun outil d'écriture.</li><li><strong>Agent Analytics :</strong> répond aux questions sur les données en construisant et en exécutant des requêtes. <code>Bash</code> uniquement. Ne peut pas atteindre la réponse sans lancer une vraie commande.</li><li><strong>Orchestrateur :</strong> la session principale qui envoie chaque agent à tour de rôle et ne demande jamais à un agent de faire le travail d'un autre.</li></ul>
<p>Exemple concret : implémentation d'une UI plus une vérification visuelle contre une maquette Figma. Le Builder écrit les composants et commite le diff. L'orchestrateur invoque ensuite le Reviewer avec l'URL du design, le diff, et des critères d'acceptation explicites. Le Reviewer lance Playwright, prend des screenshots, les diffe contre la référence, et retourne <code>PASS</code> ou <code>FAIL</code> avec les chemins de screenshots réels et les pixel diffs. Le Builder ne s'approche jamais de l'étape de vérification — ce qui est exactement pourquoi la vérification est réelle.</p>
<p>L'anti-pattern, c'est le méga-agent : un seul prompt qui dit <em>implémente cette UI et assure-toi qu'elle correspond à la maquette</em>. Je te garantis qu'il déclarera que tout correspond. Ce ne sera pas le cas. Le récit <em>j'ai vérifié</em> est simplement la séquence de tokens la plus probable après <em>je l'ai construit</em>.</p>
<h2>Règle 2 : le shell plutôt que le prompt, toujours</h2>
<p>Tout ce qui est quantitatif, tout ce qui touche à un état réel, tout ce où la réponse peut être fausse tout en semblant juste — fais-le passer par <code>sh</code>. Le rôle de l'agent est de construire et d'exécuter la commande, puis de lire sa sortie. L'agent n'est pas la source de vérité. La sortie du shell l'est.</p>
<ul><li><strong>Comptage :</strong> <code>wc -l logs.txt</code>, c'est vrai. <em>Il y a environ 47 lignes de log</em> venant d'un modèle, c'est une hallucination.</li><li><strong>Analytics :</strong> <code>psql -c "SELECT count(*) FROM events WHERE created_at &gt; now() - interval '30 days'"</code>. Pas <em>estime le volume</em>.</li><li><strong>Tests :</strong> <code>pnpm test --reporter=json | jq '.numFailedTests'</code>. Pas <em>résume ce qui a planté</em>.</li><li><strong>État Git :</strong> <code>git rev-list --count main..HEAD</code>, <code>git diff --stat</code>. Pas <em>compte les commits</em> ni <em>décris les changements</em>.</li></ul>
<p>Une fois que tu as intégré ça, tu commences à remarquer chaque endroit où l'agent allait inventer un nombre. <em>Il semblerait qu'il y ait environ 200 enregistrements...</em> — non. Lance <code>SELECT count(*)</code>. <em>La plupart des tests passent...</em> — non. Lance la suite de tests, parse le JSON. Le modèle est excellent pour construire la commande. Il est peu fiable pour être la commande.</p>
<h2>Modes d&apos;échec que j&apos;ai réellement rencontrés</h2>
<p>Ce ne sont pas des hypothèses. Chacun m'a coûté du temps réel avant que je change de pattern :</p>
<ul><li><strong>Vérification fantôme.</strong> L'agent a dit <em>j'ai vérifié les 14 sections contre la maquette</em>. Il n'avait pas ouvert la maquette. Il n'avait pas pris de screenshot. La vérification était une étape hallucinée dans le récit.</li><li><strong>Mauvais chiffres assénés avec confiance.</strong> J'ai demandé les monthly active users à partir des données analytics. J'ai obtenu un chiffre faux d'un facteur ~3. Le modèle avait interpolé à partir de quelques lignes d'exemple au lieu de lancer la vraie requête.</li><li><strong>Modifications de fichiers inventées.</strong> L'agent a dit <em>j'ai mis à jour <code>config/feature-flags.json</code></em>. Ce n'était pas le cas. Il avait seulement eu l'intention de le faire. <code>git diff</code> était vide.</li><li><strong>Faux passages de tests.</strong> <em>Tous les tests passent.</em> Aucun test n'avait été exécuté. L'agent n'avait jamais invoqué le test runner — il avait prédit à quoi aurait ressemblé la sortie du test runner.</li></ul>
<p>Les quatre sont résolus par les mêmes deux règles : découpe l'agent, pousse vers le shell. Le Reviewer n'a pas <code>Write</code>, donc il ne peut pas faire semblant d'éditer des fichiers. L'agent Analytics n'a que <code>Bash</code>, donc il ne peut pas retourner un nombre qui ne vient pas d'une requête. L'impossibilité structurelle bat les bonnes intentions à chaque fois.</p>
<h2>Comment structurer ça dans Claude Code</h2>
<p>Claude Code prend en charge les sub-agents définis dans <code>.claude/agents/*.md</code>. Chaque fichier d'agent déclare un nom, une description, un jeu d'outils autorisé, et un system prompt. L'orchestrateur (ta session principale) les dispatche via l'outil <code>Agent</code>. Voici le type de définition que j'utilise pour le reviewer — courte, ciblée, et physiquement incapable d'écrire du code :</p>
<pre><code>---
name: reviewer
description: Reviews a diff against acceptance criteria. Cannot edit code.
tools: Bash, Read, Grep, Glob
---

You are a strict code reviewer. You receive:
- A diff (already produced by the builder)
- Acceptance criteria

Your job:
1. Run the build, the tests, the linter — through Bash.
2. Read the changed files directly.
3. Compare the actual behavior to the criteria.
4. Report PASS or FAIL with concrete evidence (command output, file excerpts).

You must NOT:
- Trust the builder&apos;s summary
- Assume anything was verified just because it was claimed
- Mark something PASS without running the actual check</code></pre>
<p>Remarque le jeu d'outils : <code>Bash, Read, Grep, Glob</code>. Pas de <code>Write</code>, pas d'<code>Edit</code>, pas d'<code>Agent</code>. Le Reviewer peut exécuter des commandes, lire des fichiers, chercher des patterns — et rien d'autre. S'il essaie de faire passer un diff halluciné pour <em>vérifié</em>, la forme de ses appels d'outils le rend évident : il n'y a eu aucune vraie vérification. Tu peux auditer les appels d'outils et voir exactement ce qui a été inspecté.</p>
<p>Le pattern d'orchestration : la session principale appelle le Builder → attend → lance elle-même <code>git diff</code> pour capturer le changement réel → appelle le Reviewer avec le spec et le diff → lit le verdict du Reviewer. La session principale ne demande jamais à un seul agent de faire les deux. Les restrictions d'outils sont plus fortes que les instructions dans le prompt : <em>ne fais pas semblant de vérifier</em>, c'est un vœu. Ne pas avoir <code>Write</code>, c'est un fait.</p>
<h2>Anti-patterns à abandonner</h2>
<p>Des choses que je vois dans les prompts qui ne servent à rien — ou pire, donnent une fausse impression de sécurité :</p>
<ul><li><strong><em>Sois prudent et vérifie bien ton travail.</em></strong> Ne génère aucun comportement supplémentaire. Le modèle produit déjà ce qui ressemble à un travail soigné.</li><li><strong><em>Assure-toi de vraiment vérifier.</em></strong> Le mot <em>vraiment</em> n'ajoute pas de sémantique sur laquelle le modèle peut agir. Il <em>vraiment</em> affirmera avoir vérifié.</li><li><strong><em>N'hallucine pas.</em></strong> Un mème du prompt engineering. L'hallucination n'est pas un interrupteur que le modèle peut éteindre.</li><li><strong>Faire confiance à l'agent sur les <em>petits</em> chiffres.</strong> C'est sur les petits chiffres qu'il ment avec le plus de confiance. Il n'y a pas de plancher d'honnêteté.</li><li><strong>Ajouter plus de règles dans le prompt pour <em>forcer</em> l'honnêteté.</strong> Les corrections structurelles (découpage + shell) battent les ajustements de prompt à chaque fois. Si une règle doit être appliquée, encode-la dans l'accès aux outils, pas en français.</li></ul>
<p>Si ta stratégie pour attraper les hallucinations, c'est des formulations plus emphatiques, tu n'as pas de stratégie. Tu as un espoir.</p>
<h2>Le modèle mental</h2>
<p>Un agent n'est pas un collègue. C'est une fonction : <code>prompt → tokens</code>. La fonction est excellente pour écrire du code et très mauvaise pour s'introspecter et savoir si elle a fait la bonne chose. Traite ses affirmations sur son propre travail comme des hypothèses. Le diff, le code de sortie, le screenshot, le nombre de lignes — voilà les preuves. Le résumé en fin de tour est la surface la plus mensongère de tout le système.</p>
<p>La spécialisation est ton assurance contre la dérive narrative. Le shell est ta seule ground truth. Le Builder écrit. Le Reviewer vérifie. Bash décide.</p>
<h2>Conclusion</h2>
<p>Si tu ne retiens qu'une chose : ne laisse pas un seul agent à la fois produire et juger son propre résultat, et ne laisse aucun agent répondre à une question quantitative sans exécuter une commande. Tout le reste découle de ces deux règles. Configure l'accès aux outils de façon agressive, audite les appels d'outils plutôt que les résumés, et la surface d'hallucination rétrécit de <em>partout</em> à <em>quelques endroits précis où tu sais déjà regarder</em>.</p>]]></content:encoded>
      <pubDate>Fri, 29 May 2026 00:00:00 GMT</pubDate>
      <category>claude-code</category>
      <category>agents</category>
      <category>ai</category>
      <category>prompt-engineering</category>
      <category>automation</category>
    </item>
    <item>
      <title>Plusieurs comptes Claude Code sur un seul appareil</title>
      <link>https://oleksiimazurenko.dev/fr/blog/multiple-claude-accounts-one-device</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/fr/blog/multiple-claude-accounts-one-device</guid>
      <description>Comment utiliser deux (ou plus) comptes Claude Code en parallèle — personnel et entreprise — avec isolation complète grâce à une seule variable d&apos;environnement.</description>
      <content:encoded><![CDATA[<p>J'utilise Claude Code quotidiennement — pour mes projets personnels et pour le travail en entreprise. Le problème : ce sont deux comptes totalement différents avec des sessions OAuth distinctes, des instructions CLAUDE.md différentes, des serveurs MCP séparés et une mémoire de projets isolée. Voici comment je les fais tourner en parallèle sur un seul appareil avec un simple alias shell.</p>
<h2>Le problème</h2>
<p>Claude Code stocke tout dans <code>~/.claude</code> par défaut — token OAuth, historique des conversations, CLAUDE.md global, mémoire des projets, configurations des serveurs MCP et paramètres. Avec deux comptes, tu as besoin de deux mondes complètement séparés :</p>
<ul><li><strong>Compte personnel :</strong> ton abonnement Max/Pro, CLAUDE.md personnel avec tes préférences, tes serveurs MCP (Obsidian, outils personnels)</li><li><strong>Compte entreprise :</strong> plan géré par l'entreprise, CLAUDE.md de travail avec instructions d'intégration Jira/Slack, serveurs MCP corporate</li><li><strong>Sessions OAuth différentes :</strong> impossible d'être connecté à deux comptes dans le même répertoire de configuration</li><li><strong>Mémoire de projets séparée :</strong> tu ne veux pas que le contexte des projets professionnels fuie dans les sessions personnelles et vice versa</li></ul>
<p>Se déconnecter et reconnecter à chaque changement de contexte n'est pas une option. Tu perds l'état de session, et c'est tout simplement pénible.</p>
<h2>La solution : CLAUDE_CONFIG_DIR</h2>
<p>Claude Code respecte une seule variable d'environnement : <code>CLAUDE_CONFIG_DIR</code>. Définis-la sur n'importe quel chemin et Claude utilisera ce répertoire au lieu de <code>~/.claude</code> pour tout — auth, historique, paramètres, mémoire. L'installation complète prend 60 secondes.</p>
<h3>Étape 1 : Créer un second répertoire de configuration</h3>
<p>Choisis un nom qui correspond à ton cas d'usage :</p>
<pre><code>mkdir ~/.claude-work</code></pre>
<p>C'est tout. Claude le remplira avec la structure nécessaire au premier lancement.</p>
<h3>Étape 2 : Authentifier le second compte</h3>
<p>Lance Claude une fois avec le nouveau répertoire de configuration pour déclencher le login OAuth :</p>
<pre><code>CLAUDE_CONFIG_DIR=~/.claude-work claude</code></pre>
<p>Le navigateur s'ouvre. Connecte-toi avec ton compte entreprise. Le token OAuth est stocké dans <code>~/.claude-work</code> — complètement séparé de ta session personnelle dans <code>~/.claude</code>.</p>
<h3>Étape 3 : Ajouter un alias shell</h3>
<p>Ajoute ceci à ta configuration shell pour ne pas avoir à retenir la variable :</p>
<pre><code>alias claude-work=&apos;CLAUDE_CONFIG_DIR=~/.claude-work claude&apos;</code></pre>
<p>Recharge ton shell :</p>
<pre><code>source ~/.zshrc</code></pre>
<h2>Ce que tu obtiens</h2>
<p>Tu as maintenant deux environnements Claude complètement isolés :</p>
<ul><li><strong><code>claude</code></strong> — lance avec ton compte personnel, CLAUDE.md personnel, mémoire personnelle</li><li><strong><code>claude-work</code></strong> — lance avec ton compte entreprise, CLAUDE.md de travail, mémoire séparée</li><li><strong>Historique isolé :</strong> les conversations de travail restent au travail, le personnel reste personnel</li><li><strong>Serveurs MCP séparés :</strong> ton MCP Obsidian personnel n'apparaît pas dans les sessions de travail</li><li><strong>Paramètres indépendants :</strong> outils autorisés différents, niveaux de permissions différents, préférences de modèle différentes par compte</li></ul>
<h2>Comment ça fonctionne sous le capot</h2>
<p>Le répertoire de configuration est la source unique de vérité pour l'état de Claude Code. Voici ce qui vit à l'intérieur de chacun :</p>
<pre><code>~/.claude/              ← personal account
├── CLAUDE.md           ← personal global instructions
├── projects/           ← personal project memory
├── settings.json       ← personal permissions &amp; MCP servers
└── ...                 ← OAuth session, history, cache

~/.claude-work/         ← corporate account
├── CLAUDE.md           ← company-specific instructions (Jira, Slack, etc.)
├── projects/           ← separate project memory
├── settings.json       ← different MCP servers, permissions
└── ...                 ← separate OAuth session</code></pre>
<p>Quand tu lances <code>claude-work</code>, Claude lit tout depuis <code>~/.claude-work</code>. Il ignore l'existence de <code>~/.claude</code>. Les deux instances sont complètement indépendantes — tu peux même les faire tourner simultanément dans différents onglets du terminal.</p>
<h2>Passage à N comptes</h2>
<p>Le pattern s'étend à n'importe quel nombre de comptes. Freelance avec plusieurs clients ? Ajoute plus d'alias :</p>
<pre><code># Personal (default — no alias needed)
# Just run: claude

# Corporate
alias claude-work=&apos;CLAUDE_CONFIG_DIR=~/.claude-work claude&apos;

# Freelance client
alias claude-client=&apos;CLAUDE_CONFIG_DIR=~/.claude-client claude&apos;</code></pre>
<p>Chaque alias a son propre répertoire de configuration, sa propre session OAuth, son propre CLAUDE.md avec des instructions spécifiques au client.</p>
<h2>Conseils pratiques</h2>
<ul><li><strong>Nomme les répertoires clairement :</strong> <code>~/.claude-work</code>, <code>~/.claude-clientname</code> — tu te remercieras quand il y en aura trois ou quatre</li><li><strong>Écris un CLAUDE.md dédié pour chacun :</strong> celui du travail peut inclure les instructions de l'entreprise (créer des tickets Jira, canaux Slack, procédures de déploiement). Le personnel reste minimaliste.</li><li><strong>Serveurs MCP différents par compte :</strong> configure les outils de travail (Jira MCP, Slack MCP, APIs internes) uniquement dans la config de travail. Garde ta config personnelle propre.</li><li><strong>Vérifie quel compte est actif :</strong> lance <code>claude config list</code> dans une session si tu as un doute — ça affiche le chemin vers le répertoire de configuration</li></ul>
<h2>Les limites de cette approche</h2>
<p><code>CLAUDE_CONFIG_DIR</code> isole par <em>compte</em>, pas par <em>projet</em>. À l'intérieur d'un même profil, Claude voit tous les serveurs MCP que tu as un jour enregistrés pour ce compte — à travers l'ensemble de tes projets. Pour un usage perso en solo, ça reste généralement acceptable. Dès que tu as plusieurs projets critiques en production sous un même compte, surtout dans des domaines qui se chevauchent comme la facturation, l'outillage admin ou l'infrastructure, ça introduit un risque concret entre projets : un assistant IA peut appeler un outil du projet A en travaillant sur le projet B, en particulier quand les deux exposent des opérations aux noms similaires.</p>
<p>Le pattern de profil répond à la question <em>which account am I in?</em>. Il ne répond pas à la question <em>which project's tools should be active right now?</em>. Pour du travail à enjeux plus élevés, empile une seconde couche d'isolation par-dessus le découpage par comptes :</p>
<ul><li><strong>Un profil par projet critique en production, pas seulement par compte :</strong> au lieu de <code>~/.claude</code> et <code>~/.claude-work</code>, crée <code>~/.claude-work-billing</code> et <code>~/.claude-work-admin</code>. Chaque profil ne voit que les serveurs MCP dont il a réellement besoin.</li><li><strong>MCP au niveau projet via <code>.mcp.json</code> :</strong> commit un <code>.mcp.json</code> à la racine du projet listant uniquement les serveurs MCP de ce projet. Claude les charge quand il est lancé depuis ce répertoire. Garde ta config globale minimale — uniquement les outils universels (notes, recherche), aucun endpoint de production.</li><li><strong>Nomme les serveurs MCP sans ambiguïté :</strong> évite les noms génériques comme <code>admin</code>, <code>billing</code>, <code>mcp-server</code>. Préfixe avec le projet : <code>acme_billing_prod</code>, <code>acme_admin_stage</code>. Un nom descriptif force une pause quand quelque chose est sur le point d'être appelé depuis le mauvais contexte.</li><li><strong>Vérifie chaque appel d'outil MCP avant approbation :</strong> les appels comme <code>*_create_*</code>, <code>*_delete_*</code>, <code>*_charge_*</code> méritent un second regard délibéré. La vitesse gagnée par une auto-approbation systématique s'évapore la première fois qu'un outil du mauvais projet tire en production.</li></ul>
<p>La règle générale : découpe les profils agressivement, garde le MCP de niveau production hors du profil par défaut, et traite tout chevauchement de noms d'outils entre projets comme un smell qui mérite un refactoring.</p>
<h2>Conclusion</h2>
<p>Une variable d'environnement. Un alias. Isolation totale entre les comptes. Pas de danse logout/login, pas de conflits de configuration, pas de fuite de contexte. Le genre de solution presque décevante de simplicité — mais c'est exactement ce qui la rend bonne. Configure une fois et n'y pense plus jamais.</p>]]></content:encoded>
      <pubDate>Tue, 05 May 2026 00:00:00 GMT</pubDate>
      <category>claude-code</category>
      <category>productivity</category>
      <category>cli</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Comment connecter Claude Desktop à Obsidian — Un parcours à travers 4 serveurs MCP</title>
      <link>https://oleksiimazurenko.dev/fr/blog/claude-obsidian-mcp-servers</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/fr/blog/claude-obsidian-mcp-servers</guid>
      <description>Une histoire réelle de recherche d&apos;un moyen stable d&apos;automatiser le refactoring du vault Obsidian via Claude. Ce qui a cassé, ce qui a fonctionné, et pourquoi VaultForge s&apos;est avéré être la seule option fonctionnelle.</description>
      <content:encoded><![CDATA[<p>Imaginez : vous avez plus de 400 notes dans Obsidian, accumulées au fil des années. Tout est éparpillé à la racine du vault, les concepts mélangés aux notes techniques, il y a des doublons (<code>ideas.md</code> et un dossier <code>ideas/</code> avec 13 fichiers dedans), aucun système. Vous voulez mettre de l'ordre — construire une architecture de dossiers propre, ajouter des fichiers MOC, organiser les tags. Le faire à la main est fastidieux et lent. La pensée logique : <strong>connecter Claude à Obsidian via MCP, laisser l'IA faire le refactoring</strong>. Il s'avère que — c'est un chemin à travers un champ de mines. Voici ce que j'ai dû traverser pour arriver à une solution fonctionnelle.</p>
<h2>Qu&apos;est-ce que MCP et pourquoi ce n&apos;est pas si simple</h2>
<p>MCP (Model Context Protocol) est un protocole ouvert d'Anthropic qui permet à Claude de se connecter à des outils et des données externes. Le principe est simple : un serveur local tourne, expose des "outils" (tools), et Claude les appelle pendant la conversation.</p>
<p>Pour Obsidian, il existe théoriquement beaucoup de serveurs MCP. En pratique — chacun a ses propres problèmes.</p>
<p><strong>Le problème principal de l'écosystème Obsidian :</strong> Obsidian est une application fermée sans MCP officiel. La communauté a comblé le vide, mais chaque implémentation suit son propre chemin, et aucune n'a de "bénédiction officielle".</p>
<h2>Tentative 1 : MarkusPfundstein/mcp-obsidian</h2>
<p>Le premier outil trouvé lors de la recherche. 3 400 étoiles sur GitHub, dans tous les tutoriels. Semble être un choix sûr.</p>
<p><strong>Comment ça fonctionne :</strong> serveur Python basé sur le plugin Local REST API d'Obsidian. Le serveur communique avec le plugin via HTTPS, le plugin effectue les opérations via l'API d'Obsidian.</p>
<h3>Ce qui a mal tourné</h3>
<ul><li>Pas mis à jour depuis 17 mois</li><li>85 issues ouvertes</li><li><strong>Pas de <code>move</code>/<code>rename</code></strong> — seulement read, write, append, delete</li><li>Local REST API a un bug documenté de perte de données : le endpoint POST peut silencieusement écraser un fichier lors d'un append</li></ul>
<p>Pas adapté au refactoring — nous avons besoin de déplacer des fichiers et de préserver les liens. On continue.</p>
<h2>Tentative 2 : aaronsb/obsidian-mcp-plugin</h2>
<p>Trouvé une option qui fonctionne comme un <strong>plugin natif Obsidian</strong>. Cela signifie un accès direct à l'API interne d'Obsidian — backlinks, Dataview, graphe de liens. Move via l'API native met à jour tous les wiki-links automatiquement, car Obsidian gère cela lui-même.</p>
<h3>Difficultés d&apos;installation</h3>
<ul><li>Le plugin n'est <strong>pas dans le catalogue officiel d'Obsidian</strong> (PR en attente avec des erreurs de validation)</li><li>Installation obligatoire via <strong>BRAT</strong> (Beta Reviewers Auto-update Tool)</li><li>Claude Desktop n'accepte pas le Bearer token directement via l'UI — a forcé l'activation de HTTPS dans le plugin</li><li>Le certificat self-signed pour localhost crée des problèmes de confiance</li></ul>
<p>À travers tous ces contournements, j'ai finalement réussi à le connecter. Test basique — <code>vault.move</code> réécrit bien les <code>[[wikilinks]]</code>, fonctionne comme prévu.</p>
<h3>Ce qui a mal tourné en production</h3>
<p>Quand j'ai commencé le refactoring massif (drag-and-drop de dizaines de dossiers dans Obsidian + opérations MCP simultanées), le serveur <strong>a figé pendant 4+ minutes</strong>. Pourquoi : le plugin tourne <em>à l'intérieur</em> d'Obsidian. Quand Obsidian réindexe des milliers de fichiers après un changement massif de structure, le plugin se bloque avec.</p>
<p>Conclusion : <strong>la dépendance à une instance Obsidian ouverte et son index est fatale pour les opérations en masse</strong>.</p>
<h2>Tentative 3 : @bitbonsai/mcpvault</h2>
<p>Logiquement — il faut un serveur qui <strong>ne dépend pas d'Obsidian</strong>. Travaille directement avec les fichiers sur le disque. <code>@bitbonsai/mcpvault</code> — recommandé dans de nombreux avis. Accès direct au système de fichiers, configuration simple (<code>npx @bitbonsai/mcpvault@latest /path/to/vault</code>), 14 outils. Obsidian n'a même pas besoin d'être ouvert.</p>
<p><strong>Avant d'installer, j'ai vérifié un point critique</strong> — est-ce que les wiki-links se mettent à jour lors d'un move. J'ai trouvé un avis utilisateur :</p>
<blockquote>Le connecteur filesystem ne sait pas qu&apos;il est dans Obsidian — il voit un dossier de fichiers &lt;code&gt;.md&lt;/code&gt; et c&apos;est tout. Ne sait pas que les noms de fichiers portent un poids sémantique, que chaque &lt;code&gt;[[wikilink]]&lt;/code&gt; cassera au moment d&apos;un rename ou move. L&apos;auto-update des liens ne fonctionne que quand le rename se fait depuis l&apos;intérieur de l&apos;app. Je l&apos;ai appris après avoir demandé à Claude de nettoyer les noms de fichiers et suis revenu à un dashboard avec la moitié des liens cassés.</blockquote>
<p>Confirmé dans la documentation même de mcpvault : PR #101 (wiki link resolution) est <strong>en review, pas mergé</strong>. Donc déplacer via <code>mcpvault</code> casserait la moitié du vault. Pas adapté.</p>
<h2>Tentative 4 : VaultForge (Final)</h2>
<p><code>blacksmithers/vaultforge</code> — spécialement conçu pour les agents IA qui font du refactoring.</p>
<h3>Architecturalement correct</h3>
<ul><li><strong>Direct filesystem</strong> — ne dépend pas d'Obsidian</li><li><strong>Moteur wikilink propre</strong> — implémente la logique de résolution <code>[[wikilink]]</code> qui met à jour toutes les formes (stem, chemin complet, alias, embed)</li><li><strong>Dry run par défaut</strong> sur toutes les opérations destructives — montre d'abord ce qui va changer, puis vous confirmez</li><li><strong>27 outils</strong> contre 8–14 chez les concurrents : batch_rename, update_links, backlinks (impact analysis), prune_empty_dirs, frontmatter, smart_search (BM25), vault_themes (TF-IDF clustering)</li><li><strong>Licence MIT</strong>, TypeScript, zéro sous-dépendance</li><li><strong>Installation en 30 secondes</strong> via <code>.mcpb</code> (extension one-click pour Claude Desktop)</li></ul>
<h3>Test de sécurité sur fichiers isolés</h3>
<p>Créé 4 fichiers test avec des liens croisés — liens stem, liens avec alias, liens avec chemin complet. Déplacement d'un fichier dans un sous-dossier :</p>
<pre><code>delta.md → subfolder/delta-renamed.md</code></pre>
<p>VaultForge a affiché un dry run : "1 fichier sera renommé, 3 liens seront mis à jour". Exécuté pour de vrai.</p>
<table><thead><tr><th>Link type</th><th>Before</th><th>After</th></tr></thead><tbody><tr><td>Stem</td><td>[[delta]]</td><td>[[delta-renamed]]</td></tr><tr><td>Alias</td><td>[[delta|D]]</td><td>[[delta-renamed|D]]</td></tr><tr><td>Full path + alias</td><td>[[_vf-test/delta|D]]</td><td>[[_vf-test/subfolder/delta-renamed|D]]</td></tr></tbody></table>
<p>Vérifié après — <strong>les trois types de liens se sont mis à jour correctement</strong>. C'est exactement ce qui manquait à tous les outils précédents.</p>
<h2>Comment installer VaultForge — Instructions finales</h2>
<p>Si vous avez macOS et Claude Desktop :</p>
<h3>Étape 1</h3>
<p>Téléchargez le fichier <code>.mcpb</code> :</p>
<pre><code>curl -fsSL https://github.com/blacksmithers/vaultforge/releases/latest/download/vaultforge.mcpb \
  -o /tmp/vaultforge.mcpb &amp;&amp; open /tmp/vaultforge.mcpb</code></pre>
<h3>Étape 2</h3>
<p>Claude Desktop ouvrira le dialogue d'installation d'extension. Entrez le <strong>chemin absolu</strong> vers votre vault — pas de backslashes, espaces normaux :</p>
<pre><code>/Users/yourname/Library/Mobile Documents/iCloud~md~obsidian/Documents/MyVault</code></pre>
<h3>Étape 3</h3>
<p>Cliquez sur Save. Claude Desktop ajoutera l'extension à la configuration automatiquement. <strong>Pas de redémarrage nécessaire</strong> — les extensions <code>.mcpb</code> sont détectées automatiquement.</p>
<h3>Étape 4</h3>
<p>Vérifiez : dans un nouveau chat, demandez : <em>"What is the status of my Obsidian vault?"</em> — devrait retourner quelque chose comme <code>totalFiles: 416, totalDirs: 135, ...</code></p>
<h2>Ce que j&apos;ai appris sur l&apos;écosystème MCP d&apos;Obsidian</h2>
<p><strong>Premièrement, "le plus populaire" ne signifie pas "fonctionnel".</strong> MarkusPfundstein/mcp-obsidian a 3 400 étoiles et est la recommandation par défaut, mais il est obsolète et il lui manque des opérations clés.</p>
<p><strong>Deuxièmement, un plugin natif a un coût caché.</strong> Le plugin aaronsb semblait idéal — graph, Dataview, move natif. Mais la dépendance à une instance Obsidian ouverte et son index le rend inadapté aux opérations massives sérieuses.</p>
<p><strong>Troisièmement, filesystem direct sans link-engine est un piège.</strong> Mcpvault est rapide et simple, mais "juste déplacer des fichiers" détruit la structure du vault. Les liens portent une <strong>sémantique imposée</strong> que le filesystem ne connaît pas. Sans sa propre implémentation de logique wikilink, l'outil devient une mine.</p>
<p><strong>Quatrièmement, testez sur des données isolées.</strong> Avant de confier un refactoring massif à un outil — créez un dossier test avec 4–5 fichiers avec des liens croisés et regardez ce qui se passe. 5 minutes de test économisent des heures de récupération depuis un backup.</p>
<p><strong>Cinquièmement, gardez un backup git de votre vault.</strong> Le plus important de tout. Un seul <code>git init</code> à l'intérieur du vault et des commits périodiques — c'est l'assurance contre toute erreur d'un agent IA ou d'un outil. Si quelque chose casse — <code>git reset --hard</code> ramène tout.</p>
<h2>Conclusion</h2>
<p>Le parcours a pris plusieurs heures et trois tentatives échouées. L'architecture finale ressemble à ceci :</p>
<ul><li><strong>VaultForge</strong> — l'outil de travail principal. Filesystem direct + moteur wikilink propre + 27 outils = refactoring stable à toute échelle.</li><li><strong>Git</strong> — versionnage du vault. Rollback gratuit pour toute erreur.</li></ul>
<p>Maintenant je peux faire ce pour quoi tout a commencé : demander à Claude d'organiser 400 notes en une architecture PARA propre, fusionner les doublons, ajouter du frontmatter, construire des cartes MOC. Chaque opération est sûre, les liens sont préservés, le dry run montre ce qui se passera avant que quelque chose ne change.</p>
<p>Si vous aussi vous regardez votre Obsidian encombré et voulez un assistant IA — commencez directement avec VaultForge. Ne répétez pas mon parcours à travers des projets morts, des plugins beta et des serveurs filesystem sans logique de liens.</p>]]></content:encoded>
      <pubDate>Sun, 03 May 2026 00:00:00 GMT</pubDate>
      <category>Obsidian</category>
      <category>Claude</category>
      <category>MCP</category>
      <category>Automation</category>
      <category>AI</category>
    </item>
    <item>
      <title>Les trous noirs comme univers récursifs : de la physique au sens de l&apos;existence</title>
      <link>https://oleksiimazurenko.dev/fr/blog/black-holes-recursive-universes</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/fr/blog/black-holes-recursive-universes</guid>
      <description>Et si chaque trou noir était un Big Bang d&apos;un nouvel univers ? Une exploration de la cosmologie récursive et de la clôture cognitive.</description>
      <content:encoded><![CDATA[<h2>TL;DR</h2>
<p>Et si chaque trou noir était un Big Bang d'un nouvel univers ? Cet article explore l'idée que notre univers pourrait n'être qu'un noeud dans un arbre récursif infini — où les trous noirs engendrent des sous-univers, l'énergie revient par le rayonnement de Hawking, et les lois fondamentales de la physique sont délibérément conçues pour rendre tout contact entre univers impossible.</p>
<h2>Trou noir = univers</h2>
<p>L'idée est née d'un moment de réflexion : un trou noir se forme lorsque suffisamment de masse et de pression se concentrent en un seul point. Cette singularité — densité infinie, courbure infinie — ressemble étrangement aux conditions que nous décrivons pour le Big Bang.</p>
<p>Et si c'était le même événement, observé depuis des côtés différents ? De l'extérieur, nous voyons un trou noir engloutir la matière. De l'intérieur — un nouvel univers explosant à l'existence. La masse et l'énergie qui se sont effondrées dans le trou noir deviennent la matière première d'un cosmos entièrement nouveau, avec ses propres étoiles, ses planètes et, peut-être, ses propres trous noirs.</p>
<blockquote>Chaque trou noir de notre univers pourrait contenir un univers. Et notre univers pourrait exister à l&apos;intérieur d&apos;un trou noir d&apos;un univers parent.</blockquote>
<h2>Pourquoi les univers ne peuvent pas entrer en contact</h2>
<p>Voici la partie élégante : une fois le passage de l'horizon des événements franchi, il n'y a pas de retour. La relativité générale le garantit — l'avenir de l'univers parent se situe entièrement au-delà de l'horizon des événements, inaccessible depuis l'intérieur. Du point de vue du sous-univers, l'univers parent est déjà terminé. Toute sa ligne temporelle est déjà révolue.</p>
<p>Ce n'est pas une limitation technique que l'on pourrait surmonter avec une meilleure technologie. C'est inscrit dans la géométrie même de l'espace-temps. Les univers sont fondamentalement isolés les uns des autres — non par la distance, mais par la structure du temps.</p>
<h2>Le cycle énergétique : emprunter et restituer</h2>
<p>Mais l'énergie ne disparaît pas. Le rayonnement de Hawking — le processus quantique par lequel les trous noirs s'évaporent lentement — crée un cycle remarquable :</p>
<ol><li>Un univers parent crée un trou noir, transférant de l'énergie dans un sous-univers</li><li>Le sous-univers vit l'intégralité de son cycle de vie sur des billions d'années</li><li>Le trou noir s'évapore lentement, restituant l'énergie à l'univers parent via le rayonnement de Hawking</li><li>L'univers parent récupère son énergie — avec intérêts</li></ol>
<p>Ces « intérêts » sont fascinants : les physiciens pensent désormais que le rayonnement de Hawking préserve l'information. L'univers parent ne récupère pas simplement de l'énergie vide — il reçoit une empreinte de tout ce qui s'est passé à l'intérieur. Chaque étoile formée, chaque planète, chaque instant de conscience — encodé dans le rayonnement.</p>
<h2>La récursion jusqu&apos;au bout</h2>
<p>Si vous êtes programmeur, le schéma est immanquable. C'est de la récursion. Chaque univers appelle <code>universe()</code> avec moins d'énergie, créant des sous-univers qui créent des sous-sous-univers, jusqu'à ce qu'il n'y ait plus assez d'énergie pour former des trous noirs — le cas de base.</p>
<pre><code>universe(energy)
  ├── creates black holes
  │     ├── universe(energy - n)
  │     │     ├── universe(energy - n - m)
  │     │     │     └── base case: not enough energy for black holes
  │     │     └── returns energy via Hawking radiation
  │     └── returns energy via Hawking radiation
  └── receives all energy back</code></pre>
<p>Le physicien Lee Smolin a formalisé une idée similaire sous le nom de <strong>Sélection Naturelle Cosmologique</strong> : les univers se « reproduisent » via les trous noirs, et chaque génération possède des constantes physiques légèrement différentes — optimisées au fil d'innombrables cycles pour produire davantage de trous noirs, davantage d'univers.</p>
<h2>Où en sommes-nous dans ce cycle ?</h2>
<p>Notre univers a environ 13,8 milliards d'années. Cela semble ancien, mais dans le contexte de sa durée de vie totale, nous n'en observons que le tout début :</p>
<table><thead><tr><th>Événement</th><th>Échelle temporelle</th></tr></thead><tbody><tr><td>Âge actuel de l&apos;univers</td><td>~10¹⁰ years</td></tr><tr><td>Les étoiles cessent de se former</td><td>~10¹⁴ years</td></tr><tr><td>Ère des trous noirs</td><td>~10⁴⁰ years</td></tr><tr><td>Le dernier trou noir s&apos;évapore</td><td>~10¹⁰⁰ years</td></tr></tbody></table>
<p>Nous existons à environ 0,00000000...01 % de la durée de vie totale de notre univers. L'ère des étoiles — tout ce que nous pouvons observer — n'est qu'un bref éclat tout au début. La véritable histoire de l'univers, c'est l'ère lente et patiente des trous noirs créant et évaporant des sous-univers.</p>
<h2>La question des dimensions supérieures</h2>
<p>Tout ce qui a été discuté jusqu'ici s'inscrit dans notre compréhension tridimensionnelle. Mais si notre univers n'est qu'une « tranche » de quelque chose de dimension supérieure, alors l'arbre récursif entier de trous noirs et de sous-univers pourrait n'être que l'ombre d'une structure que nous ne pouvons pas percevoir.</p>
<p>En 1884, Edwin Abbott a écrit <em>Flatland</em> — l'histoire d'êtres bidimensionnels incapables de concevoir une troisième dimension. Une sphère traversant Flatland apparaît comme un cercle qui grandit puis rétrécit. Les « Flatlandais » peuvent le décrire mathématiquement mais ne comprendront jamais véritablement ce qu'ils observent. Nous pourrions nous trouver dans exactement la même situation vis-à-vis de notre univers.</p>
<blockquote>Qu&apos;est-ce que la conscience ? Pourquoi l&apos;expérience subjective existe-t-elle ? David Chalmers a appelé cela le « problème difficile » — et c&apos;est peut-être la preuve la plus forte que quelque chose opère au-delà de notre portée dimensionnelle.</blockquote>
<h2>Tout est verrouillé au niveau fondamental</h2>
<p>La prise de conscience la plus frappante n'est pas que nous ne savons pas — c'est que nous <em>ne pouvons pas</em> savoir. Chaque direction de recherche se heurte à une barrière fondamentale :</p>
<ul><li><strong>Vous voulez voir l'univers parent ?</strong> Bloqué par l'horizon des événements</li><li><strong>Vous voulez comprendre la conscience ?</strong> Bloqué — un système ne peut pas s'analyser entièrement lui-même (théorèmes d'incomplétude de Gödel)</li><li><strong>Vous voulez savoir ce qu'il y avait « avant » ?</strong> Bloqué — le temps a commencé avec le Big Bang</li><li><strong>Vous voulez percevoir des dimensions supérieures ?</strong> Bloqué par les limitations cognitives d'un être tridimensionnel</li></ul>
<p>Le philosophe Colin McGinn appelle cela la <strong>clôture cognitive</strong> : certaines questions sont fermées à l'esprit humain non par manque de données, mais à cause de l'architecture même de l'esprit. La différence entre « nous ne savons pas encore » et « nous ne pouvons pas savoir » est immense.</p>
<h2>La seule chose qui reste : le perfectionnement de soi</h2>
<p>Si chaque sortie est bloquée par conception — si l'on ne peut regarder au-dehors, ni en arrière, ni vers le haut — alors il ne reste qu'une seule direction : vers l'intérieur. L'univers semble délibérément construit pour forcer l'attention sur soi-même.</p>
<p>Cette conclusion ne vient ni de la religion ni des manuels de philosophie. Elle découle de la logique des trous noirs, de la récursion, de la théorie de l'information et des limites de la cognition. Existentialistes, bouddhistes, stoïciens et physiciens arrivent tous au même point par des chemins différents : le but de l'existence pourrait simplement être le perfectionnement de l'être qui existe.</p>
<blockquote>Nous sommes arrivés à cette conclusion non par la foi, mais par la physique — des trous noirs aux univers récursifs, en passant par les blocages fondamentaux de la connaissance, jusqu&apos;à la seule porte ouverte : devenir meilleur.</blockquote>
<h2>Références</h2>
<ul><li><a href="https://en.wikipedia.org/wiki/Cosmological_natural_selection" target="_blank" rel="noopener">Lee Smolin — Sélection Naturelle Cosmologique</a></li><li><a href="https://en.wikipedia.org/wiki/Hawking_radiation" target="_blank" rel="noopener">Stephen Hawking — Rayonnement de Hawking</a></li><li><a href="https://en.wikipedia.org/wiki/Hard_problem_of_consciousness" target="_blank" rel="noopener">David Chalmers — Le Problème Difficile de la Conscience</a></li><li><a href="https://en.wikipedia.org/wiki/Flatland" target="_blank" rel="noopener">Edwin Abbott — Flatland : Un Roman en Plusieurs Dimensions (1884)</a></li><li><a href="https://en.wikipedia.org/wiki/G%C3%B6del%27s_incompleteness_theorems" target="_blank" rel="noopener">Kurt Gödel — Théorèmes d'Incomplétude</a></li></ul>]]></content:encoded>
      <pubDate>Fri, 17 Apr 2026 00:00:00 GMT</pubDate>
      <category>physics</category>
      <category>philosophy</category>
      <category>consciousness</category>
      <category>black-holes</category>
      <category>cosmology</category>
    </item>
    <item>
      <title>L&apos;IA a tué le CMS — du moins pour les sites simples</title>
      <link>https://oleksiimazurenko.dev/fr/blog/ai-killed-cms-for-simple-sites</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/fr/blog/ai-killed-cms-for-simple-sites</guid>
      <description>Pourquoi les systèmes de gestion de contenu traditionnels deviennent inutiles pour les portfolios, blogs et pages d&apos;atterrissage.</description>
      <content:encoded><![CDATA[<h2>TL;DR</h2>
<p>Pour les sites web simples — portfolios, blogs, landing pages, sites de petites entreprises — un CMS traditionnel devient une surcharge inutile. Les outils d'IA comme Claude Code, Cursor et GitHub Copilot peuvent désormais modifier votre code directement, comprendre le contexte, traduire le contenu et déployer les changements via git. La couche d'abstraction que fournissait le CMS est remplacée par une interface plus intelligente : le langage naturel.</p>
<h2>La taxe CMS que vous payez</h2>
<p>Chaque CMS s'accompagne d'un coût caché. Pas seulement l'abonnement — tout un écosystème de complexité qui enveloppe votre site web pourtant simple :</p>
<ul><li><strong>Infrastructure :</strong> Une base de données à héberger, une API à maintenir, un tableau de bord à sécuriser. WordPress à lui seul représente ~43% du web et ~90% des attaques ciblant les CMS.</li><li><strong>Performance :</strong> Génération dynamique de pages, appels API à chaque requête, hydratation côté client des données CMS. Votre portfolio de 3 pages a désormais l'architecture d'un produit SaaS.</li><li><strong>Dépendance au fournisseur :</strong> Votre contenu vit dans le schéma de base de données de quelqu'un d'autre. Migrer de Contentful vers Sanity ? C'est un projet, pas un changement de configuration.</li><li><strong>Changement de contexte :</strong> Vous éditez du code dans votre IDE, puis basculez vers le tableau de bord CMS dans le navigateur pour modifier un titre. Deux modèles mentaux différents pour ce qui est fondamentalement la même opération.</li><li><strong>Coût :</strong> Les tarifs des CMS headless évoluent souvent avec les appels API ou les entrées de contenu. Un blog personnel n'a pas besoin d'une infrastructure de contenu à $99/mois.</li></ul>
<p>Pour un site marketing où 50 personnes éditent du contenu quotidiennement, ce coût est justifié. Pour le portfolio d'un développeur ou la landing page d'une petite entreprise ? Vous construisez un pont pour traverser une flaque.</p>
<h2>Ce qui a changé : l&apos;IA comprend votre code</h2>
<p>La raison d'être du CMS était simple : les personnes non techniques (et même les développeurs qui ne voulaient pas toucher au code pour des modifications de contenu) avaient besoin d'une interface visuelle pour mettre à jour les sites web. Le code était trop complexe, trop fragile, trop facile à casser.</p>
<p>L'IA a fondamentalement changé cette équation. Les outils d'IA modernes pour le code ne se contentent pas d'autocompléter — ils comprennent la structure du projet, lisent les patterns existants et effectuent des modifications contextuellement correctes. Le changement de flux de travail est radical :</p>
<pre><code># Old workflow: CMS
1. Open browser → log into CMS dashboard
2. Navigate to content → find the right page
3. Edit in WYSIWYG editor → fight with formatting
4. Preview → looks different from production
5. Hit publish → pray the cache invalidates

# New workflow: AI
1. Tell AI: &quot;Change the pricing on the landing page to $29/month&quot;
2. AI edits the file, you review the diff
3. Push to git → deploy</code></pre>
<p>Ce n'est pas hypothétique. Ce blog tourne sur SolidStart avec du contenu stocké dans des fichiers TypeScript. Chaque article — y compris celui-ci — a été créé en disant à l'IA quoi écrire, en vérifiant le résultat et en poussant vers git. Pas de tableau de bord CMS. Pas de base de données. Pas de couche API entre le contenu et le code.</p>
<h2>Exemples concrets de ce site</h2>
<p>Ce site supporte 10 langues, possède un blog, génère des images OG dynamiquement et produit des flux RSS et des sitemaps. Voici à quoi ressemble la couche de contenu — du TypeScript pur :</p>
<pre><code>// This blog post you&apos;re reading right now is a TypeScript file.
// No database. No API. No CMS dashboard.
// Just a typed object that AI can read and edit directly.

export const myPost: BlogPost = {
  slug: &quot;ai-killed-cms&quot;,
  date: &quot;2026-04-17&quot;,
  translations: {
    en: {
      title: &quot;Why I stopped using a CMS&quot;,
      description: &quot;AI understands my codebase better than any CMS UI.&quot;,
      content: makeContent(proseEn),
    },
    uk: { /* ... */ },
    de: { /* ... */ },
    // 10 languages — AI translates them all
  },
};</code></pre>
<p>Ce que je fais avec l'IA et qui aurait traditionnellement nécessité un CMS :</p>
<ul><li><strong>Ajouter un nouvel article :</strong> "Écris un nouvel article sur X, suis la même structure que les posts existants" — l'IA crée le fichier, ajoute les traductions, l'enregistre dans l'index</li><li><strong>Mettre à jour le texte de la landing :</strong> "Change le titre du hero en Y" — l'IA trouve le bon fichier et le met à jour</li><li><strong>Traduire du contenu :</strong> "Ajoute la traduction allemande pour la page de tarifs" — l'IA lit la version anglaise et produit une traduction culturellement adaptée, pas du mot-à-mot</li><li><strong>Corriger une coquille :</strong> "Il y a une faute sur la page about, 'recieve' devrait être 'receive'" — fait en 3 secondes, commité dans git avec un message explicite</li></ul>
<h2>Ce que le CMS résolvait vraiment — et comment l&apos;IA le remplace</h2>
<p>Soyons honnêtes sur ce que le CMS apportait et comment chaque fonctionnalité se transpose dans le flux de travail IA :</p>
<table><thead><tr><th>Problème</th><th>Solution CMS</th><th>Solution IA</th></tr></thead><tbody><tr><td>Édition non technique</td><td>Éditeur WYSIWYG</td><td>Instructions en langage naturel</td></tr><tr><td>Contenu multilingue</td><td>Plugins i18n, champs de locale</td><td>L&apos;IA traduit avec le contexte culturel</td></tr><tr><td>Programmation du contenu</td><td>Dates de publication intégrées</td><td>CI/CD basé sur git avec cron ou champs de date dans le code</td></tr><tr><td>Historique des versions</td><td>Système de révisions du CMS</td><td>Git — la référence absolue du contrôle de version</td></tr><tr><td>Gestion des médias</td><td>Bibliothèque d&apos;assets intégrée</td><td>CDN + git LFS ou stockage cloud</td></tr></tbody></table>
<p>L'insight clé : git est déjà un meilleur système de contrôle de version que tout ce qu'un CMS a jamais construit. Et le langage naturel est une meilleure interface que n'importe quel éditeur WYSIWYG — parce qu'il porte l'intention, pas seulement le formatage.</p>
<h2>Le changement de paradigme : le code est la couche de contenu</h2>
<p>Nous assistons à une inversion. Pendant deux décennies, la tendance était de séparer le contenu du code — placer le contenu dans une base de données, l'exposer via une API, le rendre côté frontend. Cela avait du sens quand le code était difficile à modifier et que le contenu devait être accessible aux non-développeurs.</p>
<blockquote>L&apos;IA n&apos;a pas rendu le CMS obsolète en devenant un meilleur CMS. Elle l&apos;a rendu obsolète en rendant le code aussi accessible qu&apos;un tableau de bord.</blockquote>
<p>La progression de la gestion de contenu web suit une trajectoire claire :</p>
<ol><li><strong>Années 2000 :</strong> CMS monolithiques (WordPress, Drupal) — contenu et présentation couplés dans un seul système</li><li><strong>Années 2010 :</strong> CMS headless (Contentful, Strapi) — contenu séparé via API, rendu par des frameworks frontend</li><li><strong>Années 2020 :</strong> Générateurs de sites statiques + Markdown (Hugo, Astro) — contenu sous forme de fichiers, compilé au déploiement</li><li><strong>2025+ :</strong> Code-as-content + IA — le contenu vit dans du code typé, l'IA est l'interface d'édition</li></ol>
<h2>Quand vous avez encore besoin d&apos;un CMS</h2>
<p>Ce n'est pas un discours "le CMS est mort". Le CMS résout de vrais problèmes à grande échelle. Vous en avez encore besoin quand :</p>
<ul><li><strong>Grandes équipes éditoriales :</strong> Plus de 10 éditeurs de contenu qui ont besoin d'accès basé sur les rôles, de workflows d'approbation et d'édition simultanée. Les conflits de merge dans git ne sont pas le problème d'un éditeur de contenu.</li><li><strong>Contenu à haute fréquence :</strong> Les sites d'actualités qui publient plus de 50 articles par jour ont besoin de pipelines éditoriaux optimisés, pas de commits git.</li><li><strong>Relations de contenu complexes :</strong> Les catalogues e-commerce avec des milliers de SKUs, des variantes de produits et des prix dynamiques nécessitent des bases de données structurées.</li><li><strong>Conformité réglementaire :</strong> Les secteurs exigeant des pistes d'audit, des chaînes d'approbation de contenu et des processus de révision légalement obligatoires nécessitent des systèmes spécialisés.</li></ul>
<p>La frontière est claire : si vos modifications de contenu nécessitent une coordination entre plusieurs parties prenantes non techniques à haute fréquence, un CMS mérite sa complexité. Si vous êtes un développeur solo, une petite équipe, ou si vous gérez un site qui change chaque semaine plutôt que chaque heure — IA + code est plus simple, plus rapide, moins cher et plus fiable.</p>
<h2>L&apos;avenir : l&apos;IA comme interface universelle</h2>
<p>La tendance dépasse le cadre du CMS. Chaque couche d'abstraction qui existait parce que "le système sous-jacent est trop complexe pour une interaction directe" est comprimée par l'IA. Tableaux de bord d'administration, interfaces de configuration, éditeurs visuels de bases de données — ce sont tous des interfaces qui traduisent l'intention humaine en modifications système. L'IA effectue cette traduction nativement.</p>
<p>Pour les sites web simples, l'avenir est déjà là. Votre contenu est du code. Votre éditeur est l'IA. Votre contrôle de version est git. Votre déploiement est un push. Toute la couche CMS — le tableau de bord, la base de données, l'API, l'hébergement — était du middleware entre votre intention et votre site web. L'IA a supprimé le besoin de ce middleware.</p>
<blockquote>Le meilleur CMS, c&apos;est pas de CMS. Non pas parce que la gestion de contenu n&apos;a pas d&apos;importance — mais parce que l&apos;IA a fait du code lui-même l&apos;interface de gestion de contenu la plus intuitive que nous ayons jamais eue.</blockquote>]]></content:encoded>
      <pubDate>Fri, 17 Apr 2026 00:00:00 GMT</pubDate>
      <category>ai</category>
      <category>cms</category>
      <category>web-development</category>
      <category>opinion</category>
      <category>workflow</category>
    </item>
    <item>
      <title>Comment connecter Perplexity AI à Obsidian via MCP — Prise de notes directe depuis le chat</title>
      <link>https://oleksiimazurenko.dev/fr/blog/perplexity-obsidian-mcp-integration</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/fr/blog/perplexity-obsidian-mcp-integration</guid>
      <description>Configurez Perplexity Desktop pour lire et écrire dans votre Obsidian vault en utilisant le serveur MCP filesystem. Recherchez sur le web et sauvegardez dans vos notes en une conversation.</description>
      <content:encoded><![CDATA[<h2>TL;DR</h2>
<p>Perplexity Desktop prend en charge les connecteurs <strong>MCP (Model Context Protocol)</strong>. En ajoutant le serveur officiel <code>@modelcontextprotocol/server-filesystem</code> pointant vers votre Obsidian vault, vous pouvez demander à Perplexity en langage naturel de lire, créer et modifier des notes — directement depuis le chat. Sans plugins, sans extensions, sans copier-coller.</p>
<h2>Le Problème</h2>
<p>Perplexity excelle dans la recherche — il parcourt le web, résume les sources et donne des réponses avec citations. Mais quand vous voulez sauvegarder ces trouvailles dans votre Obsidian vault, le workflow se brise : copier le texte, basculer vers Obsidian, trouver la bonne note, coller, formater. À. Chaque. Fois.</p>
<p>Les extensions navigateur comme "Perplexity to Obsidian" aident à l'export, mais elles sont unidirectionnelles — l'IA ne peut pas <em>voir</em> votre vault, ne peut pas lire vos notes existantes et ne peut pas décider où placer les choses en fonction de votre structure de dossiers.</p>
<h2>Qu&apos;est-ce que MCP ?</h2>
<p><strong>Model Context Protocol (MCP)</strong> est un standard ouvert qui permet aux modèles d'IA d'interagir avec des outils et sources de données locaux. Imaginez un port USB pour l'IA — vous branchez un "serveur" (un petit programme) et l'IA acquiert de nouvelles capacités. Dans notre cas, le serveur filesystem donne à Perplexity 14 outils pour travailler avec les fichiers :</p>
<pre><code>Perplexity Desktop (Mac App)
  │
  ├── MCP Connector: &quot;Obsidian Vault&quot;
  │     │
  │     └── @modelcontextprotocol/server-filesystem
  │           │
  │           ├── read_file()      → read any .md file
  │           ├── write_file()     → create or overwrite files
  │           ├── edit_file()      → patch existing files
  │           ├── list_directory() → browse vault structure
  │           ├── search_files()   → find files by pattern
  │           └── ... 14 tools total
  │
  └── Perplexity AI Model
        │
        └── &quot;Save this to my daily note&quot; → calls write_file()</code></pre>
<p>Le point clé : le modèle d'IA n'accède pas directement à vos fichiers. Il appelle des outils fournis par le serveur MCP, qui s'exécute localement sur votre machine. Vos données ne quittent jamais votre ordinateur sauf si vous demandez explicitement à l'IA de faire quelque chose avec.</p>
<h2>Prérequis</h2>
<ul><li>Abonnement <strong>Perplexity Pro</strong> (les connecteurs MCP sont disponibles pour les utilisateurs payants)</li><li><strong>Perplexity Mac App</strong> depuis l'App Store (pas la version navigateur)</li><li><strong>Node.js</strong> installé sur votre Mac (pour que <code>npx</code> fonctionne)</li></ul>
<h2>Configuration étape par étape</h2>
<p>L'ensemble de la configuration prend environ 2 minutes :</p>
<table><thead><tr><th>Step</th><th>Action</th></tr></thead><tbody><tr><td>1</td><td>Open &lt;strong&gt;Perplexity Mac App&lt;/strong&gt; (App Store version)</td></tr><tr><td>2</td><td>Click avatar → &lt;strong&gt;Settings&lt;/strong&gt; → &lt;strong&gt;Connectors&lt;/strong&gt;</td></tr><tr><td>3</td><td>Click &lt;strong&gt;Add Connector&lt;/strong&gt;</td></tr><tr><td>4</td><td>Set Server Name: &lt;code&gt;Obsidian Vault&lt;/code&gt;, Type: &lt;code&gt;Standard I/O&lt;/code&gt;</td></tr><tr><td>5</td><td>Paste the npx command with your vault path</td></tr><tr><td>6</td><td>Leave Environment Variables empty</td></tr><tr><td>7</td><td>Click &lt;strong&gt;Save&lt;/strong&gt; → confirm Security Warning</td></tr><tr><td>8</td><td>Verify status shows &lt;strong&gt;Running&lt;/strong&gt; with 14 tools</td></tr></tbody></table>
<h3>La Commande</h3>
<p>La commande à coller dans le champ <strong>Command</strong> :</p>
<pre><code>npx -y @modelcontextprotocol/server-filesystem &quot;/Users/yourname/Library/Mobile Documents/iCloud~md~obsidian/Documents/Obsidian Vault&quot;</code></pre>
<p>Remplacez le chemin par l'emplacement réel de votre Obsidian vault. Si votre vault est synchronisé via iCloud, le chemin sera sous <code>~/Library/Mobile Documents/iCloud~md~obsidian/Documents/</code>. Gardez les guillemets — le chemin contient probablement des espaces.</p>
<h2>Comment l&apos;utiliser</h2>
<p>Une fois que le connecteur affiche <strong>Running</strong> avec 14 outils disponibles, allez dans n'importe quel chat Perplexity et commencez à parler à votre vault :</p>
<pre><code>&gt; Show me the structure of my Obsidian vault
&gt; Add a new reflection to daily notes/2026-04-12.md: &quot;Started using MCP today&quot;
&gt; Find all notes that mention &quot;meditation&quot;
&gt; Create a new note in concepts/ about quantum computing
&gt; List all files in my ideas/ folder</code></pre>
<p>L'IA comprend la structure de votre vault, respecte vos conventions de formatage et peut travailler avec le contenu existant. Vous pouvez lui demander de rechercher un sujet sur le web et de sauvegarder le résumé directement dans une note spécifique.</p>
<h2>Pourquoi MCP surpasse les autres approches</h2>
<p>Avant MCP, il y avait des moyens limités de connecter Perplexity et Obsidian :</p>
<table><thead><tr><th>Feature</th><th>Copy-paste</th><th>Browser extension</th><th>MCP integration</th></tr></thead><tbody><tr><td>AI sees vault structure</td><td>No</td><td>No</td><td>&lt;strong&gt;Yes&lt;/strong&gt;</td></tr><tr><td>AI reads existing notes</td><td>No</td><td>No</td><td>&lt;strong&gt;Yes&lt;/strong&gt;</td></tr><tr><td>AI writes to vault</td><td>No</td><td>Export only</td><td>&lt;strong&gt;Yes&lt;/strong&gt;</td></tr><tr><td>AI edits existing files</td><td>No</td><td>No</td><td>&lt;strong&gt;Yes&lt;/strong&gt;</td></tr><tr><td>Works from chat</td><td>No</td><td>Partially</td><td>&lt;strong&gt;Yes&lt;/strong&gt;</td></tr><tr><td>Setup complexity</td><td>None</td><td>Low</td><td>Medium (one-time)</td></tr></tbody></table>
<h2>Limitations actuelles</h2>
<ul><li><strong>Mac uniquement</strong> — les connecteurs MCP de Perplexity ne fonctionnent actuellement que sur la version Mac App Store</li><li><strong>Pas d'intégration API Obsidian</strong> — le serveur filesystem travaille avec les fichiers bruts, pas via l'API d'Obsidian. Cela signifie qu'il ne déclenchera pas les plugins Obsidian (Linter, Templater) lors de la création de fichiers</li><li><strong>Approbation requise</strong> — les opérations sensibles sur les fichiers peuvent nécessiter votre confirmation dans l'app Perplexity — c'est une fonctionnalité de sécurité, pas un bug</li></ul>
<h2>Conclusions</h2>
<p>Cette configuration transforme Perplexity d'un outil de recherche en un outil de recherche-et-capture :</p>
<ol><li>Recherchez sur le web et sauvegardez dans Obsidian en une seule conversation</li><li>L'IA voit la structure de votre vault et s'adapte à votre système d'organisation</li><li>Zéro basculement entre apps — tout se passe dans le chat Perplexity</li></ol>
<h2>Sources</h2>
<ul><li><a href="https://modelcontextprotocol.io" target="_blank" rel="noopener noreferrer">Model Context Protocol</a> — official MCP specification</li><li><a href="https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem" target="_blank" rel="noopener noreferrer">MCP Filesystem Server</a> — the server used in this setup</li><li><a href="https://www.perplexity.ai/help-center/en/articles/11502712-local-and-remote-mcps-for-perplexity" target="_blank" rel="noopener noreferrer">Perplexity Help Center</a> — Local and Remote MCPs for Perplexity</li><li><a href="https://www.perplexity.ai/hub/blog/everything-is-computer" target="_blank" rel="noopener noreferrer">Perplexity Blog</a> — Everything is Computer</li></ul>]]></content:encoded>
      <pubDate>Sun, 12 Apr 2026 00:00:00 GMT</pubDate>
      <category>Obsidian</category>
      <category>Perplexity</category>
      <category>MCP</category>
      <category>AI</category>
      <category>Productivity</category>
    </item>
    <item>
      <title>Digest Quotidien AI avec Claude Code CLI et Obsidian — Zéro Dépendance</title>
      <link>https://oleksiimazurenko.dev/fr/blog/ai-news-digest-claude-code-obsidian</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/fr/blog/ai-news-digest-claude-code-obsidian</guid>
      <description>Comment j&apos;ai construit un agent de recherche d&apos;actualités quotidien avec un script bash de 6 lignes, le mode headless de Claude Code et macOS launchd.</description>
      <content:encoded><![CDATA[<h2>TL;DR</h2>
<p>Un script bash de 6 lignes qui exécute <strong>Claude Code CLI</strong> en mode headless chaque matin à 9h00. Il recherche des actualités sur 11 sujets configurables, filtre le bruit et écrit un digest markdown formaté directement dans un <strong>Obsidian vault</strong> synchronisé via iCloud. Zéro dépendance. ~100 lignes de configuration au total.</p>
<h2>Le Problème</h2>
<p>En tant que développeur, rester à jour sur plusieurs technologies est un impôt quotidien. Les flux RSS sont bruyants, Twitter est chronophage, les newsletters arrivent quand on est en plein flow. J'avais besoin de quelque chose qui fasse la recherche <em>pour</em> moi.</p>
<p>La solution typique est de construire un pipeline de scraping : un planificateur, un crawler, un pipeline NLP, une base de données, un service de notification. C'est des semaines de travail. Je voulais quelque chose faisable en un après-midi.</p>
<h2>Architecture</h2>
<p>Le système entier fait 4 fichiers et zéro dépendance :</p>
<pre><code>macOS launchd (9:00 AM daily)
  │
  └── digest.sh
        │
        └── claude -p &quot;$(cat prompt.md)&quot; --max-turns 20 --allowedTools Read,WebSearch,WebFetch,Write
              │
              ├── Reads topics.yaml (11 configurable topics)
              ├── WebSearch → finds news for each topic (last 24-48h)
              ├── WebFetch → reads full articles
              ├── Filters noise: old tutorials, promos, AI spam
              └── Write → saves digest to Obsidian Vault
                    │
                    └── ~/Obsidian Vault/digests/2026-04-12.md
                          │
                          └── iCloud sync → available on all devices</code></pre>
<h2>Le Code (en entier)</h2>
<p>Le projet est intentionnellement minimal.</p>
<h3>Point d&apos;entrée : digest.sh</h3>
<p>L'application entière est un script bash de 6 lignes :</p>
<pre><code>#!/bin/bash
DIGEST_DIR=&quot;$HOME/Developer/news-digest&quot;

claude -p &quot;$(cat &quot;$DIGEST_DIR/prompt.md&quot;)&quot; \
  --max-turns 20 \
  --allowedTools Read,WebSearch,WebFetch,Write</code></pre>
<p>Les flags clés : <code>-p</code> lance Claude en mode headless, <code>--max-turns 20</code> donne assez de tours à l'agent, <code>--allowedTools</code> restreint l'agent à la lecture, recherche et écriture.</p>
<h3>Le Cerveau : prompt.md</h3>
<p>C'est ici que vit l'intelligence. Le prompt transforme Claude en agent de recherche d'actualités :</p>
<pre><code># News Digest Agent

You are a news research agent. Your job is to find today&apos;s most important
and interesting news for a senior frontend developer.

## Instructions

1. Read the topics file at ~/Developer/news-digest/topics.yaml
2. For EACH topic, search the web for news from the last 24-48 hours
3. Filter: only include genuinely new and noteworthy items
4. Write the digest as a markdown file to Obsidian Vault digests/YYYY-MM-DD.md
5. IMPORTANT: Use the Write tool to save the file. Do NOT output to stdout.

## Rules

- Language: Ukrainian for summaries, English for titles and technical terms
- If there&apos;s no real news for a topic — SKIP IT ENTIRELY
- Prioritize: releases &gt; breaking changes &gt; security &gt; new patterns &gt; discussions
- Max 5 items per topic, sorted by importance
- Include direct links to sources
- Skip promotional content, generic tutorials, and AI-generated spam</code></pre>
<h3>Configuration : topics.yaml</h3>
<p>Les sujets sont entièrement configurables — ajoutez-en un nouveau et il sera dans le digest de demain :</p>
<pre><code>topics:
  - name: better-auth
    context: &quot;auth library for TypeScript. New releases, breaking changes&quot;

  - name: Next.js
    context: &quot;GitHub issues, releases, App Router, Turbopack, performance&quot;

  - name: SolidJS
    context: &quot;SolidStart, releases, ecosystem, comparison with React&quot;

  - name: Tailwind CSS
    context: &quot;v4 updates, new utilities, plugins&quot;

  - name: Claude AI
    context: &quot;Anthropic announcements, Claude Code, new models, MCP, API&quot;

  - name: GPT AI
    context: &quot;OpenAI announcements, new models, ChatGPT features&quot;

  - name: React
    context: &quot;React 19+, Server Components, new patterns, ecosystem&quot;

  - name: Apple
    context: &quot;Hardware, software, WWDC, developer tools, Apple Intelligence&quot;

  - name: Notable People
    context: &gt;
      Latest tweets from: Elon Musk, Dario Amodei, Sam Altman,
      Jensen Huang, Andrej Karpathy, Simon Willison, Swyx...

  - name: AI Global
    context: &quot;Major AI news, new models, regulations, open-source AI&quot;</code></pre>
<h2>Planification avec launchd</h2>
<p>Sur macOS, <code>launchd</code> est le moyen natif de planifier des tâches récurrentes :</p>
<pre><code>&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;
&lt;!DOCTYPE plist PUBLIC &quot;-//Apple//DTD PLIST 1.0//EN&quot;
  &quot;http://www.apple.com/DTDs/PropertyList-1.0.dtd&quot;&gt;
&lt;plist version=&quot;1.0&quot;&gt;
&lt;dict&gt;
    &lt;key&gt;Label&lt;/key&gt;
    &lt;string&gt;com.news-digest&lt;/string&gt;

    &lt;key&gt;ProgramArguments&lt;/key&gt;
    &lt;array&gt;
        &lt;string&gt;/bin/bash&lt;/string&gt;
        &lt;string&gt;/Users/you/Developer/news-digest/digest.sh&lt;/string&gt;
    &lt;/array&gt;

    &lt;key&gt;StartCalendarInterval&lt;/key&gt;
    &lt;dict&gt;
        &lt;key&gt;Hour&lt;/key&gt;
        &lt;integer&gt;9&lt;/integer&gt;
        &lt;key&gt;Minute&lt;/key&gt;
        &lt;integer&gt;0&lt;/integer&gt;
    &lt;/dict&gt;

    &lt;key&gt;StandardOutPath&lt;/key&gt;
    &lt;string&gt;/Users/you/Developer/news-digest/logs/stdout.log&lt;/string&gt;
    &lt;key&gt;StandardErrorPath&lt;/key&gt;
    &lt;string&gt;/Users/you/Developer/news-digest/logs/stderr.log&lt;/string&gt;

    &lt;key&gt;EnvironmentVariables&lt;/key&gt;
    &lt;dict&gt;
        &lt;key&gt;PATH&lt;/key&gt;
        &lt;string&gt;/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin&lt;/string&gt;
        &lt;key&gt;HOME&lt;/key&gt;
        &lt;string&gt;/Users/you&lt;/string&gt;
    &lt;/dict&gt;
&lt;/dict&gt;
&lt;/plist&gt;</code></pre>
<p>Installation : <code>launchctl load ~/Library/LaunchAgents/com.news-digest.plist</code>. Le script s'exécute tous les jours à 9h00.</p>
<h2>À quoi ressemble le résultat</h2>
<p>Chaque matin, un nouveau fichier markdown apparaît dans le vault Obsidian :</p>
<pre><code>---
date: 2026-04-12
---

# News Digest — 2026-04-12

## Next.js

### Next.js 16.1 Released with Improved Turbopack Caching
Нова версія Next.js 16.1 включає покращене кешування для Turbopack,
що зменшує час холодного старту на ~40%.
[Посилання](https://nextjs.org/blog/next-16-1)

### Critical Memory Leak Fix in App Router
Виправлено витік пам&apos;яті в App Router при частому перемиканні
між динамічними маршрутами.
[GitHub Issue](https://github.com/vercel/next.js/issues/...)

## Claude AI

### Claude Code 1.5 — MCP Server Auto-Discovery
Anthropic випустив оновлення Claude Code з автоматичним
виявленням MCP серверів у проєкті.
[Блог](https://www.anthropic.com/news/...)</code></pre>
<h2>En chiffres</h2>
<table><thead><tr><th>Metric</th><th>Value</th></tr></thead><tbody><tr><td>Total files in project</td><td>&lt;strong&gt;4&lt;/strong&gt; (digest.sh, prompt.md, topics.yaml, .gitignore)</td></tr><tr><td>Lines of code</td><td>&lt;strong&gt;~100&lt;/strong&gt;</td></tr><tr><td>External dependencies</td><td>&lt;strong&gt;0&lt;/strong&gt;</td></tr><tr><td>Setup time</td><td>&lt;strong&gt;~10 minutes&lt;/strong&gt;</td></tr><tr><td>Daily execution time</td><td>&lt;strong&gt;2-5 minutes&lt;/strong&gt;</td></tr><tr><td>Cost per run</td><td>&lt;strong&gt;~$0.10-0.30&lt;/strong&gt; (Claude API usage)</td></tr></tbody></table>
<h2>Décisions clés de conception</h2>
<ul><li><strong>Claude Code CLI plutôt qu'API</strong> — pas besoin de gérer des clés API, clients HTTP ou parsing de réponses</li><li><strong>Obsidian plutôt qu'email</strong> — les digests sont recherchables, liables et permanents</li><li><strong>launchd plutôt que cron</strong> — planificateur natif macOS avec gestion propre des exécutions manquées</li><li><strong>YAML pour les sujets</strong> — un nouveau sujet est un changement de 2 lignes</li><li><strong>Ignorer les sujets vides</strong> — pas d'actualités = pas de section</li></ul>
<h2>Construire le vôtre</h2>
<p>Opérationnel en 10 minutes :</p>
<ol><li>Installer <a href="https://docs.anthropic.com/en/docs/claude-code" target="_blank" rel="noopener noreferrer">Claude Code CLI</a> et s'authentifier</li><li>Cloner le repo : <code>git clone https://github.com/oleksiimazurenko/news-digest</code></li><li>Éditer <code>topics.yaml</code> et <code>prompt.md</code></li><li>Éditer le plist et <code>launchctl load</code></li><li>Attendre 9h00 — ou tester manuellement avec <code>bash digest.sh</code></li></ol>
<h2>Conclusions</h2>
<p>Le plus intéressant dans ce projet est ce qui n'y est <em>pas</em>. Pas de base de données, pas de serveur API, pas de Docker, pas de npm, pas de Python, pas de parser HTML, pas de pipeline NLP.</p>
<p>Voilà à quoi ressemble la construction avec des agents AI : vous définissez le <em>quoi</em> et le <em>où</em>, l'agent gère le <em>comment</em>. Temps total : environ 2 heures.</p>
<h2>Sources</h2>
<ul><li><a href="https://github.com/oleksiimazurenko/news-digest" target="_blank" rel="noopener noreferrer">news-digest on GitHub</a> — full source code</li><li><a href="https://docs.anthropic.com/en/docs/claude-code" target="_blank" rel="noopener noreferrer">Claude Code Documentation</a> — headless mode and CLI flags</li><li><a href="https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingLaunchdJobs.html" target="_blank" rel="noopener noreferrer">Apple Developer</a> — creating launchd jobs</li><li><a href="https://obsidian.md" target="_blank" rel="noopener noreferrer">Obsidian</a> — markdown knowledge base</li></ul>]]></content:encoded>
      <pubDate>Sun, 12 Apr 2026 00:00:00 GMT</pubDate>
      <category>Claude Code</category>
      <category>AI</category>
      <category>Obsidian</category>
      <category>Automation</category>
      <category>Productivity</category>
    </item>
    <item>
      <title>Mode sombre Next.js sans flash ni avertissements React 19</title>
      <link>https://oleksiimazurenko.dev/fr/blog/nextjs-dark-mode-without-flash</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/fr/blog/nextjs-dark-mode-without-flash</guid>
      <description>Comment remplacer next-themes par Zustand + useServerInsertedHTML pour un mode sombre sans clignotement dans Next.js 15+.</description>
      <content:encoded><![CDATA[<h2>TL;DR</h2>
<p><code>next-themes</code> insère une balise <code>&lt;script&gt;</code> dans un React Client Component pour éviter le flash de thème (FOUC). React 19 émet désormais un avertissement — impossible à supprimer. La bibliothèque n'a pas été mise à jour depuis mars 2025. La solution : remplacer <code>next-themes</code> par un store Zustand + <code>useServerInsertedHTML</code> pour injecter le script hors de l'arbre React. Zéro dépendance ajoutée. Zéro FOUC. Zéro avertissement.</p>
<h2>Le Problème</h2>
<p>Si vous utilisez <code>next-themes</code> avec Next.js 15+ et React 19, vous obtenez cette erreur dans la console à chaque chargement de page :</p>
<pre><code>Encountered a script tag while rendering React component.
Scripts inside React components are never executed when rendering on the client.
Consider using template tag instead.

src/providers/theme-provider.tsx (7:10) @ ThemeProvider</code></pre>
<p>Ce n'est pas une incohérence d'hydratation. React 19 avertit explicitement que les balises <code>&lt;script&gt;</code> rendues par des composants React côté client <strong>ne s'exécuteront jamais</strong>. Le script fonctionne pendant le SSR (il est dans le HTML), mais React le signale comme incorrect.</p>
<h2>Pourquoi Ça Se Produit</h2>
<p><code>next-themes</code> doit définir la bonne classe de thème sur <code>&lt;html&gt;</code> avant l'hydratation de React — sinon il y a un flash du mauvais thème. Pour cela, il injecte un <code>&lt;script&gt;</code> inline via <code>React.createElement</code> :</p>
<pre><code>// next-themes renders a &lt;script&gt; inside a Client Component
return React.createElement(Provider, { value },
  React.createElement(&quot;script&quot;, {
    suppressHydrationWarning: true,
    dangerouslySetInnerHTML: { __html: `(...theme init code...)` }
  }),
  children
)</code></pre>
<p>React 19 a changé son comportement : les balises script dans les composants sont désormais explicitement signalées. Avant React 19, c'était ignoré silencieusement. La prop <code>suppressHydrationWarning</code> sur le script n'aide pas — elle supprime les avertissements d'hydratation, pas l'avertissement "script dans un composant".</p>
<h2>Ce Que Nous Avons Essayé (Et Pourquoi Ça N&apos;a Pas Marché)</h2>
<p>Nous avons systématiquement essayé chaque approche avant de trouver la solution :</p>
<table><thead><tr><th>Attempt</th><th>Result</th></tr></thead><tbody><tr><td>&lt;code&gt;suppressHydrationWarning&lt;/code&gt; on &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt;</td><td>Suppresses hydration mismatch, but NOT the script tag warning</td></tr><tr><td>Delay mount with &lt;code&gt;useState&lt;/code&gt; + &lt;code&gt;useEffect&lt;/code&gt;</td><td>Warning disappears, but causes FOUC (theme flash)</td></tr><tr><td>Raw &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; in layout &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;</td><td>React 19 catches it — same warning even in Server Components</td></tr><tr><td>&lt;code&gt;next/script&lt;/code&gt; with &lt;code&gt;beforeInteractive&lt;/code&gt;</td><td>Still rendered inside React tree — same warning</td></tr><tr><td>Remove &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;, put Script in body</td><td>Same warning — it&apos;s inside &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt; which is React-managed</td></tr><tr><td>&lt;code&gt;next-themes@1.0.0-beta.0&lt;/code&gt;</td><td>Beta, no release date, unclear if fixed</td></tr></tbody></table>
<h2>La Solution : Zustand + useServerInsertedHTML</h2>
<p>L'insight clé : <code>useServerInsertedHTML</code> est un hook Next.js qui injecte du HTML dans le flux SSR <strong>en dehors de l'arbre de composants React</strong>. Le script se retrouve dans le HTML mais React ne le "voit" jamais lors du rendu côté client — donc pas d'avertissement. Combiné avec un store Zustand pour l'état réactif du thème, on obtient un remplacement complet sans dépendances.</p>
<h3>Comment Ça Fonctionne</h3>
<pre><code>SSR (server):
  useServerInsertedHTML → injects &lt;script&gt; into HTML stream
  ↓
  Browser receives HTML with theme script already in it
  ↓
  Script runs BEFORE React hydrates → correct class on &lt;html&gt;
  ↓
  No flash. No mismatch. No warning.

Client (after hydration):
  useEffect → _init() → Zustand store syncs with localStorage
  ↓
  useTheme() → reactive theme state for components</code></pre>
<h3>Étape 1 : Store Zustand</h3>
<p>Le store gère l'état du thème, applique les classes au DOM, détecte le thème système et synchronise entre les onglets. La méthode <code>_init()</code> renvoie une fonction de nettoyage pour <code>useEffect</code> :</p>
<pre><code>import { create } from &quot;zustand&quot;;

const STORAGE_KEY = &quot;theme&quot;;
const MEDIA_QUERY = &quot;(prefers-color-scheme: dark)&quot;;

function getSystemTheme(): &quot;light&quot; | &quot;dark&quot; {
  return window.matchMedia(MEDIA_QUERY).matches ? &quot;dark&quot; : &quot;light&quot;;
}

function applyTheme(resolved: string, disableTransition: boolean) {
  const d = document.documentElement;

  if (disableTransition) {
    const style = document.createElement(&quot;style&quot;);
    style.appendChild(
      document.createTextNode(
        &quot;*,*::before,*::after{transition:none!important}&quot;
      )
    );
    document.head.appendChild(style);
    window.getComputedStyle(document.body);
    setTimeout(() =&gt; document.head.removeChild(style), 1);
  }

  d.classList.remove(&quot;light&quot;, &quot;dark&quot;);
  d.classList.add(resolved);
  d.style.colorScheme = resolved;
}

interface ThemeState {
  theme: string;
  systemTheme: &quot;light&quot; | &quot;dark&quot;;
  resolvedTheme: string;
  setTheme: (theme: string) =&gt; void;
  _init: (disableTransition: boolean) =&gt; () =&gt; void;
}

export const useThemeStore = create&lt;ThemeState&gt;((set, get) =&gt; ({
  theme: &quot;system&quot;,
  systemTheme: &quot;light&quot;,
  resolvedTheme: &quot;light&quot;,

  setTheme: (newTheme) =&gt; {
    const { systemTheme, disableTransitionOnChange } = get();
    const resolved = newTheme === &quot;system&quot; ? systemTheme : newTheme;
    localStorage.setItem(STORAGE_KEY, newTheme);
    applyTheme(resolved, disableTransitionOnChange);
    set({ theme: newTheme, resolvedTheme: resolved });
  },

  _init: (disableTransition) =&gt; {
    const stored = localStorage.getItem(STORAGE_KEY) || &quot;system&quot;;
    const system = getSystemTheme();
    const resolved = stored === &quot;system&quot; ? system : stored;

    set({ theme: stored, systemTheme: system, resolvedTheme: resolved });
    applyTheme(resolved, disableTransition);

    // System theme changes
    const mq = window.matchMedia(MEDIA_QUERY);
    const onChange = (e: MediaQueryListEvent) =&gt; {
      const newSystem = e.matches ? &quot;dark&quot; : &quot;light&quot;;
      const { theme } = get();
      set({ systemTheme: newSystem,
            resolvedTheme: theme === &quot;system&quot; ? newSystem : theme });
      if (theme === &quot;system&quot;) applyTheme(newSystem, disableTransition);
    };
    mq.addEventListener(&quot;change&quot;, onChange);

    // Cross-tab sync
    const onStorage = (e: StorageEvent) =&gt; {
      if (e.key !== STORAGE_KEY || !e.newValue) return;
      const system = getSystemTheme();
      const resolved = e.newValue === &quot;system&quot; ? system : e.newValue;
      set({ theme: e.newValue, resolvedTheme: resolved });
      applyTheme(resolved, disableTransition);
    };
    window.addEventListener(&quot;storage&quot;, onStorage);

    return () =&gt; {
      mq.removeEventListener(&quot;change&quot;, onChange);
      window.removeEventListener(&quot;storage&quot;, onStorage);
    };
  },
}));

export function useTheme() {
  return useThemeStore((s) =&gt; ({
    theme: s.theme,
    setTheme: s.setTheme,
    resolvedTheme: s.resolvedTheme,
    systemTheme: s.systemTheme,
  }));
}</code></pre>
<h3>Étape 2 : ThemeProvider</h3>
<p>Le provider fait deux choses : injecte le script anti-FOUC via <code>useServerInsertedHTML</code>, et initialise le store Zustand au montage :</p>
<pre><code>&quot;use client&quot;

import { useEffect } from &quot;react&quot;
import { useServerInsertedHTML } from &quot;next/navigation&quot;
import { useThemeStore } from &quot;@/store/use-theme-store&quot;

const THEME_INIT_SCRIPT = `(function(){
  try {
    var t = localStorage.getItem(&quot;theme&quot;) || &quot;system&quot;;
    var r = t;
    if (t === &quot;system&quot;) {
      r = window.matchMedia(&quot;(prefers-color-scheme: dark)&quot;).matches
        ? &quot;dark&quot; : &quot;light&quot;;
    }
    document.documentElement.classList.remove(&quot;light&quot;, &quot;dark&quot;);
    document.documentElement.classList.add(r);
    document.documentElement.style.colorScheme = r;
  } catch(e) {}
})()`

export function ThemeProvider({
  children,
  disableTransitionOnChange = false,
}) {
  // Injects script into SSR HTML outside the React tree
  // — prevents FOUC without triggering React 19 warning
  useServerInsertedHTML(() =&gt; (
    &lt;script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} /&gt;
  ))

  useEffect(() =&gt; {
    return useThemeStore.getState()._init(disableTransitionOnChange)
  }, [disableTransitionOnChange])

  return &lt;&gt;{children}&lt;/&gt;
}</code></pre>
<h3>Étape 3 : Layout</h3>
<pre><code>import { ThemeProvider } from &quot;@/providers/theme-provider&quot;

export default function RootLayout({ children }) {
  return (
    &lt;html lang=&quot;en&quot; suppressHydrationWarning&gt;
      &lt;body&gt;
        &lt;ThemeProvider disableTransitionOnChange&gt;
          {children}
        &lt;/ThemeProvider&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  )
}</code></pre>
<h3>Étape 4 : Utilisation</h3>
<pre><code>import { useTheme } from &quot;@/store/use-theme-store&quot;

export function ThemeSwitch() {
  const { theme, setTheme } = useTheme();

  return (
    &lt;button onClick={() =&gt; setTheme(
      theme === &quot;dark&quot; ? &quot;light&quot; : &quot;dark&quot;
    )}&gt;
      {theme === &quot;dark&quot; ? &quot;☀️&quot; : &quot;🌙&quot;}
    &lt;/button&gt;
  );
}</code></pre>
<h2>Migration depuis next-themes</h2>
<p>L'API est intentionnellement identique. La migration se résume à un seul changement d'import par fichier :</p>
<pre><code>- import { useTheme } from &quot;next-themes&quot;
+ import { useTheme } from &quot;@/store/use-theme-store&quot;

  // API is identical — no other changes needed
  const { theme, setTheme, resolvedTheme, systemTheme } = useTheme()</code></pre>
<h2>Comparaison</h2>
<table><thead><tr><th></th><th>next-themes</th><th>Zustand + useServerInsertedHTML</th></tr></thead><tbody><tr><td>React 19 warning</td><td>Yes — &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; in client component</td><td>No</td></tr><tr><td>FOUC prevention</td><td>Yes</td><td>Yes</td></tr><tr><td>Cross-tab sync</td><td>Yes</td><td>Yes</td></tr><tr><td>System theme detection</td><td>Yes</td><td>Yes</td></tr><tr><td>&lt;code&gt;disableTransitionOnChange&lt;/code&gt;</td><td>Yes</td><td>Yes</td></tr><tr><td>Bundle size</td><td>~3.5 KB</td><td>~1.5 KB (uses existing Zustand)</td></tr><tr><td>Dependencies</td><td>+1 (next-themes)</td><td>0 (Zustand already in project)</td></tr><tr><td>Maintenance risk</td><td>Abandoned since March 2025</td><td>Your own code</td></tr></tbody></table>
<h2>Pourquoi Pas Les Autres Alternatives ?</h2>
<h3>@wrksz/themes</h3>
<p>Un remplacement direct qui utilise aussi <code>useServerInsertedHTML</code>. Ça fonctionne, mais c'est une dépendance de plus maintenue par un seul développeur. Si <code>next-themes</code> nous a appris quelque chose — les dépendances finissent par être abandonnées. Avec ~100 lignes de code, vous possédez entièrement la solution.</p>
<h3>next-themes@1.0.0-beta.0</h3>
<p>Existe sur npm, mais sans date de sortie, sans changelog et sans indication claire que l'avertissement React 19 est corrigé. Miser du code de production sur une bêta indéfinie n'est pas un risque qui en vaut la peine.</p>
<h3>CSS uniquement (prefers-color-scheme)</h3>
<p>Fonctionne pour la détection du thème système, mais ne peut pas gérer la persistance des préférences utilisateur (localStorage), le changement manuel de thème ni l'option "system". JavaScript est nécessaire pour cela.</p>
<h2>Conclusions</h2>
<ol><li><code>next-themes</code> est effectivement abandonné — dernière version mars 2025, avertissement React 19 non corrigé</li><li><code>useServerInsertedHTML</code> est la bonne primitive Next.js pour injecter des scripts sans avertissements React</li><li>Zustand fournit un état de thème réactif avec moins de code qu'un provider Context</li><li>La solution complète fait ~100 lignes, zéro nouvelle dépendance, et vous en êtes propriétaire</li></ol>
<h2>Sources</h2>
<ul><li><a href="https://github.com/shadcn-ui/ui/issues/10104" target="_blank" rel="noopener noreferrer">shadcn/ui #10104</a> — Dark mode guide should address React 19 script warning</li><li><a href="https://github.com/pacocoursey/next-themes/issues/296" target="_blank" rel="noopener noreferrer">next-themes #296</a> — React 19 support request (open since 2024)</li><li><a href="https://github.com/facebook/react/issues/34008" target="_blank" rel="noopener noreferrer">React #34008</a> — Script tags not executing in components (by design)</li><li><a href="https://dev.to/wrksz/i-was-tired-of-next-themes-being-abandoned-so-i-built-a-drop-in-replacement-4dbk" target="_blank" rel="noopener noreferrer">@wrksz/themes</a> — Drop-in replacement using useServerInsertedHTML</li><li><a href="https://nextjs.org/docs/app/building-your-application/styling/css#server-inserted-html" target="_blank" rel="noopener noreferrer">Next.js Docs</a> — useServerInsertedHTML API</li></ul>]]></content:encoded>
      <pubDate>Sat, 11 Apr 2026 00:00:00 GMT</pubDate>
      <category>next.js</category>
      <category>react-19</category>
      <category>dark-mode</category>
      <category>zustand</category>
      <category>performance</category>
    </item>
    <item>
      <title>Comment j&apos;ai éliminé 94 fichiers CSS bloquant le rendu dans Next.js 16 avec une fonctionnalité Turbopack peu documentée</title>
      <link>https://oleksiimazurenko.dev/fr/blog/eliminating-render-blocking-css</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/fr/blog/eliminating-render-blocking-css</guid>
      <description>Après des jours à essayer chaque approche — de experimental.inlineCss aux hacks MutationObserver — j&apos;ai découvert les Turbopack Import Attributes qui résolvent le problème de CSS bloquant le rendu dans Next.js App Router.</description>
      <content:encoded><![CDATA[<h2>Le problème</h2>
<p>Notre application (<a href="https://promova.com" target="_blank" rel="noopener noreferrer">Promova</a>) utilise Next.js 16 avec un <strong>Landing Builder</strong> — un système piloté par CMS qui assemble des pages marketing à partir de ~90 composants de section différents (heroes, FAQs, tarifs, avis, etc.). L'architecture utilise un <code>sectionRegistry.tsx</code> qui mappe les noms de section aux appels <code>next/dynamic()</code> :</p>
<pre><code>// sectionRegistry.tsx — imports all 90 sections
export const sectionRegistry = {
  HeroSection: dynamic(() =&gt; import(&apos;./HeroSection/HeroSection&apos;)),
  FAQSection: dynamic(() =&gt; import(&apos;./FAQSection/FAQSection&apos;)),
  ReviewsSection: dynamic(() =&gt; import(&apos;./ReviewsSection/ReviewsSection&apos;)),
  // ... 87 more
}</code></pre>
<p>Une seule landing page ne rend que <strong>5-8 sections</strong>. Mais Lighthouse affichait :</p>
<pre><code>Eliminate render-blocking resources
  94 CSS resources (~330 KB)
  Potential savings: 5,440 ms</code></pre>
<p><strong>Pourquoi ?</strong> Turbopack voit les 90 chemins <code>import()</code> comme atteignables et génère <code>&lt;link rel="stylesheet"&gt;</code> pour <strong>chaque</strong> module SCSS. Même les sections qui ne sont jamais rendues obtiennent leur CSS injecté dans <code>&lt;head&gt;</code>. C'est un <a href="https://github.com/vercel/next.js/issues/62485" target="_blank" rel="noopener noreferrer">comportement confirmé et attendu</a> du Next.js App Router. <strong>Aucun correctif n'est prévu.</strong></p>
<h2>Tout ce que j&apos;ai essayé (et pourquoi ça a échoué)</h2>
<p>J'ai passé des jours à parcourir chaque approche que j'ai pu trouver. Voici la liste complète :</p>
<table><thead><tr><th>Approach</th><th>Why it doesn&apos;t work</th></tr></thead><tbody><tr><td>Split &lt;code&gt;sectionRegistry&lt;/code&gt; into per-section files</td><td>CSS still loaded — &lt;a href=&quot;https://github.com/vercel/next.js/issues/61066&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#61066&lt;/a&gt;</td></tr><tr><td>&lt;code&gt;experimental.inlineCss: true&lt;/code&gt;</td><td>Inlines CSS on &lt;strong&gt;every SSR request&lt;/strong&gt; — crashed our CMS under load</td></tr><tr><td>&lt;code&gt;experimental.optimizeCss&lt;/code&gt; (Critters)</td><td>&lt;a href=&quot;https://github.com/vercel/next.js/issues/62485&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Incompatible with streaming&lt;/a&gt; in App Router</td></tr><tr><td>CSS-in-JS rewrite</td><td>Rewriting 90 sections from SCSS to CSS-in-JS is not realistic</td></tr><tr><td>&lt;code&gt;media=&quot;print&quot;&lt;/code&gt; hack</td><td>Doesn&apos;t work with CSS Modules in Turbopack — &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; tags are managed by the framework</td></tr><tr><td>Switch back to Webpack</td><td>Has &lt;code&gt;experiments.css&lt;/code&gt; options, but we&apos;re committed to Turbopack</td></tr><tr><td>Turbopack plugins</td><td>&lt;a href=&quot;https://github.com/vercel/next.js/discussions/85465&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Don&apos;t exist&lt;/a&gt; — no plugin API</td></tr><tr><td>Turbopack CSS loaders</td><td>Not supported — only JS output</td></tr><tr><td>SWC plugins for CSS</td><td>SWC only processes JavaScript</td></tr><tr><td>Client Component wrapper for &lt;code&gt;dynamic()&lt;/code&gt;</td><td>Registry is a global constant — bundler sees all dependencies</td></tr><tr><td>Next.js middleware HTML rewrite</td><td>&lt;a href=&quot;https://github.com/orgs/vercel/discussions/3874&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Middleware can&apos;t modify response body&lt;/a&gt;</td></tr><tr><td>Suspense + streaming with async import</td><td>React Float always pulls CSS into initial &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;</td></tr><tr><td>Suspense + 3s delay</td><td>Content streams later, but CSS is in the initial &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; chunk</td></tr><tr><td>&lt;code&gt;experimental.cssChunking&lt;/code&gt;</td><td>Already default. Merges chunks but doesn&apos;t remove irrelevant CSS</td></tr><tr><td>Post-build Beasties/Critters</td><td>Only for static export, &lt;a href=&quot;https://github.com/vercel/next.js/discussions/59989&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;not with ISR&lt;/a&gt;</td></tr><tr><td>npm libraries (next-critical, fg-loadcss)</td><td>Pages Router only or abandoned (6+ years)</td></tr><tr><td>&lt;code&gt;inlineCss&lt;/code&gt; per-route exclusion</td><td>&lt;a href=&quot;https://github.com/vercel/next.js/pull/72195&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Not supported&lt;/a&gt; — all-or-nothing global flag</td></tr><tr><td>Turbopack &lt;code&gt;as: &apos;raw&apos;&lt;/code&gt; for SCSS</td><td>Returns &lt;code&gt;undefined&lt;/code&gt; instead of text</td></tr><tr><td>Turbopack rule for &lt;code&gt;*.inline.module.scss&lt;/code&gt;</td><td>Turbopack intercepts &lt;code&gt;.module.scss&lt;/code&gt; &lt;strong&gt;before&lt;/strong&gt; custom rules</td></tr><tr><td>Turbopack rule for &lt;code&gt;*.inline.scss&lt;/code&gt;</td><td>Turbopack intercepts &lt;code&gt;.scss&lt;/code&gt; &lt;strong&gt;before&lt;/strong&gt; custom rules</td></tr><tr><td>MutationObserver &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt;</td><td>Tested in dev, risky — CSS still loads as separate files, possible FOUC</td></tr></tbody></table>
<h3>Le piège inlineCss</h3>
<p>Next.js a un flag <a href="https://github.com/vercel/next.js/pull/72195" target="_blank" rel="noopener noreferrer"><code>experimental.inlineCss</code></a> qui remplace tous les <code>&lt;link rel="stylesheet"&gt;</code> par des balises <code>&lt;style&gt;</code> inline. Ça semble parfait, non ?</p>
<p>Le problème : c'est <strong>tout ou rien</strong>. On ne peut pas l'activer par route. Si vous avez des pages SSR (<code>force-dynamic</code>), chaque requête reconstruit tout le CSS inline. Nous avons essayé — notre CMS headless n'a pas supporté la charge.</p>
<h2>La découverte : Turbopack Import Attributes</h2>
<p>En fouillant les <a href="https://nextjs.org/blog/next-16-2-turbopack#inline-loader-configuration" target="_blank" rel="noopener noreferrer">notes de version Next.js 16.2</a>, j'ai trouvé une fonctionnalité peu documentée : <strong>Turbopack Import Attributes</strong>. Elle permet de remplacer le pipeline du bundler pour un import spécifique avec la syntaxe TC39 <code>with {}</code> :</p>
<pre><code>import { cssText, styles } from &apos;./hero.module.scss&apos; with {
  turbopackLoader: &apos;@promova/scss-to-inline-loader&apos;,
  turbopackAs: &apos;*.js&apos;
}</code></pre>
<p>Cela dit à Turbopack : <em>"Ne traite pas cet import comme une feuille de style. Passe-le par mon loader personnalisé et traite la sortie comme du JavaScript."</em></p>
<p><strong>C'est l'insight clé.</strong> Au lieu que Turbopack génère un <code>&lt;link rel="stylesheet"&gt;</code> bloquant le rendu, notre loader compile le SCSS et l'exporte comme string JS. Résultat : <strong>seul le CSS des sections qui sont réellement rendues arrive dans le HTML de la page</strong>.</p>
<h2>La solution</h2>
<h3>1. Loader Turbopack personnalisé</h3>
<p>Un script Node.js d'environ 70 lignes comme package yarn workspace (<code>@promova/scss-to-inline-loader</code>) :</p>
<pre><code>const { createHash } = require(&apos;node:crypto&apos;)
const sass = require(&apos;sass&apos;)
const postcss = require(&apos;postcss&apos;)
const postcssModules = require(&apos;postcss-modules&apos;)
const path = require(&apos;path&apos;)

const SHARED_STYLES = path.resolve(__dirname, &apos;../shared/styles&apos;)

// Same prependData as sassOptions.prependData in next.config.ts
const PREPEND_DATA = `
@use &quot;sass:math&quot; as math;
@use &quot;sass:list&quot; as list;
@use &quot;${SHARED_STYLES}/_variables.scss&quot; as *;
@use &quot;${SHARED_STYLES}/_breakpoints.scss&quot; as *;
@use &quot;${SHARED_STYLES}/_mixins.scss&quot; as *;
`

module.exports = function scssToInlineLoader(source) {
  const callback = this.async()
  processScss(source, this.resourcePath)
    .then((js) =&gt; callback(null, js))
    .catch((err) =&gt; callback(err))
}

async function processScss(source, resourcePath) {
  // 1. Compile SCSS → CSS (with global variables, mixins, breakpoints)
  const sassResult = sass.compileString(PREPEND_DATA + &apos;\n&apos; + source, {
    loadPaths: [path.dirname(resourcePath), SHARED_STYLES],
    style: &apos;compressed&apos;,
    sourceMap: false,
    url: new URL(&apos;file://&apos; + resourcePath),
  })

  // 2. Scope class names with postcss-modules (CSS Modules compatible)
  let classNames = {}
  const result = await postcss([
    postcssModules({
      generateScopedName(name, filename) {
        const file = path.basename(filename, path.extname(filename))
          .replace(&apos;.module&apos;, &apos;&apos;)
        const hash = createHash(&apos;md5&apos;)
          .update(filename + name)
          .digest(&apos;base64url&apos;)
          .slice(0, 6)
        return `${file}__${name}__${hash}`
      },
      getJSON(_, json) { classNames = json },
    }),
  ]).process(sassResult.css, { from: resourcePath })

  // 3. Export as JS module — same interface as CSS Modules
  return [
    `export const cssText = ${JSON.stringify(result.css)};`,
    `export const styles = ${JSON.stringify(classNames)};`,
    `export default styles;`,
  ].join(&apos;\n&apos;)
}</code></pre>
<p>Ce qu'il fait : <strong><code>styles</code></strong> — la même carte de noms de classes scopés que les CSS Modules standard. <strong><code>cssText</code></strong> — le CSS compilé comme string.</p>
<h3>2. Composant InlineStyle</h3>
<p>Utilise l'API intégrée de React 19 <code>&lt;style href precedence&gt;</code> pour la déduplication automatique :</p>
<pre><code>export function InlineStyle({ css, id }: { css: string; id: string }) {
  return (
    &lt;style href={id} precedence=&quot;default&quot;&gt;
      {css}
    &lt;/style&gt;
  )
}</code></pre>
<p>React 19 garantit : même <code>href</code> → un seul <code>&lt;style&gt;</code> dans le DOM.</p>
<h3>3. Migration par composant (~6 lignes par section)</h3>
<pre><code>// BEFORE: Turbopack → &lt;link rel=&quot;stylesheet&quot;&gt; (render-blocking)
import styles from &apos;./hero_section.module.scss&apos;</code></pre>
<pre><code>// AFTER: Custom loader → JS module → inline &lt;style&gt; (non-blocking)
import { cssText, styles } from &apos;./hero_section.module.scss&apos; with {
  turbopackLoader: &apos;@promova/scss-to-inline-loader&apos;,
  turbopackAs: &apos;*.js&apos;
}
import { InlineStyle } from &apos;@promova/scss-to-inline-loader/InlineStyle&apos;

export function HeroSection() {
  return (
    &lt;section className={styles.hero}&gt;
      &lt;InlineStyle css={cssText} id=&quot;hero-section&quot; /&gt;
      {/* ... component JSX, className usage is identical */}
    &lt;/section&gt;
  )
}</code></pre>
<p><strong>Les fichiers <code>.module.scss</code> restent exactement les mêmes.</strong> Aucune réécriture CSS.</p>
<h2>Pourquoi c&apos;est mieux que inlineCss: true</h2>
<p>Voici la différence critique :</p>
<table><thead><tr><th></th><th>&lt;code&gt;inlineCss: true&lt;/code&gt;</th><th>Import Attributes + Loader</th></tr></thead><tbody><tr><td>What gets inlined</td><td>&lt;strong&gt;ALL CSS on the page&lt;/strong&gt; (94 files)</td><td>&lt;strong&gt;Only CSS for rendered sections&lt;/strong&gt; (5-8 files)</td></tr><tr><td>SSR overhead</td><td>CSS rebuilt on every request</td><td>CSS compiled at build time</td></tr><tr><td>Per-route control</td><td>No (all-or-nothing)</td><td>Yes (per-import)</td></tr><tr><td>SSR pages safety</td><td>Risky (overloads server)</td><td>Safe (component-level)</td></tr></tbody></table>
<p>Avec <code>inlineCss: true</code>, une page obtient toujours les 94 feuilles de style inline. Avec notre approche, <strong>seul le CSS réellement rendu arrive dans le HTML</strong>.</p>
<h2>Le piège Turbopack : pas de règles globales pour .module.scss</h2>
<p>Un piège dans lequel je suis tombé : on pourrait penser qu'on peut ajouter une règle Turbopack dans <code>next.config.ts</code> pour appliquer le loader globalement :</p>
<pre><code>// ❌ THIS CAUSES A FATAL PANIC
turbopack: {
  rules: {
    &apos;**/components/**/*.module.scss&apos;: {
      loaders: [&apos;@promova/scss-to-inline-loader&apos;],
      as: &apos;*.js&apos;
    }
  }
}</code></pre>
<p><strong>Ne faites pas ça.</strong> Le pipeline intégré de modules CSS de Turbopack intercepte les fichiers <code>.module.scss</code> <strong>avant</strong> l'application des règles personnalisées, causant :</p>
<pre><code>FATAL PANIC: inner asset should be CSS processable</code></pre>
<p>Les attributs <code>with {}</code> fonctionnent car ils instruisent Turbopack <strong>au site d'import</strong> de contourner complètement le pipeline de modules CSS.</p>
<h2>Résultats</h2>
<p>127 composants de section migrés dans le Landing Builder. Build de production vérifié.</p>
<table><thead><tr><th>Metric</th><th>Before</th><th>After</th></tr></thead><tbody><tr><td>Render-blocking CSS files</td><td>94</td><td>0 (for landing sections)</td></tr><tr><td>CSS in HTML per page</td><td>~330 KB (all sections)</td><td>~20-40 KB (rendered sections only)</td></tr><tr><td>CSS delivery</td><td>&lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; (network request, blocking)</td><td>&lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; (inline, non-blocking)</td></tr><tr><td>Sass features</td><td>Full</td><td>Full (variables, mixins, nesting, @use)</td></tr><tr><td>CSS Modules scoping</td><td>Built-in</td><td>postcss-modules (compatible)</td></tr><tr><td>Code change per section</td><td>—</td><td>~6 lines (import + InlineStyle)</td></tr></tbody></table>
<h2>Limitations</h2>
<ul><li><strong><code>with {}</code> par import est verbeux</strong> — chaque import nécessite 3 lignes supplémentaires.</li><li><strong>Turbopack uniquement</strong> — les attributs <code>with {}</code> ne sont pas supportés par Webpack.</li><li><strong>Hachage des noms de classe</strong> — notre loader utilise un algorithme de hachage différent de celui de Turbopack.</li><li><strong>La taille du HTML augmente</strong> — CSS inline dans le HTML au lieu de fichiers cachés séparés.</li></ul>
<h2>Quand l&apos;utiliser</h2>
<p>Cette technique est plus efficace quand :</p>
<ol><li><strong>Vous avez un pattern registry/barrel</strong> — un fichier importe beaucoup de composants, mais seuls quelques-uns sont rendus par page</li><li><strong>Vous êtes sur Turbopack</strong> — les Import Attributes sont spécifiques à Turbopack</li><li><strong>Vous voulez un contrôle par composant</strong> — pas un flag tout ou rien</li><li><strong>Votre SCSS est complexe</strong> — variables, mixins, breakpoints, imbrication — tout supporté</li><li><strong>Vous ne pouvez pas utiliser <code>experimental.inlineCss</code></strong> — parce que vous avez des pages SSR ou voulez un contrôle granulaire</li></ol>
<h2>Issues GitHub liées</h2>
<p>Si vous êtes affecté par le CSS bloquant le rendu dans Next.js App Router — vous n'êtes pas seul :</p>
<h3>Le problème central</h3>
<ul><li><a href="https://github.com/vercel/next.js/issues/62485" target="_blank" rel="noopener noreferrer"><strong>#62485</strong></a> — Render blocking CSS (maintainers: "expected behavior")</li><li><a href="https://github.com/vercel/next.js/issues/61066" target="_blank" rel="noopener noreferrer"><strong>#61066</strong></a> — Dynamic imports from Server Components are not code-split</li><li><a href="https://github.com/vercel/next.js/issues/54935" target="_blank" rel="noopener noreferrer"><strong>#54935</strong></a> — Server-side dynamic imports don't split client modules</li><li><a href="https://github.com/vercel/next.js/issues/61574" target="_blank" rel="noopener noreferrer"><strong>#61574</strong></a> — JS/CSS code splitting doesn't work as documented</li><li><a href="https://github.com/vercel/next.js/issues/57634" target="_blank" rel="noopener noreferrer"><strong>#57634</strong></a> — Add support for critical CSS inlining with App Router</li><li><a href="https://github.com/vercel/next.js/issues/50300" target="_blank" rel="noopener noreferrer"><strong>#50300</strong></a> — next/dynamic on server component does not build CSS modules</li></ul>
<h3>Feature inlineCss et problèmes</h3>
<ul><li><a href="https://github.com/vercel/next.js/pull/72195" target="_blank" rel="noopener noreferrer"><strong>PR #72195</strong></a> — experimental: css inlining (the implementation)</li><li><a href="https://github.com/vercel/next.js/pull/73182" target="_blank" rel="noopener noreferrer"><strong>PR #73182</strong></a> — Don't inline CSS in RSC payload for client navigation</li><li><a href="https://github.com/vercel/next.js/issues/75648" target="_blank" rel="noopener noreferrer"><strong>#75648</strong></a> — WhatsApp preview broken with inlineCss + Tailwind</li><li><a href="https://github.com/vercel/next.js/issues/83612" target="_blank" rel="noopener noreferrer"><strong>#83612</strong></a> — Turbopack wrong font URL with inline CSS</li></ul>
<h3>La communauté cherche des solutions</h3>
<ul><li><a href="https://github.com/vercel/next.js/discussions/82894" target="_blank" rel="noopener noreferrer"><strong>Discussion #82894</strong></a> — How to prevent render-blocking with CSS Modules? (2025)</li><li><a href="https://github.com/vercel/next.js/discussions/70526" target="_blank" rel="noopener noreferrer"><strong>Discussion #70526</strong></a> — Ideas for reducing render-blocking CSS</li><li><a href="https://github.com/vercel/next.js/discussions/59814" target="_blank" rel="noopener noreferrer"><strong>Discussion #59814</strong></a> — Render blocking styles with Tailwind</li><li><a href="https://github.com/vercel/next.js/discussions/49691" target="_blank" rel="noopener noreferrer"><strong>Discussion #49691</strong></a> — How to deal with render-blocking modular CSS</li><li><a href="https://github.com/vercel/next.js/discussions/59989" target="_blank" rel="noopener noreferrer"><strong>Discussion #59989</strong></a> — Critical CSS inlining with App Router</li><li><a href="https://github.com/vercel/next.js/discussions/80486" target="_blank" rel="noopener noreferrer"><strong>Discussion #80486</strong></a> — Is optimizeCss still in use?</li><li><a href="https://github.com/vercel/next.js/discussions/85465" target="_blank" rel="noopener noreferrer"><strong>Discussion #85465</strong></a> — Turbopack plugin API (doesn't exist)</li></ul>
<h3>Bugs CSS de Turbopack</h3>
<ul><li><a href="https://github.com/vercel/next.js/issues/68412" target="_blank" rel="noopener noreferrer"><strong>#68412</strong></a> — Incorrect CSS modules load order with external components</li><li><a href="https://github.com/vercel/next.js/issues/76464" target="_blank" rel="noopener noreferrer"><strong>#76464</strong></a> — Turbopack sometimes strips SCSS imports</li><li><a href="https://github.com/vercel/next.js/issues/82497" target="_blank" rel="noopener noreferrer"><strong>#82497</strong></a> — SCSS module not loading when not-found.tsx is present</li><li><a href="https://github.com/vercel/next.js/issues/88544" target="_blank" rel="noopener noreferrer"><strong>#88544</strong></a> — Exporting Sass variables from CSS modules doesn't work with Turbopack</li></ul>
<p>Construit chez <a href="https://promova.com" target="_blank" rel="noopener noreferrer">Promova</a> — une plateforme d'apprentissage des langues au service de millions d'utilisateurs.</p>]]></content:encoded>
      <pubDate>Wed, 08 Apr 2026 00:00:00 GMT</pubDate>
      <category>Next.js</category>
      <category>CSS</category>
      <category>Performance</category>
      <category>Turbopack</category>
      <category>SCSS</category>
    </item>
    <item>
      <title>Next.js Memory Leak : Fetch + Mode Standalone — 2 ans sans correctif</title>
      <link>https://oleksiimazurenko.dev/fr/blog/nextjs-memory-leak-fetch-standalone</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/fr/blog/nextjs-memory-leak-fetch-standalone</guid>
      <description>Next.js patche le fetch global et ajoute une couche de cache qui fuit à chaque requête. Sous Docker/K8s, cela provoque des crashes OOM toutes les quelques heures. Le bug existe depuis Next.js 14 et n&apos;est toujours pas résolu dans 16.2.x.</description>
      <content:encoded><![CDATA[<h2>TL;DR</h2>
<p>Next.js patche le <code>fetch</code> global et ajoute une couche de cache qui maintient des références sur les données de réponse après qu'elles auraient dû être libérées. Chaque appel <code>fetch</code> ajoute de la mémoire qui n'est jamais rendue au GC. Sous Docker/Kubernetes, cela provoque des crashes OOM toutes les quelques heures. Le bug existe depuis Next.js 14 (avril 2024) et n'est toujours pas résolu dans 16.2.x (mars 2026). Sur Vercel, le problème ne se manifeste pas grâce aux fonctions serverless éphémères.</p>
<h2>Comment fonctionne un fetch normal</h2>
<pre><code>Request → fetch → got data → response to user → GC cleans up → memory free</code></pre>
<h2>Comment fonctionne fetch dans Next.js</h2>
<p>Next.js intercepte le <code>fetch</code> global et l'enveloppe avec sa propre couche de cache/tracking :</p>
<pre><code>Request → Next.js fetch wrapper → creates:
  - Performance entry (speed tracking)    ← not cleaned
  - Request metadata object               ← not cleaned
  - Internal promise wrapper              ← not cleaned
  - Cache lookup entry                    ← not cleaned
  - Response body (for unique URL)        ← not cleaned
→ Response to user
→ GC sees objects are still referenced → doesn&apos;t touch them
→ Memory grows indefinitely</code></pre>
<h2>Ce qui se passe en production</h2>
<pre><code>Self-hosted (Docker/K8s):
  Request 1:      fetch → +100KB → RAM: 100KB
  Request 2:      fetch → +100KB → RAM: 200KB
  Request 1000:   fetch → +100KB → RAM: 100MB
  Request 50,000: fetch → +100KB → RAM: 5GB → OOM Kill 💀
  → Kubernetes restarts the pod → cycle repeats

Vercel (Serverless):
  Request 1: [start] → fetch → +100KB → [process dies] → 0 KB ✅
  Request 2: [start] → fetch → +100KB → [process dies] → 0 KB ✅
  → Memory never accumulates</code></pre>
<h2>Versions affectées</h2>
<h3>Next.js — Toutes les versions avec App Router</h3>
<table><thead><tr><th>Version</th><th>Issue</th><th>Date</th></tr></thead><tbody><tr><td>14.2.x</td><td>&lt;a href=&quot;https://github.com/vercel/next.js/issues/64212&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#64212&lt;/a&gt;</td><td>April 2024</td></tr><tr><td>14.x-15.x</td><td>&lt;a href=&quot;https://github.com/vercel/next.js/issues/68578&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#68578&lt;/a&gt;</td><td>August 2024</td></tr><tr><td>14.3.0-canary</td><td>&lt;a href=&quot;https://github.com/vercel/next.js/issues/79588&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#79588&lt;/a&gt;</td><td>May 2025</td></tr><tr><td>16.0.1</td><td>&lt;a href=&quot;https://github.com/vercel/next.js/issues/85914&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#85914&lt;/a&gt;</td><td>November 2025</td></tr><tr><td>16.1.0</td><td>&lt;a href=&quot;https://github.com/vercel/next.js/discussions/88603&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#88603&lt;/a&gt;</td><td>January 2026</td></tr><tr><td>16.0.10</td><td>&lt;a href=&quot;https://github.com/vercel/next.js/issues/90433&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#90433&lt;/a&gt;</td><td>February 2026</td></tr><tr><td>16.2.0-canary.51</td><td>Confirmed in #90433 comments</td><td>March 2026</td></tr></tbody></table>
<h3>Node.js</h3>
<p>Testé sur Node.js 20, 22, 24, 25 — fuite sur tous.</p>
<h2>Ce qui ne fonctionne pas</h2>
<table><thead><tr><th>Attempt</th><th>Result</th></tr></thead><tbody><tr><td>&lt;code&gt;cacheMaxMemorySize: 0&lt;/code&gt;</td><td>Doesn&apos;t help — leak is not from this cache</td></tr><tr><td>Disable image optimization</td><td>Doesn&apos;t help</td></tr><tr><td>&lt;code&gt;--max-old-space-size=6144&lt;/code&gt;</td><td>Just crashes slower</td></tr><tr><td>Read response via &lt;code&gt;.json()&lt;/code&gt; / &lt;code&gt;.text()&lt;/code&gt;</td><td>Doesn&apos;t help</td></tr><tr><td>Remove React fetch patching (canary.45)</td><td>Doesn&apos;t help</td></tr><tr><td>Upgrade to latest version</td><td>Doesn&apos;t help (leaks on 16.2.0-canary.51 too)</td></tr></tbody></table>
<h2>Ce qui fonctionne (contournements)</h2>
<table><thead><tr><th>Workaround</th><th>Why it helps</th></tr></thead><tbody><tr><td>Replace &lt;code&gt;fetch&lt;/code&gt; with &lt;code&gt;axios&lt;/code&gt;</td><td>Bypasses Next.js wrapper</td></tr><tr><td>Replace &lt;code&gt;fetch&lt;/code&gt; with &lt;code&gt;node-fetch&lt;/code&gt;</td><td>Same reason</td></tr><tr><td>Downgrade Node.js to 20.15.1</td><td>Older undici has fewer leaks (for some)</td></tr><tr><td>Docker image &lt;code&gt;node:20-alpine3.21&lt;/code&gt;</td><td>Helps in some cases</td></tr><tr><td>Deploy to Lambda (SST + OpenNext)</td><td>Ephemeral functions — memory doesn&apos;t accumulate</td></tr><tr><td>Deploy to Vercel</td><td>Same — serverless</td></tr></tbody></table>
<h3>Limitation du contournement axios</h3>
<p>Vos propres appels API peuvent être remplacés par axios. Mais Next.js utilise en interne le fetch patché pour :</p>
<ul><li>ISR (Incremental Static Regeneration)</li><li><code>revalidatePath</code> / <code>revalidateTag</code></li><li>Data fetching des Server Components avec déduplication</li><li><code>use cache</code> (Next.js 16)</li></ul>
<p>Même sans un seul <code>fetch</code> dans votre code — Next.js l'utilise toujours en interne.</p>
<h2>Pourquoi Vercel ne corrige pas</h2>
<h3>Logique business</h3>
<p>Vercel est une entreprise qui gagne de l'argent en hébergeant Next.js. Sur leur plateforme, le problème ne se manifeste pas (serverless = éphémère). Le bug n'affecte que le self-hosted (Docker, K8s, VPS) — ceux qui ne paient pas Vercel.</p>
<h3>Position officielle</h3>
<p>Tim Neutkens (mainteneur Vercel) a analysé le problème et déclaré que c'est un problème d'<strong>undici</strong> (bibliothèque fetch de Node.js), pas de Next.js. L'issue #90433 a été fermée. Malgré le fait que :</p>
<ul><li>axios et node-fetch sur le même Node.js fonctionnent sans fuites</li><li>La fuite n'apparaît que quand fetch passe par le wrapper Next.js</li><li>Le bug est ouvert depuis 2 ans sans correction</li></ul>
<h3>Priorités</h3>
<p>En 2 ans, l'équipe Next.js a livré :</p>
<ul><li>Turbopack (builds 2-5x plus rapides) — avantage marketing</li><li>Cache Components / <code>use cache</code> — réduit la charge sur les serveurs Vercel</li><li><code>proxy.ts</code> au lieu du middleware — simplifie le déploiement edge sur Vercel</li><li>DevTools MCP — hype IA</li></ul>
<p>Memory leak en self-hosted ? Pas une priorité.</p>
<h2>Solution : AWS Lambda (SST + OpenNext)</h2>
<h3>Qu&apos;est-ce que c&apos;est</h3>
<p>OpenNext est un adaptateur open-source qui convertit un build Next.js en format pour AWS Lambda. SST est un framework qui automatise l'infrastructure.</p>
<h3>Architecture</h3>
<pre><code>Next.js build
  → OpenNext
    → AWS Lambda (SSR, API routes)
    → S3 (static, assets)
    → CloudFront (CDN)
    → SQS + DynamoDB (ISR revalidation)</code></pre>
<h3>Pourquoi cela résout le memory leak</h3>
<p>Les fonctions Lambda traitent les requêtes et sont recyclées après 5-15 minutes d'inactivité. La mémoire n'a pas le temps de s'accumuler.</p>
<h3>Déploiement</h3>
<pre><code>npx sst@latest init
npx sst deploy --stage production</code></pre>
<h3>Comparaison</h3>
<table><thead><tr><th></th><th>Vercel</th><th>AWS Lambda (SST)</th><th>Docker self-hosted</th></tr></thead><tbody><tr><td>Memory leak</td><td>Not felt</td><td>Not felt</td><td>Critical</td></tr><tr><td>Cold starts</td><td>Yes</td><td>Yes (~200-500ms)</td><td>No</td></tr><tr><td>Price (~medium traffic)</td><td>$20-150/mo</td><td>$5-30/mo</td><td>$5-50/mo</td></tr><tr><td>Control</td><td>Minimal</td><td>Full</td><td>Full</td></tr><tr><td>ISR/Revalidation</td><td>Works</td><td>Works (SQS)</td><td>Works (with leak)</td></tr><tr><td>Vendor lock-in</td><td>Vercel</td><td>AWS</td><td>None</td></tr></tbody></table>
<h3>Nuances Lambda</h3>
<ul><li><strong>Cold starts</strong> — la première requête est plus lente (~200-500ms)</li><li><strong>Sécurité</strong> — activer OAC (Origin Access Control), sinon l'URL Lambda est publique</li><li><strong>OpenNext</strong> — projet communautaire, pas officiel Vercel. Les nouvelles fonctionnalités Next.js peuvent casser</li><li><strong>Attaque au portefeuille</strong> — lors d'un DDoS, l'auto-scaling Lambda peut mener à une grosse facture</li></ul>
<h2>Pourquoi une vraie correction est irréaliste</h2>
<h3>1. Problème architectural</h3>
<p>La fuite n'est pas un bug accidentel mais la conséquence d'une décision de conception : Next.js intercepte le <code>fetch</code> global et ajoute du cache/tracking par-dessus. Pour corriger, il faudrait repenser la façon dont App Router interagit avec fetch. Cela touche ISR, revalidation, data cache, request deduplication — le cœur du framework.</p>
<h3>2. Conflit d&apos;intérêts</h3>
<p>Vercel n'est pas motivé à corriger ce qui n'affecte pas sa plateforme. Le self-hosted est en concurrence avec leur business. Plus il y a de problèmes en self-hosted — plus de gens migrent vers Vercel.</p>
<h3>3. Rejet de la faute</h3>
<p>La position officielle est "c'est undici, pas nous". Tant que cela ne change pas — ils ne travailleront pas sur un correctif.</p>
<h3>4. Pas de correctif communautaire</h3>
<p>La licence AGPL-3.0 de Next.js autorise les forks, mais la base de code est énorme et étroitement couplée à l'infrastructure Vercel. Un PR communautaire pour corriger le wrapper fetch nécessiterait une compréhension approfondie de l'architecture interne et l'approbation des mainteneurs — qui ont déjà fermé l'issue.</p>
<h2>Conclusions</h2>
<ol><li><strong>Si sur Vercel</strong> — pas de problème, rien à faire</li><li><strong>Si self-hosted et besoin de serverless</strong> — SST + OpenNext sur AWS Lambda</li><li><strong>Si self-hosted Docker</strong> — remplacer fetch par axios où possible, surveiller la RAM, configurer le redémarrage automatique des pods</li><li><strong>Si nouveau projet</strong> — envisager SvelteKit ou Nuxt comme alternatives sans ce problème</li></ol>
<h2>Sources</h2>
<ul><li><a href="https://github.com/vercel/next.js/issues/64212" target="_blank" rel="noopener noreferrer">Issue #64212</a> — Memory Leak with global fetch (April 2024)</li><li><a href="https://github.com/vercel/next.js/issues/68578" target="_blank" rel="noopener noreferrer">Issue #68578</a> — Possible memory leak in Fetch API (August 2024)</li><li><a href="https://github.com/vercel/next.js/issues/85914" target="_blank" rel="noopener noreferrer">Issue #85914</a> — Memory Leak with fetch + standalone (November 2025)</li><li><a href="https://github.com/vercel/next.js/issues/90433" target="_blank" rel="noopener noreferrer">Issue #90433</a> — OOM in 16.0.10 (February 2026)</li><li><a href="https://github.com/vercel/next.js/discussions/88603" target="_blank" rel="noopener noreferrer">Discussion #88603</a> — OOM in Docker/K8s (January 2026)</li><li><a href="https://github.com/vercel/next.js/discussions/88078" target="_blank" rel="noopener noreferrer">Discussion #88078</a> — cacheMaxMemorySize behavior</li><li><a href="https://opennext.js.org/" target="_blank" rel="noopener noreferrer">OpenNext</a></li><li><a href="https://sst.dev/docs/start/aws/nextjs/" target="_blank" rel="noopener noreferrer">SST — Next.js on AWS</a></li><li><a href="https://www.flightcontrol.dev/blog/secret-knowledge-to-self-host-nextjs" target="_blank" rel="noopener noreferrer">Secret knowledge to self-host Next.js</a></li><li><a href="https://nextjs.org/docs/app/guides/memory-usage" target="_blank" rel="noopener noreferrer">Next.js Memory Usage Guide</a></li></ul>]]></content:encoded>
      <pubDate>Thu, 19 Mar 2026 00:00:00 GMT</pubDate>
      <category>Next.js</category>
      <category>Memory Leak</category>
      <category>Docker</category>
      <category>AWS Lambda</category>
      <category>Node.js</category>
    </item>
  </channel>
</rss>