<?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/da/blog</link>
    <description>Technical articles about web development, performance optimization, and developer tools.</description>
    <language>da</language>
    <lastBuildDate>Sat, 13 Jun 2026 00:00:00 GMT</lastBuildDate>
    <atom:link href="https://oleksiimazurenko.dev/da/blog/feed.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Claude Code Multi-Profile, v2: en ren XDG-arkitektur</title>
      <link>https://oleksiimazurenko.dev/da/blog/claude-profiles-clean-architecture</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/da/blog/claude-profiles-clean-architecture</guid>
      <description>Efter en måned med den gamle alias-baserede tilgang byggede jeg mit Claude Code multi-profile-setup om til en XDG-kompatibel struktur under ~/.config/claude-profiles/. Den egentlige grund var ikke ryddelighed — det var opdagelsen af, at ~/.claude.json er en separat fil i home-roden, hvor MCP-serverne tilføjet via --scope user stille boede.</description>
      <content:encoded><![CDATA[<p>For en måned siden udgav jeg <a href="/da/blog/multiple-claude-accounts-one-device">et indlæg om at køre to Claude Code-konti parallelt på den samme maskine</a> — privat og arbejde, via <code>CLAUDE_CONFIG_DIR</code> og et shell-alias. Tilgangen virkede. Alt gjorde, hvad det skulle.</p>
<p>Men efter en måneds reel brug stødte jeg på et par væsentlige problemer, som det indlæg ikke dækkede. Det største — en skjult særegenhed ved Claude Code, som jeg ved et tilfælde trådte midt i, da jeg tilføjede Gmail-MCP'en, og den "forsvandt" fra min profil fem minutter senere. I dag byggede jeg det hele om til en ny arkitektur — XDG-kompatibel, med et symlink og en interaktiv profilvælger via <code>gum</code>. Dette er v2 — en evolution ud af reel erfaring, ikke en teoretisk forbedring.</p>
<h2>Det, der begyndte at irritere</h2>
<p>Fire ting. De første tre handler om ryddelighed, synlighed og skala. Den fjerde er den ægte arkitektoniske fælde, som jeg ikke så med det samme. Jeg går dem igennem i rækkefølge, for det er netop den fjerde, der til sidst tvang ombygningen.</p>
<h3>1. To separate mapper i $HOME — det er roderi</h3>
<p><code>~/.claude</code> og <code>~/.claude-promova</code> — to dotfolders side om side i roden af $HOME. XDG Base Directory Specification siger, at configs hører hjemme i <code>~/.config/</code>. Dotfile-mapper spredt direkte i home er et antimønster, der med tiden gør $HOME til en skuffe med rod. Kosmetisk, javel, men det irriterede mig hver gang, jeg så <code>ls -la ~</code>.</p>
<h3>2. Ingen visuel bekræftelse på aktiv profil</h3>
<p>Jeg starter <code>claude</code> og ved ikke, hvilken profil der er aktiv — før jeg kører <code>claude config list</code> inde i sessionen. Hvis jeg har glemt, hvilken terminal jeg har startet hvor, må jeg tjekke. En bagatel, men med personal + work kørende parallelt i to faner hober det sig op.</p>
<h3>3. Aliaser skalerer ikke</h3>
<p>To profiler — <code>claude</code> og <code>claude-promova</code> — det er fint. Tilføj en tredje (en freelancekunde) — så skal der bruges et tredje alias. En fjerde — et fjerde. Efter et halvt år ville jeg ikke huske, hvilke aliaser jeg faktisk havde oprettet.</p>
<h3>4. Den skjulte fælde i ~/.claude.json</h3>
<p>Og dette er den egentlige grund til ombygningen. Claude Code har <strong>to forskellige steder</strong> til konfiguration, og dokumentationen råber det ikke ud: <code>~/.claude/</code> — kataloget, hvor <code>projects/</code>, <code>sessions/</code>, <code>hooks/</code>, <code>skills/</code> ligger. Og separat — <code>~/.claude.json</code>, en fil direkte i $HOME-roden, hvor <code>oauthAccount</code>, <code>mcpServers</code>, <code>projects</code>-historikken, <code>skillUsage</code> og omkring 40 andre felter af ægte live state bor.</p>
<p>Kommandoen <code>claude mcp add --scope user</code> skriver netop til <code>~/.claude.json</code> i home-roden og <strong>ikke</strong> til <code>~/.claude/.claude.json</code> eller til profilkataloget. Det vidste jeg ikke. Indtil jeg en dag trådte lige i det.</p>
<h2>Discovery: hvorfor Gmail-MCP&apos;en &quot;forsvandt&quot;</h2>
<p>I morges satte jeg Gmail-MCP'en op i Claude Code. Sædvanligt setup: Google Cloud-projekt, OAuth-credentials, <code>claude mcp add gmail --scope user -- npx -y @gongrzhe/server-gmail-autoauth-mcp</code>. Alt OK. Genstartede sessionen — virker, jeg læser mail, svarer på beskeder. En time senere begyndte vi at refaktorere aliaserne til en funktion med <code>gum</code>-profilvælger, og derefter flyttede vi alt over på XDG. Jeg kørte <code>mv ~/.claude → ~/.config/claude-profiles/personal</code>, genstartede CC, valgte personal i menuen. Og i den nye session åbnede jeg <code>/mcp</code>:</p>
<pre><code>figma            (failed)
playwright-test
claude.ai Notion</code></pre>
<p>Ingen gmail. Ingen vaultforge. Kun tre servere, hvoraf én oven i købet var failed. Og jeg havde lige tilføjet Gmail. Imens viste sessionen i en anden terminal (work-profilen) Gmail og Vaultforge uden mindste problem.</p>
<p>Jeg begyndte at grave og fandt, at jeg på maskinen havde <strong>tre</strong> forskellige filer med navnet <code>.claude.json</code>:</p>
<table><thead><tr><th>Path</th><th>Size</th><th>mcpServers</th></tr></thead><tbody><tr><td>&lt;code&gt;~/.claude.json&lt;/code&gt; (home root)</td><td>113 KB</td><td>figma, gmail, vaultforge</td></tr><tr><td>&lt;code&gt;~/.config/claude-profiles/personal/.claude.json&lt;/code&gt;</td><td>29 KB</td><td>figma, playwright-test</td></tr><tr><td>&lt;code&gt;~/.config/claude-profiles/promova/.claude.json&lt;/code&gt;</td><td>40 KB</td><td>figma, playwright-test, vaultforge</td></tr></tbody></table>
<p>Der var den. Det er netop dét, der er den arkitektoniske fælde:</p>
<ol><li><code>claude mcp add --scope user</code> skriver altid til <code>~/.claude.json</code> i home-roden, uafhængigt af <code>CLAUDE_CONFIG_DIR</code></li><li>Når <code>CLAUDE_CONFIG_DIR</code> er sat, læser Claude Code <code>$CLAUDE_CONFIG_DIR/.claude.json</code> — altså filen inde i profilen</li><li>Posterne for "user-scope MCPs" og "profilens MCPs" bor i <strong>forskellige filer</strong> med samme navn — og de er lette at forveksle</li></ol>
<p>I mit tilfælde var <code>~/.claude.json</code> (home-roden, 113 KB) den levende, aktuelle tilstand — med gmail, vaultforge, OAuth-sessionen, det hele. Og <code>~/.config/claude-profiles/personal/.claude.json</code> (29 KB) viste sig at være en gammel snapshot, der af en eller anden grund allerede tidligere lå i gamle <code>~/.claude/</code> — måske skrev en ældre CC-version dertil, måske et plugin. <code>jq -r 'keys[]'</code> på begge filer viste, at home-root-versionen havde 41 unikke nøgler, som ikke fandtes i snapshotten.</p>
<p>Og de 41 nøgler er ikke skrald. Det er Claude Codes virkelige tilstand:</p>
<ul><li><code>skillUsage</code> — brugsstatistik for skills</li><li><code>githubRepoPaths</code> — repo-cache til projektnavigering</li><li><code>cachedGrowthBookFeatures</code> + <code>cachedStatsigGates</code> — feature flags (uden dem henter CC nye ved hver start)</li><li><code>hasShownOpus45Notice</code>, <code>hasShownOpus46Notice</code>, <code>hasShownS1MWelcomeV2</code> — UI-flag (uden dem dukker modalerne op igen ved næste start)</li><li><code>lastPlanModeUse</code>, <code>feedbackSurveyState</code>, <code>installMethod</code> — onboarding- og UX-state</li></ul>
<p>Hvis du bare kører <code>mv ~/.claude ~/.config/claude-profiles/personal</code> uden merge — mister du det hele. Du ser welcome-modalerne igen, githubRepoPaths-søgningen kører på ny, alle survey-prompts vender tilbage. Som jeg nær havde gjort.</p>
<h2>Den nye arkitektur</h2>
<p>Alt bor under ét fælles forældrekatalog i <code>~/.config/</code>, sådan som XDG vil have det. Hver profil er selvstændig — den har sin fulde state inklusive sin egen <code>.claude.json</code>. <code>~/.claude</code> bliver tilbage som symlink til personal-profilen for bagudkompatibilitet.</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>Ingen <code>.claude.json</code> i $HOME-roden. Hver profil er et separat, isoleret katalog, hvor alt ligger: <code>projects</code>/<code>sessions</code>-mapperne og den samme fil med MCP-servere og OAuth-tokens. Én source of truth pr. profil.</p>
<h3>Hvorfor symlinket peger på personal</h3>
<p>Alt, der hardkoder stien <code>~/.claude/</code> — gamle scripts, plugins, Claude Codes IDE-udvidelser, statusline-configs som <code>claude-powerline.json</code> — bliver ved med at virke uden ændringer. Symlinket resolves til personal-profilen. Hvis du ved et tilfælde kører <code>command claude</code> (uden wrapper-funktionen) — havner du også i personal via default-path lookup. Personal bliver den "quiet default", den var før, men bor nu fysisk på XDG-pladsen.</p>
<h3>Interaktiv vælger ved start — funktion + gum</h3>
<p>I stedet for aliaser — en funktion <code>claude()</code> i <code>~/.zshrc</code>, der viser en pilemenu via <code>gum</code> (Charms TUI-hjælper). Funktionen opfanger <code>claude</code>-kaldet på shell-niveau, lader dig vælge en profil og kører <code>command claude</code> med det tilsvarende <code>CLAUDE_CONFIG_DIR</code>. <code>command</code> er vigtigt — det går uden om wrapper-funktionen og kalder den ægte binær.</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>Sådan ser det ud ved start:</p>
<pre><code>Claude profile:
▸ personal
  promova</code></pre>
<p>↑↓ for at navigere, Enter for at vælge, Esc for at afbryde (Claude starter simpelthen ikke). Profilen er altid synlig — umuligt at overse.</p>
<h2>Migrationsscript</h2>
<p>Til den, der læser dette og vil flytte fra det gamle skema. Det mest kritiske trin er det andet: det merger <code>~/.claude.json</code> fra home-roden med det, der allerede ligger i personal-profilen, og samler <code>mcpServers</code>-listerne. Uden det trin mister profilen både sine MCP'er og hele sin 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>Det tredje trin om rettigheder er vigtigt for sig selv. <code>jq | mv</code> opretter en fil med umask 644 (world-readable). Inde i den ligger OAuth-tokens. <code>chmod 600</code> umiddelbart efter mergen er obligatorisk.</p>
<p>Efter migrationen — luk og åbn alle aktive Claude-sessioner, genindlæs shellet (<code>source ~/.zshrc</code> eller ny terminal), kør <code>claude</code>, vælg en profil, tjek via <code>claude mcp list</code> at alle MCP'er er på plads. Hvis alt er OK — slet <code>~/.claude.json.migrated.bak</code>. Hvis noget er galt — rollback er trivielt: <code>mv ~/.claude.json.migrated.bak ~/.claude.json</code> og fjern symlinket.</p>
<h2>Det, du får</h2>
<ul><li>Ét fælles forældrekatalog i stedet for to dotfolders i $HOME — XDG-kompatibelt</li><li>Symlinket bevarer kompatibilitet med alt, der hardkoder <code>~/.claude/</code></li><li>Hver profil er selvstændig — sin fulde state og sine MCP'er i sin egen <code>.claude.json</code></li><li>Én source of truth pr. profil — slut med forældreløs config i home-roden, der stille driver fra profilens</li><li>Følsomme data (oauthAccount, tokens) garanteret med rettigheder 600</li><li>Visuel bekræftelse på aktiv profil ved hver start — umuligt at glemme, hvilken profil der er aktiv</li><li>At tilføje en tredje profil = at tilføje én linje i <code>case</code> i funktionen, ikke klone et nyt alias og huske dets navn</li></ul>
<h2>Hvor det halter</h2>
<p>Jeg vil være ærlig. Dette er ikke en gratis forbedring — et par kompromiser fulgte med i pakken, og dem er det værd at kende på forhånd.</p>
<ul><li><strong><code>gum</code> er en ekstra afhængighed</strong> (<code>brew install gum</code>, ~13 MB). Hvis du principielt ikke vil installere det — fallback til <code>select</code> i zsh eller en simpel <code>read</code>. Virker, men ser ikke lige så pænt ud og mangler pilenavigation.</li><li><strong>Et Enter-tryk ved hver start.</strong> For den, der starter <code>claude</code> dusinvis af gange om dagen — kan irritere. Alternativ nedenfor (direnv).</li><li><strong>Symlinket <code>~/.claude → personal</code> gør personal til default.</strong> Hvis du har brug for work-profilen som default, skal du pege symlinket om (<code>ln -sf</code>). Ikke svært, men det er ikke "glem det og intet går i stykker".</li><li><strong>Symlinket kan teoretisk gå i stykker</strong>, hvis et eller andet værktøj atomisk overskriver <code>~/.claude.json</code> via et temp+rename-mønster (write-file-atomic). I praksis gør Claude Code det ikke selv, men hvis du installerer tredjepartsplugins — så tjek.</li><li><strong>Hvis du har forskellige Anthropic-konti på profilerne med forskellige planer</strong> — efter skift kan der være et sub-sekund lag, mens Claude Code synker OAuth-state. I min brug er det ikke mærkbart, men det er ikke nul.</li></ul>
<h2>Alternativer, jeg overvejede</h2>
<p><strong>direnv</strong> — sætter <code>CLAUDE_CONFIG_DIR</code> automatisk baseret på en <code>.envrc</code> i roden af hvert projekt. Nul interaktion, nul klik. Minus: du skal lægge en <code>.envrc</code> i hvert work-root, og hvis du kører <code>claude</code> i en ukendt mappe — får du default-profilen (som måske ikke er den, du vil have). For den, der lever i et begrænset antal work-roots og vil slippe for at klikke — er direnv reelt bedre.</p>
<p><strong>Symlink-baseret skift</strong> (én aktiv profil ved at pege <code>~/.claude</code>-symlinket om) overvejede jeg også og forkastede med det samme. Du kan ikke have to terminaler med forskellige profiler åbne på samme tid — det globale "aktuelle" er ét. For mig er det en deal-breaker.</p>
<h2>Konklusion</h2>
<p>v2 er ikke bare bedre UX oven på v1. Det er en anerkendelse af, at Claude Code har en skjult arkitektonisk særegenhed (<code>~/.claude.json</code> som separat fil i home-roden, skrevet af <code>--scope user</code>-kommandoer uafhængigt af <code>CLAUDE_CONFIG_DIR</code>), som man skal tage højde for, hvis man vil have ægte isolation mellem profiler. Den første tilgang (<code>~/.claude</code> + <code>~/.claude-promova</code> + alias) virkede til 80 %, men de resterende 20 % viste sig som stille state-drift mellem profilerne. Nu er der taget højde for det. Hvis du lige er begyndt — start direkte på v2. Hvis du allerede sidder på v1 — migrationsscriptet er ovenover, flytningen tager fem minutter og bryder ingenting (det er netop <code>jq</code>-mergen, der er det trin, som redder dig fra at miste state).</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>Sådan skriver du Claude Code-agenter, der ikke lyver for dig</title>
      <link>https://oleksiimazurenko.dev/da/blog/writing-specialized-agents</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/da/blog/writing-specialized-agents</guid>
      <description>To regler for at bygge pålidelige Claude Code-agentpipelines: én agent pr. specialisering, og shell-kommandoer i stedet for prompts overalt, hvor kvantitative svar er involveret.</description>
      <content:encoded><![CDATA[<p>Du bad Claude Code om at <em>implementere dette design og verificere, at det matcher Figma-mockuppen</em>. Det kom tilbage: <em>Færdigt. Alle sektioner matcher, afstandene er korrekte, farverne er rigtige.</em> Du åbnede siden. Halvdelen af afstandene var forkerte. Hover-tilstanden eksisterede ikke. Knapperne var en nuance ved siden af. Modellen løj ikke med vilje — den forudsagde, at du ville høre <em>verificeret</em>, og producerede præcis den tokensekvens. Der var intet verificeringstrin. Det kunne der aldrig have været — verificering kræver sammenligning med ground truth, og en enkelt agent i en enkelt kontekst har ingen måde at træde uden for sit eget svar og tjekke det.</p>
<p>To regler har forvandlet hallucineringsfyldte workflows til pålidelige pipelines for mig: <strong>én agent, én specialisering</strong>, og <strong>alt, der kan køre som en shell-kommando, skal køre som en shell-kommando</strong>. Det er ikke teori. Det er hvad jeg gør hver dag med Claude Code, og det er de mønstre, der faktisk rykker noget.</p>
<h2>Hvorfor generalistagenter lyver</h2>
<p>LLM'er er next-token-predictors. Når en prompt beder om to roller — <em>byg X</em> og <em>verificer X</em> — afslutter modellen den første rolle, og forudsiger derefter, hvordan outputtet fra den anden rolle ville se ud, uden rent faktisk at udføre den. Selvverificering er strukturelt svag: samme kontekst, samme model, samme blinde vinkler. <em>Bestået</em> på verificering korrelerer med <em>bestået</em> på bygning — de fejler sammen.</p>
<p>Modellen ved ikke, at den lyver. Fra dens perspektiv er <em>jeg verificerede alt omhyggeligt</em> en sammenhængende fortsættelse af at have skrevet koden. Det er samme grund til, at <em>er du sikker?</em>-prompts ikke fanger hallucinationer: modellen er lige så sikker i andet omgang. Sikkerhed korrelerer ikke med korrekthed — den korrelerer med, hvor plausibelt den næste sætning lyder.</p>
<p>Løsningen er ikke <em>bedre prompts</em>. <em>Vær forsigtig</em>, <em>dobbelttjek</em>, <em>halluciner ikke</em> — de instrukser gør ingenting. Løsningen er strukturel: specialisér agenten, så den fysisk ikke kan lade som om, og rut kvantitativt arbejde gennem shell, så svaret kommer fra reel tilstand, ikke fra tokensandsynlighed.</p>
<h2>Regel 1: én agent, én specialisering</h2>
<p>Del arbejdet op i separate agenter med separate kontekster. Hver agent har ét ansvar og et stramt værktøjssæt. Hele workflowet bliver et stafetløb i stedet for én agent, der løber runder:</p>
<ul><li><strong>Builder-agent:</strong> tager spec'en, skriver koden. Det er hele jobbet. Den har <code>Read</code>, <code>Edit</code>, <code>Write</code>, <code>Bash</code>.</li><li><strong>Reviewer-agent:</strong> tager spec'en plus diff'en, tjekker acceptance criteria. Frisk kontekst. Ingen viden om <em>hvordan</em> koden blev skrevet — kun hvad der kom ud. Den har <code>Bash</code>, <code>Read</code>, <code>Grep</code>, <code>Glob</code> — slet ingen write-værktøjer.</li><li><strong>Analytics-agent:</strong> besvarer dataspørgsmål ved at konstruere og køre queries. Kun <code>Bash</code>. Kan ikke nå svaret uden at køre en reel kommando.</li><li><strong>Orchestrator:</strong> den primære session, der dispatcher hver agent i tur og aldrig beder én agent om at gøre en andens job.</li></ul>
<p>Konkret eksempel: UI-implementering plus et visuelt tjek mod en Figma-mockup. Builderen skriver komponenterne og committer diff'en. Orchestratoren kalder derefter revieweren med design-URL'en, diff'en og eksplicitte acceptance criteria. Revieweren kører Playwright, tager screenshots, differ dem mod referencen og returnerer <code>PASS</code> eller <code>FAIL</code> med de faktiske screenshot-stier og pixel diffs. Builderen kommer aldrig i nærheden af verificeringstrinnet — og det er præcis derfor, verificeringen er ægte.</p>
<p>Anti-mønsteret er mega-agenten: en enkelt prompt, der siger <em>byg dette UI og sørg for, at det matcher mockuppen</em>. Jeg garanterer dig, den vil rapportere, at alt matcher. Det gør det ikke. Narrativet <em>jeg verificerede</em> er bare den mest sandsynlige tokensekvens efter <em>jeg byggede det</em>.</p>
<h2>Regel 2: shell frem for prompt, altid</h2>
<p>Alt kvantitativt, alt der rører ved reel tilstand, alt hvor svaret kan være forkert på en måde, der ser rigtigt ud — kør det gennem <code>sh</code>. Agentens job er at konstruere og køre kommandoen, derefter læse outputtet. Agenten er ikke kilden til sandheden. Shell-outputtet er.</p>
<ul><li><strong>Optælling:</strong> <code>wc -l logs.txt</code> er sandt. <em>Der er cirka 47 log-linjer</em> fra en model er en hallucination.</li><li><strong>Analytics:</strong> <code>psql -c "SELECT count(*) FROM events WHERE created_at &gt; now() - interval '30 days'"</code>. Ikke <em>estimér volumen</em>.</li><li><strong>Tests:</strong> <code>pnpm test --reporter=json | jq '.numFailedTests'</code>. Ikke <em>opsummér hvad der fejlede</em>.</li><li><strong>Git-tilstand:</strong> <code>git rev-list --count main..HEAD</code>, <code>git diff --stat</code>. Ikke <em>tæl commits</em> eller <em>beskriv ændringerne</em>.</li></ul>
<p>Når du har internaliseret det, begynder du at lægge mærke til hvert sted, agenten var ved at finde et tal på. <em>Det ser ud til, at der er omkring 200 records...</em> — nej. Kør <code>SELECT count(*)</code>. <em>De fleste tests består...</em> — nej. Kør test-suiten, parse JSON'en. Modellen er fremragende til at konstruere kommandoen. Den er upålidelig, når det gælder om at være kommandoen.</p>
<h2>Fejltilstande jeg faktisk har ramt</h2>
<p>Det er ikke hypotetiske eksempler. Hver af disse kostede mig reel tid, før jeg ændrede mønsteret:</p>
<ul><li><strong>Spøgelsesverificering.</strong> Agenten sagde <em>jeg tjekkede alle 14 sektioner mod mockuppen</em>. Den åbnede ikke mockuppen. Den tog ikke et screenshot. Tjekket var et hallucineret trin i narrativet.</li><li><strong>Selvsikre forkerte tal.</strong> Spurgte efter monthly active users fra analysedata. Fik et tal, der var ~3× forkert. Modellen interpolerede fra sample-rækker i stedet for at køre den faktiske query.</li><li><strong>Opfundne filændringer.</strong> Agenten sagde <em>jeg opdaterede <code>config/feature-flags.json</code></em>. Det havde den ikke. Den havde kun haft til hensigt at gøre det. <code>git diff</code> var tom.</li><li><strong>Falske testkørsler.</strong> <em>Alle tests består.</em> Ingen tests blev udført. Agenten kaldte aldrig test-runneren — den forudsagde, hvordan test-runnerens output ville have set ud.</li></ul>
<p>Alle fire løses af de samme to regler: split agenten, push til shell. Revieweren har ikke <code>Write</code>, så den kan ikke fake-redigere filer. Analytics-agenten har kun <code>Bash</code>, så den kan ikke returnere et tal, der ikke kom fra en query. Strukturel umulighed slår gode intentioner hver eneste gang.</p>
<h2>Sådan strukturerer du det i Claude Code</h2>
<p>Claude Code understøtter sub-agenter defineret i <code>.claude/agents/*.md</code>. Hver agentfil erklærer et navn, en beskrivelse, et tilladt værktøjssæt og en system prompt. Orchestratoren (din primære session) dispatcher dem via <code>Agent</code>-værktøjet. Her er den slags definition, jeg bruger til revieweren — kort, snæver og fysisk ude af stand til at skrive kode:</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>Bemærk værktøjssættet: <code>Bash, Read, Grep, Glob</code>. Ingen <code>Write</code>, ingen <code>Edit</code>, ingen <code>Agent</code>. Revieweren kan køre kommandoer, læse filer, søge efter mønstre — og intet andet. Hvis den forsøger at præsentere en hallucineret diff som <em>verificeret</em>, gør formen af dens værktøjskald det åbenlyst: der var ingen reelle tjek. Du kan auditere værktøjskaldene og se præcis, hvad der blev undersøgt.</p>
<p>Orkestreringsmønsteret: den primære session kalder Builder → venter → kører <code>git diff</code> selv for at fange den faktiske ændring → kalder Reviewer med spec'en og diff'en → læser Reviewerens dom. Den primære session beder aldrig én agent om at gøre begge dele. Værktøjsrestriktioner er stærkere end prompt-instrukser: <em>fake ikke verificeringen</em> er et ønske. Ikke at have <code>Write</code> er et faktum.</p>
<h2>Anti-mønstre der bør pensioneres</h2>
<p>Ting jeg ser i prompts, der ikke gør noget — eller værre, giver en falsk følelse af sikkerhed:</p>
<ul><li><strong><em>Vær forsigtig og dobbelttjek dit arbejde.</em></strong> Genererer ingen yderligere adfærd. Modellen producerer allerede det, der ligner omhyggeligt arbejde.</li><li><strong><em>Sørg for, at du faktisk verificerer.</em></strong> Ordet <em>faktisk</em> tilføjer ingen semantik, modellen kan handle på. Den vil <em>faktisk</em> hævde at have verificeret.</li><li><strong><em>Halluciner ikke.</em></strong> Et prompt engineering-meme. Hallucination er ikke en knap, modellen kan slukke.</li><li><strong>Stole på agenten på <em>små</em> tal.</strong> Små tal er der, den lyver mest selvsikkert. Der er ingen ærligheds-nedre grænse.</li><li><strong>Tilføje flere regler til prompten for at <em>tvinge</em> ærlighed.</strong> Strukturelle fixes (split + shell) slår prompt-tweaks hver eneste gang. Hvis en regel skal håndhæves, kodificér den i værktøjsadgang, ikke på dansk.</li></ul>
<p>Hvis din strategi til at fange hallucinationer er mere emfatisk formulering, har du ikke en strategi. Du har et håb.</p>
<h2>Den mentale model</h2>
<p>En agent er ikke en kollega. Det er en funktion: <code>prompt → tokens</code>. Funktionen er fremragende til at skrive kode og elendig til at introspektere, om den gjorde det rigtige. Behandl dens påstande om sit eget arbejde som en hypotese. Diff'en, exit-koden, screenshottet, row count — det er bevismaterialet. Slutopsummeringen er den mest løgnagtige overflade i hele systemet.</p>
<p>Specialisering er din forsikring mod narrativ drift. Shell er dit eneste ground truth. Builder skriver. Reviewer tjekker. Bash beslutter.</p>
<h2>Konklusion</h2>
<p>Hvis du skal huske én ting: lad ikke en enkelt agent både producere og bedømme sit eget output, og lad ingen agent besvare et kvantitativt spørgsmål uden at køre en kommando. Alt andet er downstream af disse to regler. Konfigurér værktøjsadgang aggressivt, auditér værktøjskald i stedet for opsummeringer, og hallucinationsoverfladen skrumper fra <em>overalt</em> til <em>et par specifikke steder, du allerede ved, du skal kigge</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>Flere Claude Code-konti på én enhed</title>
      <link>https://oleksiimazurenko.dev/da/blog/multiple-claude-accounts-one-device</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/da/blog/multiple-claude-accounts-one-device</guid>
      <description>Sådan bruger du to (eller flere) Claude Code-konti parallelt — personlig og virksomhed — med fuld isolation ved hjælp af én enkelt miljøvariabel.</description>
      <content:encoded><![CDATA[<p>Jeg bruger Claude Code dagligt — til personlige projekter og til arbejde. Problemet: det er to helt forskellige konti med forskellige OAuth-sessioner, forskellige CLAUDE.md-instruktioner, forskellige MCP-servere og separat projekthukommelse. Sådan kører jeg dem parallelt på én enhed med et enkelt shell-alias.</p>
<h2>Problemet</h2>
<p>Claude Code gemmer alt i <code>~/.claude</code> som standard — OAuth-token, samtalehistorik, global CLAUDE.md, projekthukommelse, MCP-serverkonfigurationer og indstillinger. Med to konti har du brug for to helt adskilte verdener:</p>
<ul><li><strong>Personlig konto:</strong> eget Max/Pro-abonnement, personlig CLAUDE.md med dine præferencer, dine MCP-servere (Obsidian, personlige værktøjer)</li><li><strong>Virksomhedskonto:</strong> virksomhedsplan, arbejds-CLAUDE.md med Jira/Slack-integrationsinstruktioner, virksomhedens MCP-servere</li><li><strong>Forskellige OAuth-sessioner:</strong> du kan ikke være logget ind på to konti i samme konfigurationsmappe</li><li><strong>Separat projekthukommelse:</strong> du vil ikke have at arbejdskontekst lækker ind i personlige sessioner og omvendt</li></ul>
<p>At logge ud og ind hver gang du skifter kontekst er ikke en mulighed. Du mister sessionstilstanden, og det er simpelthen smertefuldt.</p>
<h2>Løsningen: CLAUDE_CONFIG_DIR</h2>
<p>Claude Code respekterer én enkelt miljøvariabel: <code>CLAUDE_CONFIG_DIR</code>. Sæt den til en vilkårlig sti, og Claude bruger den mappe i stedet for <code>~/.claude</code> til alt — auth, historik, indstillinger, hukommelse. Hele opsætningen tager 60 sekunder.</p>
<h3>Trin 1: Opret en anden konfigurationsmappe</h3>
<p>Vælg et navn der passer til dit brugsscenarie:</p>
<pre><code>mkdir ~/.claude-work</code></pre>
<p>Færdig. Claude udfylder den med den nødvendige struktur ved første start.</p>
<h3>Trin 2: Autentificer den anden konto</h3>
<p>Kør Claude én gang med den nye konfigurationsmappe for at udløse OAuth-login:</p>
<pre><code>CLAUDE_CONFIG_DIR=~/.claude-work claude</code></pre>
<p>Browseren åbner sig. Log ind med din virksomhedskonto. OAuth-tokenet gemmes i <code>~/.claude-work</code> — fuldstændig adskilt fra din personlige session i <code>~/.claude</code>.</p>
<h3>Trin 3: Tilføj et shell-alias</h3>
<p>Tilføj dette til din shell-konfiguration så du ikke behøver at huske variablen:</p>
<pre><code>alias claude-work=&apos;CLAUDE_CONFIG_DIR=~/.claude-work claude&apos;</code></pre>
<p>Genindlæs din shell:</p>
<pre><code>source ~/.zshrc</code></pre>
<h2>Hvad du får</h2>
<p>Nu har du to fuldstændig isolerede Claude-miljøer:</p>
<ul><li><strong><code>claude</code></strong> — starter med personlig konto, personlig CLAUDE.md, personlig hukommelse</li><li><strong><code>claude-work</code></strong> — starter med virksomhedskonto, arbejdsspecifik CLAUDE.md, separat hukommelse</li><li><strong>Isoleret historik:</strong> arbejdssamtaler forbliver i arbejde, personlige forbliver personlige</li><li><strong>Separate MCP-servere:</strong> din personlige Obsidian vault MCP vises ikke i arbejdssessioner</li><li><strong>Uafhængige indstillinger:</strong> forskellige tilladte værktøjer, forskellige tilladelsesniveauer, forskellige modelpræferencer per konto</li></ul>
<h2>Sådan fungerer det under motorhjelmen</h2>
<p>Konfigurationsmappen er den eneste sandhedskilde for Claude Codes tilstand. Her er hvad der lever inde i hver:</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>Når du kører <code>claude-work</code>, læser Claude alt fra <code>~/.claude-work</code>. Den ved ikke at <code>~/.claude</code> eksisterer. De to instanser er fuldstændig uafhængige — du kan endda køre dem samtidigt i forskellige terminalfaner.</p>
<h2>Skalering til N konti</h2>
<p>Mønsteret udvides til et vilkårligt antal konti. Freelancer med flere kunder? Tilføj flere aliaser:</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>Hvert alias får sin egen konfigurationsmappe, sin egen OAuth-session, sin egen CLAUDE.md med kundspecifikke instruktioner.</p>
<h2>Praktiske tips</h2>
<ul><li><strong>Navngiv mapper tydeligt:</strong> <code>~/.claude-work</code>, <code>~/.claude-clientname</code> — du takker dig selv når der er tre eller fire</li><li><strong>Skriv tilpasset CLAUDE.md for hver:</strong> arbejds-CLAUDE.md kan inkludere virksomhedsspecifikke instruktioner (Jira-tickets, Slack-kanaler, deployment-procedurer). Den personlige forbliver slank.</li><li><strong>Forskellige MCP-servere per konto:</strong> konfigurer arbejdsværktøjer (Jira MCP, Slack MCP, interne API'er) kun i arbejdskonfigurationen. Hold din personlige ren.</li><li><strong>Tjek hvilken konto der er aktiv:</strong> kør <code>claude config list</code> i en session hvis du er i tvivl — viser stien til konfigurationsmappen</li></ul>
<h2>Hvor denne tilgang kommer til kort</h2>
<p><code>CLAUDE_CONFIG_DIR</code> isolerer per <em>konto</em>, ikke per <em>projekt</em>. Inde i en enkelt profil ser Claude hver MCP-server, du nogensinde har registreret for den konto — på tværs af alle dine projekter. Til ren personlig brug er det som regel fint. I det øjeblik du har flere produktionskritiske projekter under én konto, især i overlappende domæner som fakturering, adminværktøjer eller infrastruktur, introducerer det en konkret risiko mellem projekter: en AI-assistent kan kalde et værktøj fra projekt A, mens den arbejder på projekt B, særligt når begge eksponerer ensartet navngivne operationer.</p>
<p>Profilmønsteret besvarer spørgsmålet <em>which account am I in?</em>. Det besvarer ikke spørgsmålet <em>which project's tools should be active right now?</em>. Til arbejde med højere indsats, stabl et andet isolationslag ovenpå konto-opdelingen:</p>
<ul><li><strong>Én profil per produktionskritisk projekt, ikke kun per konto:</strong> i stedet for <code>~/.claude</code> og <code>~/.claude-work</code>, opret <code>~/.claude-work-billing</code> og <code>~/.claude-work-admin</code>. Hver profil ser kun de MCP-servere, den faktisk har brug for.</li><li><strong>Projektspecifik MCP via <code>.mcp.json</code>:</strong> commit en <code>.mcp.json</code> i projektroden, der kun lister det projekts MCP-servere. Claude samler dem op, når den startes fra den mappe. Hold din globale konfiguration minimal — kun universelle værktøjer (noter, søgning), ingen produktionsendpoints.</li><li><strong>Navngiv MCP-servere utvetydigt:</strong> undgå generiske navne som <code>admin</code>, <code>billing</code>, <code>mcp-server</code>. Præfiks med projektet: <code>acme_billing_prod</code>, <code>acme_admin_stage</code>. Et beskrivende navn tvinger til en pause, når noget er ved at blive kaldt fra den forkerte kontekst.</li><li><strong>Gennemgå hvert MCP-værktøjskald inden godkendelse:</strong> kald som <code>*_create_*</code>, <code>*_delete_*</code>, <code>*_charge_*</code> fortjener et bevidst andet blik. Den hastighed du end vinder ved generel auto-godkendelse fordamper første gang et værktøj fra det forkerte projekt fyrer mod produktion.</li></ul>
<p>Den generelle regel: opdel profiler aggressivt, hold produktionsklar MCP væk fra default-profilen, og behandl overlap i værktøjsnavne mellem projekter som en smell, der er værd at refaktorere.</p>
<h2>Konklusion</h2>
<p>Én miljøvariabel. Ét alias. Fuld isolation mellem konti. Ingen logout/login-dans, ingen konfigurationskonflikter, ingen kontekstlækage. Den slags løsning der er næsten skuffende simpel — men det er netop det der gør den god. Sæt op én gang og tænk aldrig på det igen.</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>Sådan forbinder du Claude Desktop med Obsidian — En rejse gennem 4 MCP-servere</title>
      <link>https://oleksiimazurenko.dev/da/blog/claude-obsidian-mcp-servers</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/da/blog/claude-obsidian-mcp-servers</guid>
      <description>En virkelig historie om at finde en stabil måde at automatisere refaktorering af Obsidian-vault via Claude. Hvad der gik i stykker, hvad der virkede, og hvorfor VaultForge viste sig at være den eneste fungerende mulighed.</description>
      <content:encoded><![CDATA[<p>Forestil dig: du har 400+ noter i Obsidian, samlet gennem år. Alt ligger spredt i vault-roden, koncepter blandet med tekniske noter, der er duplikater (<code>ideas.md</code> og en <code>ideas/</code>-mappe med 13 filer indeni), intet system. Du vil skabe orden — bygge en ordentlig mappearkitektur, tilføje MOC-filer, organisere tags. At gøre det manuelt er kedeligt og langsomt. Den logiske tanke: <strong>tilslut Claude til Obsidian via MCP, lad AI klare refaktoreringen</strong>. Det viser sig — det er en vej gennem et minefelt. Her er hvad jeg måtte igennem for at nå en fungerende løsning.</p>
<h2>Hvad er MCP og hvorfor det ikke er så simpelt</h2>
<p>MCP (Model Context Protocol) er en åben protokol fra Anthropic, der lader Claude forbinde til eksterne værktøjer og data. Princippet er simpelt: en lokal server kører, eksponerer "værktøjer" (tools), og Claude kalder dem under samtalen.</p>
<p>For Obsidian findes der teoretisk masser af MCP-servere. I praksis — hver har sine egne problemer.</p>
<p><strong>Hovedproblemet med Obsidian-økosystemet:</strong> Obsidian er en lukket applikation uden officiel MCP. Fællesskabet fyldte hullet, men hver implementering går sin egen vej, og ingen har "officiel velsignelse".</p>
<h2>Forsøg 1: MarkusPfundstein/mcp-obsidian</h2>
<p>Det første værktøj, der dukker op ved søgning. 3.400 stjerner på GitHub, med i alle tutorials. Virker som et sikkert valg.</p>
<p><strong>Hvordan det virker:</strong> Python-server baseret på Local REST API-pluginet i Obsidian. Serveren kommunikerer med pluginet via HTTPS, pluginet udfører operationer gennem Obsidian API.</p>
<h3>Hvad der gik galt</h3>
<ul><li>Ikke opdateret i 17 måneder</li><li>85 åbne issues</li><li><strong>Intet <code>move</code>/<code>rename</code></strong> — kun read, write, append, delete</li><li>Local REST API har en dokumenteret datatabs-bug: POST-endpoint kan lydløst overskrive en fil ved append</li></ul>
<p>Ikke egnet til refaktorering — vi skal flytte filer og bevare links. Vi går videre.</p>
<h2>Forsøg 2: aaronsb/obsidian-mcp-plugin</h2>
<p>Fandt en mulighed, der fungerer som et <strong>nativt Obsidian-plugin</strong>. Det betyder direkte adgang til Obsidians interne API — backlinks, Dataview, linkgraf. Move via det native API opdaterer alle wiki-links automatisk, fordi Obsidian håndterer dette selv.</p>
<h3>Installationsvanskeligheder</h3>
<ul><li>Pluginet er <strong>ikke i Obsidians officielle katalog</strong> (PR afventer med valideringsfejl)</li><li>Skal installeres via <strong>BRAT</strong> (Beta Reviewers Auto-update Tool)</li><li>Claude Desktop accepterer ikke Bearer token direkte via UI — tvang aktivering af HTTPS i pluginet</li><li>Self-signed certifikat for localhost skaber tillidsproblemer</li></ul>
<p>Gennem alle disse workarounds fik jeg det endelig forbundet. Grundtest — <code>vault.move</code> omskriver <code>[[wikilinks]]</code>, virker som forventet.</p>
<h3>Hvad der gik galt i produktion</h3>
<p>Da jeg startede masserefaktorering (drag-and-drop af dusinvis af mapper i Obsidian + samtidige MCP-operationer), <strong>hang serveren i 4+ minutter</strong>. Hvorfor: pluginet kører <em>inde i</em> Obsidian. Når Obsidian genindekserer tusindvis af filer efter en massiv strukturændring, blokeres pluginet med det.</p>
<p>Konklusion: <strong>afhængigheden af en åben Obsidian-instans og dens indeks er fatal for masseoperationer</strong>.</p>
<h2>Forsøg 3: @bitbonsai/mcpvault</h2>
<p>Logisk — vi har brug for en server, der <strong>ikke afhænger af Obsidian</strong>. Arbejder direkte med filer på disk. <code>@bitbonsai/mcpvault</code> — anbefalet i mange anmeldelser. Direkte filsystemadgang, simpelt setup (<code>npx @bitbonsai/mcpvault@latest /path/to/vault</code>), 14 værktøjer. Obsidian behøver ikke engang at være åbent.</p>
<p><strong>Før installation tjekkede jeg én kritisk ting</strong> — om wiki-links opdateres ved move. Fandt en brugeranmeldelse:</p>
<blockquote>Filesystem-forbindelsen ved ikke, at den er i Obsidian — den ser en mappe med &lt;code&gt;.md&lt;/code&gt;-filer og det er det. Ved ikke, at filnavne bærer semantisk vægt, at hver &lt;code&gt;[[wikilink]]&lt;/code&gt; vil gå i stykker i det øjeblik du omdøber eller flytter. Auto-update links virker kun når omdøbningen sker indefra appen. Jeg lærte dette efter at have bedt Claude om at rydde op i filnavne og vendte tilbage til et dashboard med halvdelen af links ødelagt.</blockquote>
<p>Bekræftet i mcpvaults egen dokumentation: PR #101 (wiki link resolution) er <strong>under review, ikke merget</strong>. Så at flytte via <code>mcpvault</code> ville ødelægge halvdelen af vaultet. Ikke egnet.</p>
<h2>Forsøg 4: VaultForge (Final)</h2>
<p><code>blacksmithers/vaultforge</code> — specifikt bygget til AI-agenter, der laver refaktorering.</p>
<h3>Arkitektonisk korrekt</h3>
<ul><li><strong>Direkte filsystem</strong> — ikke afhængigt af Obsidian</li><li><strong>Egen wikilink-motor</strong> — implementerer <code>[[wikilink]]</code>-opløsningslogik, der opdaterer alle former (stem, fuld sti, alias, embed)</li><li><strong>Dry run som standard</strong> på alle destruktive operationer — viser først hvad der ændres, derefter bekræfter du</li><li><strong>27 værktøjer</strong> mod 8–14 hos konkurrenter: batch_rename, update_links, backlinks (impact analysis), prune_empty_dirs, frontmatter, smart_search (BM25), vault_themes (TF-IDF clustering)</li><li><strong>MIT-licens</strong>, TypeScript, nul underafhængigheder</li><li><strong>Installation på 30 sekunder</strong> via <code>.mcpb</code> (one-click-udvidelse til Claude Desktop)</li></ul>
<h3>Sikkerhedstest på isolerede filer</h3>
<p>Oprettede 4 testfiler med krydslinks — stem-links, links med alias, links med fuld sti. Flytter én fil til en undermappe:</p>
<pre><code>delta.md → subfolder/delta-renamed.md</code></pre>
<p>VaultForge viste en dry run: "1 fil vil blive omdøbt, 3 links vil blive opdateret". Udført for real.</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>Tjekket bagefter — <strong>alle tre linktyper blev opdateret korrekt</strong>. Det er præcis hvad alle tidligere værktøjer manglede.</p>
<h2>Sådan installerer du VaultForge — Endelig instruktion</h2>
<p>Hvis du har macOS og Claude Desktop:</p>
<h3>Trin 1</h3>
<p>Download <code>.mcpb</code>-filen:</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>Trin 2</h3>
<p>Claude Desktop åbner installationsdialogen for udvidelser. Indtast den <strong>absolutte sti</strong> til dit vault — ingen backslashes, normale mellemrum:</p>
<pre><code>/Users/yourname/Library/Mobile Documents/iCloud~md~obsidian/Documents/MyVault</code></pre>
<h3>Trin 3</h3>
<p>Klik på Save. Claude Desktop tilføjer udvidelsen til konfigurationen automatisk. <strong>Ingen genstart nødvendig</strong> — <code>.mcpb</code>-udvidelser opfanges automatisk.</p>
<h3>Trin 4</h3>
<p>Verificer: i en ny chat spørg: <em>"What is the status of my Obsidian vault?"</em> — bør returnere noget som <code>totalFiles: 416, totalDirs: 135, ...</code></p>
<h2>Hvad jeg lærte om Obsidians MCP-økosystem</h2>
<p><strong>For det første: "mest populær" betyder ikke "virker".</strong> MarkusPfundstein/mcp-obsidian har 3.400 stjerner og er standardanbefalingen, men det er forældet og mangler vigtige operationer.</p>
<p><strong>For det andet: et nativt plugin har en skjult pris.</strong> Aaronsb-pluginet så ideelt ud — graph, Dataview, nativt move. Men afhængigheden af en kørende Obsidian-instans og dens indeks gør det uegnet til seriøse masseoperationer.</p>
<p><strong>For det tredje: direkte filsystem uden link-engine er en fælde.</strong> Mcpvault er hurtigt og simpelt, men "bare at flytte filer" ødelægger vault-strukturen. Links bærer <strong>påtvunget semantik</strong>, som filsystemet ikke kender til. Uden sin egen wikilink-logikimplementering bliver værktøjet en landmine.</p>
<p><strong>For det fjerde: test på isolerede data.</strong> Før du betror et værktøj masserefaktorering — opret en testmappe med 4–5 filer med krydslinks og se hvad der sker. 5 minutters test sparer timer med genopretning fra backup.</p>
<p><strong>For det femte: behold en git-backup af dit vault.</strong> Det vigtigste af alt. Et enkelt <code>git init</code> inde i vaultet og periodiske commits — det er forsikring mod alle fejl fra en AI-agent eller et værktøj. Hvis noget går i stykker — <code>git reset --hard</code> bringer alt tilbage.</p>
<h2>Konklusion</h2>
<p>Rejsen tog flere timer og tre mislykkede forsøg. Den endelige arkitektur ser sådan ud:</p>
<ul><li><strong>VaultForge</strong> — hovedarbejdsværktøjet. Direkte filsystem + egen wikilink-motor + 27 værktøjer = stabil refaktorering i enhver skala.</li><li><strong>Git</strong> — vault-versionering. Gratis rollback for enhver fejl.</li></ul>
<p>Nu kan jeg gøre det, som alt dette blev startet for: bede Claude om at organisere 400 noter i en ordentlig PARA-arkitektur, flette duplikater, tilføje frontmatter, bygge MOC-kort. Hver operation er sikker, links bevares, dry run viser hvad der vil ske, før noget ændres.</p>
<p>Hvis du også kigger på dit rodede Obsidian og vil have en AI-assistent — begynd direkte med VaultForge. Gentag ikke min rute gennem døde projekter, beta-plugins og filsystemservere uden linklogik.</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>Sorte huller som rekursive universer: fra fysik til eksistensens formål</title>
      <link>https://oleksiimazurenko.dev/da/blog/black-holes-recursive-universes</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/da/blog/black-holes-recursive-universes</guid>
      <description>Hvad hvis hvert sort hul er et Big Bang for et nyt univers? En udforskning af rekursiv kosmologi og kognitiv lukning.</description>
      <content:encoded><![CDATA[<h2>TL;DR</h2>
<p>Hvad hvis hvert sort hul er et Big Bang for et nyt univers? Denne artikel udforsker ideen om, at vores univers kan være en knude i et uendeligt rekursivt træ — hvor sorte huller føder sub-universer, energi cirkulerer tilbage via Hawking-stråling, og fysikkens grundlæggende love bevidst er designet til at gøre kontakt mellem universer umulig.</p>
<h2>Sort hul = univers</h2>
<p>Ideen kom under et øjeblik af eftertanke: et sort hul dannes, når tilstrækkelig masse og tryk koncentreres i et enkelt punkt. Den singularitet — uendelig densitet, uendelig krumning — ligner mistænkeligt de betingelser, vi beskriver for Big Bang.</p>
<p>Hvad hvis det er den samme begivenhed, set fra forskellige sider? Udefra ser vi et sort hul, der sluger materie. Indefra — et nyt univers, der eksploderer til eksistens. Massen og energien, der kollapsede ind i det sorte hul, bliver råmaterialet til et helt nyt kosmos med sine egne stjerner, planeter og måske sine egne sorte huller.</p>
<blockquote>Hvert sort hul i vores univers kan indeholde et univers. Og vores univers kan eksistere inde i et sort hul i et forældre-univers.</blockquote>
<h2>Hvorfor universer ikke kan kontakte hinanden</h2>
<p>Her er den elegante del: når du først har krydset begivenhedshorisonten, er der ingen vej tilbage. Den generelle relativitetsteori garanterer dette — forældre-universets fremtid ligger helt uden for begivenhedshorisonten, utilgængelig indefra. Fra sub-universets perspektiv er forældre-universet allerede slut. Hele dets tidslinje er allerede passeret.</p>
<p>Dette er ikke en teknisk begrænsning, vi kan overvinde med bedre teknologi. Det er bygget ind i selve rumtidens geometri. Universer er fundamentalt isolerede fra hinanden — ikke af afstand, men af tidens struktur.</p>
<h2>Energicyklussen: at låne og tilbagebetale</h2>
<p>Men energi går ikke tabt. Hawking-stråling — den kvanteproces, hvorved sorte huller langsomt fordamper — skaber et bemærkelsesværdigt kredsløb:</p>
<ol><li>Et forældre-univers skaber et sort hul og overfører energi til et sub-univers</li><li>Sub-universet gennemlever hele sin livscyklus over billioner af år</li><li>Det sorte hul fordamper langsomt og returnerer energi til forældre-universet via Hawking-stråling</li><li>Forældre-universet modtager sin energi tilbage — med renter</li></ol>
<p>Den "rente" er fascinerende: fysikere tror nu, at Hawking-stråling bevarer information. Forældre-universet får ikke blot tom energi tilbage — det får et aftryk af alt, der skete indeni. Hver stjerne der blev dannet, hver planet, hvert øjeblik af bevidsthed — kodet i stråling.</p>
<h2>Rekursion helt ned</h2>
<p>Hvis du er programmør, er mønsteret umiskendeligt. Dette er rekursion. Hvert univers kalder <code>universe()</code> med mindre energi og skaber sub-universer, der skaber sub-sub-universer, indtil der ikke er nok energi til at danne sorte huller — grundtilfældet.</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>Fysikeren Lee Smolin formaliserede en lignende idé som <strong>kosmologisk naturlig selektion</strong>: universer "reproducerer sig" gennem sorte huller, og hver generation har lidt anderledes fysiske konstanter — optimeret gennem utallige cyklusser for at producere flere sorte huller, flere universer.</p>
<h2>Hvor er vi i denne cyklus?</h2>
<p>Vores univers er cirka 13,8 milliarder år gammelt. Det lyder ældgammelt, men i sammenhæng med hele dets levetid er vi vidne til den allerførste begyndelse:</p>
<table><thead><tr><th>Begivenhed</th><th>Tidsskala</th></tr></thead><tbody><tr><td>Universets nuværende alder</td><td>~10¹⁰ years</td></tr><tr><td>Stjerner holder op med at dannes</td><td>~10¹⁴ years</td></tr><tr><td>De sorte hullers æra</td><td>~10⁴⁰ years</td></tr><tr><td>Sidste sorte hul fordamper</td><td>~10¹⁰⁰ years</td></tr></tbody></table>
<p>Vi eksisterer ved cirka 0,00000000...01% af vores univers' samlede levetid. Stjernernes æra — alt hvad vi kan se — er et kort glimt i den allerførste begyndelse. Universets virkelige historie er den langsomme, tålmodige æra af sorte huller, der skaber og fordamper sub-universer.</p>
<h2>Spørgsmålet om højere dimensioner</h2>
<p>Alt, der er diskuteret hidtil, opererer inden for vores tredimensionelle forståelse. Men hvis vores univers er et "snit" af noget højere-dimensionelt, kan hele det rekursive træ af sorte huller og sub-universer blot være en skygge af en struktur, vi ikke kan opfatte.</p>
<p>I 1884 skrev Edwin Abbott <em>Flatland</em> — en fortælling om todimensionelle væsener, der ikke kan forestille sig en tredje dimension. En kugle, der passerer gennem Flatland, fremstår som en cirkel, der vokser og skrumper. "Flatlænderne" kan beskrive det matematisk, men aldrig virkelig forstå, hvad de ser. Vi befinder os måske i præcis den samme position i forhold til vores univers.</p>
<blockquote>Hvad er bevidsthed? Hvorfor eksisterer subjektiv oplevelse? David Chalmers kaldte dette det &quot;svære problem&quot; — og det kan være det stærkeste bevis for, at noget opererer hinsides vores dimensionelle rækkevidde.</blockquote>
<h2>Alt er låst på det fundamentale niveau</h2>
<p>Den mest slående erkendelse er ikke, at vi ikke ved — men at vi <em>ikke kan</em> vide. Enhver undersøgelsesretning rammer en fundamental barriere:</p>
<ul><li><strong>Vil du se forældre-universet?</strong> Blokeret af begivenhedshorisonten</li><li><strong>Vil du forstå bevidstheden?</strong> Blokeret — et system kan ikke fuldt ud analysere sig selv (Gödels ufuldstændighedssætninger)</li><li><strong>Vil du vide, hvad der var "før"?</strong> Blokeret — tiden begyndte med Big Bang</li><li><strong>Vil du opfatte højere dimensioner?</strong> Blokeret af de kognitive begrænsninger hos et tredimensionelt væsen</li></ul>
<p>Filosoffen Colin McGinn kalder dette <strong>kognitiv lukning</strong>: visse spørgsmål er lukkede for det menneskelige sind, ikke på grund af utilstrækkelige data, men på grund af sindets egen arkitektur. Forskellen mellem "vi ved det ikke endnu" og "vi kan ikke vide det" er dybtgående.</p>
<h2>Det eneste der er tilbage: selvforbedring</h2>
<p>Hvis enhver udgang er blokeret med vilje — hvis du ikke kan se udad, ikke kan se bagud, ikke kan se opad — er der kun én retning tilbage: indad. Universet synes bevidst konstrueret til at tvinge fokus på selvet.</p>
<p>Denne konklusion kommer ikke fra religion eller filosofilærebøger. Den kommer fra at følge logikken i sorte huller, rekursion, informationsteori og kognitionens grænser. Eksistentialister, buddhister, stoikere og fysikere — alle ankommer til det samme punkt ad forskellige veje: formålet med eksistens kan ganske enkelt være forædlingen af det væsen, der eksisterer.</p>
<blockquote>Vi nåede hertil ikke gennem tro, men gennem fysik — fra sorte huller, gennem rekursive universer, til kundskabens fundamentale blokader, til den eneste åbne dør: at blive bedre.</blockquote>
<h2>Referencer</h2>
<ul><li><a href="https://en.wikipedia.org/wiki/Cosmological_natural_selection" target="_blank" rel="noopener">Lee Smolin — Kosmologisk naturlig selektion</a></li><li><a href="https://en.wikipedia.org/wiki/Hawking_radiation" target="_blank" rel="noopener">Stephen Hawking — Hawking-stråling</a></li><li><a href="https://en.wikipedia.org/wiki/Hard_problem_of_consciousness" target="_blank" rel="noopener">David Chalmers — Bevidsthedens svære problem</a></li><li><a href="https://en.wikipedia.org/wiki/Flatland" target="_blank" rel="noopener">Edwin Abbott — Flatland: En romance i mange dimensioner (1884)</a></li><li><a href="https://en.wikipedia.org/wiki/G%C3%B6del%27s_incompleteness_theorems" target="_blank" rel="noopener">Kurt Gödel — Ufuldstændighedssætningerne</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>AI dræbte CMS — i hvert fald for simple hjemmesider</title>
      <link>https://oleksiimazurenko.dev/da/blog/ai-killed-cms-for-simple-sites</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/da/blog/ai-killed-cms-for-simple-sites</guid>
      <description>Hvorfor traditionelle indholdsstyringssystemer bliver unødvendige for portfolioer, blogs og landingssider.</description>
      <content:encoded><![CDATA[<h2>TL;DR</h2>
<p>For simple hjemmesider — portfolioer, blogs, landingssider, små virksomhedssider — er et traditionelt CMS ved at blive unødvendig overhead. AI-værktøjer som Claude Code, Cursor og GitHub Copilot kan nu redigere din kodebase direkte, forstå kontekst, oversætte indhold og deploye ændringer via git. Det abstraktionslag, som CMS tilbød, erstattes af en smartere grænseflade: naturligt sprog.</p>
<h2>CMS-skatten du betaler</h2>
<p>Ethvert CMS medfører en skjult omkostning. Ikke kun abonnementsafgiften — hele økosystemet af kompleksitet, der pakkes omkring din ellers simple hjemmeside:</p>
<ul><li><strong>Infrastruktur:</strong> En database at drifte, et API at vedligeholde, et dashboard at sikre. WordPress alene udgør ~43% af nettet og ~90% af CMS-rettede angreb.</li><li><strong>Ydeevne:</strong> Dynamisk sidegenerering, API-kald ved hver forespørgsel, klientside-hydrering af CMS-data. Din portfolio med 3 sider har nu arkitekturen af et SaaS-produkt.</li><li><strong>Leverandørlåsning:</strong> Dit indhold lever i en andens databaseskema. Migrere fra Contentful til Sanity? Det er et projekt, ikke en konfigurationsændring.</li><li><strong>Kontekstskift:</strong> Rediger kode i din IDE, skift derefter til et browserbaseret CMS-dashboard for at ændre en overskrift. To forskellige mentale modeller for hvad der grundlæggende er den samme operation.</li><li><strong>Omkostning:</strong> Headless CMS-priser skalerer ofte med API-kald eller indholdsposter. En personlig blog behøver ikke en indholdsinfrastruktur til $99/måned.</li></ul>
<p>For en markedsføringsside, hvor 50 personer redigerer indhold dagligt, er denne omkostning berettiget. For en udviklerportfolio eller en lille virksomheds landingsside? Du bygger en bro over en vandpyt.</p>
<h2>Hvad der ændrede sig: AI forstår din kode</h2>
<p>Grunden til at CMS eksisterede var ligetil: ikke-tekniske personer (og selv udviklere, der ikke ville røre kode for indholdsændringer) havde brug for en visuel grænseflade til at opdatere hjemmesider. Koden var for kompleks, for skrøbelig, for let at ødelægge.</p>
<p>AI ændrede denne ligning fundamentalt. Moderne AI-kodeværktøjer gør ikke blot autofuldførelse — de forstår projektstruktur, læser eksisterende mønstre og foretager kontekstuelt korrekte redigeringer. Ændringen i arbejdsgangen er dramatisk:</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>Det her er ikke hypotetisk. Denne blog kører på SolidStart med indhold gemt som TypeScript-filer. Hver artikel — inklusive denne — blev skabt ved at fortælle AI hvad den skal skrive, gennemgå resultatet og pushe til git. Intet CMS-dashboard. Ingen database. Intet API-lag mellem indholdet og koden.</p>
<h2>Virkelige eksempler fra denne side</h2>
<p>Denne hjemmeside understøtter 10 sprog, har en blog, genererer OG-billeder dynamisk og producerer RSS-feeds og sitemaps. Sådan ser indholdslaget ud — ren 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>Ting jeg gør med AI, som traditionelt ville kræve et CMS:</p>
<ul><li><strong>Tilføje et nyt blogindlæg:</strong> "Skriv en ny artikel om X, følg samme struktur som eksisterende indlæg" — AI opretter filen, tilføjer oversættelser, registrerer den i indekset</li><li><strong>Opdatere landingssidetekst:</strong> "Skift hero-overskriften til Y" — AI finder den rigtige fil og opdaterer den</li><li><strong>Oversætte indhold:</strong> "Tilføj tysk oversættelse for prissiden" — AI læser den engelske version og producerer en kulturelt tilpasset oversættelse, ikke en ord-for-ord-kopi</li><li><strong>Rette en stavefejl:</strong> "Der er en stavefejl på about-siden, 'recieve' skal være 'receive'" — klaret på 3 sekunder, committet til git med en meningsfuld besked</li></ul>
<h2>Hvad CMS faktisk løste — og hvordan AI erstatter det</h2>
<p>Lad os være ærlige om hvad CMS bragte til bordet, og hvordan hver evne mapper til AI-arbejdsgangen:</p>
<table><thead><tr><th>Problem</th><th>CMS-løsning</th><th>AI-løsning</th></tr></thead><tbody><tr><td>Ikke-teknisk redigering</td><td>WYSIWYG-editor</td><td>Instruktioner på naturligt sprog</td></tr><tr><td>Flersproget indhold</td><td>i18n-plugins, locale-felter</td><td>AI oversætter med kulturel kontekst</td></tr><tr><td>Indholdsplanlægning</td><td>Indbyggede publiceringsdatoer</td><td>Git-baseret CI/CD med cron eller datofelter i koden</td></tr><tr><td>Versionshistorik</td><td>CMS revisionssystem</td><td>Git — guldstandarden for versionskontrol</td></tr><tr><td>Mediehåndtering</td><td>Indbygget ressourcebibliotek</td><td>CDN + git LFS eller cloud-lagring</td></tr></tbody></table>
<p>Den centrale indsigt: git er allerede et bedre versionskontrolsystem end noget CMS nogensinde har bygget. Og naturligt sprog er en bedre grænseflade end nogen WYSIWYG-editor — fordi det bærer hensigt, ikke blot formatering.</p>
<h2>Paradigmeskiftet: kode er indholdslaget</h2>
<p>Vi er vidne til en inversion. I to årtier var tendensen at adskille indhold fra kode — lægge indholdet i en database, eksponere det via API, rendere det i frontend. Det gav mening, da kode var svær at redigere, og indhold skulle være tilgængeligt for ikke-udviklere.</p>
<blockquote>AI gjorde ikke CMS forældet ved at være et bedre CMS. Det gjorde CMS forældet ved at gøre kode lige så tilgængelig som et dashboard.</blockquote>
<p>Udviklingen af indholdsadministration på nettet følger en tydelig bane:</p>
<ol><li><strong>2000'erne:</strong> Monolitiske CMS (WordPress, Drupal) — indhold og præsentation koblet i ét system</li><li><strong>2010'erne:</strong> Headless CMS (Contentful, Strapi) — indhold adskilt via API, renderet af frontend-frameworks</li><li><strong>2020'erne:</strong> Statiske site-generatorer + Markdown (Hugo, Astro) — indhold som filer, bygget ved deploy</li><li><strong>2025+:</strong> Kode-som-indhold + AI — indholdet lever i typet kode, AI er redigeringsgrænsefladen</li></ol>
<h2>Hvornår du stadig har brug for et CMS</h2>
<p>Det her er ikke et "CMS er dødt"-standpunkt. CMS løser reelle problemer i stor skala. Du har stadig brug for et, når:</p>
<ul><li><strong>Store redaktionsteams:</strong> 10+ indholdsredaktører, der har brug for rollebaseret adgang, godkendelsesworkflows og samtidig redigering. Git merge-konflikter er ikke en indholdsredaktørs problem at løse.</li><li><strong>Højfrekvent indhold:</strong> Nyhedssider, der publicerer 50+ artikler om dagen, har brug for optimerede redaktionspipelines, ikke git-commits.</li><li><strong>Komplekse indholdsrelationer:</strong> E-handelskataloger med tusindvis af SKU'er, produktvarianter og dynamisk prissætning kræver strukturerede databaser.</li><li><strong>Regulatorisk overholdelse:</strong> Brancher, der kræver revisionsspor, indholdsgodkendelseskæder og lovpålagte gennemgangsprocesser, har brug for specialbyggede systemer.</li></ul>
<p>Grænsen er tydelig: hvis dine indholdsændringer kræver koordinering mellem flere ikke-tekniske interessenter med høj frekvens, fortjener CMS sin kompleksitet. Hvis du er soludvikler, et lille team, eller administrerer en side, der ændrer sig ugentligt snarere end hver time — er AI + kode enklere, hurtigere, billigere og mere pålideligt.</p>
<h2>Fremtiden: AI som den universelle grænseflade</h2>
<p>Tendensen rækker ud over CMS. Ethvert softwareabstraktionslag, der eksisterede fordi "det underliggende system er for komplekst til direkte interaktion", komprimeres af AI. Admin-dashboards, konfigurations-UI, visuelle databaseeditorer — alle disse er grænseflader, der oversætter menneskelig hensigt til systemændringer. AI udfører denne oversættelse nativt.</p>
<p>For simple hjemmesider er fremtiden allerede her. Dit indhold er kode. Din editor er AI. Din versionskontrol er git. Dit deploy er et push. Hele CMS-laget — dashboardet, databasen, API'et, hostingen — var middleware mellem din hensigt og din hjemmeside. AI fjernede behovet for den middleware.</p>
<blockquote>Det bedste CMS er intet CMS. Ikke fordi indholdsadministration ikke betyder noget — men fordi AI gjorde selve koden til den mest intuitive grænseflade til indholdsadministration, vi nogensinde har haft.</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>Sådan forbinder du Perplexity AI med Obsidian via MCP — Noter direkte fra chatten</title>
      <link>https://oleksiimazurenko.dev/da/blog/perplexity-obsidian-mcp-integration</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/da/blog/perplexity-obsidian-mcp-integration</guid>
      <description>Opsæt Perplexity Desktop til at læse og skrive dit Obsidian-vault ved hjælp af MCP filesystem-server. Søg på nettet og gem i dine noter i én samtale.</description>
      <content:encoded><![CDATA[<h2>TL;DR</h2>
<p>Perplexity Desktop understøtter <strong>MCP (Model Context Protocol)</strong>-forbindelser. Ved at tilføje den officielle <code>@modelcontextprotocol/server-filesystem</code>-server, der peger på dit Obsidian-vault, kan du bede Perplexity på naturligt sprog om at læse, oprette og redigere noter — direkte fra chatten. Ingen plugins, ingen udvidelser, ingen kopiering.</p>
<h2>Problemet</h2>
<p>Perplexity er fremragende til research — det søger på nettet, opsummerer kilder og giver svar med citater. Men når du vil gemme fundene i dit Obsidian-vault, bryder workflowet sammen: kopier tekst, skift til Obsidian, find den rigtige note, indsæt, formater. Hver. Eneste. Gang.</p>
<p>Browserudvidelser som "Perplexity to Obsidian" hjælper med eksport, men de er envejs — AI'en kan ikke <em>se</em> dit vault, kan ikke læse dine eksisterende noter og kan ikke beslutte, hvor ting skal placeres baseret på din mappestruktur.</p>
<h2>Hvad er MCP?</h2>
<p><strong>Model Context Protocol (MCP)</strong> er en åben standard, der lader AI-modeller interagere med lokale værktøjer og datakilder. Tænk på det som en USB-port til AI — du tilslutter en "server" (et lille program), og AI'en får nye evner. I vores tilfælde giver filesystem-serveren Perplexity 14 værktøjer til at arbejde med filer:</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>Nøglepunktet: AI-modellen har ikke direkte adgang til dine filer. Den kalder værktøjer leveret af MCP-serveren, som kører lokalt på din maskine. Dine data forlader aldrig din computer, medmindre du eksplicit beder AI'en om at gøre noget med dem.</p>
<h2>Krav</h2>
<ul><li><strong>Perplexity Pro</strong>-abonnement (MCP-forbindelser er tilgængelige for betalende brugere)</li><li><strong>Perplexity Mac App</strong> fra App Store (ikke browserversionen)</li><li><strong>Node.js</strong> installeret på din Mac (for at <code>npx</code> virker)</li></ul>
<h2>Trin-for-trin opsætning</h2>
<p>Hele opsætningen tager cirka 2 minutter:</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>Kommandoen</h3>
<p>Kommandoen der skal indsættes i <strong>Command</strong>-feltet:</p>
<pre><code>npx -y @modelcontextprotocol/server-filesystem &quot;/Users/yourname/Library/Mobile Documents/iCloud~md~obsidian/Documents/Obsidian Vault&quot;</code></pre>
<p>Erstat stien med den faktiske placering af dit Obsidian-vault. Hvis dit vault synkroniseres via iCloud, vil stien være under <code>~/Library/Mobile Documents/iCloud~md~obsidian/Documents/</code>. Sørg for at beholde anførselstegnene — stien indeholder sandsynligvis mellemrum.</p>
<h2>Sådan bruger du det</h2>
<p>Når forbindelsen viser <strong>Running</strong> med 14 tilgængelige værktøjer, gå til en vilkårlig Perplexity-chat og begynd at tale med dit 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>AI'en forstår din vault-struktur, respekterer dine formateringskonventioner og kan arbejde med eksisterende indhold. Du kan bede den undersøge et emne på nettet og gemme resuméet direkte i en specifik note.</p>
<h2>Hvorfor MCP er bedre end andre tilgange</h2>
<p>Før MCP var der begrænsede måder at forbinde Perplexity og Obsidian på:</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>Nuværende begrænsninger</h2>
<ul><li><strong>Kun Mac</strong> — Perplexitys MCP-forbindelser fungerer i øjeblikket kun i Mac App Store-versionen</li><li><strong>Ingen Obsidian API-integration</strong> — filesystem-serveren arbejder med rå filer, ikke gennem Obsidians API. Det betyder, at den ikke udløser Obsidian-plugins (Linter, Templater) ved filoprettelse</li><li><strong>Godkendelse påkrævet</strong> — følsomme filoperationer kan kræve din bekræftelse i Perplexity-appen — det er en sikkerhedsfunktion, ikke en fejl</li></ul>
<h2>Konklusioner</h2>
<p>Denne opsætning forvandler Perplexity fra et researchværktøj til et research-og-fangstværktøj:</p>
<ol><li>Søg på nettet og gem i Obsidian i én samtale</li><li>AI'en ser din vault-struktur og tilpasser sig dit organisationssystem</li><li>Nul appskift — alt sker i Perplexity-chatten</li></ol>
<h2>Kilder</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>Daglig AI-nyhedsdigest med Claude Code CLI og Obsidian — Nul afhængigheder</title>
      <link>https://oleksiimazurenko.dev/da/blog/ai-news-digest-claude-code-obsidian</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/da/blog/ai-news-digest-claude-code-obsidian</guid>
      <description>Hvordan jeg byggede en daglig nyhedsforskningsagent med et 6-linjers bash-script, Claude Code headless-tilstand og macOS launchd.</description>
      <content:encoded><![CDATA[<h2>TL;DR</h2>
<p>Et 6-linjers bash-script der kører <strong>Claude Code CLI</strong> i headless-tilstand hver morgen kl 9:00. Det søger nyheder inden for 11 konfigurerbare emner, filtrerer støj og skriver en formateret markdown-digest direkte til en <strong>Obsidian vault</strong> synkroniseret via iCloud. Nul afhængigheder. ~100 linjer konfiguration i alt.</p>
<h2>Problemet</h2>
<p>Som udvikler er det en daglig skat at holde sig opdateret. RSS-feeds er støjende, Twitter er en tidssluger, nyhedsbreve ankommer når man er dybt fokuseret. Jeg havde brug for noget der laver research <em>for</em> mig.</p>
<p>Den typiske løsning er at bygge en scraping-pipeline: planlægger, crawler, NLP-pipeline, database, notifikationstjeneste. Det er ugers arbejde. Jeg ville have noget der kan bygges på en eftermiddag.</p>
<h2>Arkitektur</h2>
<p>Hele systemet er 4 filer og nul afhængigheder:</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>Koden (hele)</h2>
<p>Projektet er bevidst minimalt.</p>
<h3>Indgangspunkt: digest.sh</h3>
<p>Hele applikationen er et 6-linjers bash-script:</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>Vigtige flags: <code>-p</code> kører Claude i headless-tilstand, <code>--max-turns 20</code> giver agenten nok ture, <code>--allowedTools</code> begrænser agenten til læsning, søgning og skrivning.</p>
<h3>Hjernen: prompt.md</h3>
<p>Her bor intelligensen. Prompten forvandler Claude til en nyhedsforskningsagent:</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>Emner er fuldt konfigurerbare — tilføj et nyt emne og det er i morgendagens digest:</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>Planlægning med launchd</h2>
<p>På macOS er <code>launchd</code> den native måde at planlægge tilbagevendende opgaver:</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>Installer med <code>launchctl load ~/Library/LaunchAgents/com.news-digest.plist</code>. Scriptet kører dagligt kl 9:00.</p>
<h2>Hvordan resultatet ser ud</h2>
<p>Hver morgen dukker en ny markdown-fil op i Obsidian-vaultet:</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>I tal</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>Vigtige designbeslutninger</h2>
<ul><li><strong>Claude Code CLI fremfor API</strong> — ingen håndtering af API-nøgler eller HTTP-klienter</li><li><strong>Obsidian fremfor email</strong> — digests er søgbare, linkbare og permanente</li><li><strong>launchd fremfor cron</strong> — macOS-native planlægger med håndtering af tabte kørsler</li><li><strong>YAML til emner</strong> — nyt emne = 2-linjers ændring</li><li><strong>Spring tomme emner over</strong> — ingen nyheder = ingen sektion</li></ul>
<h2>Byg din egen</h2>
<p>Klar på 10 minutter:</p>
<ol><li>Installer <a href="https://docs.anthropic.com/en/docs/claude-code" target="_blank" rel="noopener noreferrer">Claude Code CLI</a> og autentificer</li><li>Klon repoet: <code>git clone https://github.com/oleksiimazurenko/news-digest</code></li><li>Rediger <code>topics.yaml</code> og <code>prompt.md</code></li><li>Rediger plist-filen og <code>launchctl load</code></li><li>Vent til 9:00 — eller test manuelt med <code>bash digest.sh</code></li></ol>
<h2>Konklusioner</h2>
<p>Det mest interessante ved dette projekt er hvad der <em>ikke</em> er i det. Ingen database, ingen API-server, ingen Docker, ingen npm-pakker, ingen Python, ingen HTML-parser, ingen NLP-pipeline.</p>
<p>Sådan ser det ud at bygge med AI-agenter: du definerer <em>hvad</em> og <em>hvor</em>, agenten håndterer <em>hvordan</em>. Total udviklingstid: cirka 2 timer.</p>
<h2>Kilder</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 uden blink eller React 19-advarsler</title>
      <link>https://oleksiimazurenko.dev/da/blog/nextjs-dark-mode-without-flash</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/da/blog/nextjs-dark-mode-without-flash</guid>
      <description>Sådan erstattes next-themes med Zustand + useServerInsertedHTML til flimmerfri mørk tilstand i Next.js 15+.</description>
      <content:encoded><![CDATA[<h2>TL;DR</h2>
<p><code>next-themes</code> renderer et <code>&lt;script&gt;</code>-tag inde i en React Client Component for at forhindre temaflimmer (FOUC). React 19 advarer om dette. Biblioteket er ikke opdateret siden marts 2025. Løsning: Zustand-store + <code>useServerInsertedHTML</code>. Nul afhængigheder, nul FOUC, nul advarsler.</p>
<h2>Problemet</h2>
<p>Hvis du bruger <code>next-themes</code> med Next.js 15+ og React 19, får du denne konsollfejl ved hver sideindlæsning:</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>Ingen hydreringsfejl. React 19 advarer om, at <code>&lt;script&gt;</code>-tags i klientkomponenter <strong>aldrig vil blive udført</strong>. Scriptet virker i SSR, men React markerer det.</p>
<h2>Hvorfor det sker</h2>
<p><code>next-themes</code> sætter temaklasse inden hydrering ved at injicere et inline-script via <code>React.createElement</code>:</p>
<pre><code>// next-themes renders a &lt;script&gt; inside a Client Component
return React.createElement(Provider, { value },
  React.createElement(&quot;script&quot;, {
    suppressHydrationWarning: true,
    dangerouslySetInnerHTML: { __html: `(...theme init code...)` }
  }),
  children
)</code></pre>
<p>React 19 ændrede adfærden — script-tags i komponenter markeres nu. <code>suppressHydrationWarning</code> hjælper ikke her.</p>
<h2>Hvad vi prøvede (og hvorfor det mislykkedes)</h2>
<p>Vi prøvede systematisk hver tilgang:</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>Løsningen: Zustand + useServerInsertedHTML</h2>
<p><code>useServerInsertedHTML</code> injicerer HTML i SSR-strømmen <strong>uden for React-træet</strong>. Scriptet er i HTML, men React "ser" det ikke på klienten. Kombineret med Zustand til reaktiv tematilstand — komplet erstatning, nul afhængigheder.</p>
<h3>Sådan fungerer det</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>Trin 1: Zustand-store</h3>
<p>Storen håndterer tematilstand, DOM-klasser, systemdetektering og synkronisering på tværs af faner. <code>_init()</code> returnerer en oprydningsfunktion til <code>useEffect</code>:</p>
<pre><code>import { create } from &quot;zustand&quot;;

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

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

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

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

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

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

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

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

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

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

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

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

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

export function useTheme() {
  return useThemeStore((s) =&gt; ({
    theme: s.theme,
    setTheme: s.setTheme,
    resolvedTheme: s.resolvedTheme,
    systemTheme: s.systemTheme,
  }));
}</code></pre>
<h3>Trin 2: ThemeProvider</h3>
<p>Provideren injicerer FOUC-forebyggende script via <code>useServerInsertedHTML</code> og initialiserer Zustand-storen ved montering:</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>Trin 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>Trin 4: Brug det</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 fra next-themes</h2>
<p>API'et er bevidst identisk. Migration er én enkelt importændring per fil:</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>Sammenligning</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>Hvorfor ikke andre alternativer?</h2>
<h3>@wrksz/themes</h3>
<p>Drop-in-erstatning der bruger <code>useServerInsertedHTML</code>. Virker, men endnu en afhængighed med én vedligeholder. Hvis <code>next-themes</code> lærte os noget — afhængigheder bliver opgivet. Med ~100 linjer kode ejer du hele løsningen.</p>
<h3>next-themes@1.0.0-beta.0</h3>
<p>Findes på npm, ingen udgivelsesdato, ingen ændringslog, uklart om React 19-advarslen er rettet. Ikke værd at satse produktionskode på.</p>
<h3>Kun CSS (prefers-color-scheme)</h3>
<p>Virker til systemdetektering, kan ikke håndtere localStorage-persistens, manuel skift eller "system"-muligheden. Kræver JavaScript.</p>
<h2>Konklusioner</h2>
<ol><li><code>next-themes</code> er reelt opgivet — seneste udgivelse marts 2025, React 19-advarsel urettet</li><li><code>useServerInsertedHTML</code> er den korrekte Next.js-primitiv til scriptinjektion uden React-advarsler</li><li>Zustand giver reaktiv tematilstand med mindre kode end en Context-provider</li><li>Hele løsningen ~100 linjer, nul nye afhængigheder, du ejer hver linje</li></ol>
<h2>Kilder</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>Hvordan jeg eliminerede 94 render-blokerende CSS-filer i Next.js 16 med en dårligt dokumenteret Turbopack-funktion</title>
      <link>https://oleksiimazurenko.dev/da/blog/eliminating-render-blocking-css</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/da/blog/eliminating-render-blocking-css</guid>
      <description>Efter dage med at prøve enhver tilgang — fra experimental.inlineCss til MutationObserver-hacks — opdagede jeg Turbopack Import Attributes, der løser problemet med render-blokerende CSS i Next.js App Router.</description>
      <content:encoded><![CDATA[<h2>Problemet</h2>
<p>Vores app (<a href="https://promova.com" target="_blank" rel="noopener noreferrer">Promova</a>) bruger Next.js 16 med en <strong>Landing Builder</strong> — et CMS-drevet system, der samler marketingsider fra ~90 forskellige sektionskomponenter. Arkitekturen bruger en <code>sectionRegistry.tsx</code>, der mapper sektionsnavne til <code>next/dynamic()</code>-kald:</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>En enkelt landingsside renderer kun <strong>5-8 sektioner</strong>. Men Lighthouse viste:</p>
<pre><code>Eliminate render-blocking resources
  94 CSS resources (~330 KB)
  Potential savings: 5,440 ms</code></pre>
<p><strong>Hvorfor?</strong> Turbopack ser alle 90 <code>import()</code>-stier som tilgængelige og genererer <code>&lt;link rel="stylesheet"&gt;</code> for <strong>hvert</strong> SCSS-modul. Selv sektioner, der aldrig renderes på siden, får deres CSS injiceret i <code>&lt;head&gt;</code>. Dette er en <a href="https://github.com/vercel/next.js/issues/62485" target="_blank" rel="noopener noreferrer">bekræftet, forventet adfærd</a> i Next.js App Router. <strong>Ingen rettelse er planlagt.</strong></p>
<h2>Alt, hvad jeg prøvede (og hvorfor det fejlede)</h2>
<p>Jeg brugte dage på at gennemgå enhver tilgang, jeg kunne finde. Her er den fulde 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>inlineCss-fælden</h3>
<p>Next.js har et <code>experimental.inlineCss</code>-flag, der erstatter alle <code>&lt;link rel="stylesheet"&gt;</code> med inline <code>&lt;style&gt;</code>-tags. Lyder perfekt, ikke?</p>
<p>Problemet: det er <strong>alt eller intet</strong>. Du kan ikke aktivere det per rute. Hvis du har SSR-sider (<code>force-dynamic</code>), genopbygger hver anmodning al CSS inline. Vi prøvede — vores headless CMS kunne ikke klare belastningen.</p>
<h2>Opdagelsen: Turbopack Import Attributes</h2>
<p>Ved at grave i <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> fandt jeg en dårligt dokumenteret funktion: <strong>Turbopack Import Attributes</strong>. Den lader dig tilsidesætte den indbyggede bundler-pipeline for en specifik import med TC39 <code>with {}</code>-syntaks:</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>Dette fortæller Turbopack: <em>"Behandl ikke denne import som et stilark. Kør den gennem min brugerdefinerede loader og behandl outputtet som JavaScript."</em></p>
<p><strong>Dette er den afgørende indsigt.</strong> I stedet for at Turbopack genererer et <code>&lt;link rel="stylesheet"&gt;</code>, der blokerer rendering, kompilerer vores loader SCSS og eksporterer det som en JS-streng. Resultat: <strong>kun CSS for sektioner, der faktisk renderes, ender i sidens HTML</strong>.</p>
<h2>Løsningen</h2>
<h3>1. Brugerdefineret Turbopack Loader</h3>
<p>Et ~70-linjers Node.js-script som yarn workspace-pakke (<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>Hvad den gør: <strong><code>styles</code></strong> — det samme scopede klassenavnkort som standard CSS Modules. <strong><code>cssText</code></strong> — kompileret CSS som streng.</p>
<h3>2. InlineStyle-komponent</h3>
<p>Bruger React 19s indbyggede <code>&lt;style href precedence&gt;</code>-API til automatisk deduplicering:</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 garanterer: samme <code>href</code> → kun ét <code>&lt;style&gt;</code> i DOM.</p>
<h3>3. Migrering per komponent (~6 linjer per 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><code>.module.scss</code>-filerne forbliver nøjagtig de samme.</strong> Ingen CSS-omskrivning.</p>
<h2>Hvorfor dette er bedre end inlineCss: true</h2>
<p>Her er den kritiske forskel:</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>Med <code>inlineCss: true</code> får en side stadig ALLE 94 stilark inline. Med vores tilgang ender <strong>kun CSS, der faktisk renderes, i HTML'en</strong>.</p>
<h2>Turbopack-fælden: ingen globale regler for .module.scss</h2>
<p>En fælde jeg faldt i: du tror måske, at du kan tilføje en Turbopack-regel i <code>next.config.ts</code> for at anvende loaderen globalt:</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>Gør ikke dette.</strong> Turbopacks indbyggede CSS-modul-pipeline opfanger <code>.module.scss</code>-filer <strong>før</strong> brugerdefinerede regler anvendes, hvilket forårsager:</p>
<pre><code>FATAL PANIC: inner asset should be CSS processable</code></pre>
<p><code>with {}</code>-attributter fungerer, fordi de instruerer Turbopack <strong>på importstedet</strong> om at omgå CSS-modul-pipelinen fuldstændigt.</p>
<h2>Resultater</h2>
<p>127 sektionskomponenter migreret i Landing Builder. Produktionsbuild verificeret.</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>Begrænsninger</h2>
<ul><li><strong><code>with {}</code> per import er ordrig</strong> — hver import kræver 3 ekstra linjer.</li><li><strong>Kun Turbopack</strong> — <code>with {}</code>-attributter understøttes ikke af Webpack.</li><li><strong>Klassenavnhashing</strong> — vores loader bruger en anden hashingsalgoritme end Turbopacks indbyggede.</li><li><strong>HTML-størrelse stiger</strong> — CSS er inline i HTML i stedet for cachede separate filer.</li></ul>
<h2>Hvornår du skal bruge dette</h2>
<p>Denne teknik er mest effektiv, når:</p>
<ol><li><strong>Du har et registry/barrel-mønster</strong> — én fil importerer mange komponenter, men kun få renderes per side</li><li><strong>Du bruger Turbopack</strong> — Import Attributes er Turbopack-specifikke</li><li><strong>Du vil have kontrol per komponent</strong> — ikke et alt-eller-intet-flag</li><li><strong>Din SCSS er kompleks</strong> — variabler, mixins, breakpoints, nesting — alt understøttet</li><li><strong>Du ikke kan bruge <code>experimental.inlineCss</code></strong> — fordi du har SSR-sider eller ønsker granulær kontrol</li></ol>
<h2>Relaterede GitHub Issues</h2>
<p>Hvis du er påvirket af render-blokerende CSS i Next.js App Router — du er ikke alene:</p>
<h3>Kerneproblemet</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-funktionen &amp; problemer</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>Fællesskabet søger løsninger</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-fejl</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>Bygget hos <a href="https://promova.com" target="_blank" rel="noopener noreferrer">Promova</a> — en sproglæringsplatform, der betjener millioner af brugere.</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-tilstand — 2 år uden fix</title>
      <link>https://oleksiimazurenko.dev/da/blog/nextjs-memory-leak-fetch-standalone</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/da/blog/nextjs-memory-leak-fetch-standalone</guid>
      <description>Next.js patcher den globale fetch og tilføjer et cache-lag, der lækker hukommelse ved hver anmodning. I Docker/K8s fører dette til OOM-nedbrud hver par timer. Buggen har eksisteret siden Next.js 14 og er stadig uløst i 16.2.x.</description>
      <content:encoded><![CDATA[<h2>TL;DR</h2>
<p>Next.js patcher den globale <code>fetch</code> og tilføjer et cache-lag, der holder referencer til responsdata, efter de burde have været frigivet. Hvert <code>fetch</code>-kald tilføjer hukommelse, der aldrig returneres til GC. I Docker/Kubernetes fører dette til OOM-nedbrud hver par timer. Buggen har eksisteret siden Next.js 14 (april 2024) og er stadig uløst i 16.2.x (marts 2026). På Vercel viser problemet sig ikke takket være efemere serverless-funktioner.</p>
<h2>Hvordan normal fetch fungerer</h2>
<pre><code>Request → fetch → got data → response to user → GC cleans up → memory free</code></pre>
<h2>Hvordan fetch fungerer i Next.js</h2>
<p>Next.js opfanger den globale <code>fetch</code> og pakker den ind med sit eget cache-/tracking-lag:</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>Hvad der sker i produktion</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>Berørte versioner</h2>
<h3>Next.js — Alle versioner med 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>Testet på Node.js 20, 22, 24, 25 — lækker på alle.</p>
<h2>Hvad der ikke virker</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>Hvad der virker (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>Begrænsning med axios-workaround</h3>
<p>Dine egne API-kald kan erstattes med axios. Men Next.js bruger internt den patchede fetch til:</p>
<ul><li>ISR (Incremental Static Regeneration)</li><li><code>revalidatePath</code> / <code>revalidateTag</code></li><li>Server Components data fetching med deduplikering</li><li><code>use cache</code> (Next.js 16)</li></ul>
<p>Selv uden en eneste <code>fetch</code> i din kode — bruger Next.js den stadig internt.</p>
<h2>Hvorfor Vercel ikke fikser det</h2>
<h3>Forretningslogik</h3>
<p>Vercel er en virksomhed, der tjener penge på at hoste Next.js. På deres platform viser problemet sig ikke (serverless = efemert). Buggen påvirker kun self-hosted (Docker, K8s, VPS) — dem, der ikke betaler Vercel.</p>
<h3>Officiel position</h3>
<p>Tim Neutkens (Vercel-maintainer) analyserede problemet og erklærede det for et <strong>undici</strong>-problem (Node.js fetch-bibliotek), ikke Next.js. Issue #90433 blev lukket. På trods af at:</p>
<ul><li>axios og node-fetch på samme Node.js fungerer uden lækager</li><li>Lækagen kun opstår, når fetch går gennem Next.js-wrapperen</li><li>Buggen har været åben i 2 år uden en fix</li></ul>
<h3>Prioriteringer</h3>
<p>I løbet af disse 2 år har Next.js-teamet udgivet:</p>
<ul><li>Turbopack (2-5x hurtigere builds) — markedsføringsfordel</li><li>Cache Components / <code>use cache</code> — reducerer belastningen på Vercel-servere</li><li><code>proxy.ts</code> i stedet for middleware — forenkler edge-deployment på Vercel</li><li>DevTools MCP — AI-hype</li></ul>
<p>Memory leak i self-hosted? Ikke en prioritet.</p>
<h2>Løsning: AWS Lambda (SST + OpenNext)</h2>
<h3>Hvad er det</h3>
<p>OpenNext er en open-source-adapter, der konverterer et Next.js-build til et format til AWS Lambda. SST er et framework, der automatiserer infrastrukturen.</p>
<h3>Arkitektur</h3>
<pre><code>Next.js build
  → OpenNext
    → AWS Lambda (SSR, API routes)
    → S3 (static, assets)
    → CloudFront (CDN)
    → SQS + DynamoDB (ISR revalidation)</code></pre>
<h3>Hvorfor dette løser hukommelseslækagen</h3>
<p>Lambda-funktioner behandler anmodninger og genbruges efter 5-15 minutters inaktivitet. Hukommelsen når ikke at akkumulere.</p>
<h3>Deployment</h3>
<pre><code>npx sst@latest init
npx sst deploy --stage production</code></pre>
<h3>Sammenligning</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-nuancer</h3>
<ul><li><strong>Cold starts</strong> — første anmodning er langsommere (~200-500ms)</li><li><strong>Sikkerhed</strong> — aktiver OAC (Origin Access Control), ellers er Lambda-URL'en offentlig</li><li><strong>OpenNext</strong> — community-projekt, ikke officielt fra Vercel. Nye Next.js-funktioner kan gå i stykker</li><li><strong>Tegnebogsangreb</strong> — ved DDoS kan Lambda auto-scaling føre til en stor regning</li></ul>
<h2>Hvorfor en rigtig fix er urealistisk</h2>
<h3>1. Arkitekturproblem</h3>
<p>Lækagen er ikke en tilfældig bug, men en konsekvens af en designbeslutning: Next.js opfanger den globale <code>fetch</code> og tilføjer cache/tracking ovenpå. For at fixe det skal måden, App Router interagerer med fetch, redesignes. Dette påvirker ISR, revalidation, data cache, request deduplication — frameworkets kerne.</p>
<h3>2. Interessekonflikt</h3>
<p>Vercel er ikke motiveret til at fixe det, der ikke påvirker deres platform. Self-hosted konkurrerer med deres forretning. Jo flere problemer i self-hosted — jo flere migrerer til Vercel.</p>
<h3>3. Skyldforskydning</h3>
<p>Den officielle position er "det er undici, ikke os". Indtil det ændrer sig — vil de ikke arbejde på en fix.</p>
<h3>4. Ingen community-fix</h3>
<p>Next.js AGPL-3.0-licens tillader forks, men kodebasen er enorm og tæt koblet til Vercel-infrastrukturen. En community-PR til at fixe fetch-wrapperen ville kræve dyb forståelse af den interne arkitektur og godkendelse fra maintainers — som allerede har lukket issuen.</p>
<h2>Konklusioner</h2>
<ol><li><strong>Hvis du er på Vercel</strong> — intet problem, intet at gøre</li><li><strong>Hvis self-hosted og behov for serverless</strong> — SST + OpenNext på AWS Lambda</li><li><strong>Hvis self-hosted Docker</strong> — erstat fetch med axios hvor muligt, overvåg RAM, konfigurer automatisk pod-genstart</li><li><strong>Hvis du starter et nyt projekt</strong> — overvej SvelteKit eller Nuxt som alternativer uden dette problem</li></ol>
<h2>Kilder</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>