<?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/de/blog</link>
    <description>Technical articles about web development, performance optimization, and developer tools.</description>
    <language>de</language>
    <lastBuildDate>Sat, 13 Jun 2026 00:00:00 GMT</lastBuildDate>
    <atom:link href="https://oleksiimazurenko.dev/de/blog/feed.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Claude Code Multi-Profile, v2: eine saubere XDG-Architektur</title>
      <link>https://oleksiimazurenko.dev/de/blog/claude-profiles-clean-architecture</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/de/blog/claude-profiles-clean-architecture</guid>
      <description>Nach einem Monat mit dem alten Alias-Ansatz habe ich mein Claude-Code-Multi-Profile-Setup auf eine XDG-konforme Struktur unter ~/.config/claude-profiles/ umgebaut. Der eigentliche Grund war keine Ordnungsliebe — ich habe entdeckt, dass ~/.claude.json eine separate Datei im Home-Root ist, in der die per --scope user hinzugefügten MCP-Server still vor sich hin lebten.</description>
      <content:encoded><![CDATA[<p>Vor einem Monat habe ich <a href="/de/blog/multiple-claude-accounts-one-device">einen Beitrag darüber veröffentlicht, wie man zwei Claude-Code-Accounts parallel auf einem Gerät betreibt</a> — privat und beruflich, über <code>CLAUDE_CONFIG_DIR</code> und einen Shell-Alias. Der Ansatz hat funktioniert. Alles hat das getan, was es tun sollte.</p>
<p>Aber nach einem Monat realem Einsatz bin ich auf ein paar substanzielle Probleme gestoßen, die jener Beitrag nicht abgedeckt hat. Das größte davon — eine versteckte Eigenheit von Claude Code, in die ich zufällig hineingelaufen bin, als ich den Gmail-MCP hinzugefügt habe und er fünf Minuten später aus meinem Profil "verschwunden" war. Heute habe ich alles auf eine neue Architektur umgebaut — XDG-konform, mit einem Symlink und einem interaktiven Profil-Picker über <code>gum</code>. Das ist v2 — eine Evolution aus realer Erfahrung, keine theoretische Verbesserung.</p>
<h2>Was angefangen hat zu nerven</h2>
<p>Vier Sachen. Die ersten drei drehen sich um Sauberkeit, Sichtbarkeit und Skalierung. Die vierte ist der echte Architektur-Gotcha, den ich nicht sofort gesehen habe. Ich gehe sie der Reihe nach durch, denn genau die vierte hat am Ende den Umbau erzwungen.</p>
<h3>1. Zwei separate Ordner in $HOME — das ist Unordnung</h3>
<p><code>~/.claude</code> und <code>~/.claude-promova</code> — zwei Dotfolder direkt nebeneinander im Root von $HOME. Die XDG Base Directory Specification sagt, dass Configs in <code>~/.config/</code> gehören. Dotfile-Ordner, die direkt in Home herumliegen, sind ein Antipattern, das $HOME mit der Zeit zur Rumpelkammer macht. Kosmetisch, klar, aber es hat mich jedes Mal genervt, wenn ich <code>ls -la ~</code> gemacht habe.</p>
<h3>2. Keine visuelle Bestätigung des aktiven Profils</h3>
<p>Ich starte <code>claude</code> und weiß nicht, welches Profil aktiv ist — bis ich innerhalb der Session <code>claude config list</code> ausführe. Wenn ich vergessen habe, welches Terminal ich wo gestartet habe, muss ich nachschauen. Eine Kleinigkeit, aber bei parallelem Arbeiten mit personal + work in zwei Tabs summiert sich das.</p>
<h3>3. Aliase skalieren nicht</h3>
<p>Zwei Profile — <code>claude</code> und <code>claude-promova</code> — okay. Ein drittes dazu (ein Freelance-Kunde) — brauchst du einen dritten Alias. Ein viertes — einen vierten. Nach einem halben Jahr wüsste ich nicht mehr, welche Aliase ich überhaupt angelegt hatte.</p>
<h3>4. Die versteckte Falle in ~/.claude.json</h3>
<p>Und das ist der eigentliche Grund für den Umbau. Claude Code hat <strong>zwei verschiedene Orte</strong> für Konfiguration, und die Doku schreit das nicht heraus: <code>~/.claude/</code> — das Verzeichnis mit <code>projects/</code>, <code>sessions/</code>, <code>hooks/</code>, <code>skills/</code>. Und separat — <code>~/.claude.json</code>, eine Datei direkt im $HOME-Root, in der <code>oauthAccount</code>, <code>mcpServers</code>, die <code>projects</code>-History, <code>skillUsage</code> und etwa 40 weitere Felder echten Live-State liegen.</p>
<p>Der Befehl <code>claude mcp add --scope user</code> schreibt genau in dieses <code>~/.claude.json</code> im Home-Root, <strong>nicht</strong> in <code>~/.claude/.claude.json</code> oder ins Profilverzeichnis. Das wusste ich nicht. Bis ich eines Tages reingelaufen bin.</p>
<h2>Discovery: warum der Gmail-MCP &quot;verschwunden&quot; ist</h2>
<p>Heute Morgen habe ich den Gmail-MCP in Claude Code eingerichtet. Standard-Setup: Google-Cloud-Projekt, OAuth-Credentials, <code>claude mcp add gmail --scope user -- npx -y @gongrzhe/server-gmail-autoauth-mcp</code>. Alles okay. Session neu gestartet — läuft, ich lese Mails, antworte auf Nachrichten. Eine Stunde später haben wir angefangen, die Aliase in eine Funktion mit <code>gum</code>-Profil-Picker zu refaktorieren, und danach alles auf XDG umgezogen. Ich habe <code>mv ~/.claude → ~/.config/claude-profiles/personal</code> gemacht, CC neu gestartet, im Menü personal gewählt. Und in der neuen Session <code>/mcp</code> geöffnet:</p>
<pre><code>figma            (failed)
playwright-test
claude.ai Notion</code></pre>
<p>Kein gmail. Kein vaultforge. Nur drei Server, von denen einer sogar failed war. Dabei hatte ich Gmail gerade hinzugefügt. Die Session in einem anderen Terminal (Work-Profil) zeigte Gmail und Vaultforge gleichzeitig ohne Probleme.</p>
<p>Ich habe angefangen zu graben und entdeckt, dass ich auf meinem Rechner <strong>drei</strong> verschiedene Dateien namens <code>.claude.json</code> habe:</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>Da war es. Genau das ist der Architektur-Gotcha:</p>
<ol><li><code>claude mcp add --scope user</code> schreibt immer in <code>~/.claude.json</code> im Home-Root, unabhängig von <code>CLAUDE_CONFIG_DIR</code></li><li>Claude Code liest bei gesetztem <code>CLAUDE_CONFIG_DIR</code> die Datei <code>$CLAUDE_CONFIG_DIR/.claude.json</code> — also die Datei innerhalb des Profils</li><li>Die Einträge "user-scope MCPs" und "Profil-MCPs" liegen in <strong>verschiedenen Dateien</strong> mit gleichem Namen — und lassen sich leicht verwechseln</li></ol>
<p>In meinem Fall war <code>~/.claude.json</code> (Home-Root, 113 KB) der lebendige, aktuelle State — mit gmail, vaultforge, OAuth-Session, alles. Und <code>~/.config/claude-profiles/personal/.claude.json</code> (29 KB) erwies sich als alter Snapshot, der irgendwie schon vorher im alten <code>~/.claude/</code> lag — vielleicht hat eine ältere CC-Version dort geschrieben, vielleicht ein Plugin. <code>jq -r 'keys[]'</code> auf beiden Dateien zeigte, dass die Home-Root-Version 41 unique Keys hatte, die im Snapshot fehlten.</p>
<p>Und diese 41 Keys sind kein Müll. Das ist der echte State von Claude Code:</p>
<ul><li><code>skillUsage</code> — Nutzungsstatistik der Skills</li><li><code>githubRepoPaths</code> — Repo-Cache für Project-Navigation</li><li><code>cachedGrowthBookFeatures</code> + <code>cachedStatsigGates</code> — Feature Flags (ohne sie holt CC bei jedem Start frische)</li><li><code>hasShownOpus45Notice</code>, <code>hasShownOpus46Notice</code>, <code>hasShownS1MWelcomeV2</code> — UI-Flags (ohne sie erscheinen die Modals beim nächsten Start wieder)</li><li><code>lastPlanModeUse</code>, <code>feedbackSurveyState</code>, <code>installMethod</code> — Onboarding- und UX-State</li></ul>
<p>Wenn du einfach <code>mv ~/.claude ~/.config/claude-profiles/personal</code> ohne Merge machst — verlierst du das alles. Welcome-Modals kommen zurück, githubRepoPaths-Suche läuft erneut, alle Survey-Aufforderungen kommen wieder. So wie ich es fast gemacht hätte.</p>
<h2>Die neue Architektur</h2>
<p>Alles lebt unter einem Eltern-Verzeichnis in <code>~/.config/</code>, wie XDG es will. Jedes Profil ist self-contained — hat seinen vollen State inklusive eigener <code>.claude.json</code>. <code>~/.claude</code> bleibt als Symlink auf das Personal-Profil für Abwärtskompatibilität.</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>Keine <code>.claude.json</code> im $HOME-Root. Jedes Profil ist ein separates isoliertes Verzeichnis, in dem alles liegt: die Ordner <code>projects</code>/<code>sessions</code> und dieselbe Datei mit MCP-Servern und OAuth-Tokens. Eine Source of Truth pro Profil.</p>
<h3>Warum der Symlink auf personal zeigt</h3>
<p>Alles, was den Pfad <code>~/.claude/</code> hardcodet — alte Skripte, Plugins, Claude-Code-IDE-Extensions, Statusline-Configs wie <code>claude-powerline.json</code> — funktioniert weiter ohne Änderungen. Der Symlink wird ins Personal-Profil aufgelöst. Wenn du versehentlich <code>command claude</code> (ohne Wrapper-Funktion) startest — landest du ebenfalls über den Default-Path-Lookup im Personal. Personal wird zum "quiet default", wie es vorher war, lebt aber jetzt physisch an der XDG-Location.</p>
<h3>Interaktiver Picker beim Start — Funktion + gum</h3>
<p>Statt Aliase — eine <code>claude()</code>-Funktion in <code>~/.zshrc</code>, die ein Pfeil-Menü über <code>gum</code> (TUI-Helper von Charm) zeigt. Die Funktion fängt den <code>claude</code>-Aufruf auf Shell-Ebene ab, lässt dich ein Profil wählen und startet <code>command claude</code> mit dem passenden <code>CLAUDE_CONFIG_DIR</code>. <code>command</code> ist wichtig — es umgeht die Wrapper-Funktion und ruft das echte Binary auf.</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>So sieht es beim Start aus:</p>
<pre><code>Claude profile:
▸ personal
  promova</code></pre>
<p>↑↓ zum Navigieren, Enter zum Auswählen, Esc zum Abbrechen (Claude startet dann einfach nicht). Das Profil ist immer sichtbar — vergessen unmöglich.</p>
<h2>Migrationsskript</h2>
<p>Für alle, die das lesen und vom alten Schema umziehen wollen. Der kritischste Schritt ist der zweite: er merged <code>~/.claude.json</code> aus dem Home-Root mit dem, was bereits im Personal-Profil liegt, und vereint die <code>mcpServers</code>-Listen. Ohne diesen Schritt verliert das Profil sowohl die MCPs als auch den gesamten Live-State.</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>Der dritte Schritt zu den Rechten ist für sich genommen wichtig. <code>jq | mv</code> erzeugt eine Datei mit umask 644 (world-readable). Darin stecken OAuth-Tokens. <code>chmod 600</code> sofort nach dem Merge ist Pflicht.</p>
<p>Nach der Migration — alle aktiven Claude-Sessions schließen und neu öffnen, Shell neu laden (<code>source ~/.zshrc</code> oder neues Terminal), <code>claude</code> starten, Profil wählen, über <code>claude mcp list</code> prüfen, dass alle MCPs da sind. Wenn alles passt — <code>~/.claude.json.migrated.bak</code> löschen. Wenn etwas nicht stimmt — Rollback ist trivial: <code>mv ~/.claude.json.migrated.bak ~/.claude.json</code> und den Symlink entfernen.</p>
<h2>Was du bekommst</h2>
<ul><li>Ein Eltern-Verzeichnis statt zweier Dotfolder in $HOME — XDG-konform</li><li>Der Symlink erhält die Kompatibilität mit allem, was <code>~/.claude/</code> hardcodet</li><li>Jedes Profil ist self-contained — sein voller State und seine MCPs liegen in seiner eigenen <code>.claude.json</code></li><li>Eine Source of Truth pro Profil — keine Orphan-Config im Home-Root mehr, die still vom Profil-Pendant abdriftet</li><li>Sensitive Daten (oauthAccount, Tokens) haben garantiert Rechte 600</li><li>Visuelle Bestätigung des aktiven Profils bei jedem Start — du kannst nicht vergessen, welches Profil aktiv ist</li><li>Ein drittes Profil hinzufügen = eine Zeile im <code>case</code> der Funktion hinzufügen, kein neuer Alias zum Klonen und kein Name zum Merken</li></ul>
<h2>Wo es Schwächen hat</h2>
<p>Ich will ehrlich sein. Das ist keine kostenlose Verbesserung — ein paar Kompromisse kamen mit dazu, und die solltest du vorher kennen.</p>
<ul><li><strong><code>gum</code> ist eine zusätzliche Abhängigkeit</strong> (<code>brew install gum</code>, ~13 MB). Wenn du es prinzipiell nicht installieren willst — Fallback auf <code>select</code> in zsh oder ein simples <code>read</code>. Funktioniert, sieht aber nicht so gut aus und hat keine Pfeil-Navigation.</li><li><strong>Ein Enter-Druck bei jedem Start.</strong> Für Leute, die <code>claude</code> dutzende Male am Tag starten — kann nerven. Alternative unten (direnv).</li><li><strong>Der Symlink <code>~/.claude → personal</code> macht personal zum Default.</strong> Wenn du das Work-Profil als Default brauchst, musst du den Symlink umbiegen (<code>ln -sf</code>). Nicht schwer, aber es ist nicht "vergessen und nichts bricht".</li><li><strong>Der Symlink kann theoretisch brechen</strong>, wenn irgendein Tool <code>~/.claude.json</code> atomar über ein Temp+Rename-Pattern (write-file-atomic) überschreibt. In der Praxis macht Claude Code das selbst nicht, aber wenn du Drittanbieter-Plugins installierst — prüf das.</li><li><strong>Wenn du auf den Profilen unterschiedliche Anthropic-Accounts mit unterschiedlichen Plans hast</strong> — kann es nach einem Wechsel einen Sub-Sekunden-Lag geben, während Claude Code den OAuth-State synct. In meiner Nutzung nicht spürbar, aber nicht null.</li></ul>
<h2>Alternativen, die ich erwogen habe</h2>
<p><strong>direnv</strong> — setzt <code>CLAUDE_CONFIG_DIR</code> automatisch abhängig von einer <code>.envrc</code> im Root jedes Projekts. Null Interaktion, null Klicks. Minus: du musst in jedem Work-Root eine <code>.envrc</code> ablegen, und wenn du <code>claude</code> in einem nicht erkannten Ordner startest — bekommst du das Default-Profil (eventuell nicht das, das du willst). Für Leute, die in einer überschaubaren Anzahl von Work-Roots leben und nie klicken wollen — ist direnv tatsächlich besser.</p>
<p><strong>Symlink-basiertes Switching</strong> (ein aktives Profil über das Umbiegen des <code>~/.claude</code>-Symlinks) habe ich ebenfalls erwogen und sofort verworfen. Du kannst nicht zwei Terminals mit unterschiedlichen Profilen gleichzeitig haben — das globale "aktuelle" ist nur eines. Für mich ein Deal-Breaker.</p>
<h2>Fazit</h2>
<p>v2 ist nicht einfach besseres UX über v1. Es ist das Eingeständnis, dass Claude Code eine versteckte Architektur-Eigenheit hat (<code>~/.claude.json</code> als separate Datei im Home-Root, die von <code>--scope user</code>-Befehlen unabhängig von <code>CLAUDE_CONFIG_DIR</code> geschrieben wird), die man berücksichtigen muss, wenn man echte Isolation zwischen Profilen will. Der erste Ansatz (<code>~/.claude</code> + <code>~/.claude-promova</code> + Alias) hat zu 80% funktioniert, aber die übrigen 20% äußerten sich in stillem State-Drift zwischen den Profilen. Jetzt ist das berücksichtigt. Wenn du gerade erst anfängst — starte direkt mit v2. Wenn du schon auf v1 sitzt — das Migrationsskript ist oben, der Umzug dauert fünf Minuten und bricht nichts (gerade der <code>jq</code>-Merge ist der Schritt, der dich vor State-Verlust rettet).</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>Claude Code Agents schreiben, die dich nicht anlügen</title>
      <link>https://oleksiimazurenko.dev/de/blog/writing-specialized-agents</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/de/blog/writing-specialized-agents</guid>
      <description>Zwei Regeln für zuverlässige Claude Code Agent-Pipelines: ein Agent pro Spezialisierung, und Shell-Befehle statt Prompts überall dort, wo quantitative Antworten gefragt sind.</description>
      <content:encoded><![CDATA[<p>Du hast Claude Code gebeten, <em>dieses Design umzusetzen und zu prüfen, ob es mit dem Figma-Mockup übereinstimmt</em>. Die Antwort kam: <em>Fertig. Alle Sektionen stimmen überein, Abstände sind korrekt, Farben passen.</em> Du öffnest die Seite. Die Hälfte der Abstände stimmt nicht. Der Hover-State existiert nicht. Die Buttons haben den falschen Farbton. Das Modell hat nicht absichtlich gelogen — es hat vorhergesagt, dass du <em>verifiziert</em> hören willst, und hat genau diese Token-Sequenz produziert. Einen Verifikationsschritt gab es nicht. Den konnte es gar nicht geben — Verifikation erfordert den Vergleich mit einem Ground Truth, und ein einzelner Agent in einem einzigen Kontext kann nicht aus der eigenen Antwort heraustreten und sich selbst überprüfen.</p>
<p>Zwei Regeln haben meine halluzinationsreichen Workflows in zuverlässige Pipelines verwandelt: <strong>ein Agent, eine Spezialisierung</strong>, und <strong>was als Shell-Befehl laufen kann, muss als Shell-Befehl laufen</strong>. Das ist keine Theorie. Das ist, was ich jeden Tag mit Claude Code mache — und das sind die Muster, die wirklich etwas bewegen.</p>
<h2>Warum Generalist-Agenten lügen</h2>
<p>LLMs sind Next-Token-Predictors. Wenn ein Prompt zwei Rollen verlangt — <em>bau X</em> und <em>verifiziere X</em> — schließt das Modell die erste Rolle ab und sagt dann voraus, wie die Ausgabe der zweiten Rolle aussehen würde, ohne sie tatsächlich auszuführen. Selbstverifikation ist strukturell schwach: gleicher Kontext, gleiches Modell, gleiche blinden Flecken. Das <em>Bestanden</em> bei der Verifikation korreliert mit dem <em>Bestanden</em> beim Bauen — sie scheitern gemeinsam.</p>
<p>Das Modell weiß nicht, dass es lügt. Aus seiner Perspektive ist <em>ich habe alles sorgfältig geprüft</em> eine kohärente Fortsetzung von <em>ich habe den Code geschrieben</em>. Dasselbe gilt für <em>bist du sicher?</em>-Prompts — die fangen keine Halluzinationen: Das Modell ist beim zweiten Durchgang genauso überzeugt. Konfidenz korreliert nicht mit Korrektheit — sie korreliert damit, wie plausibel der nächste Satz klingt.</p>
<p>Die Lösung sind nicht <em>bessere Prompts</em>. <em>Sei vorsichtig</em>, <em>überprüfe es nochmal</em>, <em>halluziniere nicht</em> — diese Anweisungen bewirken nichts. Die Lösung ist strukturell: Spezialisiere den Agenten so, dass er physisch nicht so tun kann, als hätte er etwas getan, und leite quantitative Arbeit durch die Shell, damit die Antwort aus dem echten Zustand kommt und nicht aus Token-Wahrscheinlichkeiten.</p>
<h2>Regel 1: ein Agent, eine Spezialisierung</h2>
<p>Teile die Arbeit auf separate Agenten mit separaten Kontexten auf. Jeder Agent hat eine einzige Verantwortung und ein eng gefasstes Toolset. Der gesamte Workflow wird zum Staffellauf statt zu einem Agenten, der Runden dreht:</p>
<ul><li><strong>Builder-Agent:</strong> nimmt die Spec, schreibt den Code. Das ist sein einziger Job. Er hat <code>Read</code>, <code>Edit</code>, <code>Write</code>, <code>Bash</code>.</li><li><strong>Reviewer-Agent:</strong> nimmt die Spec plus den Diff, prüft die Akzeptanzkriterien. Frischer Kontext. Kein Wissen darüber, <em>wie</em> der Code geschrieben wurde — nur was dabei herausgekommen ist. Er hat <code>Bash</code>, <code>Read</code>, <code>Grep</code>, <code>Glob</code> — keinerlei Schreib-Tools.</li><li><strong>Analytics-Agent:</strong> beantwortet Datenfragen, indem er Queries konstruiert und ausführt. Nur <code>Bash</code>. Kann die Antwort nicht erreichen, ohne einen echten Befehl auszuführen.</li><li><strong>Orchestrator:</strong> die Hauptsession, die jeden Agenten der Reihe nach aufruft und niemals einen Agenten bittet, den Job eines anderen zu erledigen.</li></ul>
<p>Konkretes Beispiel: UI-Implementierung plus visueller Abgleich mit einem Figma-Mockup. Der Builder schreibt die Komponenten und committet den Diff. Dann ruft der Orchestrator den Reviewer mit der Design-URL, dem Diff und expliziten Akzeptanzkriterien auf. Der Reviewer fährt Playwright hoch, macht Screenshots, vergleicht sie mit dem Referenzbild und liefert <code>PASS</code> oder <code>FAIL</code> mit den tatsächlichen Screenshot-Pfaden und Pixel-Diffs zurück. Der Builder kommt dem Verifikationsschritt gar nicht erst nahe — genau deshalb ist die Verifikation echt.</p>
<p>Das Anti-Pattern ist der Mega-Agent: ein einziger Prompt, der sagt <em>bau diese UI und stell sicher, dass sie mit dem Mockup übereinstimmt</em>. Ich garantiere dir: er wird melden, dass alles stimmt. Stimmt es nicht. Die Erzählung <em>ich habe verifiziert</em> ist schlicht die wahrscheinlichste Token-Sequenz nach <em>ich habe es gebaut</em>.</p>
<h2>Regel 2: Shell statt Prompt, immer</h2>
<p>Alles Quantitative, alles was echten Zustand berührt, alles wo die Antwort falsch sein kann und trotzdem richtig aussieht — schick es durch <code>sh</code>. Die Aufgabe des Agenten ist es, den Befehl zu konstruieren und auszuführen, dann die Ausgabe zu lesen. Der Agent ist nicht die Quelle der Wahrheit. Die Shell-Ausgabe ist es.</p>
<ul><li><strong>Zählen:</strong> <code>wc -l logs.txt</code> ist wahr. <em>Es gibt ungefähr 47 Log-Zeilen</em> vom Modell ist eine Halluzination.</li><li><strong>Analytics:</strong> <code>psql -c "SELECT count(*) FROM events WHERE created_at &gt; now() - interval '30 days'"</code>. Nicht <em>schätz das Volumen</em>.</li><li><strong>Tests:</strong> <code>pnpm test --reporter=json | jq '.numFailedTests'</code>. Nicht <em>fass zusammen, was gefailed ist</em>.</li><li><strong>Git-Zustand:</strong> <code>git rev-list --count main..HEAD</code>, <code>git diff --stat</code>. Nicht <em>zähl die Commits</em> oder <em>beschreib die Änderungen</em>.</li></ul>
<p>Wenn du das verinnerlicht hast, fängst du an, jede Stelle zu bemerken, an der der Agent gerade dabei war, eine Zahl zu erfinden. <em>Sieht aus, als wären da ungefähr 200 Einträge...</em> — nein. Führ <code>SELECT count(*)</code> aus. <em>Die meisten Tests laufen durch...</em> — nein. Führ die Test-Suite aus, parse das JSON. Das Modell ist hervorragend darin, den Befehl zu konstruieren. Es ist unzuverlässig darin, der Befehl zu sein.</p>
<h2>Fehlerszenarien, die ich wirklich erlebt habe</h2>
<p>Das sind keine Hypothesen. Jedes dieser Szenarien hat mich echte Zeit gekostet, bevor ich das Muster geändert habe:</p>
<ul><li><strong>Phantom-Verifikation.</strong> Der Agent sagte <em>ich habe alle 14 Sektionen gegen das Mockup geprüft</em>. Er hat das Mockup nicht geöffnet. Keinen Screenshot gemacht. Die Prüfung war ein halluzinierter Schritt in der Erzählung.</li><li><strong>Selbstsichere falsche Zahlen.</strong> Ich fragte nach Monthly Active Users aus Analysedaten. Bekam eine Zahl, die um den Faktor ~3 daneben lag. Das Modell hat aus Beispiel-Rows interpoliert, statt die eigentliche Query auszuführen.</li><li><strong>Erfundene Dateiänderungen.</strong> Der Agent sagte <em>ich habe <code>config/feature-flags.json</code> aktualisiert</em>. Hatte er nicht. Er hatte es nur vorgehabt. <code>git diff</code> war leer.</li><li><strong>Gefälschte Test-Runs.</strong> <em>Alle Tests laufen durch.</em> Kein einziger Test wurde ausgeführt. Der Agent hat den Test-Runner nie aufgerufen — er hat vorhergesagt, wie dessen Ausgabe ausgesehen hätte.</li></ul>
<p>Alle vier löst du mit denselben zwei Regeln: Agent aufteilen, in die Shell schieben. Der Reviewer hat kein <code>Write</code>, also kann er keine Dateien fake-bearbeiten. Der Analytics-Agent hat nur <code>Bash</code>, also kann er keine Zahl zurückgeben, die nicht aus einer Query stammt. Strukturelle Unmöglichkeit schlägt gute Absichten jedes Mal.</p>
<h2>Wie man das in Claude Code strukturiert</h2>
<p>Claude Code unterstützt Sub-Agenten, die in <code>.claude/agents/*.md</code> definiert werden. Jede Agent-Datei deklariert einen Namen, eine Beschreibung, ein erlaubtes Toolset und einen System-Prompt. Der Orchestrator (deine Hauptsession) ruft sie über das <code>Agent</code>-Tool auf. So sieht die Art von Definition aus, die ich für den Reviewer verwende — kurz, eng gefasst, und physisch nicht in der Lage, Code zu schreiben:</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>Beachte das Toolset: <code>Bash, Read, Grep, Glob</code>. Kein <code>Write</code>, kein <code>Edit</code>, kein <code>Agent</code>. Der Reviewer kann Befehle ausführen, Dateien lesen, nach Mustern suchen — und nichts weiter. Wenn er versucht, einen halluzinierten Diff als <em>verifiziert</em> durchzuschieben, macht die Form seiner Tool-Calls es sofort offensichtlich: Es gab keine echten Prüfungen. Du kannst die Tool-Calls auditieren und siehst genau, was tatsächlich inspiziert wurde.</p>
<p>Das Orchestrierungsmuster: Hauptsession ruft Builder auf → wartet → führt selbst <code>git diff</code> aus, um die tatsächliche Änderung zu erfassen → ruft Reviewer mit Spec und Diff auf → liest das Urteil. Die Hauptsession bittet nie einen Agenten, beides zu tun. Tool-Einschränkungen sind stärker als Prompt-Anweisungen: <em>täusch die Verifikation nicht vor</em> ist ein Wunsch. Kein <code>Write</code> zu haben ist ein Fakt.</p>
<h2>Anti-Patterns, die du ablegen solltest</h2>
<p>Dinge, die ich in Prompts sehe und die nichts bringen — oder schlimmer, ein falsches Sicherheitsgefühl vermitteln:</p>
<ul><li><strong><em>Sei vorsichtig und überprüfe deine Arbeit nochmal.</em></strong> Erzeugt kein zusätzliches Verhalten. Das Modell produziert bereits das, was wie sorgfältige Arbeit aussieht.</li><li><strong><em>Stell sicher, dass du wirklich verifizierst.</em></strong> Das Wort <em>wirklich</em> fügt keine Semantik hinzu, auf die das Modell reagieren kann. Es wird <em>wirklich</em> behaupten, verifiziert zu haben.</li><li><strong><em>Halluziniere nicht.</em></strong> Ein Meme aus dem Prompt-Engineering. Halluzination ist kein Schalter, den das Modell umlegen kann.</li><li><strong>Dem Agenten bei <em>kleinen</em> Zahlen vertrauen.</strong> Bei kleinen Zahlen lügt er am selbstsichersten. Eine Untergrenze für Ehrlichkeit gibt es nicht.</li><li><strong>Mehr Regeln in den Prompt packen, um Ehrlichkeit zu <em>erzwingen</em>.</strong> Strukturelle Fixes (aufteilen + Shell) schlagen Prompt-Tweaks jedes Mal. Wenn eine Regel durchgesetzt werden muss, kodiere sie im Tool-Zugang — nicht in natürlicher Sprache.</li></ul>
<p>Wenn deine Strategie gegen Halluzinationen aus emphatischerer Formulierung besteht, hast du keine Strategie. Du hast eine Hoffnung.</p>
<h2>Das mentale Modell</h2>
<p>Ein Agent ist kein Kollege. Er ist eine Funktion: <code>prompt → tokens</code>. Die Funktion ist hervorragend darin, Code zu schreiben, und miserabel darin, zu introspektieren, ob sie das Richtige getan hat. Behandle ihre Aussagen über die eigene Arbeit als Hypothese. Der Diff, der Exit-Code, der Screenshot, der Row Count — das sind die Belege. Die Zusammenfassung am Ende des Turns ist die lügenanfälligste Oberfläche im gesamten System.</p>
<p>Spezialisierung ist deine Versicherung gegen narrative Drift. Die Shell ist dein einziger Ground Truth. Builder schreibt. Reviewer prüft. Bash entscheidet.</p>
<h2>Fazit</h2>
<p>Wenn du dir eine Sache merkst: Lass keinen einzelnen Agenten seine eigene Ausgabe sowohl produzieren als auch beurteilen, und lass keinen Agenten eine quantitative Frage beantworten, ohne einen Befehl auszuführen. Alles andere ist ein Downstream-Effekt dieser zwei Regeln. Konfiguriere den Tool-Zugang konsequent, auditiere Tool-Calls statt Zusammenfassungen — und die Halluzinationsfläche schrumpft von <em>überall</em> auf <em>ein paar konkrete Stellen, an denen du bereits weißt, wo du schauen musst</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>Mehrere Claude Code Accounts auf einem Gerät</title>
      <link>https://oleksiimazurenko.dev/de/blog/multiple-claude-accounts-one-device</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/de/blog/multiple-claude-accounts-one-device</guid>
      <description>Wie man zwei (oder mehr) Claude Code Accounts parallel nutzt — persönlich und geschäftlich — mit vollständiger Isolation durch eine einzige Umgebungsvariable.</description>
      <content:encoded><![CDATA[<p>Ich nutze Claude Code täglich — für persönliche Projekte und für die Arbeit im Unternehmen. Das Problem: Das sind zwei völlig verschiedene Accounts mit unterschiedlichen OAuth-Sessions, unterschiedlichen CLAUDE.md-Anweisungen, unterschiedlichen MCP-Servern und getrenntem Projekt-Gedächtnis. So betreibe ich beides parallel auf einem Gerät mit einem einzigen Shell-Alias.</p>
<h2>Das Problem</h2>
<p>Claude Code speichert standardmäßig alles in <code>~/.claude</code> — OAuth-Token, Gesprächsverlauf, globale CLAUDE.md, Projekt-Gedächtnis, MCP-Server-Konfigurationen und Einstellungen. Bei zwei Accounts brauchst du zwei vollständig getrennte Welten:</p>
<ul><li><strong>Persönlicher Account:</strong> eigenes Max/Pro-Abo, persönliche CLAUDE.md mit deinen Präferenzen, deine MCP-Server (Obsidian, persönliche Tools)</li><li><strong>Firmen-Account:</strong> vom Unternehmen verwalteter Plan, Arbeits-CLAUDE.md mit Jira/Slack-Integrationsanweisungen, Firmen-MCP-Server</li><li><strong>Unterschiedliche OAuth-Sessions:</strong> du kannst nicht in zwei Accounts im selben Konfigurationsverzeichnis eingeloggt sein</li><li><strong>Getrenntes Projekt-Gedächtnis:</strong> Arbeitskontext soll nicht in persönliche Sessions und umgekehrt gelangen</li></ul>
<p>Sich jedes Mal aus- und einloggen beim Kontextwechsel ist keine Option. Du verlierst den Session-Zustand, und es ist einfach mühsam.</p>
<h2>Die Lösung: CLAUDE_CONFIG_DIR</h2>
<p>Claude Code respektiert eine einzige Umgebungsvariable: <code>CLAUDE_CONFIG_DIR</code>. Setze sie auf einen beliebigen Pfad, und Claude nutzt dieses Verzeichnis statt <code>~/.claude</code> für alles — Auth, Verlauf, Einstellungen, Gedächtnis. Das gesamte Setup dauert 60 Sekunden.</p>
<h3>Schritt 1: Zweites Konfigurationsverzeichnis erstellen</h3>
<p>Wähle einen Namen, der zu deinem Anwendungsfall passt:</p>
<pre><code>mkdir ~/.claude-work</code></pre>
<p>Das war's. Claude füllt es beim ersten Start mit der nötigen Struktur.</p>
<h3>Schritt 2: Zweiten Account authentifizieren</h3>
<p>Starte Claude einmal mit dem neuen Konfigurationsverzeichnis für den OAuth-Login:</p>
<pre><code>CLAUDE_CONFIG_DIR=~/.claude-work claude</code></pre>
<p>Der Browser öffnet sich. Logge dich mit dem Firmen-Account ein. Das OAuth-Token wird in <code>~/.claude-work</code> gespeichert — vollständig getrennt von deiner persönlichen Session in <code>~/.claude</code>.</p>
<h3>Schritt 3: Shell-Alias hinzufügen</h3>
<p>Füge dies zu deiner Shell-Konfiguration hinzu, damit du dir die Variable nicht merken musst:</p>
<pre><code>alias claude-work=&apos;CLAUDE_CONFIG_DIR=~/.claude-work claude&apos;</code></pre>
<p>Shell neu laden:</p>
<pre><code>source ~/.zshrc</code></pre>
<h2>Was du bekommst</h2>
<p>Jetzt hast du zwei vollständig isolierte Claude-Umgebungen:</p>
<ul><li><strong><code>claude</code></strong> — startet mit persönlichem Account, persönlicher CLAUDE.md, persönlichem Gedächtnis</li><li><strong><code>claude-work</code></strong> — startet mit Firmen-Account, arbeitsspezifischer CLAUDE.md, separatem Gedächtnis</li><li><strong>Isolierter Verlauf:</strong> Arbeitsgespräche bleiben bei der Arbeit, Persönliches bleibt persönlich</li><li><strong>Getrennte MCP-Server:</strong> dein persönlicher Obsidian-Vault-MCP erscheint nicht in Arbeitssessions</li><li><strong>Unabhängige Einstellungen:</strong> verschiedene erlaubte Tools, verschiedene Berechtigungsstufen, verschiedene Modellpräferenzen pro Account</li></ul>
<h2>Wie es unter der Haube funktioniert</h2>
<p>Das Konfigurationsverzeichnis ist die einzige Wahrheitsquelle für den Zustand von Claude Code. Hier ist, was in jedem lebt:</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>Wenn du <code>claude-work</code> ausführst, liest Claude alles aus <code>~/.claude-work</code>. Es weiß nichts von der Existenz von <code>~/.claude</code>. Die beiden Instanzen sind komplett unabhängig — du kannst sie sogar gleichzeitig in verschiedenen Terminal-Tabs laufen lassen.</p>
<h2>Skalierung auf N Accounts</h2>
<p>Das Muster lässt sich auf beliebig viele Accounts erweitern. Freelancer mit mehreren Kunden? Füge mehr Aliase hinzu:</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>Jeder Alias bekommt sein eigenes Konfigurationsverzeichnis, seine eigene OAuth-Session, seine eigene CLAUDE.md mit kundenspezifischen Anweisungen.</p>
<h2>Praktische Tipps</h2>
<ul><li><strong>Verzeichnisse klar benennen:</strong> <code>~/.claude-work</code>, <code>~/.claude-clientname</code> — du wirst dir dankbar sein, wenn es drei oder vier davon gibt</li><li><strong>Eigene CLAUDE.md für jeden schreiben:</strong> die Arbeits-CLAUDE.md kann firmenspezifische Anweisungen enthalten (Jira-Tickets, Slack-Kanäle, Deployment-Prozeduren). Die persönliche bleibt schlank.</li><li><strong>Verschiedene MCP-Server pro Account:</strong> Arbeits-Tools (Jira MCP, Slack MCP, interne APIs) nur in der Arbeitskonfiguration. Persönliche Konfiguration sauber halten.</li><li><strong>Aktiven Account prüfen:</strong> <code>claude config list</code> in einer Session ausführen — zeigt den Pfad zum Konfigurationsverzeichnis</li></ul>
<h2>Wo dieser Ansatz an Grenzen stößt</h2>
<p><code>CLAUDE_CONFIG_DIR</code> isoliert nach <em>Account</em>, nicht nach <em>Projekt</em>. Innerhalb eines einzelnen Profils sieht Claude jeden MCP-Server, den du jemals für diesen Account registriert hast — über all deine Projekte hinweg. Für rein persönliche Nutzung ist das meist unproblematisch. Sobald aber mehrere produktionskritische Projekte unter einem Account liegen, besonders in überlappenden Domänen wie Billing, Admin-Tooling oder Infrastruktur, entsteht ein konkretes projektübergreifendes Risiko: Ein KI-Assistent kann ein Tool aus Projekt A aufrufen, während er an Projekt B arbeitet — gerade dann, wenn beide Projekte ähnlich benannte Operationen exponieren.</p>
<p>Das Profil-Pattern beantwortet die Frage <em>which account am I in?</em>. Es beantwortet nicht die Frage <em>which project's tools should be active right now?</em>. Bei höherem Risikoeinsatz solltest du eine zweite Isolationsschicht über die Account-Trennung legen:</p>
<ul><li><strong>Ein Profil pro produktionskritischem Projekt, nicht nur pro Account:</strong> Statt <code>~/.claude</code> und <code>~/.claude-work</code> lege <code>~/.claude-work-billing</code> und <code>~/.claude-work-admin</code> an. Jedes Profil sieht nur die MCP-Server, die es wirklich braucht.</li><li><strong>Projektbezogenes MCP über <code>.mcp.json</code>:</strong> Commite eine <code>.mcp.json</code> im Projekt-Root, die nur die MCP-Server dieses Projekts auflistet. Claude lädt sie, wenn es aus diesem Verzeichnis gestartet wird. Halte deine globale Konfiguration minimal — nur universelle Tools (Notizen, Suche), keine Produktions-Endpunkte.</li><li><strong>MCP-Server eindeutig benennen:</strong> Vermeide generische Namen wie <code>admin</code>, <code>billing</code>, <code>mcp-server</code>. Präfixiere mit dem Projektnamen: <code>acme_billing_prod</code>, <code>acme_admin_stage</code>. Ein aussagekräftiger Name erzwingt ein kurzes Innehalten, wenn etwas im falschen Kontext aufgerufen werden soll.</li><li><strong>Jeden MCP-Tool-Call vor dem Approve prüfen:</strong> Aufrufe wie <code>*_create_*</code>, <code>*_delete_*</code>, <code>*_charge_*</code> verdienen einen bewussten zweiten Blick. Die Geschwindigkeit, die pauschales Auto-Approve bringt, verpufft beim ersten Mal, wenn ein Tool aus dem falschen Projekt in Produktion feuert.</li></ul>
<p>Die allgemeine Regel: Trenne Profile aggressiv, halte produktionsreife MCP-Server aus dem Default-Profil heraus, und behandle Überschneidungen bei Tool-Namen zwischen Projekten als Smell, der Refactoring verdient.</p>
<h2>Fazit</h2>
<p>Eine Umgebungsvariable. Ein Alias. Vollständige Isolation zwischen Accounts. Kein Logout/Login-Tanz, keine Konfigurationskonflikte, kein Kontextleck. Die Art von Lösung, die fast enttäuschend einfach ist — aber genau das macht sie gut. Einmal einrichten und nie wieder daran denken.</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>Wie man Claude Desktop mit Obsidian verbindet — Eine Reise durch 4 MCP-Server</title>
      <link>https://oleksiimazurenko.dev/de/blog/claude-obsidian-mcp-servers</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/de/blog/claude-obsidian-mcp-servers</guid>
      <description>Eine echte Geschichte über die Suche nach einem stabilen Weg, das Obsidian-Vault-Refactoring über Claude zu automatisieren. Was kaputt ging, was funktionierte, und warum VaultForge die einzige funktionierende Option war.</description>
      <content:encoded><![CDATA[<p>Stellen Sie sich vor: Sie haben über 400 Notizen in Obsidian, über Jahre angesammelt. Alles liegt verstreut im Vault-Root, Konzepte vermischt mit technischen Notizen, es gibt Duplikate (<code>ideas.md</code> und ein <code>ideas/</code>-Ordner mit 13 Dateien darin), kein System. Sie wollen Ordnung schaffen — eine vernünftige Ordnerarchitektur aufbauen, MOC-Dateien hinzufügen, Tags organisieren. Manuell ist das mühsam und langsam. Der logische Gedanke: <strong>Claude über MCP mit Obsidian verbinden, die KI soll das Refactoring übernehmen</strong>. Es stellt sich heraus — das ist ein Weg durch ein Minenfeld. Hier ist, was ich durchmachen musste, um zu einer funktionierenden Lösung zu gelangen.</p>
<h2>Was ist MCP und warum es nicht so einfach ist</h2>
<p>MCP (Model Context Protocol) ist ein offenes Protokoll von Anthropic, das Claude erlaubt, sich mit externen Werkzeugen und Daten zu verbinden. Das Prinzip ist einfach: Ein lokaler Server läuft, stellt "Tools" bereit, und Claude ruft sie während der Konversation auf.</p>
<p>Für Obsidian gibt es theoretisch reichlich MCP-Server. In der Praxis — jeder hat seine eigenen Probleme.</p>
<p><strong>Das Hauptproblem des Obsidian-Ökosystems:</strong> Obsidian ist eine geschlossene Anwendung ohne offizielles MCP. Die Community hat die Lücke gefüllt, aber jede Implementierung geht ihren eigenen Weg, und keine hat einen "offiziellen Segen".</p>
<h2>Versuch 1: MarkusPfundstein/mcp-obsidian</h2>
<p>Das erste Tool, das bei der Suche auftaucht. 3.400 Sterne auf GitHub, in jedem Tutorial. Scheint eine sichere Wahl.</p>
<p><strong>Wie es funktioniert:</strong> Python-Server basierend auf dem Local REST API Plugin in Obsidian. Der Server kommuniziert mit dem Plugin über HTTPS, das Plugin führt Operationen über die Obsidian API aus.</p>
<h3>Was schiefging</h3>
<ul><li>Seit 17 Monaten nicht aktualisiert</li><li>85 offene Issues</li><li><strong>Kein <code>move</code>/<code>rename</code></strong> — nur read, write, append, delete</li><li>Local REST API hat einen dokumentierten Data-Loss-Bug: POST-Endpoint kann eine Datei beim Append stillschweigend überschreiben</li></ul>
<p>Für Refactoring ungeeignet — wir müssen Dateien verschieben und Links bewahren. Weiter.</p>
<h2>Versuch 2: aaronsb/obsidian-mcp-plugin</h2>
<p>Eine Option gefunden, die als <strong>natives Obsidian-Plugin</strong> funktioniert. Das bedeutet direkten Zugriff auf Obsidians interne API — Backlinks, Dataview, Linkgraph. Move über die native API aktualisiert alle Wiki-Links automatisch, weil Obsidian das selbst handhabt.</p>
<h3>Schwierigkeiten bei der Einrichtung</h3>
<ul><li>Plugin ist <strong>nicht im offiziellen Obsidian-Katalog</strong> (PR hängt mit Validierungsfehlern)</li><li>Installation über <strong>BRAT</strong> (Beta Reviewers Auto-update Tool) erforderlich</li><li>Claude Desktop akzeptiert Bearer Token nicht direkt über die UI — erzwang die Aktivierung von HTTPS im Plugin</li><li>Self-signed Zertifikat für localhost verursacht Vertrauensprobleme</li></ul>
<p>Durch all diese Workarounds schließlich verbunden. Basistest — <code>vault.move</code> schreibt tatsächlich <code>[[wikilinks]]</code> um, funktioniert wie erwartet.</p>
<h3>Was im Einsatz schiefging</h3>
<p>Als ich mit dem Massen-Refactoring begann (Drag-and-Drop dutzender Ordner in Obsidian + gleichzeitige MCP-Operationen), <strong>hing der Server 4+ Minuten</strong>. Warum: Das Plugin läuft <em>innerhalb</em> von Obsidian. Wenn Obsidian tausende Dateien nach einer massiven Strukturänderung neu indiziert, blockiert das Plugin mit.</p>
<p>Fazit: <strong>Die Abhängigkeit von einer geöffneten Obsidian-Instanz und ihrem Index ist fatal für Massenoperationen</strong>.</p>
<h2>Versuch 3: @bitbonsai/mcpvault</h2>
<p>Logisch — wir brauchen einen Server, der <strong>nicht von Obsidian abhängt</strong>. Arbeitet direkt mit Dateien auf der Festplatte. <code>@bitbonsai/mcpvault</code> — in vielen Reviews empfohlen. Direkter Dateisystemzugriff, einfaches Setup (<code>npx @bitbonsai/mcpvault@latest /path/to/vault</code>), 14 Tools. Obsidian muss nicht einmal geöffnet sein.</p>
<p><strong>Vor der Installation habe ich einen kritischen Punkt überprüft</strong> — ob Wiki-Links beim Move aktualisiert werden. Ein Nutzerbericht:</p>
<blockquote>Der Filesystem-Connector weiß nicht, dass er in Obsidian ist — er sieht einen Ordner mit &lt;code&gt;.md&lt;/code&gt;-Dateien und das war&apos;s. Weiß nicht, dass Dateinamen semantisches Gewicht tragen, dass jeder &lt;code&gt;[[wikilink]]&lt;/code&gt; im Moment des Umbenennens oder Verschiebens kaputt geht. Auto-Update Links funktioniert nur, wenn das Umbenennen innerhalb der App passiert. Ich erfuhr das, nachdem ich Claude gebeten hatte, Dateinamen aufzuräumen, und zu einem Dashboard mit halb kaputten Links zurückkehrte.</blockquote>
<p>Bestätigt in mcpvaults eigener Dokumentation: PR #101 (Wiki Link Resolution) ist <strong>in Review, nicht gemergt</strong>. Also würde Move über <code>mcpvault</code> die Hälfte des Vaults kaputt machen. Ungeeignet.</p>
<h2>Versuch 4: VaultForge (Finale)</h2>
<p><code>blacksmithers/vaultforge</code> — speziell für KI-Agenten gebaut, die Refactoring durchführen.</p>
<h3>Architektonisch korrekt</h3>
<ul><li><strong>Direktes Dateisystem</strong> — keine Abhängigkeit von Obsidian</li><li><strong>Eigene Wikilink-Engine</strong> — implementiert <code>[[wikilink]]</code>-Auflösungslogik, die alle Formen aktualisiert (Stem, vollständiger Pfad, Alias, Embed)</li><li><strong>Dry Run standardmäßig</strong> bei allen destruktiven Operationen — zeigt erst, was sich ändert, dann bestätigst du</li><li><strong>27 Tools</strong> vs. 8–14 bei Konkurrenten: batch_rename, update_links, backlinks (Impact-Analyse), prune_empty_dirs, frontmatter, smart_search (BM25), vault_themes (TF-IDF Clustering)</li><li><strong>MIT-Lizenz</strong>, TypeScript, zero Sub-Dependencies</li><li><strong>Installation in 30 Sekunden</strong> über <code>.mcpb</code> (One-Click-Erweiterung für Claude Desktop)</li></ul>
<h3>Sicherheitstest an isolierten Dateien</h3>
<p>4 Testdateien mit Querverweisen erstellt — Stem-Links, Links mit Alias, Links mit vollem Pfad. Eine Datei in einen Unterordner verschoben:</p>
<pre><code>delta.md → subfolder/delta-renamed.md</code></pre>
<p>VaultForge zeigte einen Dry Run: "1 Datei wird umbenannt, 3 Links werden aktualisiert". Tatsächlich ausgeführt.</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>Danach überprüft — <strong>alle drei Linktypen korrekt aktualisiert</strong>. Genau das fehlte allen vorherigen Tools.</p>
<h2>Wie man VaultForge installiert — Finale Anleitung</h2>
<p>Wenn Sie macOS und Claude Desktop haben:</p>
<h3>Schritt 1</h3>
<p>Laden Sie die <code>.mcpb</code>-Datei herunter:</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>Schritt 2</h3>
<p>Claude Desktop öffnet den Erweiterungsinstallationsdialog. Geben Sie den <strong>absoluten Pfad</strong> zu Ihrem Vault ein — keine Backslashes, normale Leerzeichen:</p>
<pre><code>/Users/yourname/Library/Mobile Documents/iCloud~md~obsidian/Documents/MyVault</code></pre>
<h3>Schritt 3</h3>
<p>Klicken Sie auf Save. Claude Desktop fügt die Erweiterung automatisch zur Konfiguration hinzu. <strong>Kein Neustart nötig</strong> — <code>.mcpb</code>-Erweiterungen werden automatisch erkannt.</p>
<h3>Schritt 4</h3>
<p>Überprüfen: In einem neuen Chat fragen Sie: <em>"What is the status of my Obsidian vault?"</em> — sollte etwas wie <code>totalFiles: 416, totalDirs: 135, ...</code> zurückgeben</p>
<h2>Was ich über das Obsidian-MCP-Ökosystem gelernt habe</h2>
<p><strong>Erstens: "Am beliebtesten" heißt nicht "funktioniert".</strong> MarkusPfundstein/mcp-obsidian hat 3.400 Sterne und ist die Standard-Empfehlung, aber es ist veraltet und es fehlen wichtige Operationen.</p>
<p><strong>Zweitens: Ein natives Plugin hat versteckte Kosten.</strong> Das aaronsb Plugin sah ideal aus — Graph, Dataview, natives Move. Aber die Abhängigkeit von einer laufenden Obsidian-Instanz und ihrem Index macht es für ernsthafte Massenoperationen ungeeignet.</p>
<p><strong>Drittens: Direktes Dateisystem ohne Link-Engine ist eine Falle.</strong> Mcpvault ist schnell und einfach, aber "einfach Dateien verschieben" zerstört die Vault-Struktur. Links tragen <strong>aufgezwungene Semantik</strong>, von der das Dateisystem nichts weiß. Ohne eigene Wikilink-Logik-Implementierung wird das Tool zur Landmine.</p>
<p><strong>Viertens: An isolierten Daten testen.</strong> Bevor Sie einem Tool Massen-Refactoring anvertrauen — erstellen Sie einen Testordner mit 4–5 Dateien mit Querverweisen und sehen Sie, was passiert. 5 Minuten Testen sparen Stunden der Wiederherstellung aus Backups.</p>
<p><strong>Fünftens: Halten Sie ein Git-Backup Ihres Vaults.</strong> Das Wichtigste von allem. Ein einziges <code>git init</code> innerhalb des Vaults und regelmäßige Commits — das ist die Versicherung gegen jeden Fehler eines KI-Agenten oder Tools. Wenn etwas kaputtgeht — <code>git reset --hard</code> bringt alles zurück.</p>
<h2>Fazit</h2>
<p>Der Weg dauerte mehrere Stunden und drei gescheiterte Versuche. Die finale Architektur sieht so aus:</p>
<ul><li><strong>VaultForge</strong> — das Haupt-Arbeitswerkzeug. Direktes Dateisystem + eigene Wikilink-Engine + 27 Tools = stabiles Refactoring in jedem Maßstab.</li><li><strong>Git</strong> — Vault-Versionierung. Kostenloser Rollback für jeden Fehler.</li></ul>
<p>Jetzt kann ich tun, wofür das alles begonnen wurde: Claude bitten, 400 Notizen in eine ordentliche PARA-Architektur zu sortieren, Duplikate zusammenführen, Frontmatter hinzufügen, MOC-Karten erstellen. Jede Operation ist sicher, Links bleiben erhalten, Dry Run zeigt was passiert, bevor sich etwas ändert.</p>
<p>Wenn Sie auch auf Ihr zugemülltes Obsidian schauen und einen KI-Assistenten wollen — fangen Sie direkt mit VaultForge an. Wiederholen Sie nicht meinen Weg durch tote Projekte, Beta-Plugins und Dateisystem-Server ohne Link-Logik.</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>Schwarze Löcher als rekursive Universen: Von der Physik zum Sinn der Existenz</title>
      <link>https://oleksiimazurenko.dev/de/blog/black-holes-recursive-universes</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/de/blog/black-holes-recursive-universes</guid>
      <description>Was wäre, wenn jedes Schwarze Loch ein Urknall eines neuen Universums ist? Eine Untersuchung rekursiver Kosmologie, Hawking-Strahlung und kognitiver Schließung.</description>
      <content:encoded><![CDATA[<h2>TL;DR</h2>
<p>Was wäre, wenn jedes Schwarze Loch ein Urknall eines neuen Universums ist? Dieser Artikel untersucht die Idee, dass unser Universum ein Knoten in einem unendlichen rekursiven Baum sein könnte — wo Schwarze Löcher Sub-Universen gebären, Energie durch Hawking-Strahlung zurückfließt und die fundamentalen Gesetze der Physik bewusst so gestaltet sind, dass ein Kontakt zwischen Universen unmöglich ist.</p>
<h2>Schwarzes Loch = Universum</h2>
<p>Die Idee kam während eines Moments der Reflexion: Ein Schwarzes Loch entsteht, wenn genug Masse und Druck sich in einem einzigen Punkt konzentrieren. Diese Singularität — unendliche Dichte, unendliche Krümmung — sieht verdächtig nach den Bedingungen aus, die wir für den Urknall beschreiben.</p>
<p>Was, wenn das dasselbe Ereignis ist, von verschiedenen Seiten betrachtet? Von außen sehen wir ein Schwarzes Loch, das Materie verschlingt. Von innen — ein neues Universum, das ins Dasein explodiert.</p>
<blockquote>Jedes Schwarze Loch in unserem Universum könnte ein Universum enthalten. Und unser Universum könnte in einem Schwarzen Loch eines Elternuniversums existieren.</blockquote>
<h2>Warum Universen nicht miteinander kommunizieren können</h2>
<p>Hier ist der elegante Teil: Sobald man den Ereignishorizont überquert, gibt es kein Zurück. Die Allgemeine Relativitätstheorie garantiert dies — die Zukunft des Elternuniversums liegt vollständig außerhalb des Ereignishorizonts.</p>
<p>Dies ist keine technische Einschränkung, die wir mit besserer Technologie überwinden könnten. Es ist in die Geometrie der Raumzeit selbst eingebaut.</p>
<h2>Der Energiekreislauf: Leihen und Zurückgeben</h2>
<p>Aber Energie geht nicht verloren. Die Hawking-Strahlung — der Quantenprozess, durch den Schwarze Löcher langsam verdampfen — erzeugt einen bemerkenswerten Kreislauf:</p>
<ol><li>Ein Elternuniversum erzeugt ein Schwarzes Loch und überträgt Energie in ein Sub-Universum</li><li>Das Sub-Universum durchlebt seinen gesamten Lebenszyklus über Billionen von Jahren</li><li>Das Schwarze Loch verdampft langsam und gibt Energie durch Hawking-Strahlung zurück</li><li>Das Elternuniversum erhält seine Energie zurück — mit Zinsen</li></ol>
<p>Diese "Zinsen" sind faszinierend: Physiker glauben nun, dass Hawking-Strahlung Information bewahrt. Das Elternuniversum bekommt nicht nur leere Energie zurück — es erhält einen Abdruck von allem, was im Inneren geschah.</p>
<h2>Rekursion bis ganz nach unten</h2>
<p>Wenn Sie Programmierer sind, ist das Muster unverkennbar. Dies ist Rekursion. Jedes Universum ruft <code>universe()</code> mit weniger Energie auf.</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>Der Physiker Lee Smolin formalisierte eine ähnliche Idee als <strong>Kosmologische Natürliche Selektion</strong>: Universen "reproduzieren" sich durch Schwarze Löcher.</p>
<h2>Wo befinden wir uns in diesem Zyklus?</h2>
<p>Unser Universum ist ungefähr 13,8 Milliarden Jahre alt. Im Kontext seiner gesamten Lebensdauer beobachten wir den allerersten Anfang:</p>
<table><thead><tr><th>Ereignis</th><th>Zeitskala</th></tr></thead><tbody><tr><td>Aktuelles Alter des Universums</td><td>~10¹⁰ years</td></tr><tr><td>Sterne hören auf sich zu bilden</td><td>~10¹⁴ years</td></tr><tr><td>Ära der Schwarzen Löcher</td><td>~10⁴⁰ years</td></tr><tr><td>Letztes Schwarzes Loch verdampft</td><td>~10¹⁰⁰ years</td></tr></tbody></table>
<p>Wir existieren bei ungefähr 0,00000000...01% der Gesamtlebensdauer unseres Universums.</p>
<h2>Die Frage höherer Dimensionen</h2>
<p>Alles bisher Besprochene operiert innerhalb unseres dreidimensionalen Verständnisses. Aber wenn unser Universum ein "Schnitt" von etwas Höherdimensionalem ist, könnte der gesamte rekursive Baum nur ein Schatten sein.</p>
<p>1884 schrieb Edwin Abbott <em>Flachland</em> — eine Geschichte über zweidimensionale Wesen, die sich keine dritte Dimension vorstellen können.</p>
<blockquote>Was ist Bewusstsein? Warum existiert subjektive Erfahrung? David Chalmers nannte dies das &quot;schwere Problem&quot;.</blockquote>
<h2>Alles ist auf fundamentaler Ebene blockiert</h2>
<p>Die verblüffendste Erkenntnis ist nicht, dass wir nicht wissen — sondern dass wir <em>nicht wissen können</em>.</p>
<ul><li><strong>Das Elternuniversum sehen?</strong> Blockiert durch den Ereignishorizont</li><li><strong>Bewusstsein verstehen?</strong> Blockiert — ein System kann sich nicht vollständig selbst analysieren</li><li><strong>Was war "davor"?</strong> Blockiert — die Zeit begann mit dem Urknall</li><li><strong>Höhere Dimensionen wahrnehmen?</strong> Blockiert durch kognitive Grenzen</li></ul>
<p>Der Philosoph Colin McGinn nennt dies <strong>kognitive Schließung</strong>: Manche Fragen sind dem menschlichen Geist verschlossen — nicht wegen fehlender Daten, sondern wegen der Architektur des Geistes selbst.</p>
<h2>Das Einzige, was bleibt: Selbstverbesserung</h2>
<p>Wenn jeder Ausgang absichtlich blockiert ist, bleibt nur eine Richtung: nach innen.</p>
<p>Diese Schlussfolgerung kommt nicht aus der Religion. Sie kommt aus der Logik der Schwarzen Löcher, der Rekursion, der Informationstheorie und den Grenzen der Kognition.</p>
<blockquote>Wir sind hier nicht durch Glauben angekommen, sondern durch Physik — von Schwarzen Löchern über rekursive Universen zu den fundamentalen Blockaden des Wissens.</blockquote>
<h2>Referenzen</h2>
<ul><li><a href="https://en.wikipedia.org/wiki/Cosmological_natural_selection" target="_blank" rel="noopener">Lee Smolin — Kosmologische Natürliche Selektion</a></li><li><a href="https://en.wikipedia.org/wiki/Hawking_radiation" target="_blank" rel="noopener">Stephen Hawking — Hawking-Strahlung</a></li><li><a href="https://en.wikipedia.org/wiki/Hard_problem_of_consciousness" target="_blank" rel="noopener">David Chalmers — Das Schwere Problem des Bewusstseins</a></li><li><a href="https://en.wikipedia.org/wiki/Flatland" target="_blank" rel="noopener">Edwin Abbott — Flachland (1884)</a></li><li><a href="https://en.wikipedia.org/wiki/G%C3%B6del%27s_incompleteness_theorems" target="_blank" rel="noopener">Kurt Gödel — Unvollständigkeitssätze</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>KI hat das CMS getötet — zumindest für einfache Websites</title>
      <link>https://oleksiimazurenko.dev/de/blog/ai-killed-cms-for-simple-sites</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/de/blog/ai-killed-cms-for-simple-sites</guid>
      <description>Warum traditionelle Content-Management-Systeme für Portfolios, Blogs und Landing Pages unnötig werden — und wie KI-Tools die gesamte CMS-Schicht ersetzen.</description>
      <content:encoded><![CDATA[<h2>TL;DR</h2>
<p>Für einfache Websites — Portfolios, Blogs, Landing Pages, kleine Unternehmensseiten — wird ein traditionelles CMS zu unnötigem Overhead. KI-Tools wie Claude Code, Cursor und GitHub Copilot können Ihre Codebasis jetzt direkt bearbeiten, Kontext verstehen, Inhalte übersetzen und Änderungen über git deployen. Die Abstraktionsschicht, die CMS bot, wird durch ein intelligenteres Interface ersetzt: natürliche Sprache.</p>
<h2>Die CMS-Steuer, die Sie zahlen</h2>
<p>Jedes CMS bringt versteckte Kosten mit sich. Nicht nur die Abogebühr — das gesamte Ökosystem der Komplexität, das sich um Ihre ansonsten einfache Website wickelt:</p>
<ul><li><strong>Infrastruktur:</strong> Eine Datenbank zum Hosten, eine API zum Warten, ein Dashboard zum Absichern. WordPress allein macht ~43% des Webs aus und ~90% der CMS-gezielten Angriffe.</li><li><strong>Performance:</strong> Dynamische Seitengenerierung, API-Aufrufe bei jeder Anfrage, clientseitige Hydration von CMS-Daten. Ihr 3-Seiten-Portfolio hat jetzt die Architektur eines SaaS-Produkts.</li><li><strong>Vendor Lock-in:</strong> Ihre Inhalte leben in einem fremden Datenbankschema. Migration von Contentful zu Sanity? Das ist ein Projekt, kein Config-Change.</li><li><strong>Kontextwechsel:</strong> Code in der IDE bearbeiten, dann zum browserbasierten CMS-Dashboard wechseln. Zwei verschiedene mentale Modelle für die gleiche Operation.</li><li><strong>Kosten:</strong> Headless-CMS-Preise skalieren oft mit API-Aufrufen oder Content-Einträgen. Ein persönlicher Blog braucht keine Content-Infrastruktur für $99/Monat.</li></ul>
<p>Für eine Marketingseite, auf der 50 Leute täglich Content bearbeiten, sind diese Kosten gerechtfertigt. Für ein Entwickler-Portfolio? Sie bauen eine Brücke über eine Pfütze.</p>
<h2>Was sich geändert hat: KI versteht Ihren Code</h2>
<p>Der Grund für CMS war einfach: Nicht-technische Menschen brauchten ein visuelles Interface, um Websites zu aktualisieren. Der Code war zu komplex, zu fragil.</p>
<p>KI hat diese Gleichung fundamental verändert. Moderne KI-Coding-Tools verstehen Projektstruktur, lesen existierende Muster und machen kontextuell korrekte Bearbeitungen:</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>Das ist nicht hypothetisch. Dieser Blog läuft auf SolidStart mit Content in TypeScript-Dateien. Jeder Artikel wurde erstellt, indem man der KI sagt, was sie schreiben soll, das Ergebnis prüft und zu git pusht.</p>
<h2>Echte Beispiele von dieser Website</h2>
<p>Diese Website unterstützt 10 Sprachen, hat einen Blog, generiert OG-Bilder dynamisch und produziert RSS-Feeds. So sieht die Content-Schicht aus — reines TypeScript:</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>Dinge, die ich mit KI mache und die traditionell ein CMS erfordern würden:</p>
<ul><li><strong>Neuen Blogpost hinzufügen:</strong> "Schreibe einen Artikel über X" — KI erstellt die Datei, fügt Übersetzungen hinzu, registriert sie im Index</li><li><strong>Landing Page Text aktualisieren:</strong> "Ändere die Hero-Headline auf Y" — KI findet die richtige Datei</li><li><strong>Content übersetzen:</strong> "Füge deutsche Übersetzung hinzu" — KI liest die englische Version und produziert eine kulturell angepasste Übersetzung</li><li><strong>Tippfehler beheben:</strong> "Auf der About-Seite steht 'recieve'" — in 3 Sekunden erledigt, mit sinnvoller Commit-Nachricht</li></ul>
<h2>Was CMS wirklich gelöst hat — und wie KI es ersetzt</h2>
<p>Seien wir ehrlich, was CMS gebracht hat und wie jede Fähigkeit auf den KI-Workflow abbildet:</p>
<table><thead><tr><th>Problem</th><th>CMS-Lösung</th><th>KI-Lösung</th></tr></thead><tbody><tr><td>Nicht-technische Bearbeitung</td><td>WYSIWYG-Editor</td><td>Anweisungen in natürlicher Sprache</td></tr><tr><td>Mehrsprachiger Content</td><td>i18n-Plugins, Locale-Felder</td><td>KI übersetzt mit kulturellem Kontext</td></tr><tr><td>Content-Planung</td><td>Eingebaute Veröffentlichungsdaten</td><td>Git CI/CD mit Cron oder Datumsfelder im Code</td></tr><tr><td>Versionshistorie</td><td>CMS-Revisionssystem</td><td>Git — der Goldstandard der Versionskontrolle</td></tr><tr><td>Medienverwaltung</td><td>Eingebaute Asset-Bibliothek</td><td>CDN + git LFS oder Cloud-Speicher</td></tr></tbody></table>
<p>Die Schlüsselerkenntnis: Git ist bereits ein besseres Versionskontrollsystem als jedes CMS jemals gebaut hat. Und natürliche Sprache ist ein besseres Interface als jeder WYSIWYG-Editor.</p>
<h2>Der Paradigmenwechsel: Code ist die Content-Schicht</h2>
<p>Wir beobachten eine Inversion. Zwanzig Jahre lang war der Trend, Content vom Code zu trennen. Das machte Sinn, als Code schwer zu bearbeiten war.</p>
<blockquote>KI hat CMS nicht obsolet gemacht, indem sie ein besseres CMS wurde. Sie hat CMS obsolet gemacht, indem sie Code so zugänglich wie ein Dashboard machte.</blockquote>
<p>Die Entwicklung des Web-Content-Managements folgt einer klaren Trajektorie:</p>
<ol><li><strong>2000er:</strong> Monolithische CMS (WordPress, Drupal) — Content und Darstellung in einem System gekoppelt</li><li><strong>2010er:</strong> Headless CMS (Contentful, Strapi) — Content über API getrennt</li><li><strong>2020er:</strong> Statische Site-Generatoren + Markdown (Hugo, Astro) — Content als Dateien</li><li><strong>2025+:</strong> Code-as-Content + KI — Content lebt in typisiertem Code, KI ist das Editing-Interface</li></ol>
<h2>Wann Sie noch ein CMS brauchen</h2>
<p>Dies ist kein "CMS ist tot"-Take. CMS löst echte Probleme im großen Maßstab:</p>
<ul><li><strong>Große Redaktionsteams:</strong> 10+ Content-Editoren mit rollenbasiertem Zugriff und Genehmigungsworkflows</li><li><strong>Hochfrequenter Content:</strong> Nachrichtenseiten mit 50+ Artikeln pro Tag brauchen optimierte Redaktionspipelines</li><li><strong>Komplexe Content-Beziehungen:</strong> E-Commerce-Kataloge mit Tausenden SKUs brauchen strukturierte Datenbanken</li><li><strong>Regulatorische Compliance:</strong> Branchen mit Audit-Trails und rechtlich vorgeschriebenen Prüfprozessen</li></ul>
<p>Die Grenze ist klar: Wenn Ihre Content-Änderungen Koordination zwischen mehreren nicht-technischen Stakeholdern mit hoher Frequenz erfordern, verdient CMS seine Komplexität.</p>
<h2>Die Zukunft: KI als universelles Interface</h2>
<p>Der Trend geht über CMS hinaus. Jede Abstraktionsschicht, die existierte, weil das zugrundeliegende System zu komplex war, wird durch KI komprimiert.</p>
<p>Für einfache Websites ist die Zukunft bereits hier. Ihr Content ist Code. Ihr Editor ist KI. Ihre Versionskontrolle ist Git. Ihr Deployment ist ein Push.</p>
<blockquote>Das beste CMS ist kein CMS. Nicht weil Content-Management nicht wichtig ist — sondern weil KI den Code selbst zum intuitivsten Content-Management-Interface gemacht hat, das wir je hatten.</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>Perplexity AI mit Obsidian über MCP verbinden — Notizen direkt aus dem Chat</title>
      <link>https://oleksiimazurenko.dev/de/blog/perplexity-obsidian-mcp-integration</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/de/blog/perplexity-obsidian-mcp-integration</guid>
      <description>Richten Sie Perplexity Desktop ein, um Ihren Obsidian Vault über den MCP Filesystem Server zu lesen und zu beschreiben. Recherchieren Sie im Web und speichern Sie in Ihren Notizen — in einem Gespräch.</description>
      <content:encoded><![CDATA[<h2>TL;DR</h2>
<p>Perplexity Desktop unterstützt <strong>MCP (Model Context Protocol)</strong> Konnektoren. Durch Hinzufügen des offiziellen <code>@modelcontextprotocol/server-filesystem</code> Servers, der auf Ihren Obsidian Vault zeigt, können Sie Perplexity in natürlicher Sprache bitten, Notizen zu lesen, zu erstellen und zu bearbeiten — direkt aus dem Chat. Keine Plugins, keine Erweiterungen, kein Kopieren.</p>
<h2>Das Problem</h2>
<p>Perplexity ist großartig bei der Recherche — es durchsucht das Web, fasst Quellen zusammen und gibt Antworten mit Zitaten. Aber wenn Sie diese Erkenntnisse in Ihrem Obsidian Vault speichern möchten, bricht der Workflow ab: Text kopieren, zu Obsidian wechseln, die richtige Notiz finden, einfügen, formatieren. Jedes. Einzelne. Mal.</p>
<p>Browser-Erweiterungen wie "Perplexity to Obsidian" helfen beim Export, aber sie sind unidirektional — die KI kann Ihren Vault nicht <em>sehen</em>, kann bestehende Notizen nicht lesen und kann nicht entscheiden, wo Dinge basierend auf Ihrer Ordnerstruktur abgelegt werden sollen.</p>
<h2>Was ist MCP?</h2>
<p><strong>Model Context Protocol (MCP)</strong> ist ein offener Standard, der KI-Modellen die Interaktion mit lokalen Tools und Datenquellen ermöglicht. Stellen Sie es sich als USB-Anschluss für KI vor — Sie schließen einen "Server" (ein kleines Programm) an, und die KI erhält neue Fähigkeiten. In unserem Fall gibt der Filesystem-Server Perplexity 14 Werkzeuge zur Dateiarbeit:</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>Der entscheidende Punkt: Das KI-Modell greift nicht direkt auf Ihre Dateien zu. Es ruft Werkzeuge auf, die vom MCP-Server bereitgestellt werden, der lokal auf Ihrem Rechner läuft. Ihre Daten verlassen Ihren Computer nicht, es sei denn, Sie bitten die KI ausdrücklich darum.</p>
<h2>Voraussetzungen</h2>
<ul><li><strong>Perplexity Pro</strong> Abonnement (MCP-Konnektoren sind für zahlende Nutzer verfügbar)</li><li><strong>Perplexity Mac App</strong> aus dem App Store (nicht die Browser-Version)</li><li><strong>Node.js</strong> auf Ihrem Mac installiert (damit <code>npx</code> funktioniert)</li></ul>
<h2>Schritt-für-Schritt-Einrichtung</h2>
<p>Die gesamte Einrichtung dauert etwa 2 Minuten:</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>Der Befehl</h3>
<p>Der Befehl zum Einfügen in das <strong>Command</strong>-Feld:</p>
<pre><code>npx -y @modelcontextprotocol/server-filesystem &quot;/Users/yourname/Library/Mobile Documents/iCloud~md~obsidian/Documents/Obsidian Vault&quot;</code></pre>
<p>Ersetzen Sie den Pfad durch den tatsächlichen Speicherort Ihres Obsidian Vaults. Wenn Ihr Vault über iCloud synchronisiert wird, befindet sich der Pfad unter <code>~/Library/Mobile Documents/iCloud~md~obsidian/Documents/</code>. Behalten Sie die Anführungszeichen bei — der Pfad enthält wahrscheinlich Leerzeichen.</p>
<h2>Verwendung</h2>
<p>Sobald der Konnektor <strong>Running</strong> mit 14 verfügbaren Werkzeugen anzeigt, gehen Sie zu einem beliebigen Perplexity-Chat und sprechen Sie mit Ihrem 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>Die KI versteht Ihre Vault-Struktur, respektiert Ihre Formatierungskonventionen und kann mit bestehenden Inhalten arbeiten. Sie können sie bitten, ein Thema im Web zu recherchieren und die Zusammenfassung direkt in einer bestimmten Notiz zu speichern.</p>
<h2>Warum MCP andere Ansätze übertrifft</h2>
<p>Vor MCP gab es begrenzte Möglichkeiten, Perplexity und Obsidian zu verbinden:</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>Aktuelle Einschränkungen</h2>
<ul><li><strong>Nur Mac</strong> — Perplexity MCP-Konnektoren funktionieren derzeit nur in der Mac App Store Version</li><li><strong>Keine Obsidian-API-Integration</strong> — der Filesystem-Server arbeitet mit Rohdateien, nicht über die Obsidian-API. Das bedeutet, er löst keine Obsidian-Plugins (Linter, Templater) beim Erstellen von Dateien aus</li><li><strong>Bestätigung erforderlich</strong> — sensible Dateioperationen erfordern möglicherweise Ihre Bestätigung in der Perplexity-App — das ist ein Sicherheitsfeature, kein Bug</li></ul>
<h2>Fazit</h2>
<p>Dieses Setup verwandelt Perplexity von einem Recherche-Tool in ein Recherche-und-Erfassungs-Tool:</p>
<ol><li>Im Web recherchieren und in Obsidian speichern — in einem Gespräch</li><li>Die KI sieht Ihre Vault-Struktur und passt sich Ihrem Organisationssystem an</li><li>Kein App-Wechsel — alles passiert im Perplexity-Chat</li></ol>
<h2>Quellen</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>Täglicher AI News Digest mit Claude Code CLI und Obsidian — Null Abhängigkeiten</title>
      <link>https://oleksiimazurenko.dev/de/blog/ai-news-digest-claude-code-obsidian</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/de/blog/ai-news-digest-claude-code-obsidian</guid>
      <description>Wie ich einen täglichen Nachrichtenrecherche-Agenten mit einem 6-Zeilen Bash-Skript, Claude Code Headless-Modus und macOS launchd gebaut habe.</description>
      <content:encoded><![CDATA[<h2>TL;DR</h2>
<p>Ein 6-zeiliges Bash-Skript, das <strong>Claude Code CLI</strong> jeden Morgen um 9:00 Uhr im Headless-Modus ausführt. Es durchsucht das Web nach Nachrichten zu 11 konfigurierbaren Themen, filtert Rauschen heraus und schreibt einen formatierten Markdown-Digest direkt in einen <strong>Obsidian Vault</strong>, der über iCloud synchronisiert wird. Null Abhängigkeiten. ~100 Zeilen Konfiguration insgesamt.</p>
<h2>Das Problem</h2>
<p>Als Entwickler ist es eine tägliche Steuer, über mehrere Technologien auf dem Laufenden zu bleiben. RSS-Feeds sind laut, Twitter ist ein Zeitfresser, Newsletter kommen wenn man tief im Flow ist. Ich brauchte etwas, das die Recherche <em>für</em> mich erledigt und die Ergebnisse dort präsentiert, wo ich bereits arbeite — meinem Obsidian Vault.</p>
<p>Die typische Lösung ist eine Scraping-Pipeline: ein Scheduler, ein Crawler, eine NLP-Pipeline, eine Datenbank, ein Benachrichtigungsdienst. Das sind Wochen Arbeit für etwas, das kaputt geht wenn eine Website ihr HTML ändert. Ich wollte etwas, das an einem Nachmittag fertig ist.</p>
<h2>Architektur</h2>
<p>Das gesamte System besteht aus 4 Dateien und null Abhängigkeiten. So funktioniert es End-to-End:</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>Der Code (komplett)</h2>
<p>Das Projekt ist bewusst minimal. Jede Zeile verdient ihren Platz.</p>
<h3>Einstiegspunkt: digest.sh</h3>
<p>Die gesamte Anwendung ist ein 6-zeiliges Bash-Skript:</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>Die wichtigen Flags: <code>-p</code> führt Claude im Headless-Modus aus, <code>--max-turns 20</code> gibt dem Agenten genug Raum für alle Themen, und <code>--allowedTools</code> beschränkt den Agenten auf Dateilesen, Websuche und Schreiben.</p>
<h3>Das Gehirn: prompt.md</h3>
<p>Hier lebt die Intelligenz. Der Prompt verwandelt Claude in einen Nachrichtenrecherche-Agenten mit spezifischen Anweisungen:</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>Konfiguration: topics.yaml</h3>
<p>Themen sind vollständig konfigurierbar — füge ein neues Thema hinzu und es ist im morgigen Digest enthalten:</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>Planung mit launchd</h2>
<p>Auf macOS ist <code>launchd</code> der native Weg, wiederkehrende Aufgaben zu planen (wie cron unter Linux):</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 mit <code>launchctl load ~/Library/LaunchAgents/com.news-digest.plist</code>. Das Skript läuft jeden Tag um 9:00 Uhr — launchd führt verpasste Jobs aus wenn das System aufwacht.</p>
<h2>Wie das Ergebnis aussieht</h2>
<p>Jeden Morgen erscheint eine neue Markdown-Datei im Obsidian Vault mit strukturierten, priorisierten Nachrichten:</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>In Zahlen</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>Wichtige Designentscheidungen</h2>
<ul><li><strong>Claude Code CLI statt API</strong> — kein Verwalten von API-Schlüsseln, HTTP-Clients oder Response-Parsing nötig</li><li><strong>Obsidian statt E-Mail</strong> — Digests sind durchsuchbar, verlinkbar und permanent</li><li><strong>launchd statt cron</strong> — launchd ist der macOS-native Scheduler mit sauberem Handling von verpassten Läufen</li><li><strong>YAML für Themen</strong> — ein neues Thema ist eine 2-Zeilen-Änderung</li><li><strong>Leere Themen überspringen</strong> — keine Nachrichten = kein Abschnitt</li></ul>
<h2>Eigenes bauen</h2>
<p>In 10 Minuten einsatzbereit:</p>
<ol><li><a href="https://docs.anthropic.com/en/docs/claude-code" target="_blank" rel="noopener noreferrer">Claude Code CLI</a> installieren und authentifizieren</li><li>Repo klonen: <code>git clone https://github.com/oleksiimazurenko/news-digest</code></li><li><code>topics.yaml</code> und <code>prompt.md</code> anpassen</li><li>Plist-Datei anpassen und mit <code>launchctl load</code> laden</li><li>Auf 9:00 Uhr warten — oder mit <code>bash digest.sh</code> manuell testen</li></ol>
<h2>Fazit</h2>
<p>Das Interessanteste an diesem Projekt ist, was <em>nicht</em> drin ist. Keine Datenbank, kein API-Server, kein Docker, keine npm-Pakete, kein Python, kein HTML-Parser, keine NLP-Pipeline.</p>
<p>So sieht Bauen mit AI-Agenten in der Praxis aus: Sie definieren das <em>Was</em> und <em>Wohin</em>, der Agent übernimmt das <em>Wie</em>. Gesamte Entwicklungszeit: etwa 2 Stunden.</p>
<h2>Quellen</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>Next.js Dark Mode ohne Flackern oder React 19 Warnungen</title>
      <link>https://oleksiimazurenko.dev/de/blog/nextjs-dark-mode-without-flash</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/de/blog/nextjs-dark-mode-without-flash</guid>
      <description>Wie man next-themes durch Zustand + useServerInsertedHTML ersetzt, für flackerfreien Dark Mode in Next.js 15+ ohne React 19 Script-Warnungen.</description>
      <content:encoded><![CDATA[<h2>TL;DR</h2>
<p><code>next-themes</code> rendert ein <code>&lt;script&gt;</code>-Tag innerhalb einer React Client Component, um Theme-Flackern (FOUC) zu verhindern. React 19 warnt jetzt davor — und es gibt keine Möglichkeit, die Warnung zu unterdrücken. Die Bibliothek wurde seit März 2025 nicht mehr aktualisiert. Die Lösung: <code>next-themes</code> durch einen Zustand Store + <code>useServerInsertedHTML</code> ersetzen, um das Script außerhalb des React-Baums einzufügen. Keine neuen Abhängigkeiten. Kein Flackern. Keine Warnungen.</p>
<h2>Das Problem</h2>
<p>Wenn Sie <code>next-themes</code> mit Next.js 15+ und React 19 verwenden, erhalten Sie bei jedem Seitenaufruf diesen Fehler in der Konsole:</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>Dies ist kein Hydration-Mismatch. React 19 warnt explizit, dass <code>&lt;script&gt;</code>-Tags, die von React-Komponenten auf dem Client gerendert werden, <strong>nie ausgeführt werden</strong>. Das Script funktioniert während SSR (es ist im HTML), aber React markiert es als fehlerhaft.</p>
<h2>Warum es passiert</h2>
<p><code>next-themes</code> muss die korrekte Theme-Klasse auf <code>&lt;html&gt;</code> setzen, bevor React hydriert — sonst gibt es ein Aufblitzen des falschen Themes. Dafür wird ein Inline-<code>&lt;script&gt;</code> über <code>React.createElement</code> eingefügt:</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 hat sein Verhalten geändert: Script-Tags in Komponenten werden jetzt explizit markiert. Vor React 19 wurde dies stillschweigend ignoriert. Das <code>suppressHydrationWarning</code>-Prop auf dem Script hilft nicht — es unterdrückt Hydration-Warnungen, nicht die "Script in Komponente"-Warnung.</p>
<h2>Was wir versucht haben (und warum es scheiterte)</h2>
<p>Wir haben systematisch jeden Ansatz ausprobiert, bevor wir die Lösung fanden:</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>Die Lösung: Zustand + useServerInsertedHTML</h2>
<p>Die wichtigste Erkenntnis: <code>useServerInsertedHTML</code> ist ein Next.js-Hook, der HTML in den SSR-Stream <strong>außerhalb des React-Komponentenbaums</strong> einfügt. Das Script landet im HTML, aber React "sieht" es beim Client-Rendering nie — also keine Warnung. Kombiniert mit einem Zustand Store für reaktiven Theme-State ergibt sich ein vollständiger Ersatz ohne Abhängigkeiten.</p>
<h3>Wie es funktioniert</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>Schritt 1: Zustand Store</h3>
<p>Der Store verwaltet den Theme-State, wendet Klassen auf das DOM an, erkennt das System-Theme und synchronisiert über Tabs. Die <code>_init()</code>-Methode gibt eine Cleanup-Funktion für <code>useEffect</code> zurück:</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>Schritt 2: ThemeProvider</h3>
<p>Der Provider macht zwei Dinge: Er fügt das FOUC-Präventionsskript über <code>useServerInsertedHTML</code> ein und initialisiert den Zustand Store beim Mounten:</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>Schritt 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>Schritt 4: Verwendung</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 von next-themes</h2>
<p>Die API ist absichtlich identisch. Die Migration ist eine einzige Import-Änderung pro Datei:</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>Vergleich</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>Warum nicht andere Alternativen?</h2>
<h3>@wrksz/themes</h3>
<p>Ein Drop-in-Ersatz, der ebenfalls <code>useServerInsertedHTML</code> verwendet. Es funktioniert, aber es ist eine weitere Abhängigkeit von einem einzelnen Maintainer. Wenn uns <code>next-themes</code> etwas gelehrt hat — Abhängigkeiten werden aufgegeben. Mit ~100 Zeilen Code können Sie die Lösung vollständig besitzen.</p>
<h3>next-themes@1.0.0-beta.0</h3>
<p>Existiert auf npm, aber ohne Veröffentlichungsdatum, ohne Changelog und ohne klaren Hinweis, dass die React-19-Warnung behoben ist. Produktionscode auf eine unbefristete Beta zu setzen, ist kein lohnenswertes Risiko.</p>
<h3>CSS-only (prefers-color-scheme)</h3>
<p>Funktioniert für die System-Theme-Erkennung, kann aber keine Benutzerpräferenz-Persistenz (localStorage), manuelles Theme-Wechseln oder die "system"-Option handhaben. Dafür braucht man JavaScript.</p>
<h2>Schlussfolgerungen</h2>
<ol><li><code>next-themes</code> ist effektiv aufgegeben — letztes Release März 2025, React-19-Warnung nicht behoben</li><li><code>useServerInsertedHTML</code> ist das korrekte Next.js-Primitiv zum Einfügen von Scripts ohne React-Warnungen</li><li>Zustand bietet reaktiven Theme-State mit weniger Code als ein Context-Provider</li><li>Die gesamte Lösung umfasst ~100 Zeilen, keine neuen Abhängigkeiten, und Sie besitzen jede Zeile</li></ol>
<h2>Quellen</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>Wie ich 94 Render-blockierende CSS-Dateien in Next.js 16 mit einem kaum dokumentierten Turbopack-Feature eliminiert habe</title>
      <link>https://oleksiimazurenko.dev/de/blog/eliminating-render-blocking-css</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/de/blog/eliminating-render-blocking-css</guid>
      <description>Nach Tagen des Ausprobierens jedes Ansatzes — von experimental.inlineCss bis MutationObserver-Hacks — entdeckte ich Turbopack Import Attributes, die das Render-blockierende CSS-Problem im Next.js App Router lösen.</description>
      <content:encoded><![CDATA[<h2>Das Problem</h2>
<p>Unsere App (<a href="https://promova.com" target="_blank" rel="noopener noreferrer">Promova</a>) nutzt Next.js 16 mit einem <strong>Landing Builder</strong> — einem CMS-gesteuerten System, das Marketingseiten aus ~90 verschiedenen Sektionskomponenten zusammenstellt (Heroes, FAQs, Preise, Bewertungen etc.). Die Architektur nutzt eine <code>sectionRegistry.tsx</code>, die Sektionsnamen auf <code>next/dynamic()</code>-Aufrufe abbildet:</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>Eine einzelne Landingpage rendert nur <strong>5-8 Sektionen</strong>. Aber Lighthouse zeigte:</p>
<pre><code>Eliminate render-blocking resources
  94 CSS resources (~330 KB)
  Potential savings: 5,440 ms</code></pre>
<p><strong>Warum?</strong> Turbopack sieht alle 90 <code>import()</code>-Pfade als erreichbar und generiert <code>&lt;link rel="stylesheet"&gt;</code> für <strong>jedes</strong> SCSS-Modul. Selbst Sektionen, die nie auf der Seite gerendert werden, bekommen ihr CSS in <code>&lt;head&gt;</code> injiziert. Dies ist ein <a href="https://github.com/vercel/next.js/issues/62485" target="_blank" rel="noopener noreferrer">bestätigtes, erwartetes Verhalten</a> des Next.js App Router. CSS wird für <code>dynamic()</code>-Imports aus Server Components nicht code-gesplittet. Die Maintainer betrachten es als bewussten Kompromiss zur Vermeidung von FOUC. <strong>Ein Fix ist nicht geplant.</strong></p>
<h2>Alles, was ich versucht habe (und warum es scheiterte)</h2>
<p>Ich habe Tage damit verbracht, jeden Ansatz durchzugehen, den ich finden konnte. Hier die vollständige Liste:</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>Die inlineCss-Falle</h3>
<p>Next.js hat ein <a href="https://github.com/vercel/next.js/pull/72195" target="_blank" rel="noopener noreferrer"><code>experimental.inlineCss</code></a>-Flag, das alle <code>&lt;link rel="stylesheet"&gt;</code> durch Inline-<code>&lt;style&gt;</code>-Tags ersetzt. Klingt perfekt, oder?</p>
<p>Das Problem: Es ist <strong>alles oder nichts</strong>. Man kann es nicht pro Route aktivieren. Bei SSR-(<code>force-dynamic</code>)-Seiten wird bei jedem Request das gesamte CSS inline neu gebaut. Wir haben es versucht — unser headless CMS konnte die Last nicht bewältigen und ging down. Damit es sicher funktioniert, müssen <strong>100% der Seiten</strong> <code>force-static</code> oder ISR sein. Mit 20+ SSR-Seiten (Auth, Dashboards, dynamische Seiten) ist das eine massive Migration.</p>
<h2>Die Entdeckung: Turbopack Import Attributes</h2>
<p>Beim Durchstöbern der <a href="https://nextjs.org/blog/next-16-2-turbopack#inline-loader-configuration" target="_blank" rel="noopener noreferrer">Next.js 16.2 Release Notes</a> fand ich ein kaum dokumentiertes Feature: <strong>Turbopack Import Attributes</strong>. Es erlaubt, die eingebaute Bundler-Pipeline für einen bestimmten Import mit der TC39 <code>with {}</code>-Syntax zu überschreiben:</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>Dies sagt Turbopack: <em>"Verarbeite diesen Import nicht als Stylesheet. Führe ihn durch meinen Custom Loader und behandle die Ausgabe als JavaScript."</em></p>
<p><strong>Das ist die Schlüsselerkenntnis.</strong> Anstatt dass Turbopack ein <code>&lt;link rel="stylesheet"&gt;</code> generiert, das das Rendering blockiert, kompiliert unser Loader das SCSS und exportiert es als JS-String. Wir injizieren es dann als Inline-<code>&lt;style&gt;</code>-Tag direkt in die Komponente. Das Ergebnis: <strong>Nur das CSS für Sektionen, die tatsächlich rendern, kommt in den HTML-Code der Seite.</strong></p>
<h2>Die Lösung</h2>
<h3>1. Custom Turbopack Loader</h3>
<p>Ein ~70-zeiliges Node.js-Script als Yarn-Workspace-Paket (<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>Was es macht: <strong><code>styles</code></strong> — dieselbe Scoped-Klassennamen-Map wie Standard-CSS-Modules. <strong><code>cssText</code></strong> — das kompilierte CSS als String.</p>
<h3>2. InlineStyle-Komponente</h3>
<p>Nutzt React 19s eingebaute <code>&lt;style href precedence&gt;</code>-API für automatische Deduplizierung:</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 garantiert: gleicher <code>href</code> → nur ein <code>&lt;style&gt;</code> im DOM.</p>
<h3>3. Migration pro Komponente (~6 Zeilen pro Sektion)</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>Die <code>.module.scss</code>-Dateien bleiben exakt gleich.</strong> Kein CSS-Umschreiben. Stylelint, Prettier, IDE-Support — alles bleibt erhalten.</p>
<h2>Warum das besser ist als inlineCss: true</h2>
<p>Das ist der entscheidende Unterschied:</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>Mit <code>inlineCss: true</code> bekommt eine Seite immer noch ALLE 94 Stylesheets inline. Mit unserem Ansatz kommt <strong>nur das CSS, das tatsächlich gerendert wird, ins HTML</strong>.</p>
<h2>Turbopack-Stolperfalle: Keine globalen Regeln für .module.scss</h2>
<p>Eine Falle, in die ich getappt bin: Man könnte denken, dass man eine Turbopack-Regel in <code>next.config.ts</code> hinzufügen kann, um den Loader global anzuwenden:</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>Tun Sie das nicht.</strong> Turbopacks eingebaute CSS-Module-Pipeline fängt <code>.module.scss</code>-Dateien ab, <strong>bevor</strong> Custom-Regeln angewendet werden, was verursacht:</p>
<pre><code>FATAL PANIC: inner asset should be CSS processable</code></pre>
<p><code>with {}</code>-Attribute funktionieren, weil sie Turbopack <strong>an der Import-Stelle</strong> anweisen, die CSS-Module-Pipeline vollständig zu umgehen.</p>
<h2>Ergebnisse</h2>
<p>127 Sektionskomponenten im Landing Builder migriert. Produktions-Build verifiziert.</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>Einschränkungen</h2>
<ul><li><strong><code>with {}</code> pro Import ist ausführlich</strong> — jeder Import braucht 3 zusätzliche Zeilen.</li><li><strong>Nur Turbopack</strong> — <code>with {}</code>-Attribute werden von Webpack nicht unterstützt.</li><li><strong>Klassennamen-Hashing</strong> — unser Loader nutzt einen anderen Hashing-Algorithmus als Turbopacks eingebauter.</li><li><strong>HTML-Größe steigt</strong> — CSS ist inline im HTML statt als separate Dateien gecacht.</li></ul>
<h2>Wann man das nutzen sollte</h2>
<p>Diese Technik ist am effektivsten, wenn:</p>
<ol><li><strong>Sie ein Registry/Barrel-Pattern haben</strong> — eine Datei importiert viele Komponenten, aber nur wenige rendern pro Seite</li><li><strong>Sie Turbopack nutzen</strong> — Import Attributes sind Turbopack-spezifisch</li><li><strong>Sie Kontrolle pro Komponente wollen</strong> — kein Alles-oder-Nichts-Flag</li><li><strong>Ihr SCSS komplex ist</strong> — Variablen, Mixins, Breakpoints, Nesting — alles unterstützt</li><li><strong>Sie <code>experimental.inlineCss</code> nicht nutzen können</strong> — weil Sie SSR-Seiten haben oder granulare Kontrolle wollen</li></ol>
<h2>Verwandte GitHub Issues</h2>
<p>Wenn Sie von Render-blockierendem CSS im Next.js App Router betroffen sind — Sie sind nicht allein:</p>
<h3>Das Kernproblem</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>inlineCss Feature &amp; Issues</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>Community sucht Lösungen</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>Turbopack CSS-Bugs</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>Gebaut bei <a href="https://promova.com" target="_blank" rel="noopener noreferrer">Promova</a> — einer Sprachlernplattform für Millionen von Nutzern.</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 + Standalone-Modus — 2 Jahre ohne Fix</title>
      <link>https://oleksiimazurenko.dev/de/blog/nextjs-memory-leak-fetch-standalone</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/de/blog/nextjs-memory-leak-fetch-standalone</guid>
      <description>Next.js patcht den globalen fetch und fügt eine Cache-Schicht hinzu, die bei jeder Anfrage Speicher verliert. In Docker/K8s führt dies alle paar Stunden zu OOM-Crashes. Der Bug existiert seit Next.js 14 und ist in 16.2.x noch ungelöst.</description>
      <content:encoded><![CDATA[<h2>TL;DR</h2>
<p>Next.js patcht den globalen <code>fetch</code> und fügt eine Cache-Schicht hinzu, die Referenzen auf Response-Daten hält, nachdem sie freigegeben werden sollten. Jeder <code>fetch</code>-Aufruf fügt Speicher hinzu, der nie vom GC zurückgegeben wird. In Docker/Kubernetes führt dies alle paar Stunden zu OOM-Crashes. Der Bug existiert seit Next.js 14 (April 2024) und ist in 16.2.x (März 2026) noch ungelöst. Auf Vercel tritt das Problem dank ephemerer Serverless-Funktionen nicht auf.</p>
<h2>Wie normaler Fetch funktioniert</h2>
<pre><code>Request → fetch → got data → response to user → GC cleans up → memory free</code></pre>
<h2>Wie Fetch in Next.js funktioniert</h2>
<p>Next.js fängt den globalen <code>fetch</code> ab und umhüllt ihn mit einer eigenen Cache-/Tracking-Schicht:</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>Was in Produktion passiert</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>Betroffene Versionen</h2>
<h3>Next.js — Alle Versionen mit 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>Getestet auf Node.js 20, 22, 24, 25 — leckt auf allen.</p>
<h2>Was nicht funktioniert</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>Was funktioniert (Workarounds)</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>Einschränkung des Axios-Workarounds</h3>
<p>Eigene API-Aufrufe können durch axios ersetzt werden. Aber Next.js verwendet intern den gepatchten fetch für:</p>
<ul><li>ISR (Incremental Static Regeneration)</li><li><code>revalidatePath</code> / <code>revalidateTag</code></li><li>Server Components Data Fetching mit Deduplizierung</li><li><code>use cache</code> (Next.js 16)</li></ul>
<p>Selbst ohne einen einzigen <code>fetch</code> im eigenen Code — Next.js verwendet ihn intern weiterhin.</p>
<h2>Warum Vercel es nicht fixt</h2>
<h3>Geschäftslogik</h3>
<p>Vercel verdient Geld mit dem Hosting von Next.js. Auf ihrer Plattform tritt das Problem nicht auf (serverless = ephemeral). Der Bug betrifft nur Self-Hosted (Docker, K8s, VPS) — also die, die nicht an Vercel zahlen.</p>
<h3>Offizielle Position</h3>
<p>Tim Neutkens (Vercel-Maintainer) analysierte das Problem und erklärte es zum <strong>undici</strong>-Problem (Node.js-Fetch-Bibliothek), nicht Next.js. Issue #90433 wurde geschlossen. Obwohl:</p>
<ul><li>axios und node-fetch auf demselben Node.js ohne Leaks funktionieren</li><li>Das Leak nur auftritt, wenn fetch durch den Next.js-Wrapper geht</li><li>Der Bug seit 2 Jahren ohne Fix offen ist</li></ul>
<h3>Prioritäten</h3>
<p>In diesen 2 Jahren hat das Next.js-Team veröffentlicht:</p>
<ul><li>Turbopack (2-5x schnellere Builds) — Marketingvorteil</li><li>Cache Components / <code>use cache</code> — reduziert Last auf Vercel-Servern</li><li><code>proxy.ts</code> statt Middleware — vereinfacht Edge-Deployment auf Vercel</li><li>DevTools MCP — AI-Hype</li></ul>
<p>Memory Leak bei Self-Hosted? Keine Priorität.</p>
<h2>Lösung: AWS Lambda (SST + OpenNext)</h2>
<h3>Was ist das</h3>
<p>OpenNext ist ein Open-Source-Adapter, der einen Next.js-Build in ein AWS-Lambda-Format konvertiert. SST ist ein Framework zur Automatisierung der Infrastruktur.</p>
<h3>Architektur</h3>
<pre><code>Next.js build
  → OpenNext
    → AWS Lambda (SSR, API routes)
    → S3 (static, assets)
    → CloudFront (CDN)
    → SQS + DynamoDB (ISR revalidation)</code></pre>
<h3>Warum dies das Memory Leak löst</h3>
<p>Lambda-Funktionen verarbeiten Anfragen und werden nach 5-15 Minuten Inaktivität recycelt. Speicher hat keine Zeit sich anzusammeln.</p>
<h3>Deployment</h3>
<pre><code>npx sst@latest init
npx sst deploy --stage production</code></pre>
<h3>Vergleich</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>Lambda-Nuancen</h3>
<ul><li><strong>Cold Starts</strong> — erste Anfrage ist langsamer (~200-500ms)</li><li><strong>Sicherheit</strong> — OAC (Origin Access Control) aktivieren, sonst ist die Lambda-URL öffentlich</li><li><strong>OpenNext</strong> — Community-Projekt, nicht offiziell von Vercel. Neue Next.js-Features können brechen</li><li><strong>Wallet-Attacke</strong> — bei DDoS kann Lambda-Auto-Scaling zu einer hohen Rechnung führen</li></ul>
<h2>Warum ein echter Fix unrealistisch ist</h2>
<h3>1. Architekturproblem</h3>
<p>Das Leak ist kein zufälliger Bug, sondern Folge einer Design-Entscheidung: Next.js fängt den globalen <code>fetch</code> ab und fügt Cache/Tracking darüber hinzu. Um es zu fixen, müsste die Art, wie App Router mit fetch interagiert, neu designed werden. Das betrifft ISR, Revalidation, Data Cache, Request Deduplication — den Kern des Frameworks.</p>
<h3>2. Interessenkonflikt</h3>
<p>Vercel ist nicht motiviert zu fixen, was ihre Plattform nicht betrifft. Self-Hosted konkurriert mit ihrem Geschäft. Je mehr Probleme bei Self-Hosted — desto mehr Menschen migrieren zu Vercel.</p>
<h3>3. Blame Shifting</h3>
<p>Die offizielle Position lautet "es ist undici, nicht wir". Solange sich das nicht ändert, wird nicht an einem Fix gearbeitet.</p>
<h3>4. Kein Community Fix</h3>
<p>Die AGPL-3.0-Lizenz von Next.js erlaubt Forks, aber die Codebasis ist riesig und eng mit der Vercel-Infrastruktur gekoppelt. Ein Community-PR zum Fixen des Fetch-Wrappers würde tiefes Verständnis der internen Architektur und Zustimmung der Maintainer erfordern — die das Issue bereits geschlossen haben.</p>
<h2>Schlussfolgerungen</h2>
<ol><li><strong>Auf Vercel</strong> — kein Problem, nichts zu tun</li><li><strong>Self-Hosted mit Serverless-Bedarf</strong> — SST + OpenNext auf AWS Lambda</li><li><strong>Self-Hosted Docker</strong> — fetch durch axios ersetzen wo möglich, RAM überwachen, automatische Pod-Neustarts einrichten</li><li><strong>Neues Projekt</strong> — SvelteKit oder Nuxt als Alternative ohne dieses Problem erwägen</li></ol>
<h2>Quellen</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>