<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Digital Craft Workshop</title>
    <description>The latest articles on DEV Community by Digital Craft Workshop (@danielrusnok).</description>
    <link>https://dev.to/danielrusnok</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F277448%2Fdf127f3b-f0f6-45fe-b25e-1608deb963fe.png</url>
      <title>DEV Community: Digital Craft Workshop</title>
      <link>https://dev.to/danielrusnok</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/danielrusnok"/>
    <language>en</language>
    <item>
      <title>Replaying 10,000 Production Fingerprints Through JSDOM in 30 Seconds</title>
      <dc:creator>Digital Craft Workshop</dc:creator>
      <pubDate>Sun, 06 Sep 2026 15:02:51 +0000</pubDate>
      <link>https://dev.to/danielrusnok/replaying-10000-production-fingerprints-through-jsdom-in-30-seconds-12mh</link>
      <guid>https://dev.to/danielrusnok/replaying-10000-production-fingerprints-through-jsdom-in-30-seconds-12mh</guid>
      <description>&lt;h1&gt;
  
  
  Replaying 10,000 Production Fingerprints Through JSDOM in 30 Seconds
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;Not a member? &lt;a href="https://danielrusnok.medium.com/membership" rel="noopener noreferrer"&gt;Use this link.&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I had ten thousand selectors sitting in a Postgres table and I was not sure the code that wrote them was still compatible with the code that read them. Parser strictness, schema renames, scoring algorithm changes. Any of them could have drifted between writer and reader.&lt;/p&gt;

&lt;p&gt;Unit tests cannot cover this; fixtures only contain what I remembered to write. The honest move is to replay production data through current code and count what survives.&lt;/p&gt;

&lt;p&gt;It took one CSV export and fifty lines of Node, in thirty seconds, to get a clear answer about compatibility over 10,000 records.&lt;/p&gt;




