<?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: Nathan</title>
    <description>The latest articles on DEV Community by Nathan (@nathan-brodin).</description>
    <link>https://dev.to/nathan-brodin</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%2F3806287%2Fccb3d6e1-eb01-4eff-8594-cc7e3a837f9e.jpg</url>
      <title>DEV Community: Nathan</title>
      <link>https://dev.to/nathan-brodin</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nathan-brodin"/>
    <language>en</language>
    <item>
      <title>OG Images Without an Image CDN</title>
      <dc:creator>Nathan</dc:creator>
      <pubDate>Fri, 10 Jul 2026 11:54:19 +0000</pubDate>
      <link>https://dev.to/nathan-brodin/og-images-without-an-image-cdn-2g0l</link>
      <guid>https://dev.to/nathan-brodin/og-images-without-an-image-cdn-2g0l</guid>
      <description>&lt;p&gt;Link previews are the first thing anyone sees of a blog post. They're also the last thing most people think about. The usual answers are a Figma file you forget to update, or &lt;code&gt;@vercel/og&lt;/code&gt;, or an image-CDN pipeline you stand up once and never touch again.&lt;/p&gt;

&lt;p&gt;I do something dumber and more fun: my OG images are just screenshots of my own React routes.&lt;/p&gt;

&lt;p&gt;Every page that needs an OG has a sibling route that renders the actual preview. The screenshot is taken at build time with Puppeteer. The result looks like my site because it &lt;em&gt;is&lt;/em&gt; my site: same fonts, same dither shader, same Grid, same theme-aware icons.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trick: render the OG, don't design it
&lt;/h2&gt;

&lt;p&gt;I have three OG routes, each a normal TanStack Router file route returning a normal React component:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/og&lt;/code&gt; — the site-wide preview, used as default &lt;code&gt;og:image&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/blog/og&lt;/code&gt; — the blog listing preview ("Nathan's Blog").&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/blog/$slug/og&lt;/code&gt; — the per-post preview, with the post title, its date, and a small dither shader header.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can even see it by yourself: e.g &lt;a href="https://brodin.dev/og" rel="noopener noreferrer"&gt;brodin.dev/og&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The component in each route is the same shape: a constrained &lt;code&gt;div&lt;/code&gt; with &lt;code&gt;id="og"&lt;/code&gt;, sized exactly &lt;code&gt;1200 × 630&lt;/code&gt;, reusing my real UI primitives.&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"og"&lt;/span&gt;
  &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"relative mt-2 flex h-full flex-col items-center justify-between overflow-hidden border p-14"&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;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="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* ...PageTitle, PageDescription, tech-stack icons... */&lt;/span&gt;&lt;span class="si"&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="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"pointer-events-none absolute inset-0 -z-1 h-full w-full overflow-hidden opacity-10"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Dithering&lt;/span&gt;
      &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;630&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;colorBack&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;resolvedTheme&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dark&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;#000000&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;#FFFFFF&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;colorFront&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;resolvedTheme&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dark&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;#cbfbf1&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;#00786f&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"warp"&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"4x4"&lt;/span&gt;
      &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;speed&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mf"&gt;1.84&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;&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;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Grid&lt;/span&gt; &lt;span class="p"&gt;/&amp;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;a href="https://shaders.paper.design/" rel="noopener noreferrer"&gt;Paper Shaders&lt;/a&gt; dither is the same one I use on my section dividers. The &lt;code&gt;PageTitle&lt;/code&gt;is the same component that renders the H1 on every real page. The Grid is the same&lt;code&gt;Grid&lt;/code&gt; from my home page. There is no separate design system for OG. The OG &lt;em&gt;is&lt;/em&gt; the design system, just constrained to a poster.&lt;/p&gt;

&lt;p&gt;Because the per-post route is a real route with a real loader, it pulls the post title and date from Content Collections just like the actual blog post page does. No drift. Update the post, the OG regenerates on the next build.&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://github.com/NathanBrodin/Portfolio/blob/main/scripts/generate-og.ts" rel="noopener noreferrer"&gt;&lt;code&gt;scripts/generate-og.ts&lt;/code&gt;&lt;/a&gt; does the work:&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;captureOg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;url&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="nx"&gt;outputPath&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="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;domcontentloaded&lt;/span&gt;&lt;span class="dl"&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForSelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#og&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;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&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;og&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&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;#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;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;og&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`OG element (#og) not found at &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;url&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="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;mkdirSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dirname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;outputPath&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;recursive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;og&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;screenshot&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;outputPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;png&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;stats&lt;/span&gt; &lt;span class="o"&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;statSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;outputPath&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;fileSizeMB&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&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;2&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;stats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;MAX_FILE_SIZE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`OG file size (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;fileSizeMB&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;MB) exceeds 8MB limit: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;outputPath&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="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;`  &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;outputPath&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="nx"&gt;fileSizeMB&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;MB)`&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 details matter here. First, &lt;code&gt;waitUntil: 'domcontentloaded'&lt;/code&gt; instead of &lt;code&gt;'networkidle'&lt;/code&gt;. &lt;code&gt;networkidle&lt;/code&gt; waits for &lt;em&gt;quiet&lt;/em&gt;, which fonts and async chunks can delay by seconds. I don't need quiet. I need the &lt;code&gt;#og&lt;/code&gt; element to be painted, so I &lt;code&gt;waitForSelector('#og', { timeout: 30000 })&lt;/code&gt;. It's faster and more reliable than waiting for the network to settle.&lt;/p&gt;

&lt;p&gt;Second, I screenshot the &lt;code&gt;#og&lt;/code&gt; element, not the viewport. The Puppeteer page is set to &lt;code&gt;1920 × 1080&lt;/code&gt;, but the captured image is the element at exactly &lt;code&gt;1200 × 630&lt;/code&gt;. That means the OG box can live anywhere in the layout and I still get a clean crop. No pixel-counting in the script.&lt;/p&gt;

&lt;h2&gt;
  
  
  One image per blog post, automatically
&lt;/h2&gt;

&lt;p&gt;The script crawls &lt;code&gt;content/blog/*.md&lt;/code&gt; and screenshots each post's OG route, writing &lt;code&gt;public/og/blog/&amp;lt;slug&amp;gt;.png&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getBlogSlugs&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="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&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;readdirSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CONTENT_BLOG_DIR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;recursive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.md&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="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;md$/&lt;/span&gt;&lt;span class="p"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;New post in &lt;code&gt;content/blog/&lt;/code&gt;, run &lt;code&gt;pnpm generate:og&lt;/code&gt;, get one new image. No manual step, no Figma export.&lt;/p&gt;

&lt;p&gt;The blog post route then wires that image into its &lt;code&gt;head()&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ogImage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;siteConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/og/blog/&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;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.png`&lt;/span&gt;
&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;property&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;og:image&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ogImage&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;twitter:image&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ogImage&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So updating a post title or date updates the OG on the next build. The link preview always matches the post.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 8 MB guardrail
&lt;/h2&gt;

&lt;p&gt;After every capture, the script stats the output file and throws if it exceeds 8 MB. A single bloated OG would silently tank sharing-time LCP the moment a link gets pasted somewhere busy. I'd rather have the build fail. It's six lines of code and the cheapest insurance I ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  Theme-aware icons, for free
&lt;/h2&gt;

&lt;p&gt;The site-wide OG includes my tech-stack icons. Each one has light and dark variants, swapped with Tailwind's &lt;code&gt;dark:&lt;/code&gt; variants:&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;img&lt;/span&gt;
  &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`/tech-stack-icons/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tech&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="s2"&gt;-light.svg`&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tech&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="s2"&gt; light icon`&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"block dark:hidden"&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;img&lt;/span&gt;
  &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`/tech-stack-icons/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tech&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="s2"&gt;-dark.svg`&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tech&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="s2"&gt; dark icon`&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"hidden dark:block"&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dither shader does the same thing at the color level: &lt;code&gt;colorBack&lt;/code&gt; and &lt;code&gt;colorFront&lt;/code&gt; switch on &lt;code&gt;resolvedTheme&lt;/code&gt;. So a dark-mode screenshot and a light-mode screenshot look like genuinely different posters, not the same image with inverted colors. I run the script under whichever theme I want; usually dark, since that's my brand default.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest downsides
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Build-time, not on-demand.&lt;/strong&gt; New content needs a redeploy and a script run. If I wanted to ship a post and have its OG live in under a minute, I would reach for &lt;code&gt;@vercel/og&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Puppeteer is heavy.&lt;/strong&gt; It pulls Chromium into your CI. It adds seconds-per-image to the build. For a portfolio with a handful of routes it's a non-issue, but if I had thousands of pages I'd reconsider.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single captures.&lt;/strong&gt; One theme per run. If I wanted dark &lt;em&gt;and&lt;/em&gt; light OGs I'd have to render the route twice. Right now I don't.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;@vercel/og&lt;/code&gt; is the right answer once you have a real scale problem. The reason I don't use it here isn't that it's bad: it's that I don't have that problem, and &lt;code&gt;og.screenshot()&lt;/code&gt; lets my OG reuse the components I've already built.&lt;/p&gt;




&lt;p&gt;The OG route components, side by side, are in &lt;a href="https://github.com/NathanBrodin/Portfolio/tree/main/src/routes/og" rel="noopener noreferrer"&gt;&lt;code&gt;/src/routes/og/&lt;/code&gt;&lt;/a&gt;, &lt;a href="https://github.com/NathanBrodin/Portfolio/tree/main/src/routes/blog/og" rel="noopener noreferrer"&gt;&lt;code&gt;/src/routes/blog/og/&lt;/code&gt;&lt;/a&gt;, and &lt;a href="https://github.com/NathanBrodin/Portfolio/tree/main/src/routes/blog/$slug/og" rel="noopener noreferrer"&gt;&lt;code&gt;/src/routes/blog/$slug/og/&lt;/code&gt;&lt;/a&gt;. Worth reading in order: the per-post one is where most of the logic lives.&lt;/p&gt;




&lt;ul&gt;
&lt;li&gt;My Portfolio: &lt;a href="https://brodin.dev" rel="noopener noreferrer"&gt;brodin.dev&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Source code: &lt;a href="https://github.com/NathanBrodin/Portfolio" rel="noopener noreferrer"&gt;github.com/NathanBrodin/Portfolio&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;@vercel/og&lt;/code&gt; (when you &lt;em&gt;do&lt;/em&gt; have the scale problem): &lt;a href="https://vercel.com/docs/functions/og-image-generation" rel="noopener noreferrer"&gt;vercel.com/docs/functions/og-image-generation&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Paper Shaders (for the dither ones): &lt;a href="https://shaders.paper.design/" rel="noopener noreferrer"&gt;shaders.paper.design&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>react</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Every Optimization Behind a Perfect Lighthouse Score</title>
      <dc:creator>Nathan</dc:creator>
      <pubDate>Fri, 03 Jul 2026 13:28:43 +0000</pubDate>
      <link>https://dev.to/nathan-brodin/every-optimization-behind-a-perfect-lighthouse-score-283n</link>
      <guid>https://dev.to/nathan-brodin/every-optimization-behind-a-perfect-lighthouse-score-283n</guid>
      <description>&lt;p&gt;I run Lighthouse on &lt;a href="https://brodin.dev" rel="noopener noreferrer"&gt;brodin.dev&lt;/a&gt; the way some people refresh their GitHub contribution graph. It currently lands at &lt;strong&gt;100 on every local run&lt;/strong&gt;, and &lt;strong&gt;100 in Vercel's Real Experience Score&lt;/strong&gt;. These performances don't come from a "performance checklist", but rather from moving work from the browser to the build, in places where every generic guide tells you to just "lazy-load it."&lt;/p&gt;

&lt;p&gt;So here's the actual list: no &lt;code&gt;next/image&lt;/code&gt; worship, no advice I didn't personally ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Static prerendering
&lt;/h2&gt;

&lt;p&gt;The single biggest win. In &lt;code&gt;vite.config.ts&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="nf"&gt;tanstackStart&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;prerender&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;crawlLinks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;code&gt;enabled: true&lt;/code&gt; turns the whole site into static HTML at build time. &lt;code&gt;crawlLinks: true&lt;/code&gt; means TanStack Start follows every internal link it finds and prerenders those routes too, so I never have a route that ships zero hydration cost "by accident" because I forgot to list it somewhere.&lt;/p&gt;

&lt;p&gt;The small tradeoff some could argue about would be slower build times. But dor a content site that deploys a few times a month, that's a non-issue. For an app that ships 30 times a day, I'd think harder. But the payoff is that there's almost no JavaScript execution between "user hits Enter" and "user sees a painted page": the HTML is already there.&lt;/p&gt;

&lt;p&gt;Next.js does offer the same feature, so modern React static apps can all benefit from it.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The 1000ms nobody talks about: my dotted world map
&lt;/h2&gt;

&lt;p&gt;This is the one I'm proudest of, because it's a fix for a specific third-party library doing a specific expensive thing.&lt;/p&gt;

&lt;p&gt;My home page has a dotted world map with markers for the places I've lived. The naive version uses the &lt;a href="https://www.npmjs.com/package/svg-dotted-map" rel="noopener noreferrer"&gt;&lt;code&gt;svg-dotted-map&lt;/code&gt;&lt;/a&gt; library's &lt;code&gt;createMap()&lt;/code&gt; at runtime. That function parses GeoJSON and runs point-in-polygon sampling for ~5000 dots. On my machine, that blocks the main thread for &lt;strong&gt;around 1000ms&lt;/strong&gt;. On a throttled mid-tier Android, multiply that. Lighthouse's "Reduce JavaScript execution time" audit doesn't even know what hit it: it's a synthetic main-thread cliff.&lt;/p&gt;

&lt;p&gt;You can't lazy-load your way out of this. The data is intrinsic to the visual. So I moved the entire computation to build time in &lt;a href="https://github.com/NathanBrodin/Portfolio/blob/main/scripts/generate-map-data.ts" rel="noopener noreferrer"&gt;&lt;code&gt;scripts/generate-map-data.ts&lt;/code&gt;&lt;/a&gt;. It runs &lt;code&gt;createMap()&lt;/code&gt; once in Node and writes three things to a generated file:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;One SVG path string&lt;/strong&gt; for all 5000 dots, collapsed into a single &lt;code&gt;&amp;lt;path d="..."&amp;gt;&lt;/code&gt;. One paint operation instead of 5000 &lt;code&gt;&amp;lt;circle&amp;gt;&lt;/code&gt; elements. The browser renders this basically for free.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A linear longitude projection&lt;/strong&gt; (&lt;code&gt;x = LNG_ORIGIN_X + lng * LNG_SCALE_X&lt;/code&gt;). Trivial, extracted from two sample points.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A latitude interpolation lookup table.&lt;/strong&gt; This is the non-obvious one: the map uses a Mercator-like projection, so latitude → Y is non-linear. I sample every degree from -70 to 85 at build time, store the &lt;code&gt;[lat, y]&lt;/code&gt; pairs, and at runtime do a binary search + linear interpolation between the two nearest entries.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The runtime file the browser loads is just a big string and two lookup tables. The browser does zero parsing, zero sampling, zero point-in-polygon. The ~1000ms became "an SVG path, rendered."&lt;/p&gt;

&lt;p&gt;That's the kind of optimization that doesn't show up in a checklist. You only find it by profiling and asking, &lt;em&gt;"why is my main thread busy before I've even rendered anything?"&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Font preloading
&lt;/h2&gt;

&lt;p&gt;Three &lt;code&gt;rel="preload"&lt;/code&gt; declarations in the &lt;a href="https://github.com/NathanBrodin/Portfolio/blob/main/src/routes/__root.tsx" rel="noopener noreferrer"&gt;root route's head&lt;/a&gt;:&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="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;rel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;preload&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/fonts/iAWriterQuattroV.woff2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;as&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;font&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;font/woff2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;crossOrigin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;anonymous&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="c1"&gt;// ...and the same for Lora and iA Writer Mono&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The gotcha that bit me once: if you self-host and forget &lt;code&gt;crossOrigin: 'anonymous'&lt;/code&gt;, the browser silently double-fetches the font. It's the kind of thing that doesn't break anything visibly but quietly wrecks your preload and shows up as a longer LCP. Three fonts is enough fonts so I'm not preloading everything, just the three that render above-the-fold text.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. One paint, not five thousand (and SMIL for the pulses)
&lt;/h2&gt;

&lt;p&gt;This is the SVG sibling of the map story. Splitting the dots into one &lt;code&gt;&amp;lt;path&amp;gt;&lt;/code&gt; instead of thousands of &lt;code&gt;&amp;lt;circle&amp;gt;&lt;/code&gt; nodes collapses the DOM size and gives one paint op. For the animated marker pulses on the map, I use declarative SMIL &lt;code&gt;&amp;lt;animate&amp;gt;&lt;/code&gt; instead of a JS rAF loop:&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;animate&lt;/span&gt; &lt;span class="na"&gt;attributeName&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"r"&lt;/span&gt; &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;markerSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"2"&lt;/span&gt; &lt;span class="na"&gt;dur&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"2s"&lt;/span&gt; &lt;span class="na"&gt;repeatCount&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"indefinite"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;animate&lt;/span&gt; &lt;span class="na"&gt;attributeName&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"opacity"&lt;/span&gt; &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"0.5"&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"0"&lt;/span&gt; &lt;span class="na"&gt;dur&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"2s"&lt;/span&gt; &lt;span class="na"&gt;repeatCount&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"indefinite"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SMIL gets a bad rap sometimes, but for an always-on pulse it's cheaper than driving it from React state and triggering re-renders. The honest caveat: it's not prefetchable the way a JS animation sometimes is, and on very low-end devices SMIL can be inconsistently throttled. For a portfolio it's fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Animations that respect the GPU
&lt;/h2&gt;

&lt;p&gt;I use &lt;code&gt;motion&lt;/code&gt; (framer-motion's successor) for the curved path draw-ins on the map, but I keep it boring: &lt;code&gt;animate={{ pathLength: 1 }}&lt;/code&gt; once on mount, with a small stagger. No scroll-gated choreography, no parallax, nothing that requires listening to the scroll position. The dither shaders on the section dividers run on the GPU through &lt;a href="https://shaders.paper.design/" rel="noopener noreferrer"&gt;&lt;code&gt;@paper-design/shaders-react&lt;/code&gt;&lt;/a&gt;. And on the OG image route specifically, I set the dither's &lt;code&gt;speed={0}&lt;/code&gt;, that one's actually about screenshot correctness (I capture it with Puppeteer), but it does mean the share-preview version of the shader is free.&lt;/p&gt;

&lt;p&gt;The rule I follow: animate things the user can &lt;em&gt;see&lt;/em&gt;, animate them once, and let the browser handle it on the compositor thread.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Vector where vector works
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;favicon.svg&lt;/code&gt;. Tech-stack icons as &lt;code&gt;.svg&lt;/code&gt; with light/dark variants. The map dots are a single SVG path. The one raster I keep on the home page is the GitHub contribution screenshot, and that's &lt;code&gt;.webp&lt;/code&gt;. Rasters are reserved for things that are genuinely photographic (a screenshot of an actual UI). If it's a logo or a shape, it's a vector. Less weight, infinitely scalable, no CLS from missing dimensions.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Build-time guardrails
&lt;/h2&gt;

&lt;p&gt;Nothing runtime-clever here, just two cheap safety nets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;My OG image generation script (&lt;a href="https://github.com/NathanBrodin/Portfolio/blob/main/scripts/generate-og.ts" rel="noopener noreferrer"&gt;&lt;code&gt;scripts/generate-og.ts&lt;/code&gt;&lt;/a&gt;) screenshots the OG routes with Puppeteer and throws if any captured image exceeds &lt;strong&gt;8 MB&lt;/strong&gt;. A single bloated OG would silently tank sharing-time LCP once a link gets pasted somewhere busy. I'd rather have the build fail.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;vp check&lt;/code&gt; runs format, lint, and a real TypeScript typecheck on staged files. It won't catch every perf regression, but it catches the dumb ones: a fat dependency, a console-laden module, a mis-typed hot path, before they land in prod.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The honest "what I did not do"
&lt;/h2&gt;

&lt;p&gt;This is the part most perf posts skip, and it's the most useful.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No image CDN / &lt;code&gt;next/image&lt;/code&gt;-equivalent.&lt;/strong&gt; I have one image that benefits from responsive sizing. One. A full image pipeline isn't worth the dependency.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No advanced code-splitting beyond route-level.&lt;/strong&gt; The site is small. Route-level chunks from TanStack Start are enough. Anything more is over-engineering.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There's still ~130 KiB of "unused JavaScript" on initial load.&lt;/strong&gt; That's framework runtime: React, TanStack, the small &lt;code&gt;motion&lt;/code&gt; slice. It's the cost of SSR-with-hydration. On desktop it doesn't cost the score. On throttled mid-tier mobile, it's the ceiling I can't break without dropping the framework, and I'm not willing to do that for a portfolio.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A perfect Lighthouse score on a small site is a vanity metric.&lt;/strong&gt; I'm not going to pretend it matters as much as it would for a media site with a million visitors. The point isn't the 100. The point is the lesson underneath it: &lt;strong&gt;measure, then move work to build time.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to see how all of this hangs together, &lt;a href="https://github.com/NathanBrodin/Portfolio" rel="noopener noreferrer"&gt;the source is open&lt;/a&gt;. Steal the map precompute especially: that one deserves to be more than a footnote in my repo.&lt;/p&gt;




&lt;ul&gt;
&lt;li&gt;My Portfolio: &lt;a href="https://brodin.dev" rel="noopener noreferrer"&gt;brodin.dev&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Source code: &lt;a href="https://github.com/NathanBrodin/Portfolio" rel="noopener noreferrer"&gt;github.com/NathanBrodin/Portfolio&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;TanStack Start prerendering: &lt;a href="https://tanstack.com/start/latest" rel="noopener noreferrer"&gt;tanstack.com/start&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Paper Shaders (the dither ones): &lt;a href="https://shaders.paper.design/" rel="noopener noreferrer"&gt;shaders.paper.design&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>react</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>react-i18next Was Fine. Then I Found Paraglide.</title>
      <dc:creator>Nathan</dc:creator>
      <pubDate>Mon, 04 May 2026 11:41:16 +0000</pubDate>
      <link>https://dev.to/nathan-brodin/react-i18next-was-fine-then-i-found-paraglide-15oa</link>
      <guid>https://dev.to/nathan-brodin/react-i18next-was-fine-then-i-found-paraglide-15oa</guid>
      <description>&lt;p&gt;I have a confession: I used react-i18next for years and genuinely never questioned it. It worked. It was everywhere. Every project I joined during my internships at DNB had it set up. You install it, you configure it, you wrap your app in a provider, and you ship. Done.&lt;/p&gt;

&lt;p&gt;But then I started building more things on my own, projects where I got to choose the stack from scratch, and I started noticing friction I had previously just accepted. So let me tell you what I was silently tolerating with react-i18next, and why Paraglide JS fixed basically all of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The react-i18next Era
&lt;/h2&gt;

&lt;p&gt;To be clear: react-i18next is a solid library. It's been around forever, it has a massive community, and it handles a lot of edge cases out of the box. I'm not here to drag it through the mud.&lt;/p&gt;

&lt;p&gt;But here are the three things that made me raise an eyebrow every single time. Worth noting: my hands-on react-i18next experience was back in 2024, so some of this may have improved since, but these were real pain points at the time, and the defaults still matter.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Quick reality check before I complain: I once set up internationalization in a Flutter app at uni. After that experience, anything in the React ecosystem is a luxury. So take my "rants" with the appropriate grain of salt.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  1. No type safety (or not by default, at least)
&lt;/h3&gt;

&lt;p&gt;This one was the most egregious for me back then. When you use react-i18next, your translation key is just... a string.&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useTranslation&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;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dashboard.welcome.title&lt;/span&gt;&lt;span class="dl"&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;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;TypeScript has absolutely no idea if &lt;code&gt;dashboard.welcome.title&lt;/code&gt; is a real key or something you mistyped at 2am. You only find out at runtime, when your users see an empty string or the raw key rendered on screen. I love type safety. Coming from a TypeScript-first mindset, that kind of feedback loop gives me actual anxiety.&lt;/p&gt;

&lt;p&gt;You can &lt;a href="https://www.i18next.com/overview/typescript" rel="noopener noreferrer"&gt;set up type augmentation&lt;/a&gt; manually and make it work. And to be fair, i18next has been actively improving here, v25 shipped a proper &lt;code&gt;enableSelector&lt;/code&gt; option that makes keys fully typed, with plans to make it the default in &lt;a href="https://www.i18next.com/overview/typescript" rel="noopener noreferrer"&gt;v26&lt;/a&gt;. So this is becoming less of a valid complaint over time. But in 2024, none of that was there, it required extra configuration, and the fact that it was opt-in told you everything you needed to know about where it sat on the priority list. Paraglide just gives you this for free, from day one, no setup required.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Your entire translation file ships to the client
&lt;/h3&gt;

&lt;p&gt;This is the quiet performance problem that nobody talks about enough. By default, react-i18next loads your full JSON translation file on the client, regardless of which keys are actually used on the current page.&lt;/p&gt;

&lt;p&gt;If your app has been around for a while, that JSON file is probably enormous. Every abandoned feature, every old marketing copy, every screen the current user will never visit, it all gets shipped. Every time.&lt;br&gt;
It also opens the door to accidental data leaks, shipping unreleased feature copy or internal admin strings to the client simply because they share a JSON file.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. You need a hook. For everything.
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Server component? Utility function? Class? Doesn't matter.&lt;/span&gt;
&lt;span class="c1"&gt;// You need useTranslation() or you're not translating anything.&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;t&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useTranslation&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;useTranslation&lt;/code&gt; hook is fine in a standard React component tree. But the moment you step outside that, a utility function, a constant, a non-component file and you're stuck. You either restructure your code to work around it, or you do something hacky. Neither is great.&lt;/p&gt;


&lt;h2&gt;
  
  
  Enter Paraglide JS
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://inlang.com/m/gerre34r/library-inlang-paraglideJs" rel="noopener noreferrer"&gt;Paraglide JS&lt;/a&gt; is an i18n library by the folks at &lt;a href="https://inlang.com/" rel="noopener noreferrer"&gt;inlang&lt;/a&gt;. I first heard about it from TanStack's own &lt;a href="https://tanstack.com/router/latest/docs/guide/internationalization-i18n#tanstack-router--paraglide-client-only" rel="noopener noreferrer"&gt;internationalization docs&lt;/a&gt;, where it's the officially recommended solution. Given that I use TanStack for everything at this point, that was enough of an endorsement for me to try it.&lt;/p&gt;

&lt;p&gt;I've now used it in both personal projects and at work, and here is what actually changed.&lt;/p&gt;
&lt;h3&gt;
  
  
  Direct imports, no hook required
&lt;/h3&gt;

&lt;p&gt;This is the one that immediately felt right. Paraglide generates your translation messages as real importable functions:&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;m&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;@/paraglide/messages&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="c1"&gt;// In a component&lt;/span&gt;
&lt;span class="p"&gt;;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&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;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dashboard_welcome_title&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;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="c1"&gt;// In a utility function&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;formatGreeting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&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="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;welcome_user&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// In a constant&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PAGE_TITLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;page_title&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No hook. No provider wrapping. No &lt;code&gt;useTranslation()&lt;/code&gt; call at the top of every file. You just import and call. It works everywhere: components, utilities, server code, wherever. That simplicity compounds surprisingly fast when you're translating dozens of strings across a whole app.&lt;/p&gt;

&lt;h3&gt;
  
  
  Type safety out of the box
&lt;/h3&gt;

&lt;p&gt;Because Paraglide generates actual TypeScript functions from your message files, you get full type safety for free. Autocomplete works. Typos are caught at compile time. If a message has parameters, TypeScript tells you exactly what you need to pass:&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;// m.welcome_user expects { name: string } — TypeScript knows this&lt;/span&gt;
&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;welcome_user&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Nathan&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="c1"&gt;// ✅&lt;/span&gt;
&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;welcome_user&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// ❌ TypeScript error&lt;/span&gt;
&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wellcome_user&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Nathan&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="c1"&gt;// ❌ TypeScript error, key doesn't exist&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the baseline I want from any typed language. The fact that it just works without extra setup is a big deal.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tree shaking
&lt;/h3&gt;

&lt;p&gt;Here's the quiet win: Paraglide only ships the messages that are actually used. Because translations are imported as individual functions, your bundler can tree shake everything else away. If a page only uses five translation keys, only those five keys end up in the bundle. Not your entire dictionary. Not every string from every screen your users will never visit.&lt;/p&gt;

&lt;p&gt;For smaller projects it's a nice-to-have. For larger apps, this is genuinely meaningful.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ultralight, and SSR-friendly
&lt;/h3&gt;

&lt;p&gt;The runtime is tiny. We're talking a few hundred bytes. Compare that to react-i18next's runtime overhead and the JSON payload you're loading on top of it, and the difference is noticeable. It also works well with SSR, which was a concern before I tried it, TanStack Start's server rendering plays nicely with Paraglide's design, no hydration weirdness.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Honest Downsides
&lt;/h2&gt;

&lt;p&gt;I said I wasn't here to drag react-i18next, but I should be equally honest about Paraglide's rough edges.&lt;/p&gt;

&lt;h3&gt;
  
  
  A lot of files in your repo
&lt;/h3&gt;

&lt;p&gt;Setting up Paraglide means you're going to have a &lt;code&gt;project.inlang/&lt;/code&gt; directory at the root, and a generated &lt;code&gt;src/paraglide/&lt;/code&gt; directory with all the output. It's not one config file you forget about, it's a handful of files you'll see every time you open your project tree. Nothing breaks, but it's visually noisier than what you'd get with other i18n setups.&lt;/p&gt;

&lt;h3&gt;
  
  
  The docs are scattered
&lt;/h3&gt;

&lt;p&gt;This is my biggest complaint. The documentation lives across &lt;a href="https://inlang.com" rel="noopener noreferrer"&gt;inlang.com&lt;/a&gt;, the Paraglide-specific pages, and the TanStack docs. None of them are bad on their own, but if you're trying to piece together a specific setup — say, Paraglide + TanStack Router + SSR, you'll end up jumping between multiple sites and cross-referencing. It works out eventually, but it's not the smooth onboarding you'd hope for.&lt;/p&gt;




&lt;h2&gt;
  
  
  So, Should You Switch?
&lt;/h2&gt;

&lt;p&gt;If you're starting a new project with React and TanStack (you should), use Paraglide. It's the officially recommended solution, it's designed to match how modern React apps are structured, and the DX improvement is real.&lt;/p&gt;

&lt;p&gt;If you're maintaining an existing app heavily invested in react-i18next, the migration cost is probably not worth it unless you're already hitting the pain points I described. It works fine. You'll just be quietly annoyed every time you need to translate something outside a component.&lt;/p&gt;

&lt;p&gt;For me, the no-hook import alone was enough to never look back.&lt;/p&gt;




&lt;ul&gt;
&lt;li&gt;Paraglide JS: &lt;a href="https://inlang.com/m/gerre34r/library-inlang-paraglideJs" rel="noopener noreferrer"&gt;inlang.com/m/gerre34r/library-inlang-paraglideJs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;TanStack i18n guide: &lt;a href="https://tanstack.com/router/latest/docs/guide/internationalization-i18n" rel="noopener noreferrer"&gt;tanstack.com/router/latest/docs/guide/internationalization-i18n&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;react-i18next TypeScript setup: &lt;a href="https://www.i18next.com/overview/typescript" rel="noopener noreferrer"&gt;i18next.com/overview/typescript&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Portfolio: &lt;a href="https://brodin.dev" rel="noopener noreferrer"&gt;brodin.dev&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>react</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>A Totally Legitimate Way to Look More Productive on GitHub</title>
      <dc:creator>Nathan</dc:creator>
      <pubDate>Fri, 17 Apr 2026 06:38:44 +0000</pubDate>
      <link>https://dev.to/nathan-brodin/a-totally-legitimate-way-to-look-more-productive-on-github-4cb0</link>
      <guid>https://dev.to/nathan-brodin/a-totally-legitimate-way-to-look-more-productive-on-github-4cb0</guid>
      <description>&lt;p&gt;Yes, I have a repo whose sole purpose is to make my GitHub profile look busier than it is. No, I'm not ashamed. And here's how you can do it too.&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.amazonaws.com%2Fuploads%2Farticles%2Fpwop1sp1a41wut7b83zy.webp" 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.amazonaws.com%2Fuploads%2Farticles%2Fpwop1sp1a41wut7b83zy.webp" alt="My Github Contribution Graph" width="800" height="230"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;It's quite easy to spot when I started it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How It Works
&lt;/h2&gt;

&lt;p&gt;Github's contribution graph tracks commits across all your repos, public and private, as long as they're on the default branch and the commit email matches your account. The whole system is activity-based: it doesn't care what you committed, just that you did. Which means it's trivially gameable.&lt;/p&gt;

&lt;p&gt;The plan is simple: a private repo, a scheduled GitHub Action as the engine, and a Python script to make it look human.&lt;/p&gt;

&lt;h2&gt;
  
  
  GitHub Workflow
&lt;/h2&gt;

&lt;p&gt;With a free GitHub Account, you have access to 2,000 minutes of free actions compute every month, so let's use that to run a 25s action.&lt;br&gt;
A GitHub Workflow also allows you to define cron jobs, so we can simply define one to run every day.&lt;/p&gt;

&lt;p&gt;Create a file at &lt;code&gt;.github/workflows/contributor.yml&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;First, define the trigger with a cron job:
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Runs at 08:00 UTC every day&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;8&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;ol&gt;
&lt;li&gt;Define the job and give it permission to push commits:
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;daily-commits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt; &lt;span class="c1"&gt;# Grants permission to push commits&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;ol&gt;
&lt;li&gt;Add the steps. First, checkout the repo:
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&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;Checkout repository&lt;/span&gt;
    &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;ol&gt;
&lt;li&gt;Set up Python and install dependencies:
&lt;/li&gt;
&lt;/ol&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;Set up Python&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-python@v4&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;python-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;3.9'&lt;/span&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;Install dependencies&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pip install holidays&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;ol&gt;
&lt;li&gt;Configure git. Make sure to use the same email address as your GitHub account, else contributions will not be counted:
&lt;/li&gt;
&lt;/ol&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;Configure Git&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;git config --global user.name "Nathan Brodin"&lt;/span&gt;
    &lt;span class="s"&gt;git config --global user.email "nathan@brodin.dev"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;ol&gt;
&lt;li&gt;Run the auto-commit script:
&lt;/li&gt;
&lt;/ol&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;Run Auto-Commit Script&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;python autocommit.py&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;ol&gt;
&lt;li&gt;Finally, push changes:
&lt;/li&gt;
&lt;/ol&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;Push changes&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;git push&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  A Tiny Script To Make It Look Realistic
&lt;/h2&gt;

&lt;p&gt;If you would make the exact same amount of commits, monday to sunday, even on Christmas day, you would easily be flagged as a faker. So to make it look natural, we'll write a tiny Python script to handle that. Create a file called &lt;code&gt;autocommit.py&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;First, add the imports:
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;holidays&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;ol&gt;
&lt;li&gt;Define the &lt;code&gt;git_commit&lt;/code&gt; function (must come before &lt;code&gt;main()&lt;/code&gt;):
&lt;/li&gt;
&lt;/ol&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;git_commit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;activity_log.txt&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;a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&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;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Commit made at &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;timestamp&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;git&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;add&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;activity_log.txt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

    &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Update activity log: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;timestamp&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;git&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;commit&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;-m&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;ol&gt;
&lt;li&gt;Create the &lt;code&gt;main()&lt;/code&gt; function. First, get today's date and check for weekends:
&lt;/li&gt;
&lt;/ol&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;main&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;today&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;today&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;today&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;weekday&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Today is a weekend. Skipping.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;ol&gt;
&lt;li&gt;You also skip public holidays, using the &lt;code&gt;holidays&lt;/code&gt; package:
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;    &lt;span class="n"&gt;country_code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NO&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# Replace with your country code
&lt;/span&gt;    &lt;span class="n"&gt;country_holidays&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;holidays&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;country_holidays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;country_code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;today&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;country_holidays&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Today is a holiday (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;country_holidays&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;today&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;). Skipping.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;ol&gt;
&lt;li&gt;Remove days where you shouldn't be working (birthday, grandma's funeral...):
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;    &lt;span class="n"&gt;quiet_days&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&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="n"&gt;today&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;quiet_days&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Today is a quiet day. Skipping.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;ol&gt;
&lt;li&gt;Randomize the count of commits, and weight them to make it more natural. Make sure the sum sums up to 100.
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;    &lt;span class="c1"&gt;# Randomize Commits: 0 commits has a 32% chance, max capped at 9
&lt;/span&gt;    &lt;span class="n"&gt;num_commits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;range&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="n"&gt;weights&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;32&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="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&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="n"&gt;k&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Scheduled for &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;num_commits&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; commits today.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;ol&gt;
&lt;li&gt;Finally, call the commit function in a loop:
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;num_commits&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;current_time&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;datetime&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="nf"&gt;strftime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;%Y-%m-%d %H:%M:%S&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;git_commit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&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="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Good job, You're now officially a 10x engineer (with a healthy work/life balance), at least visually.&lt;/p&gt;



&lt;p&gt;Full 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="c1"&gt;# .github/workflows/contributor.yml&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;Daily Contribution Automation&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Runs at 08:00 UTC every day&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;8&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*'&lt;/span&gt;
  &lt;span class="c1"&gt;# Allows to run this workflow manually from the Actions tab for testing&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;daily-commits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt; &lt;span class="c1"&gt;# Grants permission to push commits&lt;/span&gt;

    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&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;Checkout repository&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v3&lt;/span&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;Set up Python&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-python@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;python-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;3.9'&lt;/span&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;Install dependencies&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pip install holidays&lt;/span&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;Configure Git&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;git config --global user.name "Nathan Brodin"&lt;/span&gt;
          &lt;span class="s"&gt;git config --global user.email "nathan@brodin.dev"&lt;/span&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;Run Auto-Commit Script&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;python autocommit.py&lt;/span&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;Push changes&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;git push&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# autocommit.py
&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;holidays&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;git_commit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;activity_log.txt&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;a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&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;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Commit made at &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;timestamp&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;git&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;add&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;activity_log.txt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

    &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Update activity log: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;timestamp&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;git&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;commit&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;-m&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;country_code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NO&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;today&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;today&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="c1"&gt;# Weekend Check
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;today&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;weekday&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Today is a weekend. Skipping.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;

    &lt;span class="c1"&gt;# Holiday Check
&lt;/span&gt;    &lt;span class="n"&gt;country_holidays&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;holidays&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;country_holidays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;country_code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;today&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;country_holidays&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Today is a holiday (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;country_holidays&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;today&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;). Skipping.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;

    &lt;span class="c1"&gt;# Quiet Days Check
&lt;/span&gt;    &lt;span class="n"&gt;quiet_days&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&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="n"&gt;today&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;quiet_days&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Today is a quiet day. Skipping.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;

    &lt;span class="c1"&gt;# Randomize Commits — 0 commits has a 32% chance, max capped at 9
&lt;/span&gt;    &lt;span class="n"&gt;num_commits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;range&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="n"&gt;weights&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;32&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="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&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="n"&gt;k&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Scheduled for &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;num_commits&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; commits today.&lt;/span&gt;&lt;span class="sh"&gt;"&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;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;num_commits&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;current_time&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;datetime&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="nf"&gt;strftime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;%Y-%m-%d %H:%M:%S&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;git_commit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Commit &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&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="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;num_commits&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; created.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&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="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>github</category>
      <category>programming</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Between Tradition and Modernity: Building a Full Stack App with Django and React</title>
      <dc:creator>Nathan</dc:creator>
      <pubDate>Mon, 13 Apr 2026 08:15:14 +0000</pubDate>
      <link>https://dev.to/nathan-brodin/between-tradition-and-modernity-building-a-full-stack-app-with-django-and-react-4pma</link>
      <guid>https://dev.to/nathan-brodin/between-tradition-and-modernity-building-a-full-stack-app-with-django-and-react-4pma</guid>
      <description>&lt;p&gt;If I tell you I made a full-stack app with &lt;a href="https://react.dev/blog/2024/12/05/react-19" rel="noopener noreferrer"&gt;React 19&lt;/a&gt; (+ &lt;a href="https://react.dev/learn/react-compiler" rel="noopener noreferrer"&gt;Compiler&lt;/a&gt;), &lt;a href="https://tanstack.com/router/latest" rel="noopener noreferrer"&gt;Tanstack Router&lt;/a&gt;, &lt;a href="https://tailwindcss.com/" rel="noopener noreferrer"&gt;tailwindcss&lt;/a&gt;, &lt;a href="https://base-ui.com/" rel="noopener noreferrer"&gt;Base UI&lt;/a&gt;, and &lt;a href="https://pnpm.io/" rel="noopener noreferrer"&gt;pnpm&lt;/a&gt;, you would probably expect a &lt;a href="https://hono.dev/" rel="noopener noreferrer"&gt;Hono backend&lt;/a&gt; or Tanstack Start &lt;a href="https://tanstack.com/start/latest/docs/framework/react/guide/server-functions" rel="noopener noreferrer"&gt;Server functions&lt;/a&gt; with &lt;a href="https://orm.drizzle.team/" rel="noopener noreferrer"&gt;Drizzle&lt;/a&gt;, or at least some cutting-edge TS solution. Well, I've built a &lt;a href="https://www.django-rest-framework.org/" rel="noopener noreferrer"&gt;Django&lt;/a&gt; backend, and it works pretty well!&lt;/p&gt;

&lt;h2&gt;
  
  
  The Constraints vs. The Freedom
&lt;/h2&gt;

&lt;p&gt;When starting a new project, you always face constraints. Your job as the person designing the software architecture is to find the most elegant way to build around them.&lt;/p&gt;

&lt;p&gt;Here are the constraints I was handed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Django for the backend (to match the team's existing legacy projects).&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.keycloak.org/" rel="noopener noreferrer"&gt;Keycloak&lt;/a&gt; for authentication.&lt;/li&gt;
&lt;li&gt;Dockerizing the entire stack for self-hosted deployment.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The freedom? I got to decide absolutely everything else.&lt;/p&gt;

&lt;p&gt;If you’ve read my previous blog posts, you know I’ve fallen in love with the Tanstack ecosystem. I went with Tanstack Router, Query, Form, Table, and Pacer. Notice that I &lt;em&gt;didn't&lt;/em&gt; go with Tanstack Start. Given the actual goals of this app, I couldn't justify the SSR overhead, and I absolutely did not want to spend a single second fixing hydration issues (I still have nightmares about them).&lt;/p&gt;

&lt;p&gt;For the UI, I finally got to use TailwindCSS and shadcn/ui at work, freeing myself from plain CSS and the horrors of &lt;code&gt;styled-components&lt;/code&gt;. I really love the pattern of creating headless, reusable components:&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;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;PageHeader&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;HTMLAttributes&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;HTMLElement&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="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;header&lt;/span&gt;
      &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;cn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;grid auto-rows-min items-start gap-2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;data-slot&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"page-header"&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="si"&gt;}&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;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;header&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;(Yes, it kind of looks like styled-components in a way. Maybe time is a flat circle?)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A quick shoutout to two other bangers in the frontend stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://inlang.com/m/gerre34r/library-inlang-paraglideJs" rel="noopener noreferrer"&gt;Paraglide JS&lt;/a&gt;:&lt;/strong&gt; After fighting with &lt;code&gt;react-i18next&lt;/code&gt; (lack of type safety, fetching all keys client-side), I switched to Paraglide JS on &lt;a href="https://tanstack.com/router/latest/docs/guide/internationalization-i18n#tanstack-router--paraglide-client-only" rel="noopener noreferrer"&gt;Tanstack's recommendation&lt;/a&gt;. Zero downsides so far.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://knip.dev/" rel="noopener noreferrer"&gt;Knip&lt;/a&gt;:&lt;/strong&gt; Analyzes your codebase for unused files, exports, and dependencies. Even with the strictest ESLint/Prettier setup, you’ll have dead code. Knip is a godsend for cleanup.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Bridging the Gap: The Auth Weirdness
&lt;/h2&gt;

&lt;p&gt;Let's talk about the &lt;em&gt;weird&lt;/em&gt; authentication layer in the app, that I am not a big fan of.&lt;/p&gt;

&lt;p&gt;The client authenticates with Keycloak. This means I need to check auth on the frontend (using &lt;code&gt;react-oidc-context&lt;/code&gt; and &lt;code&gt;oidc-client-ts&lt;/code&gt;, which have pretty bad documentation) and &lt;a href="https://tanstack.com/router/latest/docs/how-to/setup-authentication#2-configure-router" rel="noopener noreferrer"&gt;store the auth context&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I then pass the key to the backend during API calls, where Django verifies it using &lt;code&gt;jwt.decode&lt;/code&gt; against the public key. It doesn't sound that bad, except that Django has its own pre-built auth system with user tables, and Keycloak isn't designed to store app-specific user metadata. So, I had to build a weird, performant sync layer between the two (e.g., if a Keycloak email changes, reflecting it locally in the Postgres DB). It’s clunky, but it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Type Safety is still possible
&lt;/h2&gt;

&lt;p&gt;I love type safety. Coming from TypeScript, and having played with C and Dart in school, dynamic typing gives me hives.&lt;/p&gt;

&lt;p&gt;Out of the box, Python can feel a bit like the Wild West compared to a strict TypeScript setup. With standard dependencies often living in a simple &lt;code&gt;.txt&lt;/code&gt; file and optional linting, it gives you a lot of freedom. But that freedom means you have to actively put in the work to enforce a strong Developer Experience, otherwise code quality can slip quickly.&lt;/p&gt;

&lt;p&gt;However, having a Python backend and a TypeScript frontend doesn't mean you have to sacrifice end-to-end type safety. Here is how I forced the two to play nice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Django properly defines the models with strict types and comments.&lt;/li&gt;
&lt;li&gt;Backend views have full documentation on response types using those models.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://drf-spectacular.readthedocs.io/en/latest/" rel="noopener noreferrer"&gt;drf-spectacular&lt;/a&gt; generates the OpenAPI specs.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://orval.dev/" rel="noopener noreferrer"&gt;Orval&lt;/a&gt; generates TS types and query hooks from those specs.&lt;/li&gt;
&lt;li&gt;The frontend consumes the &lt;a href="https://tanstack.com/query/latest" rel="noopener noreferrer"&gt;Tanstack Query Hooks&lt;/a&gt; to fetch data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And just like that... End-to-end type safety. You know exactly what the endpoint needs, and exactly what it's going to return. When you make a change in a model, you get the feedback all the way to your frontend component.&lt;/p&gt;




&lt;h2&gt;
  
  
  DX: Making the Containers Bearable
&lt;/h2&gt;

&lt;p&gt;After the users, my top priority is the developers (and code quality). Because of the strict constraints, I got to tear my hair out properly learning Docker. Setting up multiple services, ensuring they communicate, and managing deployment across two environments in a server full of existing apps was a massive headache.&lt;/p&gt;

&lt;p&gt;But once it works, Docker is magic. Starting the project takes one command. You get fully reproducible environments between local dev and production. So now you can use the "But it works on my machine" excuse more confidently.&lt;/p&gt;

&lt;p&gt;I also spent some time creating &lt;a href="https://www.gnu.org/software/make/manual/html_node/Phony-Targets.html" rel="noopener noreferrer"&gt;Make commands&lt;/a&gt;. These Docker commands are quite long and spending 5 minutes going up in the terminal history trying to find the specific command to run the tests can be quite annoying. So I wrote a &lt;code&gt;Makefile&lt;/code&gt;. Now, a simple &lt;code&gt;make codegen&lt;/code&gt; spins up the OpenAPI specs and frontend types:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight make"&gt;&lt;code&gt;&lt;span class="nv"&gt;COMPOSE_FILE&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; docker-compose.dev.yml
&lt;span class="nv"&gt;ENV_FILE&lt;/span&gt;     &lt;span class="o"&gt;:=&lt;/span&gt; .env.local
&lt;span class="nv"&gt;COMPOSE&lt;/span&gt;      &lt;span class="o"&gt;:=&lt;/span&gt; docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="p"&gt;$(&lt;/span&gt;COMPOSE_FILE&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nt"&gt;--env-file&lt;/span&gt; &lt;span class="p"&gt;$(&lt;/span&gt;ENV_FILE&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nl"&gt;.PHONY&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;codegen&lt;/span&gt;
&lt;span class="nl"&gt;codegen&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;schema types &lt;/span&gt;&lt;span class="c"&gt;##&lt;/span&gt;&lt;span class="nf"&gt; Generate both Schema and Types&lt;/span&gt;

&lt;span class="nl"&gt;.PHONY&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;schema&lt;/span&gt;
&lt;span class="nl"&gt;schema&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="c"&gt;##&lt;/span&gt;&lt;span class="nf"&gt; Generate Open API schema from Backend&lt;/span&gt;
    &lt;span class="p"&gt;$(&lt;/span&gt;COMPOSE&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;exec &lt;/span&gt;backend python manage.py spectacular &lt;span class="nt"&gt;--file&lt;/span&gt; openapi.yml &lt;span class="nt"&gt;--validate&lt;/span&gt;

&lt;span class="nl"&gt;.PHONY&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;types&lt;/span&gt;
&lt;span class="nl"&gt;types&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="c"&gt;##&lt;/span&gt;&lt;span class="nf"&gt; Generate TypeScript types from Open API schema&lt;/span&gt;
    &lt;span class="p"&gt;$(&lt;/span&gt;COMPOSE&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;exec &lt;/span&gt;frontend pnpm run generate-types
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I also built a strong CI pipeline. It handles backend linting and formatting, 800+ Django tests, migration checks, OpenAPI schema validation, Frontend Schema Types validation, frontend type checking, frontend build, and finally Playwright tests.&lt;/p&gt;

&lt;p&gt;It sounds heavy, but it only takes ~7 minutes if &lt;em&gt;all&lt;/em&gt; steps run, thanks to aggressive caching, sharding, and parallel jobs. If I only touch backend code, the pipeline finishes in 2 minutes. Stop the pipeline early, run only what changed. It’s worth the initial setup time.&lt;/p&gt;




&lt;h2&gt;
  
  
  Boring isn't bad
&lt;/h2&gt;

&lt;p&gt;Django is not the most exciting tech, but it’s great for a CRUD app exposing APIs to Postgres. Yes, I still have to handle some complexity: RBAC, Redis caching, querying a Clickhouse DB with raw SQL, and WebSockets for live notifications, but I’m not building a crazy app for millions of users.&lt;/p&gt;

&lt;p&gt;Django is simple, predictable, and LLMs understand it perfectly. Need a cache layer? Two lines of code. It’s fast enough that running 800+ tests (including DB writes) takes 10 seconds.&lt;br&gt;
I still have some issues with it, like if there is an internal server error, an endpoint will return some html by default. So you need a custom middleware to formalize all kinds of errors. And of course, it has to be in Python. But overall: it just works.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Silo Problem: Designing APIs in a Vacuum
&lt;/h2&gt;

&lt;p&gt;Now, for a bit of a reality check regarding backend development.&lt;/p&gt;

&lt;p&gt;In theory, backend engineers handle incredibly important tasks: complex business logic, rock-solid security, scaling, and database optimizations. This should put to shame frontend engineers like me who spend 2 hours changing the color of a button.&lt;/p&gt;

&lt;p&gt;But in my early years of corporate experience, my reality has been quite different. I have only encountered codebases where basic software engineering practices, like mandatory PR reviews, automated testing, or even basic linting, just weren't part of the culture. When teams don't put effort into those foundations, you quickly end up with messy codebases, weak RBAC, major security oversights, and poor performance. More importantly, I’ve encountered the "Silo Problem."&lt;/p&gt;

&lt;p&gt;It goes like this: the backend team designs their database models and endpoints in isolation, without involving the frontend or the designer. The result never maps to what the UI actually needs, data models that clash with the design system, endpoints structured in a way that require several workarounds on the client just to render a basic view, and OpenAPI specs handed down after the fact that rarely match the live responses. I've seen it more than once, and it never stops being painful.&lt;br&gt;
If you are a backend developer adding a new endpoint, you aren't doing it for fun. You are doing it because the user interface needs that data. Designing APIs without consulting the client-side needs is like building a steering wheel without checking what kind of car it's going into.&lt;/p&gt;

&lt;p&gt;Thankfully, on my current project, I am the frontend, backend, and DevOps engineer. Everything communicates nicely, because I actually talk to myself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Corporate Reality
&lt;/h2&gt;

&lt;p&gt;I started this new grad job in mid-2025 at a small non-tech company, specifically because I wanted the freedom to build good products and actually care about software architecture.&lt;br&gt;
And I did get that freedom. I spent hours refining small details, optimizing DX, and over-engineering the type safety.&lt;/p&gt;

&lt;p&gt;But as the months go by, a weird realization has set in. This app will never scale to the hundreds of thousands of users I was used to handling in previous roles. Projects like this one are inherently temporary, business priorities shift, tools get replaced, and what matters today might be irrelevant in two years. The business goal is to ship a working solution, not to build a lasting technical marvel.&lt;/p&gt;

&lt;p&gt;It's a strange feeling. You pour real craft into something and the honest answer is: it probably didn't need to be this good. But I think that's okay. The craftsmanship wasn't wasted: I learned a lot building it this way, and I'd rather over-engineer once and learn than ship something I'm not proud of.&lt;/p&gt;

&lt;p&gt;And at the end of the day, I get to log off and live in one of &lt;a href="https://www.google.com/search?q=troms%C3%B8+aurora&amp;amp;tbm=isch" rel="noopener noreferrer"&gt;the most beautiful places in the world&lt;/a&gt;. That counts for something.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>django</category>
      <category>react</category>
      <category>full</category>
    </item>
    <item>
      <title>"You Wouldn’t Steal a DIV": How I Built My Portfolio</title>
      <dc:creator>Nathan</dc:creator>
      <pubDate>Thu, 12 Mar 2026 14:35:18 +0000</pubDate>
      <link>https://dev.to/nathan-brodin/you-wouldnt-steal-a-div-how-i-built-my-portfolio-2a25</link>
      <guid>https://dev.to/nathan-brodin/you-wouldnt-steal-a-div-how-i-built-my-portfolio-2a25</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;A story about how I built my portfolio and what went through my mind while building it&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Hello, World! Here's my first ever blog.&lt;/p&gt;

&lt;p&gt;I wanted to talk a bit about my &lt;a href="https://brodin.dev" rel="noopener noreferrer"&gt;portfolio&lt;/a&gt;, how I built it, why I made some of these architectural and design decisions, and why I shamelessly 'borrowed' my way to designs I love.&lt;/p&gt;

&lt;h2&gt;
  
  
  1 Stack, 2 Stack, Tech Stack, 4
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;(If you didn't read that header &lt;a href="https://youtu.be/F2hiFbuQ-Qw?si=h6omylv9pJwJIVgp&amp;amp;t=93" rel="noopener noreferrer"&gt;in Slim Shady's voice&lt;/a&gt;, I don't know what to tell you.)&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;First of all, let's talk tech stack. I used to have all of my personal projects (including my previous portfolio) built with &lt;a href="https://nextjs.org/" rel="noopener noreferrer"&gt;Next.js&lt;/a&gt;. These were small side projects, without complicated logic, and with simple needs. Yet, I would always run into Next.js edge cases. Turbopack was failing my dev builds because it wouldn't play nice with Contentlayer, HMR was taking actual seconds to refresh, and production builds were taking 50 seconds for projects that barely scaled. When I got to choose a stack for a new project at my job, I couldn't justify the Next.js overhead for production apps, considering the friction I had with small personal projects.&lt;/p&gt;

&lt;p&gt;At that time, everyone was talking about &lt;a href="https://tanstack.com/start/latest" rel="noopener noreferrer"&gt;Tanstack Start&lt;/a&gt;: the "DX God." But even their starter app had linting and TS issues, so I wasn't fully impressed. I stuck with only Tanstack Router for that project, and as weeks passed by working with the entire Tanstack Ecosystem (except Start), I was really enjoying it. The documentation is well written and extensive (even too much sometimes), DX actually made sense, and I could find all of the features I liked about Next.js (file base routing for example) while still having great performance (15s builds).&lt;/p&gt;

&lt;p&gt;So this led me to give a second chance to Tanstack Start, and I built this portfolio with it! I still love Vercel's UX, and wander around their dashboard to see how I should implement my stuff, but I think I'll leave Next.js behind for now. And personal projects are made to experiment with stuff. So I just want to try out different tech and see what I like or not, purely on feelings.&lt;/p&gt;

&lt;p&gt;Then for the component library, I was really into &lt;a href="https://coss.com/ui" rel="noopener noreferrer"&gt;coss ui&lt;/a&gt;. I stumbled upon it randomly one day and loved it so much. My &lt;a href="https://chat.brodin.dev" rel="noopener noreferrer"&gt;Nathan's AI&lt;/a&gt; project already had a UI heavily inspired by &lt;a href="https://cal.com/" rel="noopener noreferrer"&gt;cal.com&lt;/a&gt;: send button, message suggestions... So when I saw they had a &lt;a href="https://ui.shadcn.com/" rel="noopener noreferrer"&gt;shadcn/ui&lt;/a&gt; library with that kind of style, it was perfect for what I needed.&lt;/p&gt;

&lt;p&gt;I can also consider myself a proud Open Source Contributor after getting &lt;strong&gt;two PRs&lt;/strong&gt; merged into &lt;a href="https://coss.com/ui" rel="noopener noreferrer"&gt;coss ui&lt;/a&gt; (a 2-line diff and I'm not even kidding, but quality over quantity, I guess...?).&lt;/p&gt;

&lt;h2&gt;
  
  
  Stealing...I Mean, Getting Inspired By Chanhdai and Zed
&lt;/h2&gt;

&lt;p&gt;Now I'm not going to lie and say I designed and coded everything myself. I took large parts of the code and page layout from the open-source portfolio by &lt;a href="https://chanhdai.com" rel="noopener noreferrer"&gt;Chanhdai&lt;/a&gt; and design inspiration from &lt;a href="https://zed.dev" rel="noopener noreferrer"&gt;zed.dev&lt;/a&gt;, which I had previously attempted in unfinished side projects (&lt;a href="https://trends.brodin.dev" rel="noopener noreferrer"&gt;https://trends.brodin.dev&lt;/a&gt; and &lt;a href="https://ui.brodin.dev" rel="noopener noreferrer"&gt;https://ui.brodin.dev&lt;/a&gt; if they're still available).&lt;/p&gt;

&lt;p&gt;I still went a different way than Chanhdai for the code implementation. For example, I chose to store my content in markdown files using &lt;a href="https://www.content-collections.dev/" rel="noopener noreferrer"&gt;Content Collections&lt;/a&gt;. I did this specifically so I could easily serve my portfolio's content in plain text, perfect for LLM consumption, which ties right back into the needs of my &lt;a href="https://chat.brodin.dev" rel="noopener noreferrer"&gt;Nathan's AI&lt;/a&gt; project.&lt;br&gt;
I also used Base-ui (because of coss ui) for components, and generally went with a different style. So this was not just a simple copy/paste or a fork, there was still a lot of work involved.&lt;/p&gt;

&lt;p&gt;For Zed, I took the &lt;a href="https://zed.dev/attributions#fonts" rel="noopener noreferrer"&gt;same fonts&lt;/a&gt; for the headings and text, and the grid layout with the "diamonds" at the intersections. It really adds personality to my portfolio (not &lt;em&gt;my&lt;/em&gt; personality since I stole it, but &lt;strong&gt;some&lt;/strong&gt; personality).&lt;/p&gt;

&lt;p&gt;Also, by a great coincidence, Chanhdai had very basic sound design, like a sound when changing the theme, and I wanted to push things further by having different sounds around the entire app. I was looking around on the web for good sound libraries and all, but couldn't find anything interesting. Then the next day, I saw a tweet about the upcoming release of &lt;a href="https://www.soundcn.xyz/" rel="noopener noreferrer"&gt;Soundcn&lt;/a&gt;, which was &lt;strong&gt;exactly&lt;/strong&gt; what I was looking for. So now you get a bunch of sounds when clicking on stuff in my portfolio. It's a nice touch!&lt;/p&gt;

&lt;h2&gt;
  
  
  From good to WHOA
&lt;/h2&gt;

&lt;p&gt;Lastly, the thing that elevated my portfolio from a good portfolio to a &lt;strong&gt;Whoa&lt;/strong&gt; portfolio are the dither shaders (from &lt;a href="https://shaders.paper.design/dithering" rel="noopener noreferrer"&gt;Paper Shaders&lt;/a&gt;) that I have for page headings and section dividers. I had dither shaders in mind since the first day I saw them on &lt;a href="https://reactbits.dev/backgrounds/dither" rel="noopener noreferrer"&gt;React Bits&lt;/a&gt;, but couldn't find a good way to integrate them. So it ended up being a crossover between inspiration from the heading of zed.dev (open the dev console and see &lt;code&gt;data-paper-shader&lt;/code&gt; on the headings) and &lt;a href="https://www.fumadocs.dev/" rel="noopener noreferrer"&gt;Fumadocs&lt;/a&gt;, which had them as well.&lt;/p&gt;

&lt;h2&gt;
  
  
  To wrap things up
&lt;/h2&gt;

&lt;p&gt;So in conclusion, I would say this portfolio is a cross between Chanhdai and Zed, with a better stack.&lt;/p&gt;

&lt;p&gt;Go ahead, click around to hear the sound integration (it gets annoying after a while, be careful), check out the dither shaders on the headings, and &lt;a href="https://github.com/NathanBrodin/Portfolio" rel="noopener noreferrer"&gt;check out the source code here&lt;/a&gt; if you want to steal from me like I did from everyone (but at least leave a star on the repo).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Blog Title reference: &lt;a href="https://www.youtube.com/watch?v=P-pYiWGSN8w" rel="noopener noreferrer"&gt;This classic: "You Wouldn't Steal a Car"&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;My Portfolio: &lt;a href="https://brodin.dev" rel="noopener noreferrer"&gt;brodin.dev&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Initially posted on &lt;a href="https://brodin.dev/blog/how-i-built-my-portfolio" rel="noopener noreferrer"&gt;my blog&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>portfolio</category>
      <category>react</category>
      <category>nextjs</category>
    </item>
  </channel>
</rss>