&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Export the stored fingerprints to a CSV:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;COPY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fp&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;element_fingerprints&lt;/span&gt;
    &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;
    &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;STDOUT&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;CSV&lt;/span&gt; &lt;span class="n"&gt;HEADER&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each &lt;code&gt;fp&lt;/code&gt; is a JSON blob like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Ver"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"v3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Txt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Submit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Sels"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Sel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"button[type=&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;submit&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Prio"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Offset"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"IsStateful"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Sel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;".form button"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="nl"&gt;"Prio"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Offset"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"IsStateful"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The question I want to answer: &lt;strong&gt;can every stored selector still be parsed by the current code path?&lt;/strong&gt; Every call ends in &lt;code&gt;document.querySelector(sel)&lt;/code&gt;, so what I actually need to check is whether the browser's CSS parser accepts the string.&lt;/p&gt;

&lt;p&gt;You don't need a browser to answer that. JSDOM ships with a CSS selector engine that implements the same W3C Selectors grammar Chrome uses. That is enough to validate selector strings without booting a browser.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdypja82247dnk3rgdvyx.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdypja82247dnk3rgdvyx.jpg" alt="Replay pipeline diagram: Postgres COPY, CSV file, Node script, JSDOM parser, output tally" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Postgres COPY to CSV to Node and JSDOM, ending in a compatibility tally | Generated with Claude&lt;/p&gt;




&lt;h2&gt;
  
  
  The script
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;JSDOM&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jsdom&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;JSDOM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;!DOCTYPE html&amp;gt;&amp;lt;body&amp;gt;&amp;lt;/body&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;parseCsv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;field&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;inQuotes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inQuotes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;field&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;inQuotes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="nx"&gt;field&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;inQuotes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;field&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt; &lt;span class="nx"&gt;field&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\r&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nx"&gt;field&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseCsv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./fingerprints.csv&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;totalSelectors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;invalidSelectors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;fullyCompatible&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;partially&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;allInvalid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;emptySels&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;invalidExamples&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fpRaw&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;fpRaw&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;fpRaw&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;null&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;emptySels&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fpRaw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Sels&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;fp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Sels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;emptySels&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bad&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;fp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Sels&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;totalSelectors&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Sel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;bad&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;invalidSelectors&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invalidExamples&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;invalidExamples&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Sel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;allInvalid&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bad&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;partially&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nx"&gt;fullyCompatible&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;totalRows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;fullyCompatible&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;partially&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;allInvalid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;emptySels&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;totalSelectors&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;invalidSelectors&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;invalidRatio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invalidSelectors&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;totalSelectors&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;invalidExamples&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;invalidExamples&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node validate.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;totalRows:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;fullyCompatible:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9956&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;partially:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;allInvalid:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;emptySels:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;44&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;totalSelectors:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;221693&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;invalidSelectors:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;invalidRatio:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="mf"&gt;0.000&lt;/span&gt;&lt;span class="err"&gt;%'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;invalidExamples:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fof6z00hf29htoz4cx8sc.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fof6z00hf29htoz4cx8sc.jpg" alt="Terminal output: 10,000 rows, 9,956 fully compatible, zero parse failures across 221,693 selectors" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The replay verdict from node validate.js | Generated with Claude&lt;/p&gt;




&lt;h2&gt;
  
  
  Why this matters more than unit tests
&lt;/h2&gt;

&lt;p&gt;Your unit test suite probably has a dozen selector fixtures. Maybe a hundred. Production has patterns you didn't anticipate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;code&gt;div[tracker="[object Object]"]&lt;/code&gt; — yes, someone's app stringified an object into an attribute&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;.dark\:border-gray-800 .w-full&lt;/code&gt; — escaped Tailwind variant colons&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;.group-hover\:bg-background-50&lt;/code&gt; — 12 such classes chained together&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;label[for="mat-mdc-checkbox-10-input"]&lt;/code&gt; — Angular Material&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;label[for="radix-:r7:"]&lt;/code&gt; — Radix UI's colon-slug ids (these we filter out at capture, but old data still has them)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most fixtures I've written are five or six selectors I remembered to type. The replay covered 221,693 stored ones at once.&lt;/p&gt;




&lt;h2&gt;
  
  
  What JSDOM catches that a type check won't
&lt;/h2&gt;

&lt;p&gt;TypeScript checks the shape of &lt;code&gt;fp.Sels[i]&lt;/code&gt; but not whether the string inside is a valid CSS selector. The CSS grammar has its own error modes: unclosed brackets, unknown pseudo-classes, malformed escapes. The only way to catch them cheaply is to run them through a parser.&lt;/p&gt;

&lt;p&gt;The parser does a lot of small things you do not notice when you write a fixture. It tokenises the input, checks bracket balance, validates pseudo-class names, and either returns a node or throws. The throw is the only signal I care about here; the node lookup is a side effect.&lt;/p&gt;

&lt;p&gt;That is why this loop scales linearly with selector count and not with DOM size. The DOM has one body element. The parser runs the full string-to-AST pipeline on every selector I feed it. On 221,693 selectors that pipeline took about thirty seconds on a laptop, single-threaded, no warm-up.&lt;/p&gt;

&lt;p&gt;You might worry about using JSDOM's parser to stand in for the browser's. They're not literally the same implementation, but for CSS selector syntax both implement the W3C grammar. The invalid selectors JSDOM rejects are the ones Chrome will also reject. (If you want certainty, run the same script under &lt;code&gt;puppeteer&lt;/code&gt; and call &lt;code&gt;page.evaluate(sel =&amp;gt; document.querySelector(sel))&lt;/code&gt; — same result, minutes instead of seconds.)&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Sidebar: this kind of "use a cheap tool to validate against reality" move is one of the patterns I keep coming back to as a solo builder. I have a free email series, &lt;a href="https://drippery.app/subscribe/a8ff09ab-6665-409f-a8e4-acb87e2f1a12" rel="noopener noreferrer"&gt;AI as a Solo Founder's Tool&lt;/a&gt;, that walks through more of them.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Making it a CI job
&lt;/h2&gt;

&lt;p&gt;The thirty-second runtime is the interesting part. This isn't a "once a quarter" audit — it's a &lt;a href="https://medium.com/p/db51a8d7cc74" rel="noopener noreferrer"&gt;cheap pre-deploy check&lt;/a&gt; you can run on every PR that touches the fingerprint code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Fingerprint compatibility check&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;psql -c "COPY (SELECT id, fp FROM fingerprints ORDER BY random() LIMIT 5000) TO STDOUT WITH CSV HEADER" &amp;gt; sample.csv&lt;/span&gt;
    &lt;span class="s"&gt;node scripts/validate-fingerprints.js sample.csv --fail-on-regression&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I take a random sample of 5,000 from the production table and compare it against the baseline (&lt;code&gt;fullyCompatible / totalRows&lt;/code&gt;). If the ratio drops by more than 1%, the PR fails. The threshold is tunable; I started at 1% and have not had to change it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Generalizing the pattern
&lt;/h2&gt;

&lt;p&gt;I use the same loop on more than fingerprints. Any time three things line up, the trick works:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;You have stored structured data&lt;/strong&gt; (selectors, queries, configs, serialized state).&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Code consumes that data&lt;/strong&gt; with a parser somewhere in the path.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;The parser can reject&lt;/strong&gt; inputs the writer thought were valid.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi5829j06mqm0mqklh9ik.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi5829j06mqm0mqklh9ik.jpg" alt="Replay-validation triangle: stored data, code consumer, parser that can reject" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The three conditions that make replay-based validation worth running | Generated with Claude&lt;/p&gt;

&lt;p&gt;Whenever that triangle exists, replay-based validation is worth running. A handful of examples from our own codebase:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  SQL queries stored for saved reports → run each through &lt;code&gt;EXPLAIN&lt;/code&gt; and check for errors&lt;/li&gt;
&lt;li&gt;  Zod schemas versioned over time → re-validate last month's records against this month's schemas&lt;/li&gt;
&lt;li&gt;  Mongo aggregation pipelines saved in documents → dry-run them against &lt;code&gt;db.runCommand({aggregate, explain:true})&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;  Regex patterns stored in a rules engine → compile each; anything that throws is a stored record you can't read anymore&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's the cheapest form of backward-compatibility testing you'll ever write, and it's quantitative: instead of "I think it still works," you get "99.56% are fully compatible; the other 44 had empty selector lists I can investigate one at a time." It pairs naturally with a &lt;a href="https://medium.com/generative-ai/how-i-put-a-code-reviewer-in-front-of-every-git-push-to-main-with-a-claude-code-subagent-17408aa3236e?sk=b141e0baa56cea334ad5887116c4f771" rel="noopener noreferrer"&gt;pre-push validation&lt;/a&gt; step that watches for the kind of drift this script measures.&lt;/p&gt;




&lt;h2&gt;
  
  
  External Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://github.com/jsdom/jsdom" rel="noopener noreferrer"&gt;JSDOM&lt;/a&gt; — Node.js DOM implementation used as the parser&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://www.w3.org/TR/selectors/" rel="noopener noreferrer"&gt;W3C Selectors specification&lt;/a&gt; — CSS selector grammar&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://pptr.dev/" rel="noopener noreferrer"&gt;Puppeteer documentation&lt;/a&gt; — headless Chrome alternative for browser-grade verification&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://github.com/capricorn86/happy-dom" rel="noopener noreferrer"&gt;happy-dom&lt;/a&gt; — faster DOM alternative referenced in the post&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://www.postgresql.org/docs/current/sql-copy.html" rel="noopener noreferrer"&gt;PostgreSQL COPY command&lt;/a&gt; — used to export the fingerprint CSV&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Production is the test suite I didn't write
&lt;/h2&gt;

&lt;p&gt;I had a year of production data I had never used as a test. I left mine sitting in Postgres because some half-formed worry told me I could not run real data locally. I could. Thirty seconds and fifty lines of JavaScript. The replay took thirty seconds. I should have done it the first week.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If this kind of cheap reality-check is useful, I write up more of them in a free email series, &lt;a href="https://drippery.app/subscribe/a8ff09ab-6665-409f-a8e4-acb87e2f1a12" rel="noopener noreferrer"&gt;AI as a Solo Founder's Tool&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build small tools and kits for solo creators. You can find them here: &lt;a href="https://danielrusnok.gumroad.com" rel="noopener noreferrer"&gt;https://danielrusnok.gumroad.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>My App Returned 403 But My Code Never Did</title>
      <dc:creator>Digital Craft Workshop</dc:creator>
      <pubDate>Fri, 04 Sep 2026 15:04:09 +0000</pubDate>
      <link>https://dev.to/danielrusnok/my-app-returned-403-but-my-code-never-did-1ok0</link>
      <guid>https://dev.to/danielrusnok/my-app-returned-403-but-my-code-never-did-1ok0</guid>
      <description>&lt;p&gt;&lt;em&gt;Not a member? Use &lt;a href="https://danielrusnok.medium.com/membership" rel="noopener noreferrer"&gt;this friend link&lt;/a&gt; to read this story for free.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;My Drippery autosaves started returning 403 on one specific email series. I spent the next two hours searching the codebase for a status code my code does not emit.&lt;/p&gt;

&lt;p&gt;The hosting platform was rejecting the request before it ever reached my Node process, because the JSON body contained raw HTML.&lt;/p&gt;

&lt;p&gt;Here is the debugging trail, the root cause, and the 40-line fix.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fapvwx0wxquj7ope96gz4.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fapvwx0wxquj7ope96gz4.jpg" alt="Edge WAF blocks the POST before it reaches the Node app" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The edge WAF blocks the POST before the Node app ever sees it | Generated with Claude&lt;/p&gt;




&lt;h2&gt;
  
  
  The app and the endpoint that broke
&lt;/h2&gt;

&lt;p&gt;I am building &lt;a href="https://drippery.app" rel="noopener noreferrer"&gt;Drippery&lt;/a&gt;, a drip email tool for content creators. Users write emails in a rich text editor and the app saves the HTML to PostgreSQL.&lt;/p&gt;

&lt;p&gt;The stack is Next.js 16, Clerk auth, Drizzle ORM, PostgreSQL, hosted on Render.com.&lt;/p&gt;

&lt;p&gt;The bulk-save endpoint receives every change to an email series in a single POST. A typical payload looks like this.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sequence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"My Newsletter Series"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"updatedEmails"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"abc-123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"subject"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Welcome!"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"html"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;h1&amp;gt;Welcome to the series&amp;lt;/h1&amp;gt;&amp;lt;p&amp;gt;Here is what you will learn...&amp;lt;/p&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"textContent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Welcome to the series..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"dayOffset"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"emailOrder"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"abc-123"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing suspicious. Clean HTML, no script tags, no event handlers. Just formatted email content wrapped in a JSON string.&lt;/p&gt;

&lt;p&gt;Saving this payload should have been the most boring operation in the app. It wasn't.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Sidebar: debugging trails like this one live in my Claude Code sessions, and keeping that context between sessions is its own small problem. &lt;a href="https://drippery.app/subscribe/6e302973-cf5f-47ff-8803-22122ac3bd9d" rel="noopener noreferrer"&gt;The Claude Code Memory Starter&lt;/a&gt; is a free email series on how I solved it.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  A 403 that reproduced every time
&lt;/h2&gt;

&lt;p&gt;One specific series with five emails, about 11 KB of total HTML, failed on every save attempt. The browser console showed the same line three times in a row.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /api/series/c91e0a38-.../bulk-save 403 (Forbidden)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three autosave attempts in a row, every one a 403. The response body was the same every time. I had not yet bothered to open it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Searching for a 403 that does not exist
&lt;/h2&gt;

&lt;p&gt;My first instinct was to grep the codebase for the status code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"403"&lt;/span&gt; src/app/api/
&lt;span class="c"&gt;# (no matches)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bulk-save endpoint only returns 401 for unauthorized requests and 500 for unexpected errors. The 403 I was chasing was nowhere in my own code.&lt;/p&gt;

&lt;p&gt;I checked the auth middleware next. The app runs Clerk with &lt;code&gt;auth.protect()&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/middleware.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;clerkMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isPublicRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;protect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I dug into Clerk's source. On API routes &lt;code&gt;auth.protect()&lt;/code&gt; hides protected endpoints by returning a 404, not a 403. Clerk was not the source either.&lt;/p&gt;

&lt;p&gt;Next.js 16 ships CSRF protection for Server Actions, not for API route handlers, and none of the experimental error-handling flags that could short-circuit a request were enabled in my config. So I added debug logs everywhere.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// bulk-save/route.ts&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`[bulk-save] Request received for sequence &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tenant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getCurrentTenant&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`[bulk-save] Tenant &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; saving sequence &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The logs never appeared.&lt;/strong&gt; The request was never reaching my application code in the first place.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the response body actually said
&lt;/h2&gt;

&lt;p&gt;I finally opened the full response body of the 403 in DevTools. It was not JSON.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Your request was blocked by this site's
web application firewall (WAF).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The edge WAF in front of Render was intercepting the POST before it reached my app. The JSON body contained raw HTML — &lt;code&gt;&amp;lt;h1&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;a href="..."&amp;gt;&lt;/code&gt; — and the firewall's pattern matcher flagged it as a potential XSS injection.&lt;/p&gt;

&lt;p&gt;Legitimate email content wrapped in a JSON string field had the same shape as an attack payload, as far as the firewall was concerned.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkg812yt6f4x0oob36l56.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkg812yt6f4x0oob36l56.jpg" alt="DevTools Network tab showing a WAF block page where JSON was expected" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The 403 response body was the WAF block page, not JSON | Generated with Claude&lt;/p&gt;




&lt;h2&gt;
  
  
  Why this took two hours
&lt;/h2&gt;

&lt;p&gt;The 403 status code sent me the wrong way from the start. My reflex was to grep my own code first. When the code does not produce that status, I start doubting the auth library, then the framework, then anything in between. Infrastructure is the last layer I suspect.&lt;/p&gt;

&lt;p&gt;The missing logs reinforced the wrong theory. Because the WAF blocked the request before Node ran, there was nothing in the application log. No exception, no failed request entry. From the app's perspective the call simply did not exist.&lt;/p&gt;

&lt;p&gt;I spent an embarrassing amount of time inside Clerk's TypeScript definitions, looking for a branch that could throw a 403 instead of a 401. Then another half hour reading the Next.js 16 changelog for any new CSRF or rate-limit middleware I might have inherited from an upgrade. Both were dead ends, but they felt closer to the code, so they got my attention first.&lt;/p&gt;

&lt;p&gt;The minimal repro that finally pinned the blame on the edge was a pair of curl calls. The endpoint accepted a small body with no HTML. Strip the HTML, status 200. Drop a single raw &lt;code&gt;&amp;lt;h1&amp;gt;&lt;/code&gt; tag into the same body, status 403. That was the moment the WAF hypothesis stopped feeling speculative.&lt;/p&gt;

&lt;p&gt;And it was payload-dependent. Small emails saved fine. The WAF only blocked the request once the HTML content was large enough to match its rule set, so the failure looked like a content-specific bug in my own code, not a piece of infrastructure I had never thought about.&lt;/p&gt;




&lt;h2&gt;
  
  
  The fix: base64 in transit
&lt;/h2&gt;

&lt;p&gt;The shape of the fix is small. Base64-encode the HTML fields on the client, decode them on the server before writing to Postgres.&lt;/p&gt;

&lt;p&gt;The shared encoding helper has to be Unicode-safe, because plain &lt;code&gt;btoa()&lt;/code&gt; throws &lt;code&gt;InvalidCharacterError&lt;/code&gt; the moment a user pastes an accented character or an emoji.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/lib/html-encoding.ts&lt;/span&gt;

&lt;span class="cm"&gt;/** Encode a string to base64 (Unicode-safe) */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;encodeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;binary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;binary&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCharCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;btoa&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;binary&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cm"&gt;/** Decode a base64 string back to HTML (Unicode-safe) */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;decodeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;encoded&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;encoded&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;encoded&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;binary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;atob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;encoded&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;binary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;binary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;binary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextDecoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The TextEncoder dance is the only non-obvious part. It serializes the string into UTF-8 bytes that &lt;code&gt;btoa()&lt;/code&gt; can safely process, then reverses the same trip on the way back.&lt;/p&gt;

&lt;p&gt;I considered cheaper transformations before reaching for base64. HTML-escaping the angle brackets keeps the body human-readable but still leaves the dangerous substrings the WAF is matching on. URL-encoding shifts the bytes around without changing what the pattern matcher sees. Base64 is the smallest transformation that mangles the patterns enough for the rule set to ignore the body entirely.&lt;/p&gt;

&lt;p&gt;On the client, encode every HTML field before sending.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/series/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/bulk-save`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;newEmails&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;newEmails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;encodeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;encodeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;})),&lt;/span&gt;
    &lt;span class="na"&gt;updatedEmails&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;updatedEmails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;encodeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;encodeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;})),&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the server, decode before writing to the database.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;decodeHtml&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/html-encoding&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;created&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;emails&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;decodeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newEmail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;decodeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newEmail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;emails&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;decodeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;updated&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;decodeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;updated&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The WAF now sees &lt;code&gt;PGgxPkhlbGxvIFfDtnJsZCE8L2gxPg==&lt;/code&gt; instead of &lt;code&gt;&amp;lt;h1&amp;gt;Hello World!&amp;lt;/h1&amp;gt;&lt;/code&gt;. &lt;strong&gt;No HTML patterns in the body, no firewall trigger.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa6oydl7g5yonyn337pr8.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa6oydl7g5yonyn337pr8.jpg" alt="Same request before and after base64 encoding the HTML field" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Same request before and after base64 encoding the HTML field | Generated with Claude&lt;/p&gt;




&lt;h2&gt;
  
  
  Every other endpoint I had to patch
&lt;/h2&gt;

&lt;p&gt;Once I had the root cause I audited every endpoint that accepts HTML in a POST or PATCH body.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;code&gt;/api/series/[id]/bulk-save&lt;/code&gt; — POST, html + textContent&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;/api/series/[id]/emails&lt;/code&gt; — POST, html + textContent&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;/api/series/[id]/emails/[emailId]&lt;/code&gt; — PUT, html + textContent&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;/api/emails/send-test&lt;/code&gt; — POST, html + textContent&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;/api/tenant&lt;/code&gt; — PATCH, emailHeader + emailFooter&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of them needed the same encode-on-client, decode-on-server treatment. Bulk-save just happened to trip first because it sends the most HTML in a single request.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxtppokv63gk2qkrznc8v.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxtppokv63gk2qkrznc8v.jpg" alt="Five Drippery endpoints routed through one base64 codec helper" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Five HTML-accepting endpoints, all routed through one base64 helper | Generated with Claude&lt;/p&gt;

&lt;p&gt;This is the same fan-out audit I did when chasing memory leaks in &lt;a href="https://levelup.gitconnected.com/our-next-js-app-crashed-every-24-hours-here-are-the-six-memory-fixes-35245f6548e4?source=rss-7467f0f889eb------2" rel="noopener noreferrer"&gt;the Next.js crash story&lt;/a&gt;. Find the root cause, then assume the rest of the surface area has the same shape.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;p&gt;The two-line lesson is: &lt;strong&gt;read the full response body before searching your codebase&lt;/strong&gt;. If I had opened the 403 body in DevTools first, I would have seen the WAF page and solved this in minutes.&lt;/p&gt;

&lt;p&gt;The deeper lesson is to think in layers. When your application log is empty for a failed request, the request was intercepted upstream by a WAF or a reverse proxy. The absence of a log entry is itself a signal.&lt;/p&gt;

&lt;p&gt;A WAF does not understand context. It sees &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; in a POST body and blocks it. It does not know the HTML is a value inside a JSON string field that will be stored in a database and rendered in a completely different context. Base64 is a pragmatic workaround for that context mismatch.&lt;/p&gt;

&lt;p&gt;There is a real cost to the workaround. Base64 inflates the payload by about thirty-three percent, and the request body in any proxy log is now opaque, so a support ticket where a user reports broken HTML is harder to reproduce without first running the body through the decoder. I accept that. The alternative was a daily false-positive 403 in production, which was worse.&lt;/p&gt;

&lt;p&gt;Render does let you configure rules and disable categories, but turning the WAF off entirely removes protection against actual attacks. Base64 keeps the protection in place for everything else while routing legitimate HTML around the false positive.&lt;/p&gt;

&lt;p&gt;Custom WAF rules that allowlist specific paths are another option where the host supports them. Encoding is portable: it works regardless of who runs the firewall in front of your app.&lt;/p&gt;

&lt;p&gt;I push every change in Drippery straight to production, so I care about exactly which safety nets are in front of my code. The same logic I use in &lt;a href="https://medium.com/p/db51a8d7cc74" rel="noopener noreferrer"&gt;my pre-deploy script&lt;/a&gt; applies here. I want the safety nets. I just want to know which payloads they eat.&lt;/p&gt;




&lt;h2&gt;
  
  
  External Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://render.com/docs/ddos-protection" rel="noopener noreferrer"&gt;Render network protection&lt;/a&gt; — what sits in front of every Render service by default&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://owasp.org/www-community/attacks/xss/" rel="noopener noreferrer"&gt;OWASP XSS overview&lt;/a&gt; — why WAFs aggressively reject raw HTML in request bodies&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder" rel="noopener noreferrer"&gt;MDN TextEncoder&lt;/a&gt; — the Unicode-safe primitive used in the encode helper&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://developer.mozilla.org/en-US/docs/Glossary/Base64" rel="noopener noreferrer"&gt;MDN base64&lt;/a&gt; — why &lt;code&gt;btoa&lt;/code&gt; alone breaks on non-ASCII input&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  My SaaS endpoint returned 403 but my code never emits that status.&lt;/li&gt;
&lt;li&gt;  The full response body was an HTML page from the edge WAF, not JSON.&lt;/li&gt;
&lt;li&gt;  The WAF flagged raw HTML inside the JSON POST body as potential XSS.&lt;/li&gt;
&lt;li&gt;  The fix is base64-encoding HTML fields on the client and decoding them on the server.&lt;/li&gt;
&lt;li&gt;  Audit every endpoint with the same payload shape; they will all eventually trip.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;If you want more of this in your inbox, &lt;a href="https://drippery.app/subscribe/6e302973-cf5f-47ff-8803-22122ac3bd9d" rel="noopener noreferrer"&gt;The Claude Code Memory Starter&lt;/a&gt; is a free email series on keeping your tooling's context — and your own — between sessions.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build small tools and kits for solo creators. You can find them here: &lt;a href="https://danielrusnok.gumroad.com" rel="noopener noreferrer"&gt;https://danielrusnok.gumroad.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Don't Trust AI-Generated Code Without Testing It First</title>
      <dc:creator>Digital Craft Workshop</dc:creator>
      <pubDate>Wed, 02 Sep 2026 15:04:07 +0000</pubDate>
      <link>https://dev.to/danielrusnok/dont-trust-ai-generated-code-without-testing-it-first-49d3</link>
      <guid>https://dev.to/danielrusnok/dont-trust-ai-generated-code-without-testing-it-first-49d3</guid>
      <description>&lt;p&gt;I let an AI refactor a database query in my .NET project. The code looked clean. I glanced at it, nodded, committed, and pushed. Then QA pinged me with a stack trace.&lt;/p&gt;

&lt;p&gt;The error: &lt;code&gt;InvalidOperationException: Lambda expression used inside Include is not valid.&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The AI had used a filtered &lt;code&gt;.Include()&lt;/code&gt; with a &lt;code&gt;.Where()&lt;/code&gt; clause inside. That syntax shipped in Entity Framework Core 5.0. Our project runs on EF Core 3.1. The AI did not check the version, and on a busy afternoon I did not either.&lt;/p&gt;

&lt;p&gt;That moment, staring at the error in the QA environment and knowing I had rubber-stamped AI output without ever running the code, was one of the most embarrassing moments in my recent career. The bug was not complex. I had every tool to catch it and I let it through anyway.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Task Was Simple
&lt;/h2&gt;

&lt;p&gt;I was fixing an export feature for our Knowledge Base. The original query worked but had a suboptimal structure. It loaded data through &lt;code&gt;ArticleFeedbacks&lt;/code&gt; and joined articles on the side. I wanted to flip the approach: start from &lt;code&gt;ArticlesContents&lt;/code&gt;, include the related data, and filter feedbacks inline.&lt;/p&gt;

&lt;p&gt;The AI rewrote the query into a single LINQ expression with filtered includes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ArticlesContents&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ThenInclude&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Category&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ThenInclude&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ArticlesViewsSimple&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ArticleFeedbacks&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrEmpty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Source&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
            &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;dateFrom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HasValue&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreatedAt&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;dateFrom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
            &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;dateTo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HasValue&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreatedAt&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;dateToEndOfDay&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProjectId&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;projectId&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsDeleted&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsDeleted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OrderByDescending&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreatedAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToListAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The filtered &lt;code&gt;Include&lt;/code&gt; syntax, where you pass a &lt;code&gt;.Where()&lt;/code&gt; lambda inside &lt;code&gt;.Include()&lt;/code&gt;, was one of the headline features of EF Core 5.0. It shows up everywhere in modern .NET tutorials and Stack Overflow answers from the last few years.&lt;/p&gt;

&lt;p&gt;We are on EF Core 3.1. In 3.1, the only valid argument to &lt;code&gt;.Include()&lt;/code&gt; is a simple navigation property expression. No filtering. No logic. Just &lt;code&gt;a =&amp;gt; a.ArticleFeedbacks&lt;/code&gt;, and the query plan flows from there.&lt;/p&gt;




&lt;h2&gt;
  
  
  The AI Hallucinated a Newer Framework
&lt;/h2&gt;

&lt;p&gt;AI coding assistants are trained on a lot of code. They have seen millions of EF Core queries, most of them from newer versions. The filtered Include pattern is everywhere in modern .NET content, from official samples to weekend blog posts.&lt;/p&gt;

&lt;p&gt;The AI does not open my &lt;code&gt;.csproj&lt;/code&gt; file to read the package version. It does not know I have a project pinned to a legacy framework version. It generates what looks correct based on the most common patterns it has learned across the corpus, and most of that corpus assumes a newer EF Core than the one I actually ship against.&lt;/p&gt;

&lt;p&gt;The query would compile against a project a few framework versions ahead of mine. In mine, it crashed at runtime as soon as the export endpoint was hit.&lt;/p&gt;

&lt;p&gt;I have seen this pattern repeatedly with AI-generated code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  It uses APIs that exist in newer versions of your framework&lt;/li&gt;
&lt;li&gt;  It imports packages you do not have installed&lt;/li&gt;
&lt;li&gt;  It uses syntax from a language version your compiler does not support&lt;/li&gt;
&lt;li&gt;  It assumes configuration or infrastructure that does not exist in your environment&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The AI optimizes for code that matches the bulk of its training data. The bulk of that training data sits on newer framework versions than the legacy projects most working developers actually maintain. That mismatch shows up at runtime rather than at compile time, often inside the very request that hits the real database for the first time after the change.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcm3xgi39b3lqoffmk3vj.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcm3xgi39b3lqoffmk3vj.jpg" alt="AI training corpus skewed to EF Core 5-8 sending a version-blind suggestion into a project still on EF Core 3.1." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;How a version-blind suggestion lands in a legacy project. | Generated with Claude&lt;/p&gt;

&lt;p&gt;If you'd rather have the setup that keeps my AI assistant from repeating these mistakes, I put it into &lt;a href="https://drippery.app/subscribe/6e302973-cf5f-47ff-8803-22122ac3bd9d" rel="noopener noreferrer"&gt;The Claude Code Memory Starter&lt;/a&gt; — a free email series.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Real Problem: My Review Process
&lt;/h2&gt;

&lt;p&gt;The AI making a mistake is not where I want to focus. The story is that I did not catch it.&lt;/p&gt;

&lt;p&gt;I looked at the code. It made logical sense. The query structure was clean. The filtering was correct. I thought "yeah, that is better than what we had" and moved on.&lt;/p&gt;

&lt;p&gt;I did not:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  Run the application locally&lt;/li&gt;
&lt;li&gt;  Hit the export endpoint even once&lt;/li&gt;
&lt;li&gt;  Write or run a single test against it&lt;/li&gt;
&lt;li&gt;  Check if filtered Include was supported in our EF Core version&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I treated the AI output like code from a senior colleague I trust completely. The AI is no senior colleague. It generates plausible code without knowing my project constraints, and on a quiet afternoon it will hand me a snippet that compiles in a different version of the world.&lt;/p&gt;

&lt;p&gt;When QA pinged me, my first reaction was confusion. The query looked fine in the diff. I pulled the branch, ran the export endpoint locally, and got the same stack trace on the first request. The fix took less time than the conversation about the bug.&lt;/p&gt;

&lt;p&gt;Load all feedbacks via a plain &lt;code&gt;.Include(a =&amp;gt; a.ArticleFeedbacks)&lt;/code&gt; and filter them in memory after materialization:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ArticlesContents&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ThenInclude&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Category&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ThenInclude&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ArticlesViewsSimple&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ArticleFeedbacks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProjectId&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;projectId&lt;/span&gt;
        &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsDeleted&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsDeleted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OrderByDescending&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreatedAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToListAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ArticleFeedbacks&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ArticleFeedbacks&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrEmpty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Source&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
            &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;dateFrom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HasValue&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreatedAt&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;dateFrom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
            &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;dateTo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HasValue&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreatedAt&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;dateToEndOfDay&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToList&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 30-second fix for a bug that should never have shipped. Running the code once would have caught it, and one local hit on the export endpoint was the difference between a silent commit and a QA ticket.&lt;/p&gt;

&lt;p&gt;This is the same instinct I now put behind every git push. I wrote about wiring &lt;a href="https://medium.com/generative-ai/how-i-put-a-code-reviewer-in-front-of-every-git-push-to-main-with-a-claude-code-subagent-17408aa3236e" rel="noopener noreferrer"&gt;a code-reviewer subagent in front of every push to main&lt;/a&gt; for exactly this reason. The hook reads the diff before the push actually goes out and flags the kinds of things I would normally only notice during a slow PR review, which gives me a second pair of eyes on every commit even when I am working alone.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Embarrassment Factor
&lt;/h2&gt;

&lt;p&gt;Let me be honest about why this stung. I have been writing C# and working with Entity Framework for years. If a junior developer on my team had submitted this code in a pull request, I would have caught the filtered Include issue immediately and left a comment on the line.&lt;/p&gt;

&lt;p&gt;Because the AI generated it, I treated it differently. There is something about AI-generated code that short-circuits the normal review instinct. The structure is professional. The variable names are sane and the indentation is consistent, so the eye glides over it without ever landing on the lambda inside the Include.&lt;/p&gt;

&lt;p&gt;So I let my guard down.&lt;/p&gt;

&lt;p&gt;Then QA finds a crash, and I am sitting there explaining that I pushed code I never ran because "the AI wrote it and it looked fine."&lt;/p&gt;

&lt;p&gt;That is not a good look for anyone.&lt;/p&gt;

&lt;p&gt;It is also not the first time I have had to rethink how I work with AI. I went from &lt;a href="https://medium.com/@danielrusnok/stop-using-ai-as-a-search-engine-use-it-as-a-co-founder-0d0eeeae8857" rel="noopener noreferrer"&gt;treating AI like a faster Google&lt;/a&gt; to actually pairing with it on real changes. As my trust grew, the verification gap got bigger instead of smaller. The well-structured looking diffs are the ones I now have to remind myself to actually read, because the polish is what makes them feel safe to skim.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmtn9b47i51lpu19k7dem.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmtn9b47i51lpu19k7dem.jpg" alt="The same diff looking clean in my editor versus blowing up as an InvalidOperationException stack trace in QA." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The same diff in my editor and in QA. | Generated with Claude&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The AI-as-a-tool workflow behind posts like this is a short email series I run — &lt;a href="https://drippery.app/subscribe/a8ff09ab-6665-409f-a8e4-acb87e2f1a12" rel="noopener noreferrer"&gt;AI as a Solo Founder’s Tool&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Five Rules I Follow Now
&lt;/h2&gt;

&lt;p&gt;After this incident I changed how I work with AI coding assistants. These are not theoretical guidelines. They are rules I follow because I got burned in QA in front of the whole team and I would prefer not to repeat the experience.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule 1: Always run it.&lt;/strong&gt; No exceptions. If the AI changes a query, I hit that endpoint locally before committing. If it modifies a component, I open the browser and click through the happy path at least once. The minimum bar is that the code runs end-to-end on real data without throwing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule 2: Check version-sensitive APIs.&lt;/strong&gt; When the AI uses a method or pattern I am not certain about for my current framework version, I verify. A quick search for "EF Core 3.1 filtered include" would have saved me in 30 seconds. The same is true for anything tied to a package version, an SDK version, or a runtime that the AI cannot see from the prompt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule 3: Treat AI output like a PR from a new hire.&lt;/strong&gt; Smart, probably correct, but worth a second look. I read every line and question the assumptions baked into the diff. If I would push back on a teammate's PR for missing a test on a behavior change, I push back on the AI's output the same way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule 4: Don't let clean code fool you.&lt;/strong&gt; The most dangerous AI-generated bugs are in code that looks perfect. Syntax errors are easy to catch in review. Version incompatibilities in well-structured code slip through because the reviewer brain says "this looks like what a senior would write" and quietly stops working.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule 5: Own the code.&lt;/strong&gt; The moment I commit it, it is mine. "The AI wrote it" is not an excuse anyone wants to hear in a postmortem. I review the diff like I wrote it, because once it is in the repo, I did.&lt;/p&gt;

&lt;p&gt;I treat these like a seatbelt: boring, repeatable, applied even when I am tired and the laptop fan is loud. &lt;a href="https://medium.com/@danielrusnok/i-thought-code-comments-were-anti-patterns-then-ai-changed-my-mind-c1e1e1e6e4ff" rel="noopener noreferrer"&gt;My AI workflow also changed how I write comments&lt;/a&gt;, but these five rules are the ones that actually live in muscle memory now. I do not always remember them at the start of a task. I remember them at the moment my finger hovers over &lt;code&gt;git push&lt;/code&gt;, which turns out to be enough.&lt;/p&gt;




&lt;h2&gt;
  
  
  Faster Is Only Faster If It Ships Clean
&lt;/h2&gt;

&lt;p&gt;AI coding assistants are genuinely useful. I use them every day. They save time on the boilerplate I would otherwise type by hand, and they let me move through a sprint at a pace I could not sustain alongside a side project and a small daughter at home.&lt;/p&gt;

&lt;p&gt;Moving faster only counts if I am moving in the right direction. Shipping broken code faster is a liability dressed up as a productivity gain.&lt;/p&gt;

&lt;p&gt;The developer community is going through an awkward phase with AI tools. We are past the "wow, it can write code" excitement and not yet at the point where we have built reliable habits for verifying AI output. The tools are good enough to be dangerous, and the workflows around them have not fully caught up to that reality.&lt;/p&gt;

&lt;p&gt;Every minute I save by skipping a test of AI-generated code, I pay back with interest when something breaks at runtime, because the AI does not know my project the way I do.&lt;/p&gt;

&lt;p&gt;When I weigh the cost of running the code locally against the cost of a stack trace landing in QA the next morning, the local run is always cheaper. So I run it. Then I push.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;\→ Get it free: &lt;a href="https://drippery.app/subscribe/a8ff09ab-6665-409f-a8e4-acb87e2f1a12" rel="noopener noreferrer"&gt;AI as a Solo Founder’s Tool&lt;/a&gt;, a 5-part email series.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  External Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://learn.microsoft.com/en-us/ef/core/querying/related-data/eager" rel="noopener noreferrer"&gt;EF Core eager loading docs&lt;/a&gt; — official Microsoft docs on what is valid inside &lt;code&gt;.Include()&lt;/code&gt; per version.&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://devblogs.microsoft.com/dotnet/announcing-entity-framework-core-efcore-5-0/" rel="noopener noreferrer"&gt;EF Core 5.0 announcement&lt;/a&gt; — Microsoft blog post listing filtered &lt;code&gt;Include&lt;/code&gt; as a headline feature.&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://github.com/dotnet/efcore/issues/1833" rel="noopener noreferrer"&gt;dotnet/efcore #1833&lt;/a&gt; — the long-running GitHub issue that became filtered &lt;code&gt;Include&lt;/code&gt; in EF Core 5.&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://learn.microsoft.com/en-us/aspnet/core/test/integration-tests" rel="noopener noreferrer"&gt;ASP.NET Core integration tests&lt;/a&gt; — official guide to the kind of test that would have caught the bug in one run.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Daniel Rusnok is a solo builder shipping content, SaaS, and games.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you want that habit as a few short emails instead of a hard afternoon, &lt;a href="https://drippery.app/subscribe/6e302973-cf5f-47ff-8803-22122ac3bd9d" rel="noopener noreferrer"&gt;The Claude Code Memory Starter&lt;/a&gt; walks you through it — free.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build small tools and kits for solo creators. You can find them here: &lt;a href="https://danielrusnok.gumroad.com" rel="noopener noreferrer"&gt;https://danielrusnok.gumroad.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>What Upgrading to Next.js 16 Taught Me About Trusting Framework Defaults</title>
      <dc:creator>Digital Craft Workshop</dc:creator>
      <pubDate>Mon, 31 Aug 2026 15:05:54 +0000</pubDate>
      <link>https://dev.to/danielrusnok/what-upgrading-to-nextjs-16-taught-me-about-trusting-framework-defaults-n8</link>
      <guid>https://dev.to/danielrusnok/what-upgrading-to-nextjs-16-taught-me-about-trusting-framework-defaults-n8</guid>
      <description>&lt;p&gt;I used to think the cost of a framework upgrade was the stuff in the migration guide. The renamed APIs. The deprecated config. The list of things the maintainers tell you to change. You read it, you do the work, you move on.&lt;/p&gt;

&lt;p&gt;That is the visible tax. It is not the one that hurts.&lt;/p&gt;

&lt;p&gt;The tax that hurts is the convention that still compiles, still looks right, still matches the docs, and quietly does something different than it did in the last major version. No error at upgrade time. No warning in the console. Just a behavior that drifted out from under you while you were not looking. You find it weeks later, in production, when something that always worked stops working and nothing tells you why.&lt;/p&gt;

&lt;p&gt;I learned this the slow way when I moved to Next.js 16. One file convention I had trusted for years took down a page, returned a clean 200 status, and gave me almost nothing to debug from. &lt;strong&gt;The fix was five lines. Finding it cost an hour.&lt;/strong&gt; And the hour was the lesson, not the fix.&lt;/p&gt;




&lt;h2&gt;
  
  
  The convention I trusted
&lt;/h2&gt;

&lt;p&gt;Back in April I shipped a public blog at &lt;code&gt;drippery.app/blog&lt;/code&gt;. Brand-new Next.js 16 app, App Router, Turbopack (the bundler that replaced webpack as the Next 16 default), the works. Routes rendered fine in dev. &lt;code&gt;/blog&lt;/code&gt; listed posts. &lt;code&gt;/blog/[slug]&lt;/code&gt; rendered the article body, the breadcrumb JSON-LD, the FAQ schema, everything I expected.&lt;/p&gt;

&lt;p&gt;Then I added a file-based &lt;code&gt;opengraph-image.tsx&lt;/code&gt; next to the detail page, the way the docs say to, and the detail page stopped rendering.&lt;/p&gt;

&lt;p&gt;A quick recap for anyone who has not used the convention. In the App Router, &lt;code&gt;opengraph-image&lt;/code&gt; is one of the special file names that live inside a route segment. You drop a dynamic &lt;code&gt;opengraph-image.tsx&lt;/code&gt; next to a &lt;code&gt;page.tsx&lt;/code&gt;, export a default function that returns an &lt;code&gt;ImageResponse&lt;/code&gt; from &lt;code&gt;next/og&lt;/code&gt;, and Next.js evaluates it and injects the matching &lt;code&gt;og:image&lt;/code&gt; meta tags into the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; of every page in that segment. For a blog with dynamic slugs, each post gets its own social card, generated from the post title, with zero manual wiring.&lt;/p&gt;

&lt;p&gt;The payoff is link previews. A page without a valid &lt;code&gt;og:image&lt;/code&gt; shows up as a bare text link when shared, and I have written before about &lt;a href="https://levelup.gitconnected.com/one-missing-html-tag-is-killing-your-social-media-traffic-dcdf25e2b012" rel="noopener noreferrer"&gt;how much traffic one missing HTML tag can cost you&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;So the plan was simple. Add the file, get per-post social cards. That is the workflow the convention promises, and on Next.js 14 it had worked for me exactly that way. &lt;strong&gt;I trusted it because it had earned the trust.&lt;/strong&gt; That is the trap. The conventions you trust most are the ones that bite hardest, because you stop suspecting them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7ju9ykduyj4jz76iluoq.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7ju9ykduyj4jz76iluoq.jpg" alt="Next.js file convention: what the docs promise versus what Next 16 delivered" width="800" height="347"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What "broke" actually looked like
&lt;/h2&gt;

&lt;p&gt;The page did not 500. It returned a 200, but the React tree was missing. The HTML shell came back, the body content did not. In the server logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TypeError: Cannot read properties of undefined (reading 'default')
at /app/(marketing)/blog/[slug]/page
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The error fired during metadata resolution, not during the page render itself. Turbopack was trying to import the &lt;code&gt;opengraph-image&lt;/code&gt; module to compute the &lt;code&gt;&amp;lt;meta&amp;gt;&lt;/code&gt; tag, and the dynamic import returned &lt;code&gt;undefined&lt;/code&gt;. That crashed the whole metadata pipeline, which then silently bailed out of rendering the page.&lt;/p&gt;

&lt;p&gt;Metadata resolution runs as part of serving the page, which is why the symptom is so confusing. When it throws, the page body never renders, even though your page component is perfectly healthy. The file I added for link previews was the thing breaking the page.&lt;/p&gt;

&lt;p&gt;The stack trace makes it worse. It points at the page segment, because the import happens inside the framework's metadata machinery for that segment. Nothing in it names &lt;code&gt;opengraph-image.tsx&lt;/code&gt;, which is a big part of why I spent the next hour blaming everything except the new file.&lt;/p&gt;

&lt;p&gt;Removing the file made everything work again. Adding it back, even with a hello-world export, broke it again. My image code never ran far enough to matter.&lt;/p&gt;

&lt;p&gt;This is what a silent breaking change feels like from the inside. &lt;strong&gt;The framework did not tell me the convention changed. The page just lied to me with a 200.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Why the upgrade guide will never catch this
&lt;/h2&gt;

&lt;p&gt;Here is the uncomfortable part. There was nothing to read.&lt;/p&gt;

&lt;p&gt;A migration guide can document what the maintainers changed on purpose. It cannot document the interaction between a new default bundler, a file convention, and &lt;code&gt;generateMetadata&lt;/code&gt; that nobody set out to change and nobody noticed shifted. The Next 16 release notes told me Turbopack was now the default. They did not tell me that a convention I had used since Next 14 would behave differently underneath it, because as far as anyone knew, it would not.&lt;/p&gt;

&lt;p&gt;That is the shape of the real upgrade tax. It is not in the changelog. It lives in the gap between what the framework says it does and what it actually does on your specific combination of features. The bigger the framework, the more conventions it ships, the more of those gaps exist. Every convention is a promise the framework makes on your behalf, and a major version is the moment those promises quietly get renegotiated without your signature.&lt;/p&gt;

&lt;p&gt;I am not anti-convention. File-based routing, colocated metadata, auto-discovered images — these save real time and I use them every day. But I had been treating them as guarantees, and they are not guarantees. They are conveniences with an implicit asterisk: &lt;em&gt;this works until a default underneath it changes.&lt;/em&gt; The upgrade is exactly when that asterisk comes due.&lt;/p&gt;




&lt;h2&gt;
  
  
  How I narrowed it down
&lt;/h2&gt;

&lt;p&gt;I want to spell out the debugging sequence, because the 200 status code sent me in the wrong direction for a while, and the sequence is reusable on any "silent breakage" problem, not just this one.&lt;/p&gt;

&lt;p&gt;My first suspicion was the data layer. The detail page pulls the post from the database by slug, and a missing post would explain an empty page. But the post was there, and the listing page that uses the same query rendered fine. Second suspicion was the structured data. I had added breadcrumb JSON-LD and an FAQ schema to the same page recently, and a malformed script tag can do strange things to hydration. Stripping those out changed nothing.&lt;/p&gt;

&lt;p&gt;The browser console was clean the entire time. That is the trap: every signal on the client side says the page is fine, and only the server log contains the actual error. I have been burned by this exact shape before. When &lt;a href="https://levelup.gitconnected.com/our-next-js-app-crashed-every-24-hours-here-are-the-six-memory-fixes-35245f6548e4" rel="noopener noreferrer"&gt;our Next.js app crashed every 24 hours&lt;/a&gt;, the visible symptom also pointed nowhere near the cause, and the server-side evidence was the only trail worth following. Same when my &lt;a href="https://levelup.gitconnected.com/how-to-make-chrome-extension-iframes-transparent-on-dark-mode-sites-css-color-scheme-fix-09e6e8162ba3" rel="noopener noreferrer"&gt;extension iframes painted solid white over dark-mode sites&lt;/a&gt; — the culprit was a CSS color-scheme default that nothing in the symptom hinted at.&lt;/p&gt;

&lt;p&gt;Once I read the server log carefully, the stack trace mentioned the page segment, and the only recent change in that segment besides the JSON-LD was the new image file. From there it was a binary search with one variable. Delete &lt;code&gt;opengraph-image.tsx&lt;/code&gt;, restart dev, page renders. Recreate it with the simplest possible content — a div with static text inside an &lt;code&gt;ImageResponse&lt;/code&gt; — page breaks. At that point there was nothing of mine left in the file to blame.&lt;/p&gt;

&lt;p&gt;That test is the one I want you to take away. &lt;strong&gt;If a hello-world version of the file reproduces the failure, the failure happens before your code runs.&lt;/strong&gt; Stop auditing your own logic and start suspecting the convention. That single move is what turns an hour of flailing into a five-minute diagnosis the next time a default betrays you.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3ccuawzztry2131vujem.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3ccuawzztry2131vujem.jpg" alt="Binary-search debugging timeline ruling out the data layer, JSON-LD and the browser console" width="800" height="454"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;If build notes like this are your thing, &lt;a href="https://drippery.app/subscribe/6e302973-cf5f-47ff-8803-22122ac3bd9d" rel="noopener noreferrer"&gt;The Claude Code Memory Starter&lt;/a&gt; is a short free email series where I unpack the setup behind them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: stop trusting the convention, wire it yourself
&lt;/h2&gt;

&lt;p&gt;The lesson translated into a concrete decision. I gave up on the file-based convention for this segment and built a regular route handler instead — the kind of explicit code the framework cannot quietly change underneath me:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/og/blog/[slug]/route.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ImageResponse&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next/og&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getPostBySlug&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/blog&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;runtime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;edge&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;_req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getPostBySlug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Not found&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ImageResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        drippery.app
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;630&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the same &lt;code&gt;ImageResponse&lt;/code&gt; API the convention uses under the hood, the same JSX, the same edge runtime. The file just lives under &lt;code&gt;app/api&lt;/code&gt; as a normal route handler, with nothing implicit about it.&lt;/p&gt;

&lt;p&gt;Two details trip people up. &lt;code&gt;params&lt;/code&gt; has been a Promise since Next 15 (the sync fallback is gone in 16), so await it before reading the slug. And the file needs the &lt;code&gt;.tsx&lt;/code&gt; extension because of the JSX. Both are easy to miss when you copy a route handler template from an older project.&lt;/p&gt;

&lt;p&gt;The route by itself does nothing for your meta tags. You wire it up in the page's &lt;code&gt;generateMetadata&lt;/code&gt;, in the &lt;code&gt;openGraph.images&lt;/code&gt; field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;seoTitle&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metaDescription&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;openGraph&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;images&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`/api/og/blog/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;630&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The relative URL gets resolved against your &lt;code&gt;metadataBase&lt;/code&gt;, the base URL you set once in the root layout metadata, so the rendered tag carries the full absolute address social crawlers need. The only conceptual difference from the convention is that nothing is auto-discovered. I am wiring the URL myself.&lt;/p&gt;

&lt;p&gt;There is a real trade-off in that sentence, so let me be fair to the convention. With the file-based approach, the framework keeps the image URL, the &lt;code&gt;alt&lt;/code&gt; text, the dimensions, and the content type in sync for you. With the explicit route, those live in two places: the route handler defines the image, and &lt;code&gt;generateMetadata&lt;/code&gt; declares it. Rename the route and forget the metadata object, and your &lt;code&gt;og:image&lt;/code&gt; points at a 404 with no error to warn you.&lt;/p&gt;

&lt;p&gt;I accept that maintenance cost on purpose. &lt;strong&gt;The explicit version fails as a broken preview card. The convention failed as a broken page.&lt;/strong&gt; Those are not the same severity, and I will trade a little duplication for a failure mode I can actually see.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq6uu0lxkhvd7o1bqqoww.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq6uu0lxkhvd7o1bqqoww.jpg" alt="Request flow forking into the broken auto-discovered path and the explicit API route" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Why I think it happens (and why the cause is not the point)
&lt;/h2&gt;

&lt;p&gt;This is a working theory. I do not have a confirmed root cause, and I want to be honest about that.&lt;/p&gt;

&lt;p&gt;My theory is that Turbopack's dynamic import for the file-based metadata convention is not resolved correctly in the module graph when the route segment also exports &lt;code&gt;generateMetadata&lt;/code&gt;. The docs say you can use both together, and in Next.js 14 with webpack I did. In this Next.js 16 project, the auto-imported &lt;code&gt;opengraph-image&lt;/code&gt; module ends up as &lt;code&gt;{ default: undefined }&lt;/code&gt; at the moment metadata is resolved, and the framework calls into the missing default export and throws.&lt;/p&gt;

&lt;p&gt;The error string has history. The Next.js issue tracker has &lt;a href="https://github.com/vercel/next.js/issues/42402" rel="noopener noreferrer"&gt;a closed Next 13 issue&lt;/a&gt; with the same TypeError, thrown from React's module-resolution layer, &lt;code&gt;resolveModuleMetaData&lt;/code&gt;. The trigger back then was client component navigation, not an image file, but the shape matches: the metadata system imports a module, the module comes back undefined, the page fails to render. That pattern made me more comfortable blaming the resolution machinery than my own code.&lt;/p&gt;

&lt;p&gt;I also did not isolate everything. I did not retest the segment with webpack instead of Turbopack, and I did not test the convention in a segment without &lt;code&gt;generateMetadata&lt;/code&gt;. If the combination matters the way I think it does, one of those two changes would make the file convention work again. For my project, the API route was cheaper than the experiment.&lt;/p&gt;

&lt;p&gt;And honestly, the precise cause is not the point. &lt;strong&gt;A convention I trusted broke in a way no upgrade guide warned me about, and the only durable defense was to stop trusting it for this segment.&lt;/strong&gt; Whether the bug is in Turbopack's module graph or in some interaction three layers down, the engineering response is the same: make the implicit thing explicit and move on.&lt;/p&gt;




&lt;h2&gt;
  
  
  How to not get burned on the next upgrade
&lt;/h2&gt;

&lt;p&gt;I cannot give you a checklist that catches every silent convention change, because by definition you do not know which convention drifted until something breaks. But the upgrade taught me a handful of habits that turn a lost hour into a quick diagnosis, and they generalize past Next.js.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Suspect the new default first, not last.&lt;/strong&gt; When a major version ships a new bundler, runtime, or compiler default, treat every "magic" convention layered on top of it as unverified until you have seen it work in your project. The thing the release notes brag about is exactly the thing most likely to have shifted behavior underneath your conventions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reproduce with a hello-world before you debug your own code.&lt;/strong&gt; If replacing your logic with the simplest possible version still fails, the failure is upstream of you. This one move would have saved me most of the hour. It is the fastest way to tell "my bug" from "the framework's bug."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read the server log, not the browser.&lt;/strong&gt; Anything that fails during metadata resolution, server rendering, or the build will leave its real evidence server-side while the client reports a clean 200. If the symptom is on the client but the console is empty, your trail is in the server output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prefer explicit wiring at the boundaries that matter.&lt;/strong&gt; I treat file conventions as optional sugar now, not guarantees. For anything load-bearing — SEO tags, social cards, anything a silent failure would cost me real traffic on — I write the explicit route and wire it by hand. Five extra lines buys a failure mode I can see, and a thing the next major version cannot quietly renegotiate.&lt;/p&gt;

&lt;p&gt;The migration guide tells you what changed on purpose. These habits are how you survive what changed by accident. The first kind of upgrade tax you pay once, at the top of the changelog. The second kind you pay every time you trust a default a little more than it deserves.&lt;/p&gt;




&lt;p&gt;If you want a few more of these in your inbox, &lt;a href="https://drippery.app/subscribe/6e302973-cf5f-47ff-8803-22122ac3bd9d" rel="noopener noreferrer"&gt;The Claude Code Memory Starter&lt;/a&gt; walks through one of my setups over a handful of short emails — free.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build small tools and kits for solo creators. You can find them here: &lt;a href="https://danielrusnok.gumroad.com" rel="noopener noreferrer"&gt;https://danielrusnok.gumroad.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>How I Wired Persistent Memory Into Every Claude Code Session</title>
      <dc:creator>Digital Craft Workshop</dc:creator>
      <pubDate>Fri, 24 Jul 2026 15:06:27 +0000</pubDate>
      <link>https://dev.to/danielrusnok/how-i-wired-persistent-memory-into-every-claude-code-session-4b1h</link>
      <guid>https://dev.to/danielrusnok/how-i-wired-persistent-memory-into-every-claude-code-session-4b1h</guid>
      <description>&lt;p&gt;&lt;em&gt;Not&amp;nbsp;a&amp;nbsp;member?&amp;nbsp;&lt;a href="https://medium.com/@danielrusnok/membership" rel="noopener noreferrer"&gt;Use&amp;nbsp;this&amp;nbsp;link.&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A&amp;nbsp;memory&amp;nbsp;you&amp;nbsp;have&amp;nbsp;to&amp;nbsp;remember&amp;nbsp;to&amp;nbsp;use&amp;nbsp;is&amp;nbsp;not&amp;nbsp;memory.&amp;nbsp;It&amp;nbsp;is&amp;nbsp;a&amp;nbsp;database&amp;nbsp;with&amp;nbsp;a&amp;nbsp;search&amp;nbsp;bar.&lt;/p&gt;

&lt;p&gt;That&amp;nbsp;was&amp;nbsp;the&amp;nbsp;gap.&amp;nbsp;After&amp;nbsp;eight&amp;nbsp;hours&amp;nbsp;of&amp;nbsp;a&amp;nbsp;day&amp;nbsp;job,&amp;nbsp;on&amp;nbsp;the&amp;nbsp;evening&amp;nbsp;shift&amp;nbsp;where&amp;nbsp;I&amp;nbsp;do&amp;nbsp;my&amp;nbsp;own&amp;nbsp;building,&amp;nbsp;I&amp;nbsp;never&amp;nbsp;remembered&amp;nbsp;the&amp;nbsp;search&amp;nbsp;bar&amp;nbsp;was&amp;nbsp;there.&amp;nbsp;I&amp;nbsp;gave&amp;nbsp;Claude&amp;nbsp;Code&amp;nbsp;that&amp;nbsp;memory&amp;nbsp;last&amp;nbsp;month:&amp;nbsp;a&amp;nbsp;&lt;code&gt;mem0_search&lt;/code&gt;&amp;nbsp;tool.&amp;nbsp;Then&amp;nbsp;I&amp;nbsp;spent&amp;nbsp;a&amp;nbsp;month&amp;nbsp;barely&amp;nbsp;using&amp;nbsp;it,&amp;nbsp;because&amp;nbsp;using&amp;nbsp;it&amp;nbsp;meant&amp;nbsp;remembering&amp;nbsp;it&amp;nbsp;existed.&lt;/p&gt;

&lt;p&gt;So&amp;nbsp;I&amp;nbsp;spent&amp;nbsp;one&amp;nbsp;evening&amp;nbsp;wiring&amp;nbsp;it&amp;nbsp;into&amp;nbsp;the&amp;nbsp;session&amp;nbsp;lifecycle&amp;nbsp;itself.&amp;nbsp;Now&amp;nbsp;recall&amp;nbsp;happens&amp;nbsp;when&amp;nbsp;a&amp;nbsp;session&amp;nbsp;opens&amp;nbsp;and&amp;nbsp;capture&amp;nbsp;happens&amp;nbsp;when&amp;nbsp;it&amp;nbsp;closes&amp;nbsp;—&amp;nbsp;both&amp;nbsp;without&amp;nbsp;me.&amp;nbsp;Claude&amp;nbsp;walks&amp;nbsp;into&amp;nbsp;every&amp;nbsp;session&amp;nbsp;already&amp;nbsp;knowing&amp;nbsp;what&amp;nbsp;we&amp;nbsp;decided&amp;nbsp;last&amp;nbsp;week,&amp;nbsp;and&amp;nbsp;closes&amp;nbsp;it&amp;nbsp;having&amp;nbsp;written&amp;nbsp;down&amp;nbsp;what&amp;nbsp;we&amp;nbsp;decided&amp;nbsp;today.&lt;/p&gt;

&lt;p&gt;I&amp;nbsp;have&amp;nbsp;not&amp;nbsp;typed&amp;nbsp;"search&amp;nbsp;your&amp;nbsp;memory"&amp;nbsp;in&amp;nbsp;a&amp;nbsp;month.&lt;/p&gt;

&lt;p&gt;Three&amp;nbsp;hooks&amp;nbsp;do&amp;nbsp;it.&amp;nbsp;Here&amp;nbsp;is&amp;nbsp;the&amp;nbsp;wiring.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TLDR:&lt;/strong&gt;&amp;nbsp;A&amp;nbsp;Claude&amp;nbsp;Code&amp;nbsp;shell-command&amp;nbsp;hook&amp;nbsp;cannot&amp;nbsp;call&amp;nbsp;an&amp;nbsp;MCP&amp;nbsp;tool.&amp;nbsp;It&amp;nbsp;can&amp;nbsp;only&amp;nbsp;inject&amp;nbsp;text.&amp;nbsp;So&amp;nbsp;my&amp;nbsp;SessionStart&amp;nbsp;hook&amp;nbsp;does&amp;nbsp;not&amp;nbsp;&lt;em&gt;do&lt;/em&gt;&amp;nbsp;the&amp;nbsp;recall&amp;nbsp;—&amp;nbsp;it&amp;nbsp;injects&amp;nbsp;an&amp;nbsp;instruction&amp;nbsp;telling&amp;nbsp;Claude&amp;nbsp;to&amp;nbsp;do&amp;nbsp;it,&amp;nbsp;with&amp;nbsp;the&amp;nbsp;tools&amp;nbsp;Claude&amp;nbsp;already&amp;nbsp;has.&amp;nbsp;SessionEnd&amp;nbsp;spawns&amp;nbsp;a&amp;nbsp;detached&amp;nbsp;Python&amp;nbsp;process&amp;nbsp;that&amp;nbsp;extracts&amp;nbsp;the&amp;nbsp;session's&amp;nbsp;decisions&amp;nbsp;with&amp;nbsp;Gemini&amp;nbsp;and&amp;nbsp;writes&amp;nbsp;them&amp;nbsp;to&amp;nbsp;Pinecone,&amp;nbsp;after&amp;nbsp;my&amp;nbsp;terminal&amp;nbsp;is&amp;nbsp;already&amp;nbsp;closed.&amp;nbsp;Three&amp;nbsp;scripts,&amp;nbsp;around&amp;nbsp;250&amp;nbsp;lines,&amp;nbsp;zero&amp;nbsp;ongoing&amp;nbsp;effort.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The&amp;nbsp;realization:&amp;nbsp;hooks&amp;nbsp;inject,&amp;nbsp;they&amp;nbsp;don't&amp;nbsp;execute
&lt;/h2&gt;

&lt;p&gt;The&amp;nbsp;first&amp;nbsp;thing&amp;nbsp;I&amp;nbsp;got&amp;nbsp;wrong&amp;nbsp;was&amp;nbsp;assuming&amp;nbsp;a&amp;nbsp;hook&amp;nbsp;could&amp;nbsp;run&amp;nbsp;&lt;code&gt;mem0_search&lt;/code&gt;&amp;nbsp;itself&amp;nbsp;and&amp;nbsp;paste&amp;nbsp;the&amp;nbsp;result&amp;nbsp;into&amp;nbsp;context.&lt;/p&gt;

&lt;p&gt;It&amp;nbsp;can't&amp;nbsp;—&amp;nbsp;not&amp;nbsp;the&amp;nbsp;kind&amp;nbsp;of&amp;nbsp;hook&amp;nbsp;I&amp;nbsp;am&amp;nbsp;using.&amp;nbsp;A&amp;nbsp;hook&amp;nbsp;that&amp;nbsp;is&amp;nbsp;a&amp;nbsp;shell&amp;nbsp;command,&amp;nbsp;which&amp;nbsp;is&amp;nbsp;what&amp;nbsp;all&amp;nbsp;three&amp;nbsp;of&amp;nbsp;mine&amp;nbsp;are,&amp;nbsp;cannot&amp;nbsp;call&amp;nbsp;an&amp;nbsp;MCP&amp;nbsp;tool.&amp;nbsp;It&amp;nbsp;has&amp;nbsp;no&amp;nbsp;MCP&amp;nbsp;client.&amp;nbsp;The&amp;nbsp;only&amp;nbsp;thing&amp;nbsp;it&amp;nbsp;can&amp;nbsp;hand&amp;nbsp;back&amp;nbsp;is&amp;nbsp;text:&amp;nbsp;a&amp;nbsp;small&amp;nbsp;JSON&amp;nbsp;object&amp;nbsp;with&amp;nbsp;an&amp;nbsp;&lt;code&gt;additionalContext&lt;/code&gt;&amp;nbsp;field.&lt;/p&gt;

&lt;p&gt;(Newer&amp;nbsp;Claude&amp;nbsp;Code&amp;nbsp;does&amp;nbsp;have&amp;nbsp;an&amp;nbsp;&lt;code&gt;mcp_tool&lt;/code&gt;&amp;nbsp;hook&amp;nbsp;type&amp;nbsp;that&amp;nbsp;calls&amp;nbsp;a&amp;nbsp;tool&amp;nbsp;directly.&amp;nbsp;But&amp;nbsp;a&amp;nbsp;blind,&amp;nbsp;unconditional&amp;nbsp;tool&amp;nbsp;call&amp;nbsp;is&amp;nbsp;not&amp;nbsp;what&amp;nbsp;I&amp;nbsp;want&amp;nbsp;here&amp;nbsp;—&amp;nbsp;I&amp;nbsp;want&amp;nbsp;Claude&amp;nbsp;to&amp;nbsp;decide&amp;nbsp;whether&amp;nbsp;and&amp;nbsp;how&amp;nbsp;to&amp;nbsp;recall,&amp;nbsp;with&amp;nbsp;judgment.&amp;nbsp;A&amp;nbsp;command&amp;nbsp;hook&amp;nbsp;that&amp;nbsp;injects&amp;nbsp;an&amp;nbsp;instruction&amp;nbsp;gives&amp;nbsp;me&amp;nbsp;exactly&amp;nbsp;that.)&lt;/p&gt;

&lt;p&gt;That&amp;nbsp;sounds&amp;nbsp;like&amp;nbsp;a&amp;nbsp;wall.&amp;nbsp;It&amp;nbsp;is&amp;nbsp;actually&amp;nbsp;the&amp;nbsp;design.&lt;/p&gt;

&lt;p&gt;A&amp;nbsp;shell-command&amp;nbsp;hook&amp;nbsp;does&amp;nbsp;not&amp;nbsp;perform&amp;nbsp;the&amp;nbsp;recall.&amp;nbsp;It&amp;nbsp;writes&amp;nbsp;a&amp;nbsp;prompt.&amp;nbsp;It&amp;nbsp;injects&amp;nbsp;an&amp;nbsp;instruction&amp;nbsp;—&amp;nbsp;"before&amp;nbsp;you&amp;nbsp;answer,&amp;nbsp;search&amp;nbsp;your&amp;nbsp;memory&amp;nbsp;for&amp;nbsp;X"&amp;nbsp;—&amp;nbsp;and&amp;nbsp;Claude,&amp;nbsp;seeing&amp;nbsp;that&amp;nbsp;instruction&amp;nbsp;sitting&amp;nbsp;in&amp;nbsp;context,&amp;nbsp;runs&amp;nbsp;the&amp;nbsp;search&amp;nbsp;with&amp;nbsp;the&amp;nbsp;&lt;code&gt;mem0_search&lt;/code&gt;&amp;nbsp;tool&amp;nbsp;it&amp;nbsp;already&amp;nbsp;has.&amp;nbsp;&lt;strong&gt;The&amp;nbsp;hook&amp;nbsp;is&amp;nbsp;a&amp;nbsp;prompt-writer.&amp;nbsp;Claude&amp;nbsp;is&amp;nbsp;still&amp;nbsp;the&amp;nbsp;one&amp;nbsp;doing&amp;nbsp;the&amp;nbsp;work,&amp;nbsp;with&amp;nbsp;full&amp;nbsp;judgment&amp;nbsp;about&amp;nbsp;how&amp;nbsp;to&amp;nbsp;do&amp;nbsp;it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Once&amp;nbsp;that&amp;nbsp;clicked,&amp;nbsp;all&amp;nbsp;three&amp;nbsp;hooks&amp;nbsp;got&amp;nbsp;simple.&amp;nbsp;Two&amp;nbsp;of&amp;nbsp;them&amp;nbsp;write&amp;nbsp;recall&amp;nbsp;instructions.&amp;nbsp;One&amp;nbsp;of&amp;nbsp;them&amp;nbsp;captures.&amp;nbsp;None&amp;nbsp;of&amp;nbsp;them&amp;nbsp;touch&amp;nbsp;mem0&amp;nbsp;directly&amp;nbsp;except&amp;nbsp;the&amp;nbsp;last&amp;nbsp;—&amp;nbsp;and&amp;nbsp;even&amp;nbsp;that&amp;nbsp;one&amp;nbsp;runs&amp;nbsp;outside&amp;nbsp;the&amp;nbsp;session&amp;nbsp;entirely.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fizf4izaiipuyiubio5j0.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fizf4izaiipuyiubio5j0.jpg" alt="A hook that runs a command hands Claude a static fact; a hook that injects an instruction hands Claude a task" width="800" height="347"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Hook&amp;nbsp;1:&amp;nbsp;recall&amp;nbsp;the&amp;nbsp;moment&amp;nbsp;a&amp;nbsp;session&amp;nbsp;opens
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;SessionStart&lt;/code&gt;&amp;nbsp;fires&amp;nbsp;when&amp;nbsp;I&amp;nbsp;open&amp;nbsp;Claude&amp;nbsp;Code.&amp;nbsp;My&amp;nbsp;hook&amp;nbsp;here&amp;nbsp;is&amp;nbsp;a&amp;nbsp;bash&amp;nbsp;script&amp;nbsp;that&amp;nbsp;does&amp;nbsp;one&amp;nbsp;cheap&amp;nbsp;thing:&amp;nbsp;look&amp;nbsp;at&amp;nbsp;the&amp;nbsp;current&amp;nbsp;directory,&amp;nbsp;and&amp;nbsp;if&amp;nbsp;it&amp;nbsp;is&amp;nbsp;one&amp;nbsp;of&amp;nbsp;my&amp;nbsp;projects,&amp;nbsp;inject&amp;nbsp;a&amp;nbsp;recall&amp;nbsp;instruction&amp;nbsp;scoped&amp;nbsp;to&amp;nbsp;that&amp;nbsp;project.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;BASENAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;basename&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CWD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="c"&gt;# ...match BASENAME against my known project slugs, set $SLUG...&lt;/span&gt;

&lt;span class="nv"&gt;MSG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"[session-context] cwd is project '&lt;/span&gt;&lt;span class="nv"&gt;$SLUG&lt;/span&gt;&lt;span class="s2"&gt;'. Before answering the
first prompt, call mem0_search with query=&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$SLUG&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; for past
decisions, preferences, and in-flight work."&lt;/span&gt;

jq &lt;span class="nt"&gt;-nc&lt;/span&gt; &lt;span class="nt"&gt;--arg&lt;/span&gt; msg &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MSG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'{hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: $msg}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That&amp;nbsp;is&amp;nbsp;the&amp;nbsp;whole&amp;nbsp;mechanism.&amp;nbsp;The&amp;nbsp;JSON&amp;nbsp;goes&amp;nbsp;back&amp;nbsp;to&amp;nbsp;Claude&amp;nbsp;Code,&amp;nbsp;the&amp;nbsp;&lt;code&gt;additionalContext&lt;/code&gt;&amp;nbsp;string&amp;nbsp;lands&amp;nbsp;in&amp;nbsp;the&amp;nbsp;session&amp;nbsp;as&amp;nbsp;if&amp;nbsp;it&amp;nbsp;had&amp;nbsp;always&amp;nbsp;been&amp;nbsp;there,&amp;nbsp;and&amp;nbsp;because&amp;nbsp;the&amp;nbsp;instruction&amp;nbsp;is&amp;nbsp;explicit&amp;nbsp;and&amp;nbsp;imperative,&amp;nbsp;Claude&amp;nbsp;runs&amp;nbsp;the&amp;nbsp;search&amp;nbsp;before&amp;nbsp;it&amp;nbsp;answers&amp;nbsp;my&amp;nbsp;first&amp;nbsp;message.&amp;nbsp;By&amp;nbsp;the&amp;nbsp;time&amp;nbsp;I&amp;nbsp;have&amp;nbsp;typed&amp;nbsp;anything,&amp;nbsp;the&amp;nbsp;recall&amp;nbsp;results&amp;nbsp;are&amp;nbsp;already&amp;nbsp;in&amp;nbsp;context.&lt;/p&gt;

&lt;p&gt;The&amp;nbsp;scoping&amp;nbsp;is&amp;nbsp;deterministic&amp;nbsp;on&amp;nbsp;purpose.&amp;nbsp;The&amp;nbsp;script&amp;nbsp;matches&amp;nbsp;the&amp;nbsp;folder&amp;nbsp;name&amp;nbsp;against&amp;nbsp;a&amp;nbsp;hard-coded&amp;nbsp;list&amp;nbsp;of&amp;nbsp;my&amp;nbsp;project&amp;nbsp;slugs&amp;nbsp;—&amp;nbsp;no&amp;nbsp;LLM&amp;nbsp;call&amp;nbsp;inside&amp;nbsp;the&amp;nbsp;hook,&amp;nbsp;no&amp;nbsp;latency,&amp;nbsp;no&amp;nbsp;cost.&amp;nbsp;In&amp;nbsp;a&amp;nbsp;project&amp;nbsp;folder&amp;nbsp;it&amp;nbsp;recalls&amp;nbsp;that&amp;nbsp;project.&amp;nbsp;In&amp;nbsp;a&amp;nbsp;random&amp;nbsp;folder&amp;nbsp;it&amp;nbsp;falls&amp;nbsp;back&amp;nbsp;to&amp;nbsp;a&amp;nbsp;generic&amp;nbsp;recall&amp;nbsp;keyed&amp;nbsp;on&amp;nbsp;the&amp;nbsp;folder&amp;nbsp;name.&amp;nbsp;I&amp;nbsp;gave&amp;nbsp;the&amp;nbsp;hook&amp;nbsp;a&amp;nbsp;five-second&amp;nbsp;timeout,&amp;nbsp;and&amp;nbsp;it&amp;nbsp;never&amp;nbsp;comes&amp;nbsp;close&amp;nbsp;to&amp;nbsp;it.&lt;/p&gt;

&lt;p&gt;The&amp;nbsp;payoff&amp;nbsp;shows&amp;nbsp;up&amp;nbsp;as&amp;nbsp;a&amp;nbsp;kind&amp;nbsp;of&amp;nbsp;friction&amp;nbsp;quietly&amp;nbsp;disappearing.&amp;nbsp;I&amp;nbsp;no&amp;nbsp;longer&amp;nbsp;re-explain&amp;nbsp;decisions&amp;nbsp;I&amp;nbsp;already&amp;nbsp;made&amp;nbsp;—&amp;nbsp;which&amp;nbsp;database&amp;nbsp;a&amp;nbsp;project&amp;nbsp;uses,&amp;nbsp;why&amp;nbsp;an&amp;nbsp;endpoint&amp;nbsp;is&amp;nbsp;shaped&amp;nbsp;the&amp;nbsp;way&amp;nbsp;it&amp;nbsp;is,&amp;nbsp;the&amp;nbsp;API&amp;nbsp;quirk&amp;nbsp;that&amp;nbsp;cost&amp;nbsp;me&amp;nbsp;an&amp;nbsp;evening&amp;nbsp;last&amp;nbsp;month.&amp;nbsp;The&amp;nbsp;recall&amp;nbsp;puts&amp;nbsp;those&amp;nbsp;back&amp;nbsp;in&amp;nbsp;front&amp;nbsp;of&amp;nbsp;Claude&amp;nbsp;before&amp;nbsp;its&amp;nbsp;first&amp;nbsp;answer,&amp;nbsp;so&amp;nbsp;it&amp;nbsp;builds&amp;nbsp;on&amp;nbsp;them&amp;nbsp;instead&amp;nbsp;of&amp;nbsp;slowly&amp;nbsp;reinventing&amp;nbsp;them.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Side note: if you'd rather have this whole setup broken into a few short, copy-paste steps in your inbox, I turned it into &lt;a href="https://drippery.app/subscribe/6e302973-cf5f-47ff-8803-22122ac3bd9d" rel="noopener noreferrer"&gt;The Claude Code Memory Starter&lt;/a&gt; — a free email series. Back to the hooks.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Hook&amp;nbsp;2:&amp;nbsp;catch&amp;nbsp;the&amp;nbsp;project&amp;nbsp;I&amp;nbsp;name&amp;nbsp;mid-conversation
&lt;/h2&gt;

&lt;p&gt;SessionStart&amp;nbsp;covers&amp;nbsp;where&amp;nbsp;I&amp;nbsp;am&amp;nbsp;sitting.&amp;nbsp;But&amp;nbsp;half&amp;nbsp;my&amp;nbsp;prompts&amp;nbsp;mention&amp;nbsp;a&amp;nbsp;&lt;em&gt;different&lt;/em&gt;&amp;nbsp;project&amp;nbsp;than&amp;nbsp;the&amp;nbsp;folder&amp;nbsp;I&amp;nbsp;am&amp;nbsp;in&amp;nbsp;—&amp;nbsp;"wait,&amp;nbsp;how&amp;nbsp;did&amp;nbsp;I&amp;nbsp;handle&amp;nbsp;inbound&amp;nbsp;email&amp;nbsp;in&amp;nbsp;subhook?"&lt;/p&gt;

&lt;p&gt;So&amp;nbsp;the&amp;nbsp;second&amp;nbsp;hook&amp;nbsp;runs&amp;nbsp;on&amp;nbsp;&lt;code&gt;UserPromptSubmit&lt;/code&gt;,&amp;nbsp;before&amp;nbsp;each&amp;nbsp;message&amp;nbsp;reaches&amp;nbsp;Claude.&amp;nbsp;It&amp;nbsp;reads&amp;nbsp;the&amp;nbsp;prompt&amp;nbsp;text,&amp;nbsp;lowercases&amp;nbsp;it,&amp;nbsp;and&amp;nbsp;scans&amp;nbsp;for&amp;nbsp;any&amp;nbsp;known&amp;nbsp;project&amp;nbsp;slug&amp;nbsp;as&amp;nbsp;a&amp;nbsp;whole&amp;nbsp;word.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;s &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;$HUB_SLUGS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  if &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROMPT_LC&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qE&lt;/span&gt; &lt;span class="s2"&gt;"(^|[^a-z0-9])&lt;/span&gt;&lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="s2"&gt;([^a-z0-9]|&lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nv"&gt;MATCHED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MATCHED&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;fi
done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The&amp;nbsp;whole-word&amp;nbsp;boundary&amp;nbsp;in&amp;nbsp;that&amp;nbsp;regex&amp;nbsp;is&amp;nbsp;load-bearing.&amp;nbsp;Without&amp;nbsp;it,&amp;nbsp;a&amp;nbsp;short&amp;nbsp;slug&amp;nbsp;matches&amp;nbsp;inside&amp;nbsp;longer&amp;nbsp;unrelated&amp;nbsp;words&amp;nbsp;and&amp;nbsp;ordinary&amp;nbsp;prompts&amp;nbsp;trigger&amp;nbsp;recalls&amp;nbsp;all&amp;nbsp;day.&amp;nbsp;With&amp;nbsp;it,&amp;nbsp;only&amp;nbsp;a&amp;nbsp;real&amp;nbsp;mention&amp;nbsp;counts.&lt;/p&gt;

&lt;p&gt;If&amp;nbsp;a&amp;nbsp;slug&amp;nbsp;matches,&amp;nbsp;the&amp;nbsp;hook&amp;nbsp;injects&amp;nbsp;the&amp;nbsp;same&amp;nbsp;kind&amp;nbsp;of&amp;nbsp;recall&amp;nbsp;instruction&amp;nbsp;—&amp;nbsp;pull&amp;nbsp;context&amp;nbsp;for&amp;nbsp;&lt;em&gt;that&lt;/em&gt;&amp;nbsp;project&amp;nbsp;before&amp;nbsp;answering.&amp;nbsp;If&amp;nbsp;nothing&amp;nbsp;matches,&amp;nbsp;the&amp;nbsp;hook&amp;nbsp;exits&amp;nbsp;silently&amp;nbsp;and&amp;nbsp;adds&amp;nbsp;nothing&amp;nbsp;at&amp;nbsp;all.&amp;nbsp;Most&amp;nbsp;prompts&amp;nbsp;hit&amp;nbsp;the&amp;nbsp;silent&amp;nbsp;path.&lt;/p&gt;

&lt;p&gt;This&amp;nbsp;is&amp;nbsp;the&amp;nbsp;part&amp;nbsp;I&amp;nbsp;notice&amp;nbsp;most&amp;nbsp;in&amp;nbsp;daily&amp;nbsp;use.&amp;nbsp;I&amp;nbsp;name&amp;nbsp;a&amp;nbsp;project&amp;nbsp;and&amp;nbsp;Claude&amp;nbsp;already&amp;nbsp;has&amp;nbsp;its&amp;nbsp;history&amp;nbsp;before&amp;nbsp;it&amp;nbsp;responds,&amp;nbsp;even&amp;nbsp;when&amp;nbsp;that&amp;nbsp;repo&amp;nbsp;is&amp;nbsp;three&amp;nbsp;directories&amp;nbsp;away&amp;nbsp;from&amp;nbsp;where&amp;nbsp;I&amp;nbsp;am&amp;nbsp;actually&amp;nbsp;working.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hook&amp;nbsp;3:&amp;nbsp;capture&amp;nbsp;without&amp;nbsp;making&amp;nbsp;me&amp;nbsp;wait
&lt;/h2&gt;

&lt;p&gt;The&amp;nbsp;first&amp;nbsp;two&amp;nbsp;hooks&amp;nbsp;are&amp;nbsp;reads.&amp;nbsp;Reads&amp;nbsp;are&amp;nbsp;easy&amp;nbsp;—&amp;nbsp;they&amp;nbsp;are&amp;nbsp;fast,&amp;nbsp;and&amp;nbsp;a&amp;nbsp;few&amp;nbsp;hundred&amp;nbsp;milliseconds&amp;nbsp;at&amp;nbsp;session&amp;nbsp;start&amp;nbsp;is&amp;nbsp;invisible.&lt;/p&gt;

&lt;p&gt;The&amp;nbsp;third&amp;nbsp;hook&amp;nbsp;is&amp;nbsp;the&amp;nbsp;write,&amp;nbsp;and&amp;nbsp;writes&amp;nbsp;are&amp;nbsp;where&amp;nbsp;this&amp;nbsp;got&amp;nbsp;ugly.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SessionEnd&lt;/code&gt;&amp;nbsp;fires&amp;nbsp;when&amp;nbsp;I&amp;nbsp;close&amp;nbsp;a&amp;nbsp;session.&amp;nbsp;The&amp;nbsp;work&amp;nbsp;it&amp;nbsp;wants&amp;nbsp;to&amp;nbsp;do&amp;nbsp;is&amp;nbsp;not&amp;nbsp;fast:&amp;nbsp;read&amp;nbsp;the&amp;nbsp;whole&amp;nbsp;transcript,&amp;nbsp;send&amp;nbsp;it&amp;nbsp;to&amp;nbsp;an&amp;nbsp;LLM&amp;nbsp;to&amp;nbsp;extract&amp;nbsp;what&amp;nbsp;actually&amp;nbsp;mattered,&amp;nbsp;dedupe&amp;nbsp;each&amp;nbsp;item&amp;nbsp;against&amp;nbsp;the&amp;nbsp;vector&amp;nbsp;store,&amp;nbsp;write&amp;nbsp;the&amp;nbsp;survivors.&amp;nbsp;That&amp;nbsp;is&amp;nbsp;several&amp;nbsp;seconds&amp;nbsp;of&amp;nbsp;work.&amp;nbsp;If&amp;nbsp;the&amp;nbsp;hook&amp;nbsp;does&amp;nbsp;it&amp;nbsp;inline,&amp;nbsp;my&amp;nbsp;terminal&amp;nbsp;hangs&amp;nbsp;for&amp;nbsp;several&amp;nbsp;seconds&amp;nbsp;every&amp;nbsp;single&amp;nbsp;time&amp;nbsp;I&amp;nbsp;close&amp;nbsp;it.&amp;nbsp;Not&amp;nbsp;acceptable.&lt;/p&gt;

&lt;p&gt;So&amp;nbsp;the&amp;nbsp;SessionEnd&amp;nbsp;hook&amp;nbsp;does&amp;nbsp;almost&amp;nbsp;nothing&amp;nbsp;itself.&amp;nbsp;It&amp;nbsp;spawns&amp;nbsp;a&amp;nbsp;detached&amp;nbsp;background&amp;nbsp;process&amp;nbsp;and&amp;nbsp;returns&amp;nbsp;immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="o"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PYTHON_BIN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TRANSCRIPT_PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SESSION_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOOK_EVENT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CWD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &amp;lt;/dev/null &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;&amp;amp;1 &amp;amp;
  &lt;span class="nb"&gt;disown&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The&amp;nbsp;extraction&amp;nbsp;runs&amp;nbsp;after&amp;nbsp;my&amp;nbsp;terminal&amp;nbsp;is&amp;nbsp;already&amp;nbsp;gone.&amp;nbsp;I&amp;nbsp;find&amp;nbsp;out&amp;nbsp;whether&amp;nbsp;it&amp;nbsp;worked&amp;nbsp;by&amp;nbsp;reading&amp;nbsp;a&amp;nbsp;log&amp;nbsp;file&amp;nbsp;later,&amp;nbsp;never&amp;nbsp;by&amp;nbsp;waiting&amp;nbsp;on&amp;nbsp;it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0vmonkav1hr57d9xewea.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0vmonkav1hr57d9xewea.jpg" alt="SessionEnd spawns a detached process that reads the transcript, extracts with Gemini, dedupes, and writes to Pinecone" width="800" height="320"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three&amp;nbsp;things&amp;nbsp;bit&amp;nbsp;me&amp;nbsp;here.&amp;nbsp;They&amp;nbsp;are&amp;nbsp;the&amp;nbsp;reason&amp;nbsp;this&amp;nbsp;section&amp;nbsp;is&amp;nbsp;the&amp;nbsp;longest&amp;nbsp;one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Parallel&amp;nbsp;runs.&lt;/strong&gt;&amp;nbsp;At&amp;nbsp;the&amp;nbsp;end&amp;nbsp;of&amp;nbsp;an&amp;nbsp;evening&amp;nbsp;I&amp;nbsp;close&amp;nbsp;three&amp;nbsp;or&amp;nbsp;four&amp;nbsp;sessions&amp;nbsp;in&amp;nbsp;a&amp;nbsp;burst.&amp;nbsp;Without&amp;nbsp;a&amp;nbsp;lock,&amp;nbsp;that&amp;nbsp;is&amp;nbsp;three&amp;nbsp;or&amp;nbsp;four&amp;nbsp;extractors&amp;nbsp;hammering&amp;nbsp;the&amp;nbsp;vector&amp;nbsp;store&amp;nbsp;at&amp;nbsp;once.&lt;/p&gt;

&lt;p&gt;(My&amp;nbsp;last&amp;nbsp;memory&amp;nbsp;article&amp;nbsp;had&amp;nbsp;a&amp;nbsp;footnote&amp;nbsp;about&amp;nbsp;a&amp;nbsp;"helpful"&amp;nbsp;hook&amp;nbsp;that&amp;nbsp;once&amp;nbsp;spawned&amp;nbsp;seven&amp;nbsp;parallel&amp;nbsp;Claude&amp;nbsp;instances.&amp;nbsp;I&amp;nbsp;have&amp;nbsp;held&amp;nbsp;a&amp;nbsp;grudge&amp;nbsp;against&amp;nbsp;unguarded&amp;nbsp;hooks&amp;nbsp;ever&amp;nbsp;since.)&amp;nbsp;The&amp;nbsp;fix&amp;nbsp;is&amp;nbsp;two&amp;nbsp;locks:&amp;nbsp;a&amp;nbsp;global&amp;nbsp;one&amp;nbsp;created&amp;nbsp;with&amp;nbsp;&lt;code&gt;mkdir&lt;/code&gt;&amp;nbsp;—&amp;nbsp;atomic&amp;nbsp;because&amp;nbsp;the&amp;nbsp;filesystem&amp;nbsp;either&amp;nbsp;creates&amp;nbsp;the&amp;nbsp;directory&amp;nbsp;or&amp;nbsp;fails,&amp;nbsp;with&amp;nbsp;no&amp;nbsp;gap&amp;nbsp;between&amp;nbsp;the&amp;nbsp;check&amp;nbsp;and&amp;nbsp;the&amp;nbsp;create,&amp;nbsp;unlike&amp;nbsp;testing&amp;nbsp;for&amp;nbsp;a&amp;nbsp;lockfile&amp;nbsp;and&amp;nbsp;then&amp;nbsp;writing&amp;nbsp;it&amp;nbsp;—&amp;nbsp;plus&amp;nbsp;a&amp;nbsp;per-session&amp;nbsp;lock&amp;nbsp;with&amp;nbsp;a&amp;nbsp;120-second&amp;nbsp;cooldown&amp;nbsp;so&amp;nbsp;one&amp;nbsp;session&amp;nbsp;cannot&amp;nbsp;double-fire.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inherited&amp;nbsp;environment.&lt;/strong&gt;&amp;nbsp;The&amp;nbsp;extractor&amp;nbsp;is&amp;nbsp;a&amp;nbsp;plain&amp;nbsp;Python&amp;nbsp;script,&amp;nbsp;but&amp;nbsp;spawned&amp;nbsp;from&amp;nbsp;inside&amp;nbsp;Claude&amp;nbsp;Code&amp;nbsp;it&amp;nbsp;inherits&amp;nbsp;Claude&amp;nbsp;Code's&amp;nbsp;entire&amp;nbsp;environment&amp;nbsp;—&amp;nbsp;&lt;code&gt;ANTHROPIC_BASE_URL&lt;/code&gt;,&amp;nbsp;an&amp;nbsp;OAuth&amp;nbsp;token,&amp;nbsp;a&amp;nbsp;dozen&amp;nbsp;&lt;code&gt;CLAUDE_CODE_*&lt;/code&gt;&amp;nbsp;variables.&amp;nbsp;That&amp;nbsp;environment&amp;nbsp;makes&amp;nbsp;the&amp;nbsp;subprocess&amp;nbsp;behave&amp;nbsp;as&amp;nbsp;if&amp;nbsp;it&amp;nbsp;is&amp;nbsp;still&amp;nbsp;running&amp;nbsp;inside&amp;nbsp;Claude.&amp;nbsp;The&amp;nbsp;fix&amp;nbsp;is&amp;nbsp;one&amp;nbsp;&lt;code&gt;unset&lt;/code&gt;&amp;nbsp;block&amp;nbsp;before&amp;nbsp;the&amp;nbsp;Python&amp;nbsp;runs,&amp;nbsp;clearing&amp;nbsp;every&amp;nbsp;inherited&amp;nbsp;Claude&amp;nbsp;variable.&amp;nbsp;This&amp;nbsp;one&amp;nbsp;cost&amp;nbsp;me&amp;nbsp;a&amp;nbsp;confused&amp;nbsp;hour.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key&amp;nbsp;source.&lt;/strong&gt;&amp;nbsp;The&amp;nbsp;extractor&amp;nbsp;needs&amp;nbsp;API&amp;nbsp;keys.&amp;nbsp;I&amp;nbsp;could&amp;nbsp;hard-code&amp;nbsp;them&amp;nbsp;into&amp;nbsp;the&amp;nbsp;script.&amp;nbsp;Instead&amp;nbsp;it&amp;nbsp;reads&amp;nbsp;them&amp;nbsp;straight&amp;nbsp;out&amp;nbsp;of&amp;nbsp;&lt;code&gt;~/.claude.json&lt;/code&gt;&amp;nbsp;—&amp;nbsp;the&amp;nbsp;same&amp;nbsp;file&amp;nbsp;the&amp;nbsp;live&amp;nbsp;mem0&amp;nbsp;MCP&amp;nbsp;server&amp;nbsp;already&amp;nbsp;reads&amp;nbsp;its&amp;nbsp;own&amp;nbsp;keys&amp;nbsp;from.&amp;nbsp;One&amp;nbsp;source&amp;nbsp;of&amp;nbsp;truth.&amp;nbsp;When&amp;nbsp;I&amp;nbsp;rotate&amp;nbsp;a&amp;nbsp;key&amp;nbsp;I&amp;nbsp;rotate&amp;nbsp;it&amp;nbsp;in&amp;nbsp;one&amp;nbsp;place,&amp;nbsp;and&amp;nbsp;both&amp;nbsp;the&amp;nbsp;hook&amp;nbsp;and&amp;nbsp;the&amp;nbsp;server&amp;nbsp;pick&amp;nbsp;it&amp;nbsp;up.&lt;/p&gt;

&lt;h2&gt;
  
  
  What&amp;nbsp;the&amp;nbsp;extractor&amp;nbsp;actually&amp;nbsp;keeps
&lt;/h2&gt;

&lt;p&gt;The&amp;nbsp;background&amp;nbsp;script&amp;nbsp;sends&amp;nbsp;the&amp;nbsp;transcript&amp;nbsp;to&amp;nbsp;Gemini&amp;nbsp;2.5&amp;nbsp;Flash&amp;nbsp;Lite&amp;nbsp;with&amp;nbsp;a&amp;nbsp;tight&amp;nbsp;extraction&amp;nbsp;prompt:&amp;nbsp;keep&amp;nbsp;architectural&amp;nbsp;decisions&amp;nbsp;and&amp;nbsp;their&amp;nbsp;reasoning,&amp;nbsp;keep&amp;nbsp;preferences,&amp;nbsp;keep&amp;nbsp;root&amp;nbsp;causes&amp;nbsp;of&amp;nbsp;bugs,&amp;nbsp;keep&amp;nbsp;project-specific&amp;nbsp;facts.&amp;nbsp;Skip&amp;nbsp;transient&amp;nbsp;state,&amp;nbsp;skip&amp;nbsp;routine&amp;nbsp;procedure.&amp;nbsp;Flash&amp;nbsp;Lite&amp;nbsp;sits&amp;nbsp;on&amp;nbsp;the&amp;nbsp;free&amp;nbsp;tier,&amp;nbsp;so&amp;nbsp;a&amp;nbsp;chatty&amp;nbsp;day&amp;nbsp;of&amp;nbsp;building&amp;nbsp;costs&amp;nbsp;nothing&amp;nbsp;—&amp;nbsp;and&amp;nbsp;transcript&amp;nbsp;extraction&amp;nbsp;is&amp;nbsp;genuinely&amp;nbsp;not&amp;nbsp;a&amp;nbsp;job&amp;nbsp;that&amp;nbsp;needs&amp;nbsp;a&amp;nbsp;frontier&amp;nbsp;model.&amp;nbsp;A&amp;nbsp;small&amp;nbsp;OpenAI&amp;nbsp;model&amp;nbsp;is&amp;nbsp;wired&amp;nbsp;in&amp;nbsp;as&amp;nbsp;a&amp;nbsp;fallback&amp;nbsp;for&amp;nbsp;the&amp;nbsp;days&amp;nbsp;Gemini&amp;nbsp;fails.&lt;/p&gt;

&lt;p&gt;Each&amp;nbsp;surviving&amp;nbsp;item&amp;nbsp;comes&amp;nbsp;back&amp;nbsp;as&amp;nbsp;structured&amp;nbsp;JSON&amp;nbsp;—&amp;nbsp;the&amp;nbsp;fact,&amp;nbsp;a&amp;nbsp;project&amp;nbsp;scope,&amp;nbsp;and&amp;nbsp;a&amp;nbsp;category&amp;nbsp;like&amp;nbsp;&lt;code&gt;decision&lt;/code&gt;&amp;nbsp;or&amp;nbsp;&lt;code&gt;learning&lt;/code&gt;.&amp;nbsp;In&amp;nbsp;practice&amp;nbsp;a&amp;nbsp;memory&amp;nbsp;looks&amp;nbsp;like&amp;nbsp;one&amp;nbsp;short&amp;nbsp;sentence:&amp;nbsp;"subhook&amp;nbsp;uses&amp;nbsp;Postmark&amp;nbsp;inbound&amp;nbsp;for&amp;nbsp;subdomain&amp;nbsp;mail&amp;nbsp;because&amp;nbsp;Cloudflare&amp;nbsp;Email&amp;nbsp;Routing&amp;nbsp;only&amp;nbsp;forwards&amp;nbsp;the&amp;nbsp;apex&amp;nbsp;on&amp;nbsp;the&amp;nbsp;free&amp;nbsp;plan,"&amp;nbsp;tagged&amp;nbsp;&lt;code&gt;{project:&amp;nbsp;subhook,&amp;nbsp;category:&amp;nbsp;decision}&lt;/code&gt;.&amp;nbsp;Before&amp;nbsp;writing&amp;nbsp;anything,&amp;nbsp;the&amp;nbsp;script&amp;nbsp;searches&amp;nbsp;the&amp;nbsp;vector&amp;nbsp;store&amp;nbsp;for&amp;nbsp;that&amp;nbsp;exact&amp;nbsp;text&amp;nbsp;and&amp;nbsp;skips&amp;nbsp;it&amp;nbsp;if&amp;nbsp;something&amp;nbsp;already&amp;nbsp;sits&amp;nbsp;above&amp;nbsp;0.88&amp;nbsp;cosine&amp;nbsp;similarity.&amp;nbsp;&lt;strong&gt;That&amp;nbsp;dedupe&amp;nbsp;is&amp;nbsp;the&amp;nbsp;line&amp;nbsp;between&amp;nbsp;a&amp;nbsp;memory&amp;nbsp;that&amp;nbsp;compounds&amp;nbsp;and&amp;nbsp;a&amp;nbsp;memory&amp;nbsp;that&amp;nbsp;fills&amp;nbsp;up&amp;nbsp;with&amp;nbsp;a&amp;nbsp;hundred&amp;nbsp;near-identical&amp;nbsp;entries.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;What&amp;nbsp;survives&amp;nbsp;gets&amp;nbsp;written&amp;nbsp;to&amp;nbsp;Pinecone&amp;nbsp;with&amp;nbsp;&lt;code&gt;infer=False&lt;/code&gt;&amp;nbsp;—&amp;nbsp;store&amp;nbsp;the&amp;nbsp;text&amp;nbsp;verbatim.&amp;nbsp;The&amp;nbsp;extraction&amp;nbsp;already&amp;nbsp;happened,&amp;nbsp;in&amp;nbsp;the&amp;nbsp;Gemini&amp;nbsp;step.&amp;nbsp;I&amp;nbsp;do&amp;nbsp;not&amp;nbsp;want&amp;nbsp;the&amp;nbsp;vector&amp;nbsp;store&amp;nbsp;re-paraphrasing&amp;nbsp;facts&amp;nbsp;I&amp;nbsp;already&amp;nbsp;shaped&amp;nbsp;into&amp;nbsp;the&amp;nbsp;form&amp;nbsp;I&amp;nbsp;wanted&amp;nbsp;them&amp;nbsp;in.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd9x4l6kdp5g7v3wzn020.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd9x4l6kdp5g7v3wzn020.jpg" alt="Three Claude Code lifecycle hooks: SessionStart and UserPromptSubmit recall memory in, SessionEnd captures it out" width="800" height="347"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  If&amp;nbsp;you&amp;nbsp;copy&amp;nbsp;one&amp;nbsp;idea&amp;nbsp;from&amp;nbsp;this
&lt;/h2&gt;

&lt;p&gt;Not&amp;nbsp;the&amp;nbsp;project-slug&amp;nbsp;list.&amp;nbsp;Not&amp;nbsp;Pinecone,&amp;nbsp;not&amp;nbsp;Gemini.&amp;nbsp;Those&amp;nbsp;are&amp;nbsp;my&amp;nbsp;plumbing,&amp;nbsp;and&amp;nbsp;yours&amp;nbsp;will&amp;nbsp;be&amp;nbsp;different.&lt;/p&gt;

&lt;p&gt;Copy&amp;nbsp;this:&amp;nbsp;&lt;strong&gt;a&amp;nbsp;hook&amp;nbsp;that&amp;nbsp;injects&amp;nbsp;an&amp;nbsp;instruction&amp;nbsp;beats&amp;nbsp;a&amp;nbsp;hook&amp;nbsp;that&amp;nbsp;runs&amp;nbsp;a&amp;nbsp;command.&lt;/strong&gt;&amp;nbsp;A&amp;nbsp;hook&amp;nbsp;that&amp;nbsp;runs&amp;nbsp;&lt;code&gt;git&amp;nbsp;status&lt;/code&gt;&amp;nbsp;and&amp;nbsp;returns&amp;nbsp;the&amp;nbsp;output&amp;nbsp;hands&amp;nbsp;Claude&amp;nbsp;a&amp;nbsp;fact.&amp;nbsp;A&amp;nbsp;hook&amp;nbsp;that&amp;nbsp;injects&amp;nbsp;"before&amp;nbsp;answering,&amp;nbsp;check&amp;nbsp;X&amp;nbsp;and&amp;nbsp;recall&amp;nbsp;Y"&amp;nbsp;hands&amp;nbsp;Claude&amp;nbsp;a&amp;nbsp;&lt;em&gt;task&lt;/em&gt;&amp;nbsp;—&amp;nbsp;carried&amp;nbsp;out&amp;nbsp;with&amp;nbsp;the&amp;nbsp;full&amp;nbsp;toolset&amp;nbsp;and&amp;nbsp;judgment&amp;nbsp;Claude&amp;nbsp;already&amp;nbsp;has.&amp;nbsp;The&amp;nbsp;first&amp;nbsp;is&amp;nbsp;static.&amp;nbsp;The&amp;nbsp;second&amp;nbsp;is&amp;nbsp;behavior.&lt;/p&gt;

&lt;p&gt;Once&amp;nbsp;I&amp;nbsp;started&amp;nbsp;seeing&amp;nbsp;shell-command&amp;nbsp;hooks&amp;nbsp;as&amp;nbsp;a&amp;nbsp;place&amp;nbsp;to&amp;nbsp;write&amp;nbsp;prompts&amp;nbsp;instead&amp;nbsp;of&amp;nbsp;a&amp;nbsp;place&amp;nbsp;to&amp;nbsp;run&amp;nbsp;scripts,&amp;nbsp;the&amp;nbsp;memory&amp;nbsp;wiring&amp;nbsp;stopped&amp;nbsp;looking&amp;nbsp;like&amp;nbsp;a&amp;nbsp;hack&amp;nbsp;and&amp;nbsp;started&amp;nbsp;looking&amp;nbsp;like&amp;nbsp;the&amp;nbsp;obvious&amp;nbsp;shape.&amp;nbsp;Recall&amp;nbsp;on&amp;nbsp;the&amp;nbsp;way&amp;nbsp;in.&amp;nbsp;Capture&amp;nbsp;on&amp;nbsp;the&amp;nbsp;way&amp;nbsp;out.&amp;nbsp;And&amp;nbsp;an&amp;nbsp;evening-tired&amp;nbsp;version&amp;nbsp;of&amp;nbsp;me&amp;nbsp;who&amp;nbsp;never&amp;nbsp;has&amp;nbsp;to&amp;nbsp;think&amp;nbsp;about&amp;nbsp;either&amp;nbsp;one.&lt;/p&gt;

&lt;p&gt;If&amp;nbsp;you&amp;nbsp;want&amp;nbsp;the&amp;nbsp;layer&amp;nbsp;this&amp;nbsp;builds&amp;nbsp;on,&amp;nbsp;the&amp;nbsp;same&amp;nbsp;lifecycle&amp;nbsp;thinking&amp;nbsp;shows&amp;nbsp;up&amp;nbsp;in&amp;nbsp;how&amp;nbsp;I&amp;nbsp;&lt;a href="https://medium.com/generative-ai/how-i-put-a-code-reviewer-in-front-of-every-git-push-to-main-with-a-claude-code-subagent-17408aa3236e?sk=b141e0baa56cea334ad5887116c4f771" rel="noopener noreferrer"&gt;put&amp;nbsp;a&amp;nbsp;code&amp;nbsp;reviewer&amp;nbsp;in&amp;nbsp;front&amp;nbsp;of&amp;nbsp;every&amp;nbsp;git&amp;nbsp;push&amp;nbsp;with&amp;nbsp;a&amp;nbsp;Claude&amp;nbsp;Code&amp;nbsp;subagent&lt;/a&gt;&amp;nbsp;—&amp;nbsp;another&amp;nbsp;small&amp;nbsp;piece&amp;nbsp;of&amp;nbsp;glue&amp;nbsp;that&amp;nbsp;runs&amp;nbsp;without&amp;nbsp;me&amp;nbsp;thinking&amp;nbsp;about&amp;nbsp;it.&lt;/p&gt;

&lt;h2&gt;
  
  
  External&amp;nbsp;Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;Claude&amp;nbsp;Code&amp;nbsp;hooks&amp;nbsp;reference&lt;/a&gt;&amp;nbsp;—&amp;nbsp;official&amp;nbsp;lifecycle&amp;nbsp;event&amp;nbsp;and&amp;nbsp;JSON&amp;nbsp;schema&amp;nbsp;docs&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://github.com/mem0ai/mem0" rel="noopener noreferrer"&gt;mem0&amp;nbsp;on&amp;nbsp;GitHub&lt;/a&gt;&amp;nbsp;—&amp;nbsp;the&amp;nbsp;memory&amp;nbsp;layer&amp;nbsp;this&amp;nbsp;builds&amp;nbsp;on&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://docs.pinecone.io/guides/get-started/overview" rel="noopener noreferrer"&gt;Pinecone&amp;nbsp;documentation&lt;/a&gt;&amp;nbsp;—&amp;nbsp;managed&amp;nbsp;vector&amp;nbsp;database&amp;nbsp;getting-started&amp;nbsp;guide&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://ai.google.dev/gemini-api/docs" rel="noopener noreferrer"&gt;Gemini&amp;nbsp;API&amp;nbsp;documentation&lt;/a&gt;&amp;nbsp;—&amp;nbsp;Google's&amp;nbsp;developer&amp;nbsp;docs&amp;nbsp;for&amp;nbsp;Gemini&amp;nbsp;models&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://github.com/google-gemini/cookbook" rel="noopener noreferrer"&gt;Gemini&amp;nbsp;cookbook&lt;/a&gt;&amp;nbsp;—&amp;nbsp;examples&amp;nbsp;and&amp;nbsp;guides&amp;nbsp;for&amp;nbsp;the&amp;nbsp;Gemini&amp;nbsp;API&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;If you want the starter version of this to land in your inbox, &lt;a href="https://drippery.app/subscribe/6e302973-cf5f-47ff-8803-22122ac3bd9d" rel="noopener noreferrer"&gt;The Claude Code Memory Starter&lt;/a&gt; walks you through it over a few short emails — free. The memory layer this one builds on is the earlier article on &lt;a href="https://generativeai.pub/how-i-added-persistent-semantic-memory-to-claude-code-in-15-minutes-9b91f9399a76" rel="noopener noreferrer"&gt;adding persistent semantic memory to Claude Code&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Target&amp;nbsp;publication:&lt;/strong&gt;&amp;nbsp;Generative&amp;nbsp;AI&amp;nbsp;(~50K&amp;nbsp;followers)&amp;nbsp;[PRIMARY&amp;nbsp;for&amp;nbsp;the&amp;nbsp;AI/Claude&amp;nbsp;Code&amp;nbsp;angle]&amp;nbsp;·&amp;nbsp;Level&amp;nbsp;Up&amp;nbsp;Coding&amp;nbsp;(~60K&amp;nbsp;followers)&amp;nbsp;[SECONDARY]&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tags:&lt;/strong&gt;&amp;nbsp;Claude&amp;nbsp;Code&amp;nbsp;·&amp;nbsp;AI&amp;nbsp;·&amp;nbsp;Developer&amp;nbsp;Tools&amp;nbsp;·&amp;nbsp;Programming&amp;nbsp;·&amp;nbsp;Productivity&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build small tools and kits for solo creators. You can find them here: &lt;a href="https://danielrusnok.gumroad.com" rel="noopener noreferrer"&gt;https://danielrusnok.gumroad.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>How to Make Chrome Extension Iframes Transparent on Dark-Mode Sites (CSS color-scheme Fix)</title>
      <dc:creator>Digital Craft Workshop</dc:creator>
      <pubDate>Wed, 22 Jul 2026 15:15:31 +0000</pubDate>
      <link>https://dev.to/danielrusnok/how-to-make-chrome-extension-iframes-transparent-on-dark-mode-sites-css-color-scheme-fix-2b23</link>
      <guid>https://dev.to/danielrusnok/how-to-make-chrome-extension-iframes-transparent-on-dark-mode-sites-css-color-scheme-fix-2b23</guid>
      <description>&lt;p&gt;A customer of ours emailed: "Your Chrome extension shows me a blank white page on our dashboard." I checked. His site uses &lt;code&gt;color-scheme: dark\&lt;/code&gt;. Ours doesn't set it. &lt;strong&gt;Our iframe was painting a solid white rectangle over his entire viewport&lt;/strong&gt; — and I had no idea, because every site I test on is light-themed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq5wg88typ5vrkfrvdbki.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq5wg88typ5vrkfrvdbki.jpg" alt="Same iframe overlay rendered on a color-scheme light host (invisible) versus a color-scheme dark host (opaque white rectangle)" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;
Same iframe, same transparent body — only the host's color-scheme differs.



&lt;h2&gt;
  
  
  The white rectangle nobody sees
&lt;/h2&gt;

&lt;p&gt;Drop an iframe into a customer's page. Set &lt;code&gt;background: transparent\&lt;/code&gt; on its &lt;code&gt;&amp;lt;body&amp;gt;\&lt;/code&gt;, its &lt;code&gt;&amp;lt;html&amp;gt;\&lt;/code&gt;, even &lt;code&gt;#root\&lt;/code&gt;. Check DevTools — every layer reports &lt;code&gt;background-color: rgba(0, 0, 0, 0)\&lt;/code&gt;. The iframe should be see-through.&lt;/p&gt;

&lt;p&gt;On most sites, it is.&lt;/p&gt;

&lt;p&gt;On any site that sets &lt;code&gt;color-scheme: dark\&lt;/code&gt; on its &lt;code&gt;&amp;lt;html&amp;gt;\&lt;/code&gt; element, &lt;strong&gt;the iframe paints a solid white rectangle over everything underneath&lt;/strong&gt;. Your transparent body is sitting on top of a white backdrop you didn't ask for and can't see in the computed styles panel.&lt;/p&gt;

&lt;p&gt;---&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Chrome does this
&lt;/h2&gt;

&lt;p&gt;The CSS property responsible is &lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/color-scheme" rel="noopener noreferrer"&gt;&lt;code&gt;color-scheme\&lt;/code&gt;&lt;/a&gt;. It tells the browser what theme a document expects — &lt;code&gt;light\&lt;/code&gt;, &lt;code&gt;dark\&lt;/code&gt;, or &lt;code&gt;normal\&lt;/code&gt;. The browser uses this to pick the right shade for form controls, scrollbars, and a system color called &lt;strong&gt;Canvas&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Canvas\&lt;/code&gt; is the default backdrop the browser paints behind any element with a transparent background. In light mode it's white. In dark mode it's near-black.&lt;/p&gt;

&lt;p&gt;The spec detail that matters: &lt;strong&gt;&lt;code&gt;color-scheme\&lt;/code&gt; does not propagate across iframe document boundaries&lt;/strong&gt;. Every iframe document defaults to &lt;code&gt;color-scheme: normal\&lt;/code&gt; (which resolves to light) regardless of what the parent page declares.&lt;/p&gt;

&lt;p&gt;The CSS Color Adjustment spec calls this a &lt;strong&gt;color-scheme mismatch&lt;/strong&gt;. The spec requires the user agent to substitute an opaque Canvas color matching the iframe document's resolved color scheme — light, in this case, which means white. Your transparent body is then composited on top of that opaque white backdrop, not on top of the dark parent page.&lt;/p&gt;

&lt;p&gt;In practice, this means the browser inserts an opaque layer between your iframe document and the parent page before any of your own CSS gets a chance to run. There is no DOM node you can inspect for it. The computed background-color on every element in your iframe still reports &lt;code&gt;rgba(0, 0, 0, 0)\&lt;/code&gt;. The Elements panel will not show you a white rectangle, because the white rectangle is not in the DOM — it is the user agent's substitute Canvas, painted by the compositor.&lt;/p&gt;

&lt;p&gt;This is documented in &lt;a href="https://issues.chromium.org/issues/40157837" rel="noopener noreferrer"&gt;Chromium issue 40157837&lt;/a&gt; and the &lt;a href="https://github.com/w3c/csswg-drafts/issues/4772" rel="noopener noreferrer"&gt;CSSWG drafts discussion&lt;/a&gt;. It is intentional, it is spec-compliant, and it has been breaking iframe overlays for years — &lt;a href="https://levelup.gitconnected.com/one-missing-html-tag-is-killing-your-social-media-traffic-dcdf25e2b012" rel="noopener noreferrer"&gt;one missing HTML tag has been killing social media traffic&lt;/a&gt; the same way for just as long. The web is full of these tiny spec gotchas.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnzfqc6y4t2gt4o476tc5.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnzfqc6y4t2gt4o476tc5.jpg" alt="Painting order diagram: parent page color-scheme dark, iframe Canvas backdrop opaque white from mismatch, transparent iframe body, final composite shows a white rectangle over the dark parent" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;
Four layers — only the user agent's substitute Canvas is visible to the eye, and only when the parent is dark.



&lt;p&gt;---&lt;/p&gt;

&lt;h2&gt;
  
  
  The two-line CSS fix
&lt;/h2&gt;

&lt;p&gt;Tell the iframe element explicitly what color scheme to use, and make sure its body is transparent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;iframe&lt;/span&gt;&lt;span class="nc"&gt;.my-overlay&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="py"&gt;color-scheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;light&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;transparent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. &lt;code&gt;color-scheme: light\&lt;/code&gt; on the iframe &lt;strong&gt;element&lt;/strong&gt; in the parent document tells Chrome "the iframe wants light scheme, same as you would assume by default." No mismatch, no Canvas backdrop. &lt;code&gt;background: transparent\&lt;/code&gt; makes sure the element itself doesn't paint either.&lt;/p&gt;

&lt;p&gt;If you control the iframe document too, also add this in its CSS so it's transparent even before the inline style is parsed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;html&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;body&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;#root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;transparent&lt;/span&gt; &lt;span class="cp"&gt;!important&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;color-scheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;light&lt;/span&gt; &lt;span class="cp"&gt;!important&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;!important\&lt;/code&gt; matters because some CSS resets and base layers (including configurations that add a body background) override your plain rule.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Small fixes like this are the kind of thing I collect in &lt;a href="https://drippery.app/subscribe/6e302973-cf5f-47ff-8803-22122ac3bd9d" rel="noopener noreferrer"&gt;The Claude Code Memory Starter&lt;/a&gt; — a free email series you can join in one click.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;---&lt;/p&gt;

&lt;h2&gt;
  
  
  The reusable React component
&lt;/h2&gt;

&lt;p&gt;If you have one iframe, do the CSS-only fix. If you have ten — which is what I had across our flow editor — you need a wrapper so nobody can add an iframe and forget the rule.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;forwardRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;IframeHTMLAttributes&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SafeIframe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;forwardRef&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;HTMLIFrameElement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;IframeHTMLAttributes&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;HTMLIFrameElement&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;SafeIframe&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;rest&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;iframe&lt;/span&gt;
                &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
                &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;rest&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
                &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="na"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;transparent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="na"&gt;colorScheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;light&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to notice. The wrapper uses &lt;code&gt;forwardRef\&lt;/code&gt; so callers can still attach refs (you'll need that for &lt;code&gt;getBoundingClientRect()\&lt;/code&gt; and &lt;code&gt;postMessage\&lt;/code&gt; patterns common in overlay UIs). And the default style spread comes &lt;strong&gt;before&lt;/strong&gt; &lt;code&gt;...style\&lt;/code&gt;, so any caller-provided style overrides the defaults — including setting &lt;code&gt;colorScheme: 'dark'\&lt;/code&gt; for the rare iframe that actually wants the dark backdrop.&lt;/p&gt;

&lt;p&gt;Then a &lt;code&gt;sed\&lt;/code&gt; pass replaces native iframes with the wrapper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in &lt;/span&gt;src/components/&lt;span class="k"&gt;**&lt;/span&gt;/&lt;span class="k"&gt;*&lt;/span&gt;.tsx&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="s1"&gt;'s|&amp;lt;iframe|&amp;lt;SafeIframe|g; s|&amp;lt;/iframe&amp;gt;|&amp;lt;/SafeIframe&amp;gt;|g'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add the import in each touched file. Done. Every future iframe added to the project is transparent-by-default unless somebody actively opts out.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftmkdlfbr6870kjut650q.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftmkdlfbr6870kjut650q.jpg" alt="Code diff comparing before and after migration: 16 native iframes plus 20 inline color-scheme hot-patches replaced by one SafeIframe wrapper with the defaults baked in" width="800" height="507"&gt;&lt;/a&gt;&lt;/p&gt;
One sed pass plus four imports — sixteen iframes become one wrapper.



&lt;p&gt;---&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the iframe document transparent too
&lt;/h2&gt;

&lt;p&gt;The wrapper fixes the iframe element. The CSS above fixes the iframe document. &lt;strong&gt;You want both&lt;/strong&gt;, because each protects against a different failure mode.&lt;/p&gt;

&lt;p&gt;The element-level fix breaks if a caller passes an explicit &lt;code&gt;style={{ colorScheme: 'dark' }}\&lt;/code&gt; override. The document-level fix breaks if the iframe is created somewhere outside your wrapper (a third-party library, a manual &lt;code&gt;document.createElement('iframe')\&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Add the document-level CSS to your iframe app's root stylesheet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;html&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;body&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;#root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;transparent&lt;/span&gt; &lt;span class="cp"&gt;!important&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;color-scheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;light&lt;/span&gt; &lt;span class="cp"&gt;!important&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your iframe app is React-based and uses Tailwind, put this &lt;strong&gt;after&lt;/strong&gt; &lt;code&gt;@tailwind base\&lt;/code&gt; so it overrides preflight defaults.&lt;/p&gt;

&lt;p&gt;---&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this bug is invisible on light-themed sites
&lt;/h2&gt;

&lt;p&gt;If you don't test on a dark-themed host, you will never see this bug. &lt;strong&gt;The white Canvas blends into a light parent page&lt;/strong&gt; — you see what looks like a transparent overlay, but it's actually a white rectangle on top of a white page.&lt;/p&gt;

&lt;p&gt;The bug is identical on light and dark hosts. Only the visibility changes. My dev environment is light. The marketing site is light. Most customer playground apps are light. The bug existed for months, and nobody on the team saw it because the entire test surface looked clean. The customer who reported it ran a security product with a dark dashboard — as soon as the white rectangle had something to contrast against, the "blank page" complaints started. (Same shape as &lt;a href="https://levelup.gitconnected.com/our-next-js-app-crashed-every-24-hours-here-are-the-six-memory-fixes-35245f6548e4" rel="noopener noreferrer"&gt;our Next.js app that crashed every 24 hours&lt;/a&gt; — silent production bug only outsiders ever noticed.)&lt;/p&gt;

&lt;p&gt;---&lt;/p&gt;

&lt;h2&gt;
  
  
  How to test for it before customers do
&lt;/h2&gt;

&lt;p&gt;Add this to your &lt;a href="https://medium.com/p/db51a8d7cc74" rel="noopener noreferrer"&gt;release checklist&lt;/a&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; Open any of your iframe overlays on a host page that sets &lt;code&gt;color-scheme: dark\&lt;/code&gt; on its &lt;code&gt;&amp;lt;html&amp;gt;\&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt; If you don't have such a customer, force it for testing: in DevTools, find the &lt;code&gt;&amp;lt;html&amp;gt;\&lt;/code&gt; of any site you're testing on, add &lt;code&gt;style="color-scheme: dark"\&lt;/code&gt; inline. The bug will appear immediately if it's there.&lt;/li&gt;
&lt;li&gt; Or open Chrome DevTools → Rendering tab → set "Emulate CSS media feature prefers-color-scheme" to &lt;strong&gt;dark&lt;/strong&gt;, then visit any site whose CSS respects the system preference.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If your overlay paints a visible rectangle, you have the bug. The fix is the two-line CSS rule above.&lt;/p&gt;

&lt;p&gt;In DevTools the rectangle is solid white, the exact &lt;code&gt;Canvas\&lt;/code&gt; light-mode color — not off-white, not semi-transparent. It appears the instant the iframe element gets a layout box, before any of your iframe document's own CSS runs, so there is no flash-of-transparent-content moment to catch. To verify the fix is working, apply it, hard-reload the host page, and confirm the iframe area composites cleanly against the dark parent background with no white edge anywhere along its bounding box.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F46t8iq57u1i90o39cng4.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F46t8iq57u1i90o39cng4.jpg" alt="DevTools Rendering panel set to emulate prefers-color-scheme: dark, with the host page showing a white iframe rectangle appearing the instant emulation flips to dark" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;
Force-trigger the bug from DevTools — Rendering panel emulates dark scheme on any site.



&lt;p&gt;---&lt;/p&gt;

&lt;h2&gt;
  
  
  What we shipped
&lt;/h2&gt;

&lt;p&gt;In our codebase, the fix touched four files, replaced sixteen native &lt;code&gt;&amp;lt;iframe&amp;gt;\&lt;/code&gt; elements with one &lt;code&gt;SafeIframe\&lt;/code&gt; wrapper component, added one CSS rule to the global stylesheet, and deleted around twenty inline &lt;code&gt;colorScheme: 'light'\&lt;/code&gt; hot-patches that I had been sprinkling around while hunting the bug.&lt;/p&gt;

&lt;p&gt;The wrapper component is fifteen lines. The CSS rule is three lines. The entire fix is &lt;a href="https://levelup.gitconnected.com/stop-rewriting-the-whole-article-section-scoped-edits-for-llm-content-tools-d704ec05c1e5" rel="noopener noreferrer"&gt;shorter than this paragraph&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The bug had no console error, no DevTools warning, no broken selector. I only found it because a customer's screen didn't match mine.&lt;/p&gt;

&lt;p&gt;If you inject UI into customer pages, set up a dark-themed test host. I found mine had been broken on dark customers for months.&lt;/p&gt;

&lt;p&gt;---&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If this saved you a dark-mode debugging session, &lt;a href="https://drippery.app/subscribe/6e302973-cf5f-47ff-8803-22122ac3bd9d" rel="noopener noreferrer"&gt;The Claude Code Memory Starter&lt;/a&gt; is a free email series where I share more fixes like it — one short email at a time.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>css</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>chrome</category>
    </item>
    <item>
      <title>How I Put a Code Reviewer in Front of Every git push to Main with a Claude Code Subagent</title>
      <dc:creator>Digital Craft Workshop</dc:creator>
      <pubDate>Mon, 20 Jul 2026 15:09:48 +0000</pubDate>
      <link>https://dev.to/danielrusnok/how-i-put-a-code-reviewer-in-front-of-every-git-push-to-main-with-a-claude-code-subagent-5fgb</link>
      <guid>https://dev.to/danielrusnok/how-i-put-a-code-reviewer-in-front-of-every-git-push-to-main-with-a-claude-code-subagent-5fgb</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbo7uzcfh9w6j4efxcqhh.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbo7uzcfh9w6j4efxcqhh.jpg" alt="Pasted image" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three Wednesdays ago I shipped a regression to production. The diff looked fine. CI was green. I ran the dev server, clicked through the happy path, pushed, and went to bed.&lt;/p&gt;

&lt;p&gt;The next morning a real user emailed me to say my landing-page signup button did nothing.&lt;/p&gt;

&lt;p&gt;The bug was obvious in hindsight. &lt;strong&gt;The AI had quietly removed an&lt;/strong&gt; &lt;code&gt;await&lt;/code&gt; &lt;strong&gt;from an analytics call, the call threw inside a&lt;/strong&gt; &lt;code&gt;try&lt;/code&gt; &lt;strong&gt;block, and the catch swallowed it along with the rest of the handler.&lt;/strong&gt; Three minutes to revert. Took the rest of the morning to feel okay about it again.&lt;/p&gt;

&lt;p&gt;I'm a solo builder. No senior engineer reads my PRs. Nothing sits between my keyboard and &lt;code&gt;main&lt;/code&gt;. I let Claude Code write most of my code now — which means most of what hits &lt;code&gt;main&lt;/code&gt; was written by something that doesn't know it shouldn't swallow exceptions.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Sidebar: this story is one piece of how I think about &lt;a href="https://drippery.app/subscribe/a8ff09ab-6665-409f-a8e4-acb87e2f1a12" rel="noopener noreferrer"&gt;AI as a solo founder's tool&lt;/a&gt; — I have a 5-email series on the broader pattern if you want the bigger picture.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So I built the second pair of eyes. One Claude Code subagent, one 30-line &lt;code&gt;pre-push&lt;/code&gt; hook, shared across every repo I own. It runs on every push to &lt;code&gt;main&lt;/code&gt;, takes about 20 seconds, and has caught real bugs four times in three weeks.&lt;/p&gt;

&lt;p&gt;Here's how it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fold7b76y2ia9avmkbzww.jpg" alt="Subagent checkpoint intercepts a push to main and forks into approve or block paths" width="800" height="450"&gt;The architecture
&lt;/h2&gt;

&lt;p&gt;Three pieces, nothing exotic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A subagent definition&lt;/strong&gt; — a markdown file that tells Claude how to act when invoked as a reviewer. Lives in &lt;code&gt;.claude/agents/code-reviewer.md&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A&lt;/strong&gt; &lt;code&gt;pre-push&lt;/code&gt; &lt;strong&gt;git hook&lt;/strong&gt; — bash script that gathers the diff, fires up a headless Claude Code instance pointed at the subagent, and prints the verdict.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A&lt;/strong&gt; &lt;code&gt;.shared/&lt;/code&gt; &lt;strong&gt;folder + symlinks&lt;/strong&gt; — both files live once at the top of my &lt;code&gt;foundary-tools/&lt;/code&gt; monorepo and symlink into each child repo's &lt;code&gt;.claude/&lt;/code&gt; and &lt;code&gt;.git/hooks/&lt;/code&gt;. One install script wires everything.&lt;/p&gt;

&lt;p&gt;Three pieces because each one owns a different lifetime. The subagent definition is the slowest-changing — I edit it maybe once a week as I learn what false positives look like. The hook is the contract with git — set it up once, leave it alone. The symlinks are infrastructure — wired during &lt;code&gt;install-hooks.sh&lt;/code&gt; and forgotten. Bundling them into one script would mean re-installing across nine repos every time I tweak a prompt rule. Keeping them separate means the prompt edits live in version control and propagate for free.&lt;/p&gt;

&lt;p&gt;The reviewer only fires on pushes to &lt;code&gt;main&lt;/code&gt;. Feature branches push freely. The cost is one Opus call per release-shaped push — pennies — and the upside is a 20-second sanity check before a regression goes live.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The whole thing fits in three files — and the most important one isn't the hook.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The subagent definition
&lt;/h2&gt;

&lt;p&gt;The subagent is a plain markdown file with frontmatter. Claude Code reads the &lt;code&gt;description&lt;/code&gt;, knows when to dispatch to it, and uses the body as the system prompt.&lt;/p&gt;

&lt;p&gt;---&lt;br&gt;
name: code-reviewer&lt;br&gt;
description: "Reviews staged commits before push. Flags"&lt;br&gt;
  regressions, swallowed errors, removed awaits, broken&lt;br&gt;
  invariants, leaked secrets, and missing migrations.&lt;br&gt;
model: opus&lt;/p&gt;

&lt;h2&gt;
  
  
  tools: Read, Grep, Glob, Bash
&lt;/h2&gt;

&lt;p&gt;You are reviewing a diff that is about to land on &lt;code&gt;main\&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Output exactly one of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"APPROVE: "&lt;/li&gt;
&lt;li&gt;"BLOCK: \n"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Block only for things that will break production or leak&lt;br&gt;
secrets. Style nits, naming preferences, and "could be cleaner"&lt;br&gt;
notes do not block. The author is a solo builder shipping&lt;br&gt;
fast; assume they accepted any trade-off that isn't a bug.&lt;/p&gt;

&lt;p&gt;Always check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Removed &lt;code&gt;await\&lt;/code&gt; on async calls&lt;/li&gt;
&lt;li&gt;New &lt;code&gt;catch\&lt;/code&gt; blocks that swallow without logging&lt;/li&gt;
&lt;li&gt;Hard-coded URLs that look like prod endpoints&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.env\&lt;/code&gt; keys printed to logs&lt;/li&gt;
&lt;li&gt;Schema changes without a matching migration file&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;console.log\&lt;/code&gt; of request bodies (usually leaks PII)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The output contract is one line.&lt;/strong&gt; No essay, no "great work, here are some thoughts." Either &lt;code&gt;APPROVE: ...&lt;/code&gt; or &lt;code&gt;BLOCK: ...&lt;/code&gt;. The hook script greps for the prefix and acts on it.&lt;/p&gt;

&lt;p&gt;The subagent commits to a verdict instead of hedging. Without this contract, every review came back as a four-paragraph essay that ended with "but ultimately, this is your call."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The block criteria are narrow.&lt;/strong&gt; I don't want the reviewer to lecture me about my variable names or suggest I add comments. I want it to catch things that will page me in the morning.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;solo builder shipping fast&lt;/code&gt; line in the prompt is load-bearing. Without it, the reviewer was blocking pushes for things like "consider extracting this into a helper" — which is exactly the kind of advice I added the reviewer to ignore.&lt;/p&gt;

&lt;p&gt;If you've ever fought with Claude over &lt;a href="https://medium.com/gitconnected/getting-claude-to-return-strict-json-without-drift-a-system-prompt-template-9ee1b4eed5c4" rel="noopener noreferrer"&gt;getting it to follow a strict output format&lt;/a&gt;, the same lesson applies here: give the model a verdict to commit to, not an essay to write.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pre-push hook
&lt;/h2&gt;

&lt;p&gt;The hook is a bash script at &lt;code&gt;.git/hooks/pre-push&lt;/code&gt;. Git invokes it before talking to the remote and aborts the push if the script exits non-zero.&lt;/p&gt;

&lt;h1&gt;
  
  
  !/usr/bin/env bash
&lt;/h1&gt;

&lt;p&gt;set -euo pipefail&lt;/p&gt;

&lt;p&gt;REMOTE_BRANCH="$(git rev-parse --abbrev-ref --symbolic-full-name @{u} 2&amp;gt;/dev/null || echo "")"&lt;br&gt;
[[ "$REMOTE_BRANCH" == */main ]] || exit 0&lt;/p&gt;

&lt;p&gt;DIFF="$(git log --stat origin/main..HEAD; git diff origin/main..HEAD)"&lt;br&gt;
[[ -n "$DIFF" ]] || exit 0&lt;/p&gt;

&lt;p&gt;echo "Running code-reviewer subagent..."&lt;br&gt;
VERDICT="$(echo "$DIFF" | claude \&lt;br&gt;
  --dangerously-skip-permissions \&lt;br&gt;
  --print \&lt;br&gt;
  --agent code-reviewer 2&amp;gt;&amp;amp;1)"&lt;/p&gt;

&lt;p&gt;echo "---"&lt;br&gt;
echo "$VERDICT"&lt;br&gt;
echo "---"&lt;/p&gt;

&lt;p&gt;if [[ "$VERDICT" == BLOCK:* ]]; then&lt;br&gt;
  printf "Override? [y/N] "&lt;br&gt;
  read -r REPLY &amp;lt;/dev/tty&lt;br&gt;
  [[ "$REPLY" =~ ^[Yy]$ ]] || exit 1&lt;br&gt;
fi&lt;/p&gt;

&lt;p&gt;It only runs for pushes whose upstream tracks &lt;code&gt;main&lt;/code&gt;. A push to a feature branch returns early. That's the difference between 20 seconds added to every push and 20 seconds added to releases.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Side note: if you're wiring Claude Code deeper into your own workflow, I put the setup I lean on into a short, free email series — &lt;a href="https://drippery.app/subscribe/6e302973-cf5f-47ff-8803-22122ac3bd9d" rel="noopener noreferrer"&gt;The Claude Code Memory Starter&lt;/a&gt; — one small lesson at a time.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The script ignores git's ref-list stdin and recomputes the diff from &lt;code&gt;origin/main..HEAD&lt;/code&gt;. The combination of &lt;code&gt;git log --stat&lt;/code&gt; and &lt;code&gt;git diff&lt;/code&gt; gives the reviewer commit messages plus the actual hunks — messages help it figure out intent when judging whether a deletion was deliberate.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--dangerously-skip-permissions&lt;/code&gt; is fine here because the subagent has read-only tools (&lt;code&gt;Read&lt;/code&gt;, &lt;code&gt;Grep&lt;/code&gt;, &lt;code&gt;Glob&lt;/code&gt;, &lt;code&gt;Bash&lt;/code&gt;). It can't edit anything or push.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;read -r REPLY &amp;lt;/dev/tty&lt;/code&gt; is the one tricky line. Pre-push hooks get git's ref list on stdin, so you have to redirect from the controlling TTY to read the user. Without this, the prompt prints and immediately accepts an empty answer.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fab9d42i30q4w16wl2a0w.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fab9d42i30q4w16wl2a0w.jpg" alt="Terminal output of the pre-push hook printing a BLOCK verdict with an Override prompt" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;
**One hook, one repo. The next part is how it became one hook across nine.**



&lt;h2&gt;
  
  
  Sharing one config across every repo
&lt;/h2&gt;

&lt;p&gt;I have nine repositories in &lt;code&gt;~/foundary-tools/&lt;/code&gt;. I'm not going to copy a hook and a subagent definition into each one and then forget which is the canonical version after the third edit.&lt;/p&gt;

&lt;p&gt;The fix is a &lt;code&gt;.shared/&lt;/code&gt; folder at the top of the monorepo.&lt;/p&gt;

&lt;p&gt;foundary-tools/&lt;br&gt;
├── .shared/&lt;br&gt;
│   ├── agents/code-reviewer.md&lt;br&gt;
│   ├── hooks/pre-push&lt;br&gt;
│   └── install-hooks.sh&lt;br&gt;
├── drippery/&lt;br&gt;
├── article-forge/&lt;br&gt;
├── grownote/&lt;br&gt;
└── ...&lt;/p&gt;

&lt;p&gt;&lt;code&gt;install-hooks.sh&lt;/code&gt; walks every direct child directory and creates two symlinks per repo.&lt;/p&gt;

&lt;h1&gt;
  
  
  !/usr/bin/env bash
&lt;/h1&gt;

&lt;p&gt;set -euo pipefail&lt;br&gt;
SHARED="$(cd "$(dirname "$0")" &amp;amp;&amp;amp; pwd)"&lt;/p&gt;

&lt;p&gt;for repo in "$SHARED/.."/*/; do&lt;br&gt;
  [[ -d "$repo/.git" ]] || continue&lt;br&gt;
  mkdir -p "$repo/.claude/agents"&lt;br&gt;
  ln -sf "$SHARED/agents/code-reviewer.md" \&lt;br&gt;
         "$repo/.claude/agents/code-reviewer.md"&lt;br&gt;
  ln -sf "$SHARED/hooks/pre-push" \&lt;br&gt;
         "$repo/.git/hooks/pre-push"&lt;br&gt;
  chmod +x "$repo/.git/hooks/pre-push"&lt;br&gt;
  echo "wired: $(basename "$repo")"&lt;br&gt;
done&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh3z4oz6jiwutmy493rk8.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh3z4oz6jiwutmy493rk8.jpg" alt="Symlink topology — one .shared/ folder fanned out to nine repos" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;
**Run it once. Edit** `code-reviewer.md` **later and every repo picks up the change on the next push.** No copy, no drift.



&lt;p&gt;If you have one repo, the symlink trick is overkill — just put the files directly in &lt;code&gt;.claude/agents/&lt;/code&gt; and &lt;code&gt;.git/hooks/&lt;/code&gt;. The subagent and the hook are the part doing the work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The harder problem isn't sharing the rule. It's making myself willing to obey it.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Soft warning, not hard block (and &lt;code&gt;--no-verify&lt;/code&gt; still works)
&lt;/h2&gt;

&lt;p&gt;The first version of the hook used &lt;code&gt;exit 1&lt;/code&gt; on &lt;code&gt;BLOCK:&lt;/code&gt; and forced me to amend or escape-hatch. I rewrote it within a day.&lt;/p&gt;

&lt;p&gt;I push during moments where stopping is expensive — the kid wakes up, the evening ends, the meeting starts in eight minutes. A hard block on a false positive doesn't help anyone; it just teaches me to disable the hook.&lt;/p&gt;

&lt;p&gt;The soft warning works because it changes the default. Default is don't push. Pressing Enter aborts; pressing &lt;code&gt;y&lt;/code&gt; proceeds.&lt;/p&gt;

&lt;p&gt;I read the verdict, decide in two seconds, and either fix or override. Four times in three weeks, the verdict was right and I went back to fix. Roughly ten times the verdict was wrong and I pressed &lt;code&gt;y&lt;/code&gt; and shipped.&lt;/p&gt;

&lt;p&gt;If I had set hard block, I'd have disabled the hook by week two. The override is what keeps the hook installed.&lt;/p&gt;

&lt;p&gt;This is the same lesson I learned with my &lt;a href="https://medium.com/p/db51a8d7cc74" rel="noopener noreferrer"&gt;pre-deploy script that refuses to add a staging environment&lt;/a&gt;: I keep using the tools that make the safe path easy without locking the risky path away.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;git push --no-verify&lt;/code&gt; bypasses every git hook, including this one. I don't try to "fix" that. If I need to ship a hotfix at 11pm and the reviewer is hallucinating about a deleted file, I want one well-known flag to skip the check, not a fight with my own tooling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The reviewer is a default-on layer. It's not a security gate.&lt;/strong&gt; Security gates belong in CI, where I can't &lt;code&gt;--no-verify&lt;/code&gt; past them. The reviewer's job is to catch the bugs I'd catch myself if I weren't tired.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I deliberately do not ask the reviewer
&lt;/h2&gt;

&lt;p&gt;I tried adding more checks at first. I removed most of them.&lt;/p&gt;

&lt;p&gt;The first version flagged every function over 30 lines for "consider extracting a helper." By the third push it had blocked a 45-line tenant migration that I had no intention of splitting — the SQL was inherently sequential. I deleted that rule the same evening. Same story with "magic numbers" — the reviewer kept flagging timezone offsets and HTTP status codes. Out.&lt;/p&gt;

&lt;p&gt;The reviewer does not:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  Run tests (CI does)&lt;/li&gt;
&lt;li&gt;  Run the type checker (CI does)&lt;/li&gt;
&lt;li&gt;  Check formatting (&lt;code&gt;prettier --check&lt;/code&gt; does)&lt;/li&gt;
&lt;li&gt;  Look for style violations (taste, not bugs)&lt;/li&gt;
&lt;li&gt;  Suggest refactors (different mode of work)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It checks the things a human reviewer would catch in a hurried PR scan — swallowed exceptions, dropped &lt;code&gt;await&lt;/code&gt;s, schema drift, leaked secrets — and stays out of everything else. &lt;strong&gt;That narrow scope is why it runs in 20 seconds and why I trust the verdict enough to act on it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If it flags everything I stop reading it.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9b5uqevrs7o64wgr6jvg.jpg" alt="Reviewer scope: swallowed exceptions, dropped awaits, schema drift, leaked secrets vs tests, type checks, formatting, naming" width="800" height="450"&gt;What this gets you
&lt;/h2&gt;

&lt;p&gt;Three weeks in, the receipts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  Four real catches (two dropped &lt;code&gt;await&lt;/code&gt;s, one swallowed error, one debug &lt;code&gt;console.log&lt;/code&gt; of a JWT)&lt;/li&gt;
&lt;li&gt;  Roughly ten false positives, all dismissed by pressing &lt;code&gt;y&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;  Zero "I disabled the hook because it was annoying" moments&lt;/li&gt;
&lt;li&gt;  A single canonical config across nine repos&lt;/li&gt;
&lt;li&gt;  About $0.40 in Opus calls&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The JWT one was the closest to a real incident. I'd added a &lt;code&gt;console.log(req.headers)&lt;/code&gt; while debugging a webhook signature mismatch, forgot to remove it, and went to push. The reviewer flagged the leaked token in five seconds. That alone earned the next year of the reviewer's existence.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;.shared/&lt;/code&gt; symlink trick stops mattering when you only have one repo, but the subagent and the hook still work — just live in the repo directly.&lt;/p&gt;

&lt;p&gt;If you're already using Claude Code, the whole setup is one subagent file, one hook script, and an &lt;code&gt;install-hooks.sh&lt;/code&gt; if you have more than one repo. About 30 minutes from cold to first wired push.&lt;/p&gt;

&lt;p&gt;This is the third Claude Code workflow I've shipped this month that ends up being a short script. The other two — &lt;a href="https://medium.com/generative-ai/i-automated-away-an-hour-of-project-setup-with-a-claude-code-skill-1ff025e43814" rel="noopener noreferrer"&gt;a skill that turns an hour of project setup into 90 seconds&lt;/a&gt; and &lt;a href="https://generativeai.pub/how-i-added-persistent-semantic-memory-to-claude-code-in-15-minutes-9b91f9399a76" rel="noopener noreferrer"&gt;a memory layer that survives between sessions&lt;/a&gt; — follow the same pattern. Most of my wins lately are thirty lines of glue around something Claude Code already ships.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If this was useful, you might also like &lt;a href="https://drippery.app/subscribe/6e302973-cf5f-47ff-8803-22122ac3bd9d" rel="noopener noreferrer"&gt;The Claude Code Memory Starter&lt;/a&gt; — a short, free email series on getting Claude Code to remember what matters between sessions.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>programming</category>
      <category>git</category>
      <category>ai</category>
    </item>
    <item>
      <title>My Own Content Pipeline Didn’t Have My Own Content. Here’s the Backfill That Fixed It.</title>
      <dc:creator>Digital Craft Workshop</dc:creator>
      <pubDate>Sat, 18 Jul 2026 15:07:51 +0000</pubDate>
      <link>https://dev.to/danielrusnok/my-own-content-pipeline-didnt-have-my-own-content-heres-the-backfill-that-fixed-it-31ii</link>
      <guid>https://dev.to/danielrusnok/my-own-content-pipeline-didnt-have-my-own-content-heres-the-backfill-that-fixed-it-31ii</guid>
      <description>&lt;h3&gt;
  
  
  My Own Content Pipeline Didn’t Have My Own Content. Here’s the Backfill That Fixed&amp;nbsp;It.
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Three platforms, three different integration battles — and a parser bug that silently ate three of my articles.
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flfkeyamzux7jck5oz8lk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flfkeyamzux7jck5oz8lk.png" alt="My Own Content Pipeline Didn’t Have My Own Content. Here’s the Backfill That Fixed It. cover image" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three platforms, three different ways to hand you back your own history. | Generated with&amp;nbsp;ChatGPT&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Not a member? Use&lt;/em&gt; &lt;a href="https://danielrusnok.medium.com/8f5c3efdeb02?source=friends_link&amp;amp;sk=763d630ebc58dfd77a2307f4f6b766ba" rel="noopener noreferrer"&gt;&lt;em&gt;this link.&lt;/em&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;60 articles lived in my analytics dashboard. 0 &lt;strong&gt;of them had actual content.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I built &lt;strong&gt;Article Forge&lt;/strong&gt; to manage everything I publish — drafts, &lt;a href="https://levelup.gitconnected.com/stop-rewriting-the-whole-article-section-scoped-edits-for-llm-content-tools-d704ec05c1e5" rel="noopener noreferrer"&gt;section-scoped AI edits&lt;/a&gt;, cross-posting from Medium to dev.to, analytics from all three platforms in one place.&lt;/p&gt;

&lt;p&gt;The whole point is that &lt;em&gt;my content lives in my own tool&lt;/em&gt;. But for months, only the posts I’d drafted inside Article Forge had their body text stored. Everything written before the tool existed was just a row in &lt;code&gt;article_analytics&lt;/code&gt; with a URL and zero context.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;One evening, I sat down to backfill. I expected one script. I wrote three.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feu3bi37rckp53ay9ov7k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feu3bi37rckp53ay9ov7k.png" alt="Illustration" width="800" height="506"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Architecture diagram showing Article Forge | Generated with&amp;nbsp;Claude&lt;/p&gt;




&lt;h3&gt;
  
  
  Dev.to — the platform that just&amp;nbsp;works
&lt;/h3&gt;

&lt;p&gt;Dev.to has a clean REST API. &lt;code&gt;/api/articles/me/published&lt;/code&gt; paginates through everything you've ever shipped. &lt;strong&gt;The catch: the list endpoint returns metadata only.&lt;/strong&gt; To get &lt;code&gt;body_markdown&lt;/code&gt;, you fetch each article individually via &lt;code&gt;/api/articles/{id}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The script is twenty lines of “list, then loop and fetch detail.” I wrote a dedupe by &lt;code&gt;publishedUrl&lt;/code&gt; so re-runs are safe, and linked to each new &lt;code&gt;articles&lt;/code&gt; row back to the corresponding &lt;code&gt;article_analytics&lt;/code&gt; row by &lt;code&gt;externalId&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Twenty articles inserted, twenty analytics linked, zero failures.&lt;/strong&gt; Five minutes of actual work.&lt;/p&gt;

&lt;p&gt;This is the version of “content backfill” that fits in a tweet. Then there’s Substack and Medium.&lt;/p&gt;




&lt;h3&gt;
  
  
  Substack — the platform that wants your&amp;nbsp;cookie
&lt;/h3&gt;

&lt;p&gt;Substack doesn’t have a public published-list API for owners. Or rather, it does, but &lt;strong&gt;it’s behind cookie auth&lt;/strong&gt;. The dashboard hits an endpoint called &lt;code&gt;post_management/published&lt;/code&gt; that returns a paginated list of your posts, including drafts. It accepts your &lt;code&gt;substack.sid&lt;/code&gt; cookie as the auth token.&lt;/p&gt;

&lt;p&gt;I already had this plumbing — the analytics sync uses the same endpoint to read view/open counts. The backfill enumeration was free. What was missing was the post body. For that, you call &lt;code&gt;https://{your-subdomain}.substack.com/api/v1/posts/{slug}&lt;/code&gt;, again with the cookie, and you get back full &lt;code&gt;body_html&lt;/code&gt;, cover image, subtitle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ten new articles inserted, nine linked to existing analytics rows, seven skipped because they already existed in the DB.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The auth model is honest. You don’t pretend a cookie is an API key. It is what it is — a session, scoped to your account, that you paste into your tool’s settings page. Once you accept that, the API surface is decent.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdkidjdlb11p0ndxu3kt9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdkidjdlb11p0ndxu3kt9.png" alt="Illustration" width="800" height="349"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Side-by-side comparison of three platform integration approaches | Generated with&amp;nbsp;Claude&lt;/p&gt;

&lt;p&gt;If you like reading about the small tools I build for my own workflow, I turned one of them into &lt;a href="https://drippery.app/subscribe/6e302973-cf5f-47ff-8803-22122ac3bd9d" rel="noopener noreferrer"&gt;The Claude Code Memory Starter&lt;/a&gt; — a free email series you can follow along with.&lt;/p&gt;




&lt;h3&gt;
  
  
  Medium — the platform that hands you a zip&amp;nbsp;file
&lt;/h3&gt;

&lt;p&gt;Medium has no API for reading your own historical articles. &lt;strong&gt;You can pull the latest ten from the public RSS feed, and that’s it.&lt;/strong&gt; If you want everything, you go to &lt;em&gt;Settings → Security and apps → Download your information&lt;/em&gt;, wait for an email, click a link, and download a zip of HTML files.&lt;/p&gt;

&lt;p&gt;I downloaded the zip. Eighty-eight HTML files. Three drafts (filenames start with &lt;code&gt;draft_&lt;/code&gt;), about thirty short "responses" (Medium's name for comments — stored as posts in the export), and the rest were real articles.&lt;/p&gt;

&lt;p&gt;I added &lt;code&gt;node-html-parser&lt;/code&gt; as a dependency and wrote a parser. The Medium export structure is consistent — every file is an &lt;code&gt;&amp;lt;article class="h-entry"&amp;gt;&lt;/code&gt; with &lt;code&gt;&amp;lt;h1 class="p-name"&amp;gt;&lt;/code&gt; for the title, &lt;code&gt;&amp;lt;section data-field="subtitle"&amp;gt;&lt;/code&gt; for the subtitle, &lt;code&gt;&amp;lt;section data-field="body"&amp;gt;&lt;/code&gt; for the body, and &lt;code&gt;&amp;lt;time class="dt-published"&amp;gt;&lt;/code&gt; for the date.&lt;/p&gt;

&lt;p&gt;Dry-run reported: 53 articles to insert, 29 short responses skipped, &lt;strong&gt;3 unparseable&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Three? On a stable, well-formed export format? With a parser I’d just verified worked on the other 53?&lt;/p&gt;




&lt;h3&gt;
  
  
  The silent&amp;nbsp;bug
&lt;/h3&gt;

&lt;p&gt;I opened one of the three files. The HTML was fine. Tag balance was fine. &lt;code&gt;&amp;lt;article class="h-entry"&amp;gt;&lt;/code&gt; was right where I expected it. I dropped into a one-line node REPL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;article&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// 0  &lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;h1.p-name&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Zero articles. One title.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The title element is &lt;em&gt;inside&lt;/em&gt; the article element. If the parser found one, it should find the other. Unless the parser thinks the article closed before the title.&lt;/p&gt;

&lt;p&gt;I scanned for what might trip a permissive HTML parser. The three “broken” files all had something in common: code blocks. &lt;strong&gt;Long&lt;/strong&gt; &lt;code&gt;**&amp;lt;pre&amp;gt;**&lt;/code&gt; &lt;strong&gt;blocks containing JavaScript with template literals, comparison operators, JSX-ish snippets&lt;/strong&gt; — all the things that produce raw &lt;code&gt;&amp;lt;&lt;/code&gt; characters inside what should be opaque text.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;node-html-parser&lt;/code&gt; doesn't fully escape &lt;code&gt;&amp;lt;&lt;/code&gt; inside &lt;code&gt;&amp;lt;pre&amp;gt;&lt;/code&gt;. When it scans, it sees what looks like a tag, applies its auto-closing rules, and &lt;strong&gt;silently decides that the wrapping&lt;/strong&gt; &lt;code&gt;**&amp;lt;article&amp;gt;**&lt;/code&gt; &lt;strong&gt;ended way earlier than it actually did&lt;/strong&gt;. Outer selectors miss. Inner selectors that don't depend on the wrapping element still work.&lt;/p&gt;

&lt;p&gt;The fix is one line. Instead of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;article&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;article.h-entry&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;h1.p-name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;h1.p-name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Query from the document root.&lt;/strong&gt; Don’t assume the wrapping element is reliably reachable just because the source HTML is valid.&lt;/p&gt;

&lt;p&gt;Re-ran the script. Three “unparseable” files dropped to zero. One was new content (got inserted), two were already in Article Forge as drafts and matched my title-fallback dedupe.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv7p7s7c52g4qn7jesfcs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv7p7s7c52g4qn7jesfcs.png" alt="Illustration" width="800" height="267"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Simulated terminal output showing the dry-run results | Generated with&amp;nbsp;Claude&lt;/p&gt;




&lt;h3&gt;
  
  
  The deduplication logic that saved me&amp;nbsp;twice
&lt;/h3&gt;

&lt;p&gt;I mentioned dedupe by &lt;code&gt;publishedUrl&lt;/code&gt; earlier. That's the primary key — if a URL already exists in the &lt;code&gt;articles&lt;/code&gt; table, skip it. Works for dev.to and Substack, where every published post has a stable URL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Medium broke this assumption.&lt;/strong&gt; I had eight articles already drafted in Article Forge before the backfill. They had no &lt;code&gt;publishedUrl&lt;/code&gt; yet because they were still drafts. The ZIP export didn't know about my internal drafts, and my internal drafts didn't have Medium URLs.&lt;/p&gt;

&lt;p&gt;The fallback: &lt;strong&gt;normalized title matching&lt;/strong&gt;. Strip punctuation, lowercase, compare. It caught all eight. Not elegant, but it prevented eight duplicate rows that would have confused every downstream query.&lt;/p&gt;

&lt;p&gt;If you’re building any kind of content sync, plan for the case where the same content exists on both sides with no shared identifier. &lt;strong&gt;URL-only dedupe works until it doesn’t.&lt;/strong&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  What the unified data actually&amp;nbsp;unlocked
&lt;/h3&gt;

&lt;p&gt;Eighty-something rows added across three platforms in a few hours. The row count isn’t the point. &lt;strong&gt;Every analytics row now has a corresponding article row with the actual content sitting next to the view count.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The “related articles” feature in my &lt;a href="https://medium.com/gitconnected/getting-claude-to-return-strict-json-without-drift-a-system-prompt-template-9ee1b4eed5c4" rel="noopener noreferrer"&gt;AI-powered optimizer&lt;/a&gt; can finally see my entire archive instead of just the things I drafted in-tool. When I’m writing about background jobs, it knows I already published &lt;a href="https://levelup.gitconnected.com/5-functions-1-route-0-month-my-entire-saas-background-job-architecture-24363b6f45aa" rel="noopener noreferrer"&gt;a deep dive on Inngest&lt;/a&gt; and can suggest an internal link.&lt;/p&gt;

&lt;p&gt;The Medium → dev.to cross-post sync now has the full Medium body to compare against, not a 200-character RSS snippet. &lt;strong&gt;Drift detection between platforms actually works.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And the simplest win: I can search my own articles by content, not just by title. “Which article mentioned &lt;code&gt;Promise.allSettled&lt;/code&gt;?" is now a database query, not a manual hunt across three browser tabs.&lt;/p&gt;




&lt;h3&gt;
  
  
  Three things I’ll keep&amp;nbsp;doing
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1. APIs aren’t the only data source.&lt;/strong&gt; A zip of HTML is a perfectly good integration if it’s the only thing the platform offers. Write a parser, handle edge cases, and move on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Dedupe by URL with a title fallback.&lt;/strong&gt; URL-only dedupe missed eight Medium drafts that hadn’t been published yet. A normalized-title fallback caught them all. Two layers of matching cost almost nothing and prevent real problems.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. When a permissive parser disagrees with the source, look at what’s &lt;em&gt;inside&lt;/em&gt; the elements.&lt;/strong&gt; Code blocks with raw &lt;code&gt;&amp;lt;&lt;/code&gt; will lie to your parser long before the parser admits it's confused.&lt;/p&gt;

&lt;p&gt;The next thing on the list: backfilling Medium’s &lt;code&gt;external_url&lt;/code&gt; mismatch so analytics rows actually link correctly. But that's a different evening.&lt;/p&gt;




&lt;h3&gt;
  
  
  External Sources
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://github.com/taoqf/node-html-parser" rel="noopener noreferrer"&gt;node-html-parser&lt;/a&gt; — the fast HTML parser that tripped on raw &amp;lt; in pre blocks (GitHub)&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://developers.forem.com/api/v1" rel="noopener noreferrer"&gt;Forem API v1&lt;/a&gt; — the dev.to REST API used for the cleanest backfill&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://help.medium.com/hc/en-us/articles/115004745787-Export-your-account-data" rel="noopener noreferrer"&gt;Medium data export&lt;/a&gt; — official guide for downloading your information&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want more of this in your inbox, &lt;a href="https://drippery.app/subscribe/6e302973-cf5f-47ff-8803-22122ac3bd9d" rel="noopener noreferrer"&gt;The Claude Code Memory Starter&lt;/a&gt; is a short, free email series where I walk through setups like this one.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Our Next.js App Crashed Every 24 Hours. Here Are the Six Memory Fixes.</title>
      <dc:creator>Digital Craft Workshop</dc:creator>
      <pubDate>Thu, 16 Jul 2026 15:08:29 +0000</pubDate>
      <link>https://dev.to/danielrusnok/our-nextjs-app-crashed-every-24-hours-here-are-the-six-memory-fixes-2mj1</link>
      <guid>https://dev.to/danielrusnok/our-nextjs-app-crashed-every-24-hours-here-are-the-six-memory-fixes-2mj1</guid>
      <description>&lt;p&gt;I opened the Render dashboard on Monday morning and saw the same thing I had seen every day for a week. &lt;strong&gt;Memory at 100%, service restarting.&lt;/strong&gt; Our Next.js app was eating all 512MB within ~24 hours, then OOM-killing itself.&lt;/p&gt;

&lt;p&gt;I spent a day digging through the codebase with &lt;code&gt;process.memoryUsage()&lt;/code&gt; logs and Render's built-in metrics. I found six separate problems. One of them cut memory usage by more than half in a single deploy.&lt;/p&gt;

&lt;p&gt;Here is every fix, in order of impact.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Stack
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Next.js 16 (App Router)&lt;/strong&gt; with Drizzle ORM, PostgreSQL, Clerk auth, Stripe payments, Resend for transactional email, and Inngest for background jobs. I covered the &lt;a href="https://levelup.gitconnected.com/5-functions-1-route-0-month-my-entire-saas-background-job-architecture-24363b6f45aa" rel="noopener noreferrer"&gt;full background job setup in a previous post&lt;/a&gt; — five functions, one route, $0/month.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hosting:&lt;/strong&gt; Render.com Starter plan — 512MB RAM, $7/month.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The symptom:&lt;/strong&gt; no sudden spikes. Just a &lt;strong&gt;steady upward line on the Render memory graph&lt;/strong&gt; that ended in an OOM-kill roughly every 24 hours.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Fix That Cut Memory in Half
&lt;/h2&gt;

&lt;p&gt;The Resend email SDK was being created with &lt;code&gt;new Resend(apiKey)&lt;/code&gt; inside 10 different files. API routes, Inngest functions, utility helpers. &lt;strong&gt;Every request spun up a new client with its own HTTP connections, event listeners, and internal buffers.&lt;/strong&gt; None of them got cleaned up between requests.&lt;/p&gt;

&lt;p&gt;This is what it looked like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ In 10 different files&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resend&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Resend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RESEND_API_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;resend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;emails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the fix — a lazy singleton in one file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ✅ lib/resend.ts&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Resend&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getResend&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;Resend&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;instance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Resend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RESEND_API_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every handler now calls &lt;code&gt;getResend()&lt;/code&gt;. &lt;strong&gt;One instance for the entire process lifetime.&lt;/strong&gt; I already had this pattern for Stripe and Anthropic — Resend slipped through because it was added later, in a hurry, across multiple files.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Memory dropped from ~93% to ~35% immediately after deploy.&lt;/strong&gt; This single change was responsible for more than half of the total improvement.&lt;/p&gt;




&lt;h2&gt;
  
  
  V8 Knows Your Container Limit. It Still Overallocates.
&lt;/h2&gt;

&lt;p&gt;Node.js 20+ reads cgroup limits and auto-sizes the V8 heap based on your container's available memory. So in theory, it should know about the 512MB limit. &lt;strong&gt;In practice, the auto-detected heap still leaves too little room for everything else&lt;/strong&gt; — non-heap memory, native buffers, connection pools, and the OS itself.&lt;/p&gt;

&lt;p&gt;On my 512MB container, V8's auto-detected heap was larger than what the process could safely use. Memory would climb slowly — not because of a leak, but because &lt;strong&gt;V8 was not running GC aggressively enough for the remaining headroom.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The fix is one flag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node &lt;span class="nt"&gt;--max-old-space-size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;384 server.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Setting it to &lt;strong&gt;~75% of the container limit&lt;/strong&gt; gives V8 a tighter budget. It starts garbage collecting earlier, well before the container runs out.&lt;/p&gt;

&lt;p&gt;One gotcha I hit: I initially set &lt;code&gt;NODE_OPTIONS=--max-old-space-size=384&lt;/code&gt; as a Render environment variable. That applies to the build step too. The TypeScript compiler also runs under Node — and &lt;strong&gt;it crashed at 384MB during the build.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The fix: set the flag only in the start command, not as a global env var.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;// Render start &lt;span class="nb"&gt;command&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;not an &lt;span class="nb"&gt;env &lt;/span&gt;var&lt;span class="o"&gt;)&lt;/span&gt;
node &lt;span class="nt"&gt;--max-old-space-size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;384 .next/standalone/server.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the singleton fix brought memory down to ~35%, this flag is what &lt;strong&gt;prevented it from slowly climbing back up.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you would rather get my solo-dev build-and-debug notes broken into a few short, copy-paste steps in your inbox, I put the essentials into &lt;a href="https://drippery.app/subscribe/6e302973-cf5f-47ff-8803-22122ac3bd9d" rel="noopener noreferrer"&gt;The Claude Code Memory Starter&lt;/a&gt; — a free email series.&lt;/p&gt;




&lt;h2&gt;
  
  
  Four More Leaks I Found
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Clerk Admin SDK — Same Bug, Different Package
&lt;/h3&gt;

&lt;p&gt;Same pattern as Resend, smaller scale. &lt;code&gt;createClerkClient()&lt;/code&gt; was called inside request handlers in 3 files instead of being a singleton. &lt;strong&gt;Same lazy-init fix: one&lt;/strong&gt; &lt;code&gt;**getClerkAdmin()**&lt;/code&gt; &lt;strong&gt;function, one file, one instance.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Unbounded In-Memory Rate Limiter
&lt;/h3&gt;

&lt;p&gt;My &lt;code&gt;/api/track&lt;/code&gt; analytics endpoint used a global &lt;code&gt;Map&lt;/code&gt; for rate limiting. Cleanup only kicked in when the map exceeded 1,000 entries. &lt;strong&gt;On a 512MB container, 1,000 entries of IP strings plus timestamps and counters adds up.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Fix: hard cap at 500 entries with LRU-style eviction, plus a &lt;code&gt;setInterval&lt;/code&gt; cleanup every 2 minutes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cleanup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;rateLimitMap&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;rateLimitMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;cleanup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unref&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;.unref()&lt;/code&gt; call matters. Without it, &lt;strong&gt;the interval keeps the Node process alive&lt;/strong&gt; and it will not exit cleanly during deploys or restarts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cron Jobs Loading Everything Into Memory
&lt;/h3&gt;

&lt;p&gt;Two Inngest cron functions had memory problems:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Analytics cleanup&lt;/strong&gt; used Drizzle's &lt;code&gt;.returning({ id })&lt;/code&gt; on a bulk DELETE. This loaded every deleted row ID into an array before returning. On a table with thousands of expired rows, that array gets big. Fixed with &lt;strong&gt;raw SQL that returns only&lt;/strong&gt; &lt;code&gt;**rowCount**&lt;/code&gt;&lt;strong&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Orphan image cleanup&lt;/strong&gt; listed the entire Cloudflare R2 bucket three separate times — once per context (sequences, emails, subscribers). Fixed to &lt;strong&gt;list the bucket once and filter in memory.&lt;/strong&gt; Three API calls became one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Database Pool With a Silent Zero-Timeout Default
&lt;/h3&gt;

&lt;p&gt;The PostgreSQL connection pool was created with &lt;code&gt;new Pool({ connectionString })&lt;/code&gt; and nothing else. Most defaults are fine — &lt;code&gt;max: 10&lt;/code&gt;, &lt;code&gt;idleTimeoutMillis: 10_000&lt;/code&gt;. But one default is dangerous: &lt;code&gt;**connectionTimeoutMillis**&lt;/code&gt; &lt;strong&gt;defaults to 0, which means "wait forever."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If a connection attempt hangs — slow DNS, unresponsive database, network hiccup — the request sits there holding memory and a pool slot indefinitely. &lt;strong&gt;On a 512MB container, one stuck connection is all it takes to start a cascade.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Pool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;idleTimeoutMillis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;connectionTimeoutMillis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adding &lt;code&gt;connectionTimeoutMillis: 5_000&lt;/code&gt; means a stuck attempt fails fast instead of waiting forever. &lt;strong&gt;I would not have found this without reading the node-postgres docs during the debugging session.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Baseline memory&lt;/td&gt;
&lt;td&gt;~93% (475MB)&lt;/td&gt;
&lt;td&gt;~35% (180MB)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time to OOM&lt;/td&gt;
&lt;td&gt;~24 hours&lt;/td&gt;
&lt;td&gt;Stable (no crash)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory after 24h&lt;/td&gt;
&lt;td&gt;OOM crash&lt;/td&gt;
&lt;td&gt;~45% plateau&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The Resend singleton was the biggest single win&lt;/strong&gt; — more than half of the total improvement in one deploy. The &lt;code&gt;--max-old-space-size&lt;/code&gt; flag prevented the slow climb back up by giving V8 a tighter GC budget.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Checklist
&lt;/h2&gt;

&lt;p&gt;If you are running Next.js on a memory-constrained container, check for these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;SDK clients&lt;/strong&gt; (&lt;code&gt;new Client()&lt;/code&gt;) created inside request handlers instead of module-level singletons&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Missing&lt;/strong&gt; &lt;code&gt;**--max-old-space-size**&lt;/code&gt; — even on Node 20+ where cgroup detection works, an explicit cap gives V8 a tighter GC budget&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;**--max-old-space-size**&lt;/code&gt; &lt;strong&gt;set as env var&lt;/strong&gt; instead of start command only (breaks builds)&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Unbounded&lt;/strong&gt; &lt;code&gt;**Map**&lt;/code&gt; &lt;strong&gt;/&lt;/strong&gt; &lt;code&gt;**Set**&lt;/code&gt; &lt;strong&gt;/&lt;/strong&gt; &lt;code&gt;**Array**&lt;/code&gt; at module level without eviction or size cap&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;**.returning()**&lt;/code&gt; on bulk DELETE/UPDATE loading all results into memory&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Database pool&lt;/strong&gt; with default &lt;code&gt;connectionTimeoutMillis: 0&lt;/code&gt; — set an explicit timeout&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most of these take &lt;strong&gt;5–10 minutes to fix&lt;/strong&gt; once you know what to look for.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Took Away From This
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Most Node.js memory issues on small containers are not traditional memory leaks.&lt;/strong&gt; They are per-request allocations that should be singletons, combined with V8 not running GC aggressively enough for the available headroom.&lt;/p&gt;

&lt;p&gt;Fix those two patterns and you will likely be fine on 512MB. I have been running stable for weeks on Render's cheapest plan — &lt;strong&gt;no crashes, no restarts, memory plateaus at ~45%.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you want more of how I build and ship as a solo developer landing in your inbox, &lt;a href="https://drippery.app/subscribe/6e302973-cf5f-47ff-8803-22122ac3bd9d" rel="noopener noreferrer"&gt;The Claude Code Memory Starter&lt;/a&gt; walks you through the setup over a few short emails — free.&lt;/p&gt;




&lt;h2&gt;
  
  
  External Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://nodejs.org/api/cli.html" rel="noopener noreferrer"&gt;Node.js CLI docs — --max-old-space-size&lt;/a&gt; — the official V8 heap limit flag documentation&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://resend.com/docs/send-with-nodejs" rel="noopener noreferrer"&gt;Resend Node.js SDK&lt;/a&gt; — the email SDK referenced in this article&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://orm.drizzle.team/" rel="noopener noreferrer"&gt;Drizzle ORM&lt;/a&gt; — TypeScript-first SQL ORM used in the stack&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://www.inngest.com/" rel="noopener noreferrer"&gt;Inngest&lt;/a&gt; — background job platform for the cron functions&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://node-postgres.com/apis/pool" rel="noopener noreferrer"&gt;node-postgres Pool API&lt;/a&gt; — connection pool configuration and defaults&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Daniel Rusnok is a software developer and solo maker building &lt;a href="https://drippery.app" rel="noopener noreferrer"&gt;Drippery&lt;/a&gt; — a dead-simple drip email tool for creators. He writes about TypeScript, Next.js, and the realities of shipping software alone.&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>I automated away an hour of project setup with a Claude Code skill</title>
      <dc:creator>Digital Craft Workshop</dc:creator>
      <pubDate>Tue, 14 Jul 2026 15:05:36 +0000</pubDate>
      <link>https://dev.to/danielrusnok/i-automated-away-an-hour-of-project-setup-with-a-claude-code-skill-1j2j</link>
      <guid>https://dev.to/danielrusnok/i-automated-away-an-hour-of-project-setup-with-a-claude-code-skill-1j2j</guid>
      <description>&lt;p&gt;&lt;em&gt;Not&amp;nbsp;a&amp;nbsp;member?&amp;nbsp;Use&amp;nbsp;this&amp;nbsp;link.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every&amp;nbsp;new&amp;nbsp;SaaS&amp;nbsp;project&amp;nbsp;I&amp;nbsp;start&amp;nbsp;used&amp;nbsp;to&amp;nbsp;eat&amp;nbsp;an&amp;nbsp;hour&amp;nbsp;of&amp;nbsp;clicking&amp;nbsp;—&amp;nbsp;GitHub&amp;nbsp;repo,&amp;nbsp;&lt;code&gt;npx&amp;nbsp;create-next-app&lt;/code&gt;,&amp;nbsp;copy-paste&amp;nbsp;the&amp;nbsp;DB&amp;nbsp;config&amp;nbsp;from&amp;nbsp;my&amp;nbsp;last&amp;nbsp;project,&amp;nbsp;walk&amp;nbsp;through&amp;nbsp;the&amp;nbsp;Render&amp;nbsp;dashboard,&amp;nbsp;paste&amp;nbsp;env&amp;nbsp;vars,&amp;nbsp;wait&amp;nbsp;for&amp;nbsp;the&amp;nbsp;first&amp;nbsp;deploy&amp;nbsp;to&amp;nbsp;fail&amp;nbsp;because&amp;nbsp;I&amp;nbsp;forgot&amp;nbsp;&lt;code&gt;node&amp;nbsp;server.js&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I&amp;nbsp;did&amp;nbsp;this&amp;nbsp;three&amp;nbsp;times&amp;nbsp;in&amp;nbsp;a&amp;nbsp;row&lt;/strong&gt;&amp;nbsp;for&amp;nbsp;three&amp;nbsp;sibling&amp;nbsp;projects.&amp;nbsp;Then&amp;nbsp;I&amp;nbsp;automated&amp;nbsp;the&amp;nbsp;whole&amp;nbsp;thing&amp;nbsp;into&amp;nbsp;a&amp;nbsp;90-second&amp;nbsp;command.&lt;/p&gt;

&lt;h2&gt;
  
  
  The&amp;nbsp;setup&amp;nbsp;tax
&lt;/h2&gt;

&lt;p&gt;I&amp;nbsp;keep&amp;nbsp;a&amp;nbsp;folder&amp;nbsp;called&amp;nbsp;&lt;code&gt;foundary-tools&lt;/code&gt;&amp;nbsp;where&amp;nbsp;each&amp;nbsp;subdirectory&amp;nbsp;is&amp;nbsp;a&amp;nbsp;separate&amp;nbsp;SaaS&amp;nbsp;or&amp;nbsp;internal&amp;nbsp;tool.&amp;nbsp;Three&amp;nbsp;projects&amp;nbsp;in,&amp;nbsp;&lt;strong&gt;every&amp;nbsp;new&amp;nbsp;sibling&amp;nbsp;shares&amp;nbsp;90%&amp;nbsp;of&amp;nbsp;the&amp;nbsp;same&amp;nbsp;scaffolding.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Same&amp;nbsp;Next.js&amp;nbsp;16&amp;nbsp;+&amp;nbsp;React&amp;nbsp;19&amp;nbsp;skeleton.&amp;nbsp;Same&amp;nbsp;Drizzle&amp;nbsp;+&amp;nbsp;postgres-js&amp;nbsp;setup.&amp;nbsp;Same&amp;nbsp;Tailwind&amp;nbsp;v4&amp;nbsp;config.&amp;nbsp;Same&amp;nbsp;&lt;code&gt;server.js&lt;/code&gt;&amp;nbsp;that&amp;nbsp;&lt;strong&gt;binds&amp;nbsp;IPv4&amp;nbsp;explicitly&amp;nbsp;because&amp;nbsp;Render's&amp;nbsp;port&amp;nbsp;scanner&amp;nbsp;doesn't&amp;nbsp;see&amp;nbsp;IPv6-only&amp;nbsp;processes&lt;/strong&gt;.&amp;nbsp;Same&amp;nbsp;&lt;code&gt;.gitignore&lt;/code&gt;.&amp;nbsp;Same&amp;nbsp;&lt;code&gt;tsconfig.json&lt;/code&gt;.&amp;nbsp;Even&amp;nbsp;the&amp;nbsp;&lt;a href="https://levelup.gitconnected.com/5-functions-1-route-0-month-my-entire-saas-background-job-architecture-24363b6f45aa" rel="noopener noreferrer"&gt;background&amp;nbsp;job&amp;nbsp;architecture&lt;/a&gt;&amp;nbsp;is&amp;nbsp;shared.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The&amp;nbsp;only&amp;nbsp;thing&amp;nbsp;that&amp;nbsp;changes&amp;nbsp;between&amp;nbsp;projects&amp;nbsp;is&amp;nbsp;the&amp;nbsp;domain&amp;nbsp;logic&lt;/strong&gt;&amp;nbsp;—&amp;nbsp;about&amp;nbsp;5%&amp;nbsp;of&amp;nbsp;the&amp;nbsp;initial&amp;nbsp;commit.&lt;/p&gt;

&lt;p&gt;So&amp;nbsp;the&amp;nbsp;other&amp;nbsp;95%&amp;nbsp;was&amp;nbsp;me,&amp;nbsp;manually,&amp;nbsp;every&amp;nbsp;single&amp;nbsp;time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;code&gt;gh&amp;nbsp;repo&amp;nbsp;create&amp;nbsp;danielrusnok/&amp;lt;name&amp;gt;&amp;nbsp;--private&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;  Copy&amp;nbsp;a&amp;nbsp;Next.js&amp;nbsp;skeleton&amp;nbsp;from&amp;nbsp;the&amp;nbsp;last&amp;nbsp;project&lt;/li&gt;
&lt;li&gt;  Rename&amp;nbsp;&lt;code&gt;"name"&lt;/code&gt;&amp;nbsp;in&amp;nbsp;&lt;code&gt;package.json&lt;/code&gt;,&amp;nbsp;update&amp;nbsp;&lt;code&gt;CLAUDE.md&lt;/code&gt;,&amp;nbsp;update&amp;nbsp;the&amp;nbsp;metadata&amp;nbsp;in&amp;nbsp;&lt;code&gt;layout.tsx&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;  Wire&amp;nbsp;Drizzle&amp;nbsp;against&amp;nbsp;the&amp;nbsp;shared&amp;nbsp;Postgres,&amp;nbsp;pick&amp;nbsp;a&amp;nbsp;unique&amp;nbsp;&lt;code&gt;pgSchema&lt;/code&gt;&amp;nbsp;name&lt;/li&gt;
&lt;li&gt;  Paste&amp;nbsp;the&amp;nbsp;specific&amp;nbsp;&lt;code&gt;server.js&lt;/code&gt;&amp;nbsp;with&amp;nbsp;the&amp;nbsp;&lt;code&gt;0.0.0.0&lt;/code&gt;&amp;nbsp;bind&amp;nbsp;trick&lt;/li&gt;
&lt;li&gt;  Open&amp;nbsp;dashboard.render.com,&amp;nbsp;click&amp;nbsp;New&amp;nbsp;→&amp;nbsp;Web&amp;nbsp;Service,&amp;nbsp;connect&amp;nbsp;repo,&amp;nbsp;fill&amp;nbsp;in&amp;nbsp;build/start&amp;nbsp;commands,&amp;nbsp;paste&amp;nbsp;&lt;code&gt;DATABASE_URL&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;  Commit,&amp;nbsp;push,&amp;nbsp;wait&amp;nbsp;3&amp;nbsp;minutes,&amp;nbsp;watch&amp;nbsp;the&amp;nbsp;first&amp;nbsp;deploy&amp;nbsp;fail&amp;nbsp;because&amp;nbsp;I&amp;nbsp;typed&amp;nbsp;something&amp;nbsp;wrong&lt;/li&gt;
&lt;li&gt;  Fix,&amp;nbsp;push&amp;nbsp;again,&amp;nbsp;wait&amp;nbsp;another&amp;nbsp;3&amp;nbsp;minutes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;An&amp;nbsp;hour,&amp;nbsp;easily.&lt;/strong&gt;&amp;nbsp;And&amp;nbsp;at&amp;nbsp;least&amp;nbsp;twice&amp;nbsp;I&amp;nbsp;deployed&amp;nbsp;a&amp;nbsp;project&amp;nbsp;that&amp;nbsp;was&amp;nbsp;silently&amp;nbsp;missing&amp;nbsp;an&amp;nbsp;env&amp;nbsp;var&amp;nbsp;because&amp;nbsp;I&amp;nbsp;was&amp;nbsp;tired&amp;nbsp;and&amp;nbsp;skipped&amp;nbsp;a&amp;nbsp;step.&lt;/p&gt;

&lt;h2&gt;
  
  
  How&amp;nbsp;Claude&amp;nbsp;Code&amp;nbsp;skills&amp;nbsp;work
&lt;/h2&gt;

&lt;p&gt;If&amp;nbsp;you&amp;nbsp;use&amp;nbsp;Claude&amp;nbsp;Code,&amp;nbsp;a&amp;nbsp;"skill"&amp;nbsp;is&amp;nbsp;&lt;strong&gt;a&amp;nbsp;procedural&amp;nbsp;playbook&amp;nbsp;Claude&amp;nbsp;loads&amp;nbsp;from&lt;/strong&gt;&amp;nbsp;&lt;code&gt;**~/.claude/skills/**&lt;/code&gt;.&amp;nbsp;Each&amp;nbsp;skill&amp;nbsp;is&amp;nbsp;a&amp;nbsp;directory&amp;nbsp;with&amp;nbsp;a&amp;nbsp;&lt;code&gt;SKILL.md&lt;/code&gt;&amp;nbsp;—&amp;nbsp;frontmatter&amp;nbsp;describing&amp;nbsp;when&amp;nbsp;to&amp;nbsp;trigger&amp;nbsp;(natural-language&amp;nbsp;phrases),&amp;nbsp;plus&amp;nbsp;step-by-step&amp;nbsp;instructions&amp;nbsp;for&amp;nbsp;Claude&amp;nbsp;to&amp;nbsp;follow.&lt;/p&gt;

&lt;p&gt;Skills&amp;nbsp;can&amp;nbsp;include&amp;nbsp;helper&amp;nbsp;scripts.&amp;nbsp;&lt;strong&gt;You&amp;nbsp;delegate&amp;nbsp;judgment&amp;nbsp;to&amp;nbsp;the&amp;nbsp;LLM&amp;nbsp;and&amp;nbsp;deterministic&amp;nbsp;work&amp;nbsp;to&amp;nbsp;scripts.&lt;/strong&gt;&amp;nbsp;I&amp;nbsp;leaned&amp;nbsp;into&amp;nbsp;this&amp;nbsp;split&amp;nbsp;even&amp;nbsp;harder&amp;nbsp;when&amp;nbsp;I&amp;nbsp;&lt;a href="https://generativeai.pub/how-i-added-persistent-semantic-memory-to-claude-code-in-15-minutes-9b91f9399a76" rel="noopener noreferrer"&gt;added&amp;nbsp;persistent&amp;nbsp;memory&amp;nbsp;to&amp;nbsp;Claude&amp;nbsp;Code&lt;/a&gt;&amp;nbsp;—&amp;nbsp;same&amp;nbsp;principle,&amp;nbsp;different&amp;nbsp;tool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The&amp;nbsp;division&amp;nbsp;I&amp;nbsp;landed&amp;nbsp;on:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Claude&amp;nbsp;handles&lt;/strong&gt;:&amp;nbsp;asking&amp;nbsp;the&amp;nbsp;user&amp;nbsp;for&amp;nbsp;inputs,&amp;nbsp;confirming&amp;nbsp;destructive&amp;nbsp;actions,&amp;nbsp;interpreting&amp;nbsp;errors&amp;nbsp;from&amp;nbsp;&lt;code&gt;gh&lt;/code&gt;&amp;nbsp;/&amp;nbsp;&lt;code&gt;npm&lt;/code&gt;&amp;nbsp;/&amp;nbsp;the&amp;nbsp;Render&amp;nbsp;API,&amp;nbsp;deciding&amp;nbsp;whether&amp;nbsp;to&amp;nbsp;retry&amp;nbsp;or&amp;nbsp;escalate.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Python&amp;nbsp;handles&lt;/strong&gt;:&amp;nbsp;walking&amp;nbsp;a&amp;nbsp;template&amp;nbsp;directory,&amp;nbsp;substituting&amp;nbsp;placeholders,&amp;nbsp;reading&amp;nbsp;env&amp;nbsp;vars&amp;nbsp;from&amp;nbsp;sibling&amp;nbsp;projects,&amp;nbsp;POSTing&amp;nbsp;to&amp;nbsp;Render's&amp;nbsp;REST&amp;nbsp;API.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Don't&amp;nbsp;make&amp;nbsp;the&amp;nbsp;LLM&amp;nbsp;do&amp;nbsp;regex&amp;nbsp;on&lt;/strong&gt;&amp;nbsp;&lt;code&gt;**package.json**&lt;/code&gt;&lt;strong&gt;.&lt;/strong&gt;&amp;nbsp;Don't&amp;nbsp;make&amp;nbsp;the&amp;nbsp;script&amp;nbsp;decide&amp;nbsp;whether&amp;nbsp;to&amp;nbsp;retry&amp;nbsp;a&amp;nbsp;failed&amp;nbsp;network&amp;nbsp;call.&amp;nbsp;Each&amp;nbsp;does&amp;nbsp;what&amp;nbsp;it's&amp;nbsp;good&amp;nbsp;at.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nine&amp;nbsp;steps,&amp;nbsp;three&amp;nbsp;approval&amp;nbsp;gates
&lt;/h2&gt;

&lt;p&gt;I&amp;nbsp;named&amp;nbsp;the&amp;nbsp;skill&amp;nbsp;&lt;code&gt;foundary-new-project&lt;/code&gt;.&amp;nbsp;It&amp;nbsp;triggers&amp;nbsp;on&amp;nbsp;phrases&amp;nbsp;like&amp;nbsp;"nový&amp;nbsp;foundary&amp;nbsp;projekt"&amp;nbsp;or&amp;nbsp;"new&amp;nbsp;foundary&amp;nbsp;project"&amp;nbsp;—&amp;nbsp;&lt;strong&gt;same&amp;nbsp;natural-language&amp;nbsp;entry&amp;nbsp;point&amp;nbsp;as&amp;nbsp;every&amp;nbsp;other&amp;nbsp;skill&amp;nbsp;I&amp;nbsp;use&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The&amp;nbsp;workflow&amp;nbsp;is&amp;nbsp;nine&amp;nbsp;steps&lt;/strong&gt;,&amp;nbsp;three&amp;nbsp;of&amp;nbsp;which&amp;nbsp;require&amp;nbsp;my&amp;nbsp;explicit&amp;nbsp;approval:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; Validate&amp;nbsp;&amp;amp;&amp;nbsp;read&amp;nbsp;secrets&amp;nbsp;from&amp;nbsp;sibling&amp;nbsp;&lt;code&gt;.env&lt;/code&gt;&amp;nbsp;files&lt;/li&gt;
&lt;li&gt; Scaffold&amp;nbsp;the&amp;nbsp;template&amp;nbsp;into&amp;nbsp;&lt;code&gt;~/foundary-tools/&amp;lt;name&amp;gt;/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt; Run&amp;nbsp;&lt;code&gt;npm&amp;nbsp;install&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt; Smoke-build&amp;nbsp;with&amp;nbsp;&lt;code&gt;npm&amp;nbsp;run&amp;nbsp;build&lt;/code&gt;&amp;nbsp;—&amp;nbsp;fail&amp;nbsp;fast&amp;nbsp;before&amp;nbsp;anything&amp;nbsp;external&amp;nbsp;happens&lt;/li&gt;
&lt;li&gt; &lt;code&gt;git&amp;nbsp;init&lt;/code&gt;&amp;nbsp;+&amp;nbsp;first&amp;nbsp;commit&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Approval&amp;nbsp;gate&lt;/strong&gt;:&amp;nbsp;create&amp;nbsp;GitHub&amp;nbsp;repo,&amp;nbsp;push&lt;/li&gt;
&lt;li&gt; Fetch&amp;nbsp;Render&amp;nbsp;owner&amp;nbsp;ID&amp;nbsp;(cached&amp;nbsp;after&amp;nbsp;the&amp;nbsp;first&amp;nbsp;run)&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Approval&amp;nbsp;gate&lt;/strong&gt;:&amp;nbsp;create&amp;nbsp;Render&amp;nbsp;web-service&amp;nbsp;via&amp;nbsp;API&lt;/li&gt;
&lt;li&gt; Update&amp;nbsp;CLAUDE.md&amp;nbsp;with&amp;nbsp;the&amp;nbsp;Render&amp;nbsp;service&amp;nbsp;ID&amp;nbsp;and&amp;nbsp;print&amp;nbsp;a&amp;nbsp;recap&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The&amp;nbsp;approval&amp;nbsp;gates&amp;nbsp;exist&amp;nbsp;because&amp;nbsp;&lt;strong&gt;my&amp;nbsp;intent&amp;nbsp;changes&amp;nbsp;between&amp;nbsp;runs&lt;/strong&gt;.&amp;nbsp;Sometimes&amp;nbsp;I&amp;nbsp;want&amp;nbsp;to&amp;nbsp;scaffold&amp;nbsp;locally&amp;nbsp;and&amp;nbsp;push&amp;nbsp;later.&amp;nbsp;Sometimes&amp;nbsp;the&amp;nbsp;name&amp;nbsp;I&amp;nbsp;picked&amp;nbsp;collides&amp;nbsp;with&amp;nbsp;an&amp;nbsp;existing&amp;nbsp;Render&amp;nbsp;service.&amp;nbsp;A&amp;nbsp;hard&amp;nbsp;confirmation&amp;nbsp;before&amp;nbsp;external&amp;nbsp;state&amp;nbsp;changes&amp;nbsp;is&amp;nbsp;cheap&amp;nbsp;insurance.&lt;/p&gt;




&lt;h2&gt;
  
  
  The&amp;nbsp;scaffold&amp;nbsp;script
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;scaffold.py&lt;/code&gt;&amp;nbsp;is&amp;nbsp;&lt;strong&gt;the&amp;nbsp;least&amp;nbsp;interesting&amp;nbsp;part&lt;/strong&gt;,&amp;nbsp;which&amp;nbsp;is&amp;nbsp;how&amp;nbsp;it&amp;nbsp;should&amp;nbsp;be:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;copy_template&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;src_root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dirs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;files&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TEMPLATE_DIR&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;src_root_p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src_root&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;rel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;src_root_p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;relative_to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TEMPLATE_DIR&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;dst_root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;rel&lt;/span&gt;
        &lt;span class="n"&gt;dst_root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mkdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parents&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exist_ok&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;fname&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;files&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;src_root_p&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;fname&lt;/span&gt;
            &lt;span class="n"&gt;dst&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dst_root&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;fname&lt;/span&gt;
            &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_bytes&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;UnicodeDecodeError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;
            &lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;substitute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three&amp;nbsp;placeholders:&amp;nbsp;&lt;code&gt;__NAME__&lt;/code&gt;,&amp;nbsp;&lt;code&gt;__SCHEMA__&lt;/code&gt;,&amp;nbsp;&lt;code&gt;__DESCRIPTION__&lt;/code&gt;.&amp;nbsp;&lt;strong&gt;The&amp;nbsp;schema&amp;nbsp;is&amp;nbsp;the&amp;nbsp;project&amp;nbsp;name&amp;nbsp;with&amp;nbsp;hyphens&amp;nbsp;swapped&amp;nbsp;for&amp;nbsp;underscores&lt;/strong&gt;&amp;nbsp;(SQL&amp;nbsp;identifier&amp;nbsp;rules).&lt;/p&gt;

&lt;p&gt;I&amp;nbsp;don't&amp;nbsp;have&amp;nbsp;a&amp;nbsp;keychain&amp;nbsp;integration.&amp;nbsp;I&amp;nbsp;don't&amp;nbsp;maintain&amp;nbsp;a&amp;nbsp;separate&amp;nbsp;secrets&amp;nbsp;file.&amp;nbsp;&lt;strong&gt;I&amp;nbsp;read&lt;/strong&gt;&amp;nbsp;&lt;code&gt;**DATABASE_URL**&lt;/code&gt;&amp;nbsp;&lt;strong&gt;from&lt;/strong&gt;&amp;nbsp;&lt;code&gt;**~/foundary-tools/drippery/.env.production**&lt;/code&gt;&amp;nbsp;and&amp;nbsp;&lt;code&gt;RENDER_API_TOKEN&lt;/code&gt;&amp;nbsp;from&amp;nbsp;&lt;code&gt;~/foundary-tools/framelock/.env.local&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Why?&amp;nbsp;Because&amp;nbsp;those&amp;nbsp;projects&amp;nbsp;are&amp;nbsp;already&amp;nbsp;configured.&amp;nbsp;The&amp;nbsp;files&amp;nbsp;exist.&amp;nbsp;Permissions&amp;nbsp;are&amp;nbsp;already&amp;nbsp;set&amp;nbsp;to&amp;nbsp;0600.&amp;nbsp;&lt;strong&gt;My&amp;nbsp;adjacent&amp;nbsp;project&amp;nbsp;is&amp;nbsp;my&amp;nbsp;secret&amp;nbsp;store&amp;nbsp;by&amp;nbsp;construction.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The&amp;nbsp;new&amp;nbsp;project's&amp;nbsp;&lt;code&gt;.env.local&lt;/code&gt;&amp;nbsp;gets&amp;nbsp;written&amp;nbsp;with&amp;nbsp;&lt;code&gt;chmod&amp;nbsp;600&lt;/code&gt;&amp;nbsp;and&amp;nbsp;added&amp;nbsp;to&amp;nbsp;a&amp;nbsp;&lt;code&gt;.gitignore&lt;/code&gt;&amp;nbsp;that&amp;nbsp;blocks&amp;nbsp;&lt;code&gt;.env*&lt;/code&gt;.&amp;nbsp;&lt;strong&gt;Normal&amp;nbsp;hygiene.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The&amp;nbsp;Render&amp;nbsp;API&amp;nbsp;call
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Render&amp;nbsp;has&amp;nbsp;a&amp;nbsp;clean&amp;nbsp;v1&amp;nbsp;REST&amp;nbsp;API&lt;/strong&gt;&amp;nbsp;where&amp;nbsp;&lt;code&gt;POST&amp;nbsp;/v1/services&lt;/code&gt;&amp;nbsp;creates&amp;nbsp;a&amp;nbsp;web-service&amp;nbsp;with&amp;nbsp;the&amp;nbsp;config&amp;nbsp;baked&amp;nbsp;in.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;create_body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;web_service&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ownerId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;owner_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;repo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;autoDeploy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;yes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;branch&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;main&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;serviceDetails&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;plan&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;free&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;region&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;oregon&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;env&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;node&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;envSpecificDetails&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;buildCommand&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;npm ci &amp;amp;&amp;amp; npm run build&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;startCommand&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;node server.js&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;envVars&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;database_url&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The&amp;nbsp;owner&amp;nbsp;ID&amp;nbsp;is&amp;nbsp;cached&amp;nbsp;after&amp;nbsp;the&amp;nbsp;first&amp;nbsp;run&lt;/strong&gt;&amp;nbsp;in&amp;nbsp;&lt;code&gt;~/.claude/skills/foundary-new-project/.render-owner-id&lt;/code&gt;,&amp;nbsp;so&amp;nbsp;subsequent&amp;nbsp;scaffolds&amp;nbsp;skip&amp;nbsp;a&amp;nbsp;round-trip.&lt;/p&gt;

&lt;p&gt;The&amp;nbsp;whole&amp;nbsp;script&amp;nbsp;is&amp;nbsp;about&amp;nbsp;80&amp;nbsp;lines&amp;nbsp;of&amp;nbsp;&lt;code&gt;urllib.request&lt;/code&gt;&amp;nbsp;—&amp;nbsp;no&amp;nbsp;SDK,&amp;nbsp;no&amp;nbsp;extra&amp;nbsp;dependency.&amp;nbsp;&lt;strong&gt;If&amp;nbsp;the&amp;nbsp;POST&amp;nbsp;returns&amp;nbsp;409&amp;nbsp;or&amp;nbsp;400,&amp;nbsp;Python&amp;nbsp;exits&amp;nbsp;with&amp;nbsp;the&amp;nbsp;error&amp;nbsp;text&lt;/strong&gt;&amp;nbsp;and&amp;nbsp;Claude&amp;nbsp;reads&amp;nbsp;it,&amp;nbsp;tells&amp;nbsp;me,&amp;nbsp;and&amp;nbsp;asks&amp;nbsp;what&amp;nbsp;to&amp;nbsp;do.&lt;/p&gt;

&lt;p&gt;That&amp;nbsp;loop&amp;nbsp;—&amp;nbsp;script&amp;nbsp;fails&amp;nbsp;loud,&amp;nbsp;LLM&amp;nbsp;interprets,&amp;nbsp;human&amp;nbsp;decides&amp;nbsp;—&amp;nbsp;&lt;strong&gt;is&amp;nbsp;why&amp;nbsp;I&amp;nbsp;reach&amp;nbsp;for&amp;nbsp;skills&amp;nbsp;instead&amp;nbsp;of&lt;/strong&gt;&amp;nbsp;&lt;code&gt;**set&amp;nbsp;-e**&lt;/code&gt;&amp;nbsp;&lt;strong&gt;bash&amp;nbsp;scripts&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If you'd rather get this kind of solo-builder setup broken into a few short, copy-paste steps in your inbox, I turned the essentials into &lt;a href="https://drippery.app/subscribe/6e302973-cf5f-47ff-8803-22122ac3bd9d" rel="noopener noreferrer"&gt;The Claude Code Memory Starter&lt;/a&gt; — a free email series.&lt;/p&gt;




&lt;h2&gt;
  
  
  Template,&amp;nbsp;not&amp;nbsp;generator
&lt;/h2&gt;

&lt;p&gt;The&amp;nbsp;&lt;code&gt;template/&lt;/code&gt;&amp;nbsp;directory&amp;nbsp;is&amp;nbsp;a&amp;nbsp;real&amp;nbsp;Next.js&amp;nbsp;skeleton&amp;nbsp;I&amp;nbsp;can&amp;nbsp;open&amp;nbsp;and&amp;nbsp;edit.&amp;nbsp;When&amp;nbsp;the&amp;nbsp;stack&amp;nbsp;evolves&amp;nbsp;—&amp;nbsp;new&amp;nbsp;Next.js&amp;nbsp;version,&amp;nbsp;Tailwind&amp;nbsp;upgrade,&amp;nbsp;a&amp;nbsp;&lt;code&gt;server.js&lt;/code&gt;&amp;nbsp;tweak&amp;nbsp;—&amp;nbsp;&lt;strong&gt;I&amp;nbsp;edit&amp;nbsp;the&amp;nbsp;template&amp;nbsp;files&amp;nbsp;directly&amp;nbsp;and&amp;nbsp;the&amp;nbsp;next&amp;nbsp;scaffold&amp;nbsp;picks&amp;nbsp;up&amp;nbsp;the&amp;nbsp;change&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No&amp;nbsp;code&amp;nbsp;generator&amp;nbsp;to&amp;nbsp;maintain.&amp;nbsp;No&amp;nbsp;AST&amp;nbsp;rewrites.&amp;nbsp;Just&amp;nbsp;files&amp;nbsp;with&amp;nbsp;three&amp;nbsp;placeholders.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The&amp;nbsp;template&amp;nbsp;has&amp;nbsp;15&amp;nbsp;files:&amp;nbsp;&lt;code&gt;app/{layout,page,globals}&lt;/code&gt;,&amp;nbsp;&lt;code&gt;lib/db/{index,schema}&lt;/code&gt;,&amp;nbsp;&lt;code&gt;lib/env.ts&lt;/code&gt;,&amp;nbsp;&lt;code&gt;server.js&lt;/code&gt;,&amp;nbsp;&lt;code&gt;drizzle.config.ts&lt;/code&gt;,&amp;nbsp;&lt;code&gt;next.config.ts&lt;/code&gt;,&amp;nbsp;&lt;code&gt;postcss.config.mjs&lt;/code&gt;,&amp;nbsp;&lt;code&gt;tsconfig.json&lt;/code&gt;,&amp;nbsp;&lt;code&gt;.gitignore&lt;/code&gt;,&amp;nbsp;&lt;code&gt;.env.example&lt;/code&gt;,&amp;nbsp;&lt;code&gt;AGENTS.md&lt;/code&gt;,&amp;nbsp;&lt;code&gt;CLAUDE.md&lt;/code&gt;,&amp;nbsp;&lt;code&gt;README.md&lt;/code&gt;,&amp;nbsp;&lt;code&gt;package.json&lt;/code&gt;.&amp;nbsp;&lt;strong&gt;Every&amp;nbsp;one&amp;nbsp;is&amp;nbsp;either&amp;nbsp;identical&amp;nbsp;across&amp;nbsp;projects&amp;nbsp;or&amp;nbsp;differs&amp;nbsp;only&amp;nbsp;by&amp;nbsp;name/description.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The&amp;nbsp;landing&amp;nbsp;page&amp;nbsp;is&amp;nbsp;deliberately&amp;nbsp;dumb&amp;nbsp;—&amp;nbsp;a&amp;nbsp;hero&amp;nbsp;with&amp;nbsp;the&amp;nbsp;project&amp;nbsp;name&amp;nbsp;and&amp;nbsp;a&amp;nbsp;"Coming&amp;nbsp;soon"&amp;nbsp;line.&amp;nbsp;No&amp;nbsp;DB&amp;nbsp;calls,&amp;nbsp;no&amp;nbsp;auth,&amp;nbsp;nothing&amp;nbsp;that&amp;nbsp;could&amp;nbsp;break&amp;nbsp;the&amp;nbsp;first&amp;nbsp;deploy.&amp;nbsp;&lt;strong&gt;The&amp;nbsp;first&amp;nbsp;deploy's&amp;nbsp;only&amp;nbsp;job&amp;nbsp;is&amp;nbsp;to&amp;nbsp;prove&amp;nbsp;the&amp;nbsp;pipeline&amp;nbsp;works.&lt;/strong&gt;&amp;nbsp;I&amp;nbsp;&lt;a href="https://medium.com/p/db51a8d7cc74" rel="noopener noreferrer"&gt;push&amp;nbsp;every&amp;nbsp;change&amp;nbsp;straight&amp;nbsp;to&amp;nbsp;production&lt;/a&gt;&amp;nbsp;anyway&amp;nbsp;—&amp;nbsp;this&amp;nbsp;just&amp;nbsp;makes&amp;nbsp;sure&amp;nbsp;the&amp;nbsp;wiring&amp;nbsp;is&amp;nbsp;correct&amp;nbsp;before&amp;nbsp;real&amp;nbsp;code&amp;nbsp;lands.&lt;/p&gt;

&lt;h2&gt;
  
  
  90&amp;nbsp;seconds,&amp;nbsp;zero&amp;nbsp;dashboard&amp;nbsp;clicks
&lt;/h2&gt;

&lt;p&gt;From&amp;nbsp;"I&amp;nbsp;want&amp;nbsp;a&amp;nbsp;new&amp;nbsp;project&amp;nbsp;called&amp;nbsp;X"&amp;nbsp;to&amp;nbsp;"a&amp;nbsp;private&amp;nbsp;GitHub&amp;nbsp;repo&amp;nbsp;exists,&amp;nbsp;a&amp;nbsp;free&amp;nbsp;Render&amp;nbsp;service&amp;nbsp;is&amp;nbsp;deploying&amp;nbsp;from&amp;nbsp;main,&amp;nbsp;and&amp;nbsp;&lt;code&gt;npm&amp;nbsp;run&amp;nbsp;dev&lt;/code&gt;&amp;nbsp;works&amp;nbsp;locally"&amp;nbsp;—&amp;nbsp;&lt;strong&gt;about&amp;nbsp;90&amp;nbsp;seconds&amp;nbsp;of&amp;nbsp;wall&amp;nbsp;time&lt;/strong&gt;.&amp;nbsp;Most&amp;nbsp;of&amp;nbsp;that&amp;nbsp;is&amp;nbsp;&lt;code&gt;npm&amp;nbsp;install&lt;/code&gt;&amp;nbsp;and&amp;nbsp;the&amp;nbsp;initial&amp;nbsp;&lt;code&gt;next&amp;nbsp;build&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three&amp;nbsp;approvals.&amp;nbsp;Zero&amp;nbsp;clicks&amp;nbsp;in&amp;nbsp;the&amp;nbsp;Render&amp;nbsp;dashboard.&amp;nbsp;Zero&amp;nbsp;copy-paste&amp;nbsp;from&amp;nbsp;old&amp;nbsp;projects.&lt;/strong&gt;&amp;nbsp;Zero&amp;nbsp;chance&amp;nbsp;I&amp;nbsp;forgot&amp;nbsp;to&amp;nbsp;rename&amp;nbsp;something&amp;nbsp;in&amp;nbsp;&lt;code&gt;package.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Compared&amp;nbsp;to&amp;nbsp;an&amp;nbsp;hour&amp;nbsp;of&amp;nbsp;manual&amp;nbsp;work&amp;nbsp;where&amp;nbsp;I&amp;nbsp;screwed&amp;nbsp;up&amp;nbsp;half&amp;nbsp;the&amp;nbsp;steps&amp;nbsp;—&amp;nbsp;&lt;strong&gt;worth&amp;nbsp;it&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Three&amp;nbsp;things&amp;nbsp;worth&amp;nbsp;stealing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Skills&amp;nbsp;beat&amp;nbsp;shell&amp;nbsp;scripts&amp;nbsp;for&amp;nbsp;anything&amp;nbsp;with&amp;nbsp;branching&amp;nbsp;logic.&lt;/strong&gt;&amp;nbsp;A&amp;nbsp;bash&amp;nbsp;script&amp;nbsp;with&amp;nbsp;&lt;code&gt;set&amp;nbsp;-e&lt;/code&gt;&amp;nbsp;exits&amp;nbsp;on&amp;nbsp;any&amp;nbsp;failure.&amp;nbsp;An&amp;nbsp;LLM-driven&amp;nbsp;skill&amp;nbsp;reads&amp;nbsp;the&amp;nbsp;error,&amp;nbsp;decides&amp;nbsp;whether&amp;nbsp;to&amp;nbsp;retry,&amp;nbsp;rephrase,&amp;nbsp;or&amp;nbsp;ask&amp;nbsp;the&amp;nbsp;human.&amp;nbsp;The&amp;nbsp;difference&amp;nbsp;shows&amp;nbsp;up&amp;nbsp;the&amp;nbsp;first&amp;nbsp;time&amp;nbsp;&lt;code&gt;gh&amp;nbsp;auth&amp;nbsp;status&lt;/code&gt;&amp;nbsp;is&amp;nbsp;expired&amp;nbsp;or&amp;nbsp;the&amp;nbsp;Render&amp;nbsp;API&amp;nbsp;rate-limits&amp;nbsp;you&amp;nbsp;—&amp;nbsp;the&amp;nbsp;bash&amp;nbsp;script&amp;nbsp;dies,&amp;nbsp;the&amp;nbsp;skill&amp;nbsp;adapts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Approval&amp;nbsp;gates&amp;nbsp;before&amp;nbsp;external&amp;nbsp;state&amp;nbsp;changes.&lt;/strong&gt;&amp;nbsp;Local&amp;nbsp;filesystem&amp;nbsp;mutations&amp;nbsp;and&amp;nbsp;&lt;code&gt;git&amp;nbsp;commit&lt;/code&gt;&amp;nbsp;are&amp;nbsp;cheap&amp;nbsp;to&amp;nbsp;undo.&amp;nbsp;GitHub&amp;nbsp;repo&amp;nbsp;creation,&amp;nbsp;Render&amp;nbsp;API&amp;nbsp;calls,&amp;nbsp;&lt;code&gt;git&amp;nbsp;push&lt;/code&gt;&amp;nbsp;—&amp;nbsp;those&amp;nbsp;leak&amp;nbsp;state&amp;nbsp;outside&amp;nbsp;my&amp;nbsp;machine.&amp;nbsp;A&amp;nbsp;hard&amp;nbsp;&lt;code&gt;[y/N]&lt;/code&gt;&amp;nbsp;confirmation&amp;nbsp;before&amp;nbsp;each&amp;nbsp;one&amp;nbsp;is&amp;nbsp;the&amp;nbsp;right&amp;nbsp;trade-off.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read&amp;nbsp;secrets&amp;nbsp;from&amp;nbsp;adjacent&amp;nbsp;projects.&lt;/strong&gt;&amp;nbsp;I&amp;nbsp;was&amp;nbsp;about&amp;nbsp;to&amp;nbsp;overthink&amp;nbsp;secret&amp;nbsp;storage&amp;nbsp;—&amp;nbsp;keychain,&amp;nbsp;1Password&amp;nbsp;CLI,&amp;nbsp;encrypted&amp;nbsp;env&amp;nbsp;file.&amp;nbsp;My&amp;nbsp;sibling&amp;nbsp;projects'&amp;nbsp;&lt;code&gt;.env.local&lt;/code&gt;&amp;nbsp;files&amp;nbsp;are&amp;nbsp;already&amp;nbsp;provisioned&amp;nbsp;and&amp;nbsp;protected.&amp;nbsp;One&amp;nbsp;less&amp;nbsp;system&amp;nbsp;to&amp;nbsp;maintain.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's&amp;nbsp;next
&lt;/h2&gt;

&lt;p&gt;The&amp;nbsp;skill&amp;nbsp;is&amp;nbsp;globally&amp;nbsp;installed&amp;nbsp;at&amp;nbsp;&lt;code&gt;~/.claude/skills/foundary-new-project/&lt;/code&gt;,&amp;nbsp;so&amp;nbsp;&lt;strong&gt;it&amp;nbsp;triggers&amp;nbsp;in&amp;nbsp;any&amp;nbsp;Claude&amp;nbsp;Code&amp;nbsp;session&amp;nbsp;regardless&amp;nbsp;of&amp;nbsp;working&amp;nbsp;directory&lt;/strong&gt;.&amp;nbsp;The&amp;nbsp;scripts&amp;nbsp;hard-code&amp;nbsp;paths&amp;nbsp;into&amp;nbsp;&lt;code&gt;~/foundary-tools/&lt;/code&gt;,&amp;nbsp;which&amp;nbsp;keeps&amp;nbsp;the&amp;nbsp;scope&amp;nbsp;tight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next&amp;nbsp;thing&amp;nbsp;I'm&amp;nbsp;automating:&amp;nbsp;the&amp;nbsp;shutdown&amp;nbsp;flow.&lt;/strong&gt;&amp;nbsp;When&amp;nbsp;a&amp;nbsp;project&amp;nbsp;turns&amp;nbsp;out&amp;nbsp;to&amp;nbsp;be&amp;nbsp;a&amp;nbsp;dead&amp;nbsp;end,&amp;nbsp;I&amp;nbsp;want&amp;nbsp;"archive&amp;nbsp;the&amp;nbsp;repo,&amp;nbsp;delete&amp;nbsp;the&amp;nbsp;Render&amp;nbsp;service,&amp;nbsp;drop&amp;nbsp;the&amp;nbsp;pgSchema,&amp;nbsp;move&amp;nbsp;the&amp;nbsp;folder&amp;nbsp;to&amp;nbsp;&lt;code&gt;~/foundary-tools/.graveyard/&lt;/code&gt;"&amp;nbsp;as&amp;nbsp;one&amp;nbsp;command.&amp;nbsp;Same&amp;nbsp;skill&amp;nbsp;pattern,&amp;nbsp;reversed.&lt;/p&gt;

&lt;p&gt;If&amp;nbsp;you&amp;nbsp;run&amp;nbsp;a&amp;nbsp;portfolio&amp;nbsp;of&amp;nbsp;similar&amp;nbsp;side&amp;nbsp;projects&amp;nbsp;as&amp;nbsp;a&amp;nbsp;solo&amp;nbsp;builder,&amp;nbsp;this&amp;nbsp;is&amp;nbsp;the&amp;nbsp;best&amp;nbsp;automation&amp;nbsp;I've&amp;nbsp;set&amp;nbsp;up&amp;nbsp;this&amp;nbsp;year.&amp;nbsp;&lt;strong&gt;The&amp;nbsp;real&amp;nbsp;win&amp;nbsp;is&amp;nbsp;the&amp;nbsp;marginal&amp;nbsp;cost&lt;/strong&gt;:&amp;nbsp;I&amp;nbsp;can&amp;nbsp;spin&amp;nbsp;up&amp;nbsp;a&amp;nbsp;new&amp;nbsp;experiment&amp;nbsp;at&amp;nbsp;9:47&amp;nbsp;PM&amp;nbsp;on&amp;nbsp;a&amp;nbsp;Tuesday&amp;nbsp;and&amp;nbsp;have&amp;nbsp;it&amp;nbsp;deploying&amp;nbsp;by&amp;nbsp;9:49.&lt;/p&gt;




&lt;h2&gt;
  
  
  External&amp;nbsp;Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://code.claude.com/docs/en/skills" rel="noopener noreferrer"&gt;Claude&amp;nbsp;Code&amp;nbsp;Skills&lt;/a&gt;&amp;nbsp;—&amp;nbsp;official&amp;nbsp;docs&amp;nbsp;on&amp;nbsp;building&amp;nbsp;and&amp;nbsp;installing&amp;nbsp;skills&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://api-docs.render.com/reference/create-service" rel="noopener noreferrer"&gt;Render&amp;nbsp;API&amp;nbsp;—&amp;nbsp;Create&amp;nbsp;Service&lt;/a&gt;&amp;nbsp;—&amp;nbsp;the&amp;nbsp;POST&amp;nbsp;/v1/services&amp;nbsp;endpoint&amp;nbsp;used&amp;nbsp;here&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://orm.drizzle.team/" rel="noopener noreferrer"&gt;Drizzle&amp;nbsp;ORM&lt;/a&gt;&amp;nbsp;—&amp;nbsp;TypeScript&amp;nbsp;ORM&amp;nbsp;with&amp;nbsp;schema-per-project&amp;nbsp;support&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://nextjs.org/" rel="noopener noreferrer"&gt;Next.js&lt;/a&gt;&amp;nbsp;—&amp;nbsp;the&amp;nbsp;React&amp;nbsp;framework&amp;nbsp;powering&amp;nbsp;the&amp;nbsp;template&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want more patterns like this landing in your inbox, &lt;a href="https://drippery.app/subscribe/6e302973-cf5f-47ff-8803-22122ac3bd9d" rel="noopener noreferrer"&gt;The Claude Code Memory Starter&lt;/a&gt; walks you through the setup over a few short emails — free.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>How I Taught My AI Coding Assistant to Remember — and You Can Too</title>
      <dc:creator>Digital Craft Workshop</dc:creator>
      <pubDate>Sun, 12 Jul 2026 15:07:06 +0000</pubDate>
      <link>https://dev.to/danielrusnok/how-i-taught-my-ai-coding-assistant-to-remember-and-you-can-too-1a21</link>
      <guid>https://dev.to/danielrusnok/how-i-taught-my-ai-coding-assistant-to-remember-and-you-can-too-1a21</guid>
      <description>&lt;p&gt;Every developer who uses AI coding assistants hits the same wall: you explain something once, and next conversation, it's forgotten. You correct a mistake, and three sessions later, the same mistake appears. It's like working with a brilliant colleague who has amnesia.&lt;/p&gt;

&lt;p&gt;Today, I want to share a workflow I've been using with Claude Code that fundamentally changes this dynamic. Instead of repeating myself, I tell Claude to &lt;strong&gt;write down what it learns&lt;/strong&gt; — and it actually sticks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem: Groundhog Day Development
&lt;/h2&gt;

&lt;p&gt;Here's a real scenario from my codebase. I'm building a team-based access control feature in a .NET + React application. During our first session, Claude:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Added comments to every file (I don't want comments)&lt;/li&gt;
&lt;li&gt;Created EF Core migrations (we use raw SQL scripts)&lt;/li&gt;
&lt;li&gt;Added unnecessary &lt;code&gt;using&lt;/code&gt; statements&lt;/li&gt;
&lt;li&gt;Used pricing flags instead of feature flags&lt;/li&gt;
&lt;li&gt;Forgot to add database GRANT permissions for new tables&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every single one of these mistakes would repeat in the next conversation. I'd have to re-explain the same rules over and over.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution: Two Persistence Mechanisms
&lt;/h2&gt;

&lt;p&gt;Claude Code has two built-in mechanisms for persistent context that survive across conversations.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. CLAUDE.md — Project Rules
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;CLAUDE.md&lt;/code&gt; sits in your project root and acts as a set of instructions Claude reads at the start of every conversation. Think of it as a &lt;code&gt;.editorconfig&lt;/code&gt; for AI behavior.&lt;/p&gt;

&lt;p&gt;Here's what mine looks like after a day of working together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Code Style Rules&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; No comments in code. Code should be self-explanatory.
&lt;span class="p"&gt;-&lt;/span&gt; No unnecessary usings. Every using must be actually needed.

&lt;span class="gu"&gt;## Database Rules&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Never create EF Core migrations. Always deliver raw PostgreSQL scripts.
&lt;span class="p"&gt;-&lt;/span&gt; Always add GRANT SELECT, INSERT, UPDATE, DELETE for application-dev user.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key insight: &lt;strong&gt;I didn't write these rules upfront.&lt;/strong&gt; They emerged organically from corrections during our work session. When I said "stop adding comments," I followed up with "write that down so you remember next time." Claude added the rule to CLAUDE.md, and the behavior stopped — permanently.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Memory Files — Contextual Knowledge
&lt;/h3&gt;

&lt;p&gt;The second mechanism is Claude's memory system — markdown files stored in &lt;code&gt;.claude/projects/.../memory/&lt;/code&gt;. Unlike CLAUDE.md (which holds rules), memory files store &lt;strong&gt;contextual knowledge&lt;/strong&gt; that helps Claude make better decisions.&lt;/p&gt;

&lt;p&gt;For example, after I explained how feature flags work in our codebase (frontend hooks, backend services, sysadmin razor pages), I told Claude to save that knowledge. It created a reference file covering:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which hook to use for feature flags vs pricing flags&lt;/li&gt;
&lt;li&gt;Where the sysadmin flag editor lives&lt;/li&gt;
&lt;li&gt;The naming convention for flags&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next time Claude needs to add a feature flag, it won't use a pricing flag by mistake. It won't forget to add the checkbox in the sysadmin razor page. The knowledge is there, indexed and searchable.&lt;/p&gt;

&lt;p&gt;If you'd rather have this whole setup broken into a few short, copy-paste steps in your inbox, I turned it into &lt;a href="https://drippery.app/subscribe/6e302973-cf5f-47ff-8803-22122ac3bd9d" rel="noopener noreferrer"&gt;The Claude Code Memory Starter&lt;/a&gt; — a free email series.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Workflow: Correct Once, Remember Forever
&lt;/h2&gt;

&lt;p&gt;The pattern is simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Claude makes a mistake&lt;/strong&gt; (or you notice a pattern worth preserving)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You correct it&lt;/strong&gt; — "don't do X" or "always do Y"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You tell Claude to save it&lt;/strong&gt; — "write that down" or "remember this for next time"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude persists it&lt;/strong&gt; — either as a CLAUDE.md rule or a memory file&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Future conversations benefit&lt;/strong&gt; — the rule or context is loaded automatically&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This turns every correction into a permanent improvement. Your AI assistant gets smarter with every session — not through model updates, but through accumulated project-specific knowledge.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Goes Where?
&lt;/h2&gt;

&lt;p&gt;Not everything belongs in the same place:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CLAUDE.md&lt;/strong&gt; is for actionable rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Code style preferences&lt;/li&gt;
&lt;li&gt;Workflow constraints (no migrations, always run tests)&lt;/li&gt;
&lt;li&gt;Project-specific patterns to follow or avoid&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Memory files&lt;/strong&gt; are for contextual knowledge:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How systems work (feature flags, auth, deployment)&lt;/li&gt;
&lt;li&gt;Project goals and motivations&lt;/li&gt;
&lt;li&gt;References to external resources&lt;/li&gt;
&lt;li&gt;Team member roles and responsibilities&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Neither&lt;/strong&gt; should contain things derivable from code (architecture, file paths) or git history (recent changes, who did what).&lt;/p&gt;

&lt;h2&gt;
  
  
  Real Impact
&lt;/h2&gt;

&lt;p&gt;After one working session with this approach, my Claude Code setup knows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Never add comments to code&lt;/li&gt;
&lt;li&gt;Always provide raw SQL with GRANT statements for new database objects&lt;/li&gt;
&lt;li&gt;How feature flags work across three layers (frontend, backend, sysadmin)&lt;/li&gt;
&lt;li&gt;The full requirements spec for the feature we're building&lt;/li&gt;
&lt;li&gt;That &lt;code&gt;tms_accs&lt;/code&gt; is a feature flag, not a pricing flag&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tomorrow, when I start a new conversation to continue this feature, none of this context will be lost. I won't waste the first 10 minutes re-explaining constraints. Claude will just... know.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting Started
&lt;/h2&gt;

&lt;p&gt;If you're using Claude Code, you can start right now:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create a &lt;code&gt;CLAUDE.md&lt;/code&gt; in your project root&lt;/li&gt;
&lt;li&gt;Next time you correct Claude, add: "and write that down so you remember"&lt;/li&gt;
&lt;li&gt;Watch as your AI assistant accumulates project knowledge over time&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The best part? These files are just markdown. You can review them, edit them, commit them to your repo (CLAUDE.md), or keep them local (memory files). Your team members can benefit from the same rules, or each developer can have their own memory layer.&lt;/p&gt;

&lt;p&gt;It's not about having a smarter model. It's about building a smarter &lt;strong&gt;working relationship&lt;/strong&gt; — one correction at a time.&lt;/p&gt;

&lt;p&gt;If you want the starter version of this to land in your inbox, &lt;a href="https://drippery.app/subscribe/6e302973-cf5f-47ff-8803-22122ac3bd9d" rel="noopener noreferrer"&gt;The Claude Code Memory Starter&lt;/a&gt; walks you through it over a few short emails — free.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>From .NET Architect to Frontend Developer — What Surprised Me, What I Miss, and What I Had to…</title>
      <dc:creator>Digital Craft Workshop</dc:creator>
      <pubDate>Fri, 10 Jul 2026 15:03:05 +0000</pubDate>
      <link>https://dev.to/danielrusnok/from-net-architect-to-frontend-developer-what-surprised-me-what-i-miss-and-what-i-had-to-337</link>
      <guid>https://dev.to/danielrusnok/from-net-architect-to-frontend-developer-what-surprised-me-what-i-miss-and-what-i-had-to-337</guid>
      <description>&lt;h3&gt;
  
  
  From&amp;nbsp;.NET&amp;nbsp;Architect&amp;nbsp;to&amp;nbsp;Frontend&amp;nbsp;Developer&amp;nbsp;—&amp;nbsp;What&amp;nbsp;Surprised&amp;nbsp;Me,&amp;nbsp;What&amp;nbsp;I&amp;nbsp;Miss,&amp;nbsp;and&amp;nbsp;What&amp;nbsp;I&amp;nbsp;Had&amp;nbsp;to&amp;nbsp;Unlearn
&lt;/h3&gt;

&lt;h4&gt;
  
  
  You&amp;nbsp;may&amp;nbsp;remember&amp;nbsp;me&amp;nbsp;from&amp;nbsp;the&amp;nbsp;days&amp;nbsp;when&amp;nbsp;I&amp;nbsp;wrote&amp;nbsp;about&amp;nbsp;CQRS,&amp;nbsp;DDD,&amp;nbsp;and&amp;nbsp;.NET.&amp;nbsp;It’s&amp;nbsp;been&amp;nbsp;three&amp;nbsp;years&amp;nbsp;—&amp;nbsp;I’ve&amp;nbsp;crossed&amp;nbsp;the&amp;nbsp;bridge&amp;nbsp;to&amp;nbsp;the&amp;nbsp;other&amp;nbsp;side.
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2znbksd8jk517e2fcr27.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2znbksd8jk517e2fcr27.png" alt="From&amp;nbsp;.NET Architect to Frontend Developer — What Surprised Me, What I Miss, and What I Had to… cover image" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;
Bridge&amp;nbsp;between&amp;nbsp;backend&amp;nbsp;and&amp;nbsp;frontend&amp;nbsp;(chatGPT&amp;nbsp;generated)



&lt;p&gt;I&amp;nbsp;burned&amp;nbsp;out&amp;nbsp;at&amp;nbsp;my&amp;nbsp;previous&amp;nbsp;job&amp;nbsp;three&amp;nbsp;years&amp;nbsp;ago&amp;nbsp;and&amp;nbsp;started&amp;nbsp;looking&amp;nbsp;for&amp;nbsp;new&amp;nbsp;opportunities.&amp;nbsp;Along&amp;nbsp;with&amp;nbsp;a&amp;nbsp;new&amp;nbsp;company&amp;nbsp;came&amp;nbsp;the&amp;nbsp;chance&amp;nbsp;to&amp;nbsp;fill&amp;nbsp;a&amp;nbsp;missing&amp;nbsp;role&amp;nbsp;on&amp;nbsp;the&amp;nbsp;front-end&amp;nbsp;team.&amp;nbsp;I&amp;nbsp;took&amp;nbsp;the&amp;nbsp;opportunity&amp;nbsp;and&amp;nbsp;gradually&amp;nbsp;transitioned&amp;nbsp;from&amp;nbsp;full-stack&amp;nbsp;to&amp;nbsp;fully&amp;nbsp;front-end&amp;nbsp;focused.&amp;nbsp;And&amp;nbsp;just&amp;nbsp;like&amp;nbsp;that,&amp;nbsp;my&amp;nbsp;entire&amp;nbsp;scope&amp;nbsp;changed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why&amp;nbsp;the&amp;nbsp;sudden&amp;nbsp;shift?
&lt;/h3&gt;

&lt;p&gt;I’ve&amp;nbsp;always&amp;nbsp;wanted&amp;nbsp;to&amp;nbsp;work&amp;nbsp;on&amp;nbsp;something&amp;nbsp;more&amp;nbsp;visible.&amp;nbsp;My&amp;nbsp;creativity&amp;nbsp;was&amp;nbsp;getting&amp;nbsp;thirstier&amp;nbsp;by&amp;nbsp;the&amp;nbsp;day,&amp;nbsp;and&amp;nbsp;the&amp;nbsp;backend&amp;nbsp;started&amp;nbsp;to&amp;nbsp;feel&amp;nbsp;dull.&lt;/p&gt;

&lt;p&gt;In&amp;nbsp;the&amp;nbsp;backend&amp;nbsp;world,&amp;nbsp;I&amp;nbsp;satisfied&amp;nbsp;my&amp;nbsp;creative&amp;nbsp;itch&amp;nbsp;through&amp;nbsp;architecture&amp;nbsp;—&amp;nbsp;from&amp;nbsp;individual&amp;nbsp;projects&amp;nbsp;and&amp;nbsp;services&amp;nbsp;to&amp;nbsp;entire&amp;nbsp;application&amp;nbsp;structures.&amp;nbsp;Now,&amp;nbsp;I&amp;nbsp;express&amp;nbsp;that&amp;nbsp;creativity&amp;nbsp;through&amp;nbsp;layout&amp;nbsp;design,&amp;nbsp;animation&amp;nbsp;choices,&amp;nbsp;and&amp;nbsp;collaboration&amp;nbsp;with&amp;nbsp;UX&amp;nbsp;designers.&lt;/p&gt;

&lt;h3&gt;
  
  
  How&amp;nbsp;do&amp;nbsp;I&amp;nbsp;feel&amp;nbsp;about&amp;nbsp;React?
&lt;/h3&gt;

&lt;p&gt;React&amp;nbsp;clicked&amp;nbsp;with&amp;nbsp;me&amp;nbsp;right&amp;nbsp;away.&amp;nbsp;Its&amp;nbsp;component-based&amp;nbsp;structure&amp;nbsp;is&amp;nbsp;exactly&amp;nbsp;my&amp;nbsp;kind&amp;nbsp;of&amp;nbsp;fun.&amp;nbsp;Finding&amp;nbsp;the&amp;nbsp;sweet&amp;nbsp;spot&amp;nbsp;between&amp;nbsp;generic&amp;nbsp;reusability&amp;nbsp;and&amp;nbsp;the&amp;nbsp;Single&amp;nbsp;Responsibility&amp;nbsp;Principle&amp;nbsp;is&amp;nbsp;what&amp;nbsp;drives&amp;nbsp;me&amp;nbsp;most.&lt;/p&gt;

&lt;h3&gt;
  
  
  What&amp;nbsp;surprised&amp;nbsp;me&amp;nbsp;about&amp;nbsp;front&amp;nbsp;end?
&lt;/h3&gt;

&lt;h4&gt;
  
  
  📈&amp;nbsp;The&amp;nbsp;Progress
&lt;/h4&gt;

&lt;p&gt;Before&amp;nbsp;React,&amp;nbsp;my&amp;nbsp;last&amp;nbsp;encounter&amp;nbsp;with&amp;nbsp;HTML,&amp;nbsp;CSS,&amp;nbsp;and&amp;nbsp;JavaScript&amp;nbsp;(mainly&amp;nbsp;jQuery)&amp;nbsp;was&amp;nbsp;about&amp;nbsp;ten&amp;nbsp;years&amp;nbsp;ago&amp;nbsp;—&amp;nbsp;and&amp;nbsp;mostly&amp;nbsp;just&amp;nbsp;minor&amp;nbsp;tweaks.&amp;nbsp;Today,&amp;nbsp;I&amp;nbsp;work&amp;nbsp;on&amp;nbsp;entire&amp;nbsp;websites&amp;nbsp;and&amp;nbsp;complex&amp;nbsp;features.&amp;nbsp;The&amp;nbsp;front&amp;nbsp;end&amp;nbsp;has&amp;nbsp;come&amp;nbsp;a&amp;nbsp;long&amp;nbsp;way.&amp;nbsp;With&amp;nbsp;React,&amp;nbsp;TypeScript,&amp;nbsp;Node.js,&amp;nbsp;and&amp;nbsp;Styled&amp;nbsp;Components,&amp;nbsp;it&amp;nbsp;feels&amp;nbsp;like&amp;nbsp;an&amp;nbsp;entirely&amp;nbsp;different&amp;nbsp;kind&amp;nbsp;of&amp;nbsp;programming.&lt;/p&gt;

&lt;h4&gt;
  
  
  🛠&amp;nbsp;Tooling
&lt;/h4&gt;

&lt;p&gt;I&amp;nbsp;expected&amp;nbsp;chaos&amp;nbsp;but&amp;nbsp;got&amp;nbsp;a&amp;nbsp;surprisingly&amp;nbsp;mature&amp;nbsp;ecosystem.&amp;nbsp;Prettier&amp;nbsp;and&amp;nbsp;ESLint&amp;nbsp;help&amp;nbsp;keep&amp;nbsp;the&amp;nbsp;codebase&amp;nbsp;nice&amp;nbsp;and&amp;nbsp;tidy.&amp;nbsp;Visual&amp;nbsp;Studio&amp;nbsp;Code&amp;nbsp;is&amp;nbsp;a&amp;nbsp;pleasant,&amp;nbsp;lightweight&amp;nbsp;environment,&amp;nbsp;and&amp;nbsp;although&amp;nbsp;Microsoft&amp;nbsp;has&amp;nbsp;made&amp;nbsp;good&amp;nbsp;progress&amp;nbsp;with&amp;nbsp;the&amp;nbsp;performance&amp;nbsp;of&amp;nbsp;Visual&amp;nbsp;Studio,&amp;nbsp;it&amp;nbsp;still&amp;nbsp;feels&amp;nbsp;bulky&amp;nbsp;to&amp;nbsp;me.&lt;/p&gt;

&lt;h4&gt;
  
  
  📦&amp;nbsp;Bundle&amp;nbsp;size&amp;nbsp;matters
&lt;/h4&gt;

&lt;p&gt;In&amp;nbsp;the&amp;nbsp;backend,&amp;nbsp;I’d&amp;nbsp;happily&amp;nbsp;install&amp;nbsp;any&amp;nbsp;NuGet&amp;nbsp;package&amp;nbsp;—&amp;nbsp;service&amp;nbsp;size&amp;nbsp;wasn’t&amp;nbsp;usually&amp;nbsp;an&amp;nbsp;issue&amp;nbsp;(unless&amp;nbsp;you&amp;nbsp;were&amp;nbsp;deploying&amp;nbsp;in&amp;nbsp;some&amp;nbsp;expensive&amp;nbsp;cloud).&amp;nbsp;In&amp;nbsp;front&amp;nbsp;end,&amp;nbsp;bundle&amp;nbsp;size&amp;nbsp;affects&amp;nbsp;load&amp;nbsp;speed,&amp;nbsp;so&amp;nbsp;you&amp;nbsp;tend&amp;nbsp;to&amp;nbsp;write&amp;nbsp;utilities&amp;nbsp;yourself&amp;nbsp;rather&amp;nbsp;than&amp;nbsp;pulling&amp;nbsp;in&amp;nbsp;a&amp;nbsp;bloated&amp;nbsp;library&amp;nbsp;just&amp;nbsp;for&amp;nbsp;two&amp;nbsp;functions.&lt;/p&gt;

&lt;h3&gt;
  
  
  What&amp;nbsp;do&amp;nbsp;I&amp;nbsp;miss&amp;nbsp;from&amp;nbsp;the&amp;nbsp;backend?
&lt;/h3&gt;

&lt;h4&gt;
  
  
  ❌&amp;nbsp;Clean&amp;nbsp;compiler&amp;nbsp;errors
&lt;/h4&gt;

&lt;p&gt;Maybe&amp;nbsp;it’s&amp;nbsp;just&amp;nbsp;our&amp;nbsp;internal&amp;nbsp;tooling,&amp;nbsp;but&amp;nbsp;deciphering&amp;nbsp;cryptic&amp;nbsp;error&amp;nbsp;messages&amp;nbsp;and&amp;nbsp;not&amp;nbsp;being&amp;nbsp;able&amp;nbsp;to&amp;nbsp;click&amp;nbsp;straight&amp;nbsp;to&amp;nbsp;the&amp;nbsp;offending&amp;nbsp;file&amp;nbsp;still&amp;nbsp;feels&amp;nbsp;frustrating.&amp;nbsp;Visual&amp;nbsp;Studio&amp;nbsp;spoiled&amp;nbsp;me.&lt;/p&gt;

&lt;h4&gt;
  
  
  🧪&amp;nbsp;Unit&amp;nbsp;testing
&lt;/h4&gt;

&lt;p&gt;&lt;em&gt;Side note: if you like reading about the craft of crossing between backend and frontend, I share short, free lessons like that in &lt;a href="https://drippery.app/subscribe/6e302973-cf5f-47ff-8803-22122ac3bd9d" rel="noopener noreferrer"&gt;The Claude Code Memory Starter&lt;/a&gt;, a tiny email series you can join in one click.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Sure,&amp;nbsp;there’s&amp;nbsp;Jest,&amp;nbsp;but&amp;nbsp;unit-testing&amp;nbsp;React&amp;nbsp;components&amp;nbsp;doesn’t&amp;nbsp;spark&amp;nbsp;joy&amp;nbsp;for&amp;nbsp;me.&amp;nbsp;It&amp;nbsp;makes&amp;nbsp;sense&amp;nbsp;in&amp;nbsp;design&amp;nbsp;systems&amp;nbsp;and&amp;nbsp;shared&amp;nbsp;libraries.&amp;nbsp;But&amp;nbsp;if&amp;nbsp;you’ve&amp;nbsp;got&amp;nbsp;a&amp;nbsp;QA&amp;nbsp;engineer&amp;nbsp;automating&amp;nbsp;tests&amp;nbsp;via&amp;nbsp;Playwright,&amp;nbsp;I&amp;nbsp;honestly&amp;nbsp;don’t&amp;nbsp;see&amp;nbsp;much&amp;nbsp;benefit.&amp;nbsp;On&amp;nbsp;the&amp;nbsp;backend,&amp;nbsp;I&amp;nbsp;often&amp;nbsp;used&amp;nbsp;TDD.&amp;nbsp;On&amp;nbsp;the&amp;nbsp;front&amp;nbsp;end,&amp;nbsp;I&amp;nbsp;haven’t&amp;nbsp;even&amp;nbsp;thought&amp;nbsp;of&amp;nbsp;it.&lt;/p&gt;

&lt;h4&gt;
  
  
  📚&amp;nbsp;Third-party&amp;nbsp;libraries
&lt;/h4&gt;

&lt;p&gt;Every&amp;nbsp;dependency&amp;nbsp;needs&amp;nbsp;to&amp;nbsp;have&amp;nbsp;a&amp;nbsp;healthy&amp;nbsp;community&amp;nbsp;and&amp;nbsp;a&amp;nbsp;small&amp;nbsp;footprint.&amp;nbsp;You&amp;nbsp;can’t&amp;nbsp;just&amp;nbsp;grab&amp;nbsp;the&amp;nbsp;first&amp;nbsp;thing&amp;nbsp;that&amp;nbsp;pops&amp;nbsp;up&amp;nbsp;in&amp;nbsp;a&amp;nbsp;search&amp;nbsp;—&amp;nbsp;you&amp;nbsp;need&amp;nbsp;to&amp;nbsp;do&amp;nbsp;at&amp;nbsp;least&amp;nbsp;some&amp;nbsp;due&amp;nbsp;diligence.&lt;/p&gt;

&lt;h3&gt;
  
  
  What&amp;nbsp;I&amp;nbsp;Had&amp;nbsp;to&amp;nbsp;Unlearn
&lt;/h3&gt;

&lt;h4&gt;
  
  
  🧠&amp;nbsp;OOP&amp;nbsp;Principles&amp;nbsp;—&amp;nbsp;used&amp;nbsp;rarely
&lt;/h4&gt;

&lt;p&gt;I&amp;nbsp;barely&amp;nbsp;use&amp;nbsp;object-oriented&amp;nbsp;principles&amp;nbsp;these&amp;nbsp;days.&amp;nbsp;Inheritance&amp;nbsp;appears&amp;nbsp;only&amp;nbsp;thanks&amp;nbsp;to&amp;nbsp;TypeScript,&amp;nbsp;mainly&amp;nbsp;for&amp;nbsp;models&amp;nbsp;and&amp;nbsp;component&amp;nbsp;interfaces.&amp;nbsp;Encapsulation?&amp;nbsp;Only&amp;nbsp;when&amp;nbsp;a&amp;nbsp;component&amp;nbsp;is&amp;nbsp;used&amp;nbsp;exclusively&amp;nbsp;in&amp;nbsp;one&amp;nbsp;file.&lt;/p&gt;

&lt;p&gt;&amp;nbsp;And&amp;nbsp;the&amp;nbsp;only&amp;nbsp;kind&amp;nbsp;of&amp;nbsp;polymorphism&amp;nbsp;I&amp;nbsp;can&amp;nbsp;think&amp;nbsp;of&amp;nbsp;is&amp;nbsp;when&amp;nbsp;&lt;code&gt;children&lt;/code&gt;&amp;nbsp;are&amp;nbsp;passed&amp;nbsp;into&amp;nbsp;a&amp;nbsp;React&amp;nbsp;component&amp;nbsp;—&amp;nbsp;allowing&amp;nbsp;it&amp;nbsp;to&amp;nbsp;take&amp;nbsp;on&amp;nbsp;different&amp;nbsp;shapes&amp;nbsp;or&amp;nbsp;content.&lt;/p&gt;

&lt;h4&gt;
  
  
  🧱&amp;nbsp;Monolithic&amp;nbsp;thinking
&lt;/h4&gt;

&lt;p&gt;I&amp;nbsp;no&amp;nbsp;longer&amp;nbsp;need&amp;nbsp;to&amp;nbsp;think&amp;nbsp;in&amp;nbsp;terms&amp;nbsp;of&amp;nbsp;big&amp;nbsp;code&amp;nbsp;blocks&amp;nbsp;—&amp;nbsp;modules,&amp;nbsp;domains,&amp;nbsp;layers,&amp;nbsp;orchestration.&lt;/p&gt;

&lt;p&gt;&amp;nbsp;Frontend&amp;nbsp;forced&amp;nbsp;me&amp;nbsp;to&amp;nbsp;think&amp;nbsp;in&amp;nbsp;much&amp;nbsp;finer&amp;nbsp;granularity&amp;nbsp;—&amp;nbsp;small&amp;nbsp;components&amp;nbsp;that&amp;nbsp;snap&amp;nbsp;together&amp;nbsp;like&amp;nbsp;Lego&amp;nbsp;bricks.&lt;/p&gt;

&lt;h4&gt;
  
  
  ⚙️&amp;nbsp;Abstraction
&lt;/h4&gt;

&lt;p&gt;On&amp;nbsp;the&amp;nbsp;backend,&amp;nbsp;I&amp;nbsp;used&amp;nbsp;to&amp;nbsp;spend&amp;nbsp;hours&amp;nbsp;building&amp;nbsp;the&amp;nbsp;perfect&amp;nbsp;abstraction.&amp;nbsp;On&amp;nbsp;the&amp;nbsp;front&amp;nbsp;end,&amp;nbsp;I&amp;nbsp;rarely&amp;nbsp;feel&amp;nbsp;the&amp;nbsp;need&amp;nbsp;—&amp;nbsp;and&amp;nbsp;often&amp;nbsp;don’t&amp;nbsp;even&amp;nbsp;see&amp;nbsp;the&amp;nbsp;room&amp;nbsp;for&amp;nbsp;it.&lt;/p&gt;

&lt;h3&gt;
  
  
  In&amp;nbsp;closing
&lt;/h3&gt;

&lt;p&gt;I’ve&amp;nbsp;missed&amp;nbsp;writing,&amp;nbsp;and&amp;nbsp;this&amp;nbsp;post&amp;nbsp;is&amp;nbsp;my&amp;nbsp;attempt&amp;nbsp;to&amp;nbsp;start&amp;nbsp;something&amp;nbsp;new.&lt;/p&gt;

&lt;p&gt;&amp;nbsp;I’d&amp;nbsp;love&amp;nbsp;to&amp;nbsp;write&amp;nbsp;blog&amp;nbsp;posts&amp;nbsp;from&amp;nbsp;the&amp;nbsp;perspective&amp;nbsp;of&amp;nbsp;someone&amp;nbsp;standing&amp;nbsp;between&amp;nbsp;the&amp;nbsp;backend&amp;nbsp;and&amp;nbsp;front&amp;nbsp;end&amp;nbsp;worlds&amp;nbsp;—&amp;nbsp;or&amp;nbsp;from&amp;nbsp;a&amp;nbsp;backend&amp;nbsp;developer&amp;nbsp;trying&amp;nbsp;to&amp;nbsp;build&amp;nbsp;good&amp;nbsp;frontend&amp;nbsp;code.&lt;/p&gt;

&lt;p&gt;Thanks&amp;nbsp;for&amp;nbsp;reading.&lt;/p&gt;

&lt;p&gt;&amp;nbsp;Have&amp;nbsp;any&amp;nbsp;of&amp;nbsp;you&amp;nbsp;made&amp;nbsp;a&amp;nbsp;similar&amp;nbsp;transition?&amp;nbsp;What&amp;nbsp;surprised&amp;nbsp;you&amp;nbsp;the&amp;nbsp;most?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If this resonated, you might also enjoy &lt;a href="https://drippery.app/subscribe/6e302973-cf5f-47ff-8803-22122ac3bd9d" rel="noopener noreferrer"&gt;The Claude Code Memory Starter&lt;/a&gt; — a short free email series, one bite-sized lesson at a time.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  &amp;nbsp;Related&amp;nbsp;Reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  [LINK&amp;nbsp;PLACEHOLDER&amp;nbsp;-&amp;nbsp;&lt;a href="https://medium.com/@danielrusnok/linq-vs-typescript-method-equivalents-at-a-glance-8be1bdcc279d%5D%C2%A0%E2%80%94%C2%A0_LINQ%C2%A0vs%C2%A0TypeScript:%C2%A0Method%C2%A0Equivalents%C2%A0at%C2%A0a%C2%A0Glance_" rel="noopener noreferrer"&gt;https://medium.com/@danielrusnok/linq-vs-typescript-method-equivalents-at-a-glance-8be1bdcc279d]&amp;nbsp;—&amp;nbsp;_LINQ&amp;nbsp;vs&amp;nbsp;TypeScript:&amp;nbsp;Method&amp;nbsp;Equivalents&amp;nbsp;at&amp;nbsp;a&amp;nbsp;Glance_&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  [LINK&amp;nbsp;PLACEHOLDER&amp;nbsp;-&amp;nbsp;&lt;a href="https://medium.com/@danielrusnok/3-typescript-features-that-make-c-developers-jealous-ab6e416cf368%5D%C2%A0%E2%80%94%C2%A0_3%C2%A0TypeScript%C2%A0Features%C2%A0That%C2%A0Make%C2%A0C#%C2%A0Developers%C2%A0Jealous_" rel="noopener noreferrer"&gt;https://medium.com/@danielrusnok/3-typescript-features-that-make-c-developers-jealous-ab6e416cf368]&amp;nbsp;—&amp;nbsp;_3&amp;nbsp;TypeScript&amp;nbsp;Features&amp;nbsp;That&amp;nbsp;Make&amp;nbsp;C#&amp;nbsp;Developers&amp;nbsp;Jealous_&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  [LINK&amp;nbsp;PLACEHOLDER&amp;nbsp;-&amp;nbsp;&lt;a href="https://levelup.gitconnected.com/5-c-patterns-that-every-typescript-developer-should-steal-c8156c32bc4c%5D%C2%A0%E2%80%94%C2%A0_5%C2%A0C#%C2%A0Patterns%C2%A0That%C2%A0Every%C2%A0TypeScript%C2%A0Developer%C2%A0Should%C2%A0Steal_" rel="noopener noreferrer"&gt;https://levelup.gitconnected.com/5-c-patterns-that-every-typescript-developer-should-steal-c8156c32bc4c]&amp;nbsp;—&amp;nbsp;_5&amp;nbsp;C#&amp;nbsp;Patterns&amp;nbsp;That&amp;nbsp;Every&amp;nbsp;TypeScript&amp;nbsp;Developer&amp;nbsp;Should&amp;nbsp;Steal_&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  [LINK&amp;nbsp;PLACEHOLDER&amp;nbsp;-&amp;nbsp;&lt;a href="https://medium.com/@danielrusnok/ai-is-quietly-erasing-the-line-between-frontend-and-backend-developers-2fe70a9da546%5D%C2%A0%E2%80%94%C2%A0_AI%C2%A0Is%C2%A0Quietly%C2%A0Erasing%C2%A0the%C2%A0Line%C2%A0Between%C2%A0Frontend%C2%A0and%C2%A0Backend%C2%A0Developers_" rel="noopener noreferrer"&gt;https://medium.com/@danielrusnok/ai-is-quietly-erasing-the-line-between-frontend-and-backend-developers-2fe70a9da546]&amp;nbsp;—&amp;nbsp;_AI&amp;nbsp;Is&amp;nbsp;Quietly&amp;nbsp;Erasing&amp;nbsp;the&amp;nbsp;Line&amp;nbsp;Between&amp;nbsp;Frontend&amp;nbsp;and&amp;nbsp;Backend&amp;nbsp;Developers_&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  [LINK&amp;nbsp;PLACEHOLDER&amp;nbsp;-&amp;nbsp;&lt;a href="https://ai.plainenglish.io/domain-driven-design-is-the-architecture-ai-agents-were-waiting-for-4742509a44db%5D%C2%A0%E2%80%94%C2%A0_Domain-Driven%C2%A0Design%C2%A0Is%C2%A0the%C2%A0Architecture%C2%A0AI%C2%A0Agents%C2%A0Were%C2%A0Waiting%C2%A0For_" rel="noopener noreferrer"&gt;https://ai.plainenglish.io/domain-driven-design-is-the-architecture-ai-agents-were-waiting-for-4742509a44db]&amp;nbsp;—&amp;nbsp;_Domain-Driven&amp;nbsp;Design&amp;nbsp;Is&amp;nbsp;the&amp;nbsp;Architecture&amp;nbsp;AI&amp;nbsp;Agents&amp;nbsp;Were&amp;nbsp;Waiting&amp;nbsp;For_&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>career</category>
      <category>frontend</category>
      <category>react</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
