
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>Yet Another Dev Blog, by Lani Akita</title>
  <link href="https://laniakita.com/atom.xml" rel="self"/>
  <link href="https://laniakita.com"/>
  <updated>2026-08-13T00:50:45.000Z</updated>
  <author>
    <name>Lani Akita</name>
    <email>me@laniakita.com</email>
    <uri>https://laniakita.com/about</uri>
  </author>
  <category term="technology" label="Technology"/>
  <generator version="1.168.58">TanStack Start</generator>
  <icon>https://laniakita.com/favicon.ico</icon>
  <logo>https://laniakita.com/icon1.svg</logo>
  <rights>Copyright © 2024-2026, Lani Akita</rights>
  <subtitle>Thoughts from a software engineer who dabbles in (and babbles about) a bit of everything.</subtitle>
  <id>https://laniakita.com/blog</id>
  <entry>
    <title>How to Install NixOS with Full Disk Encryption + Secure Boot + Unlock LUKS via TPM2</title>
    <link rel="alternate" href="https://laniakita.com/blog/nixos-fde-tpm-hm-guide"/>
    <id>https://laniakita.com/blog/nixos-fde-tpm-hm-guide</id>
    <updated>2026-08-13T00:50:45.000Z</updated>
    <category term="/categories/linux" scheme="https://laniakita.com/categories/linux" label="Linux"/>
    <category term="/categories/security" scheme="https://laniakita.com/categories/security" label="Security"/>
    <category term="/categories/guides" scheme="https://laniakita.com/categories/guides" label="Guides"/>
    <category term="/tags/nix-nixos" scheme="https://laniakita.com/tags/nix-nixos" label="Nix/NixOS"/>
    <content type="html"><![CDATA[<figure><img src="https://laniakita.com/images/open-oc/2025/nixos_fde_tpm_guide.png" alt="Background: a wireframe of a scenic mountain view, with water pooling into a pond at the base. Foreground: Directly in the center, sits the NixOS logo, a plus sign, a lock icon, a plus sign, and a motherboard icon, all imposed upon a translucent, teal rectangle." /><figcaption>Yes to love, yes to life, yes to staying in more! - Liz Lemon</figcaption></figure> <p><strong>Happy New Year!</strong> Yep, it&#x27;s <em>that</em> time of year again. A time of new beginnings, cleaned slates, and Winter weather (in the Northern Hemisphere). So, bundle up near the warmth of your PCs exhaust fans, and bask in the glow of artificial light from it&#x27;s connected display. Because, dear reader, it&#x27;s time to start anew! And what better way to achieve that, than with a clean install of NixOS?</p>
<p>Sure, perhaps you vowed this would be the year you&#x27;d stop distrohopping<sup><a href="#user-content-fn-distrohopper" id="user-content-fnref-distrohopper" data-footnote-ref="true" aria-describedby="footnote-label">1</a></sup>, saving your SSD from near certain doom. But if that&#x27;s the case, there&#x27;s a good chance you <em>might just find</em> NixOS to be that final hop you&#x27;ve been looking for ... Well, only one way to find out, right?</p>
<details><summary>[INFO]: Changelog</summary><p><strong>Aug, 12th, 2026</strong>: Fixed flake installation commands and safe reboot commands. Thank you to <a href="https://github.com/raldone01">raldone01</a> and <a href="https://github.com/benjaminrich">benjaminrich</a> for their feedback!</p><p><strong>Jan, 9th, 2025</strong>: Added notes on updating nix flakes, what a flake.lock file is, how to override nix flake inputs, and other fun, clarifying notes. See: <a href="https://github.com/laniakita/website/commit/7bb10d9dd5d6d422fdb899d7f8b939b4e478a12f">#7bb10d9</a>, <a href="https://github.com/laniakita/website/commit/5dfc95e350a76920ae2fd60058a0700ea71338ce">#5dfc95e</a>, <a href="https://github.com/laniakita/website/commit/e533186d55bb371a117e7c76fd30fa2ba3397f2d">#e533186</a>.</p></details>
<details><summary>[INFO]: On future NixOS versions</summary><p>I wrote most of this article before NixOS 24.11 (Vicuña) was made stable. At the time I went through the install process for this write-up, NixOS 24.05 (Uakari) was loaded onto my USB. Currently, NixOS 25.05 (Warbler) is the latest Unstable release as of the time of publishing (1/4/2024).</p><p>With that said, I&#x27;m currently unaware of any major differences in the install process between NixOS 24.05, 24.11, and 25.05.</p><p>Additionally, when <em>differences</em> do appear, the gcc compiler (or other compiler) used during <code>nixos-rebuild</code> will likely print out a warning message to the console explaining which deprecated (or soon to be deprecated) options you&#x27;ve used and what changes need to be made to <em>correct</em> the configuration you&#x27;re trying to build—well, theoretically at least.</p><p>As such, I hope this guide is able to serve you well! Hopefully into the future too.</p></details>
<h2 id="introduction">Introduction</h2>
<p>After taking some time to setup a fresh install of one of my favorite distros, I felt it was a good idea to post a write up about the somewhat extensive process on installing NixOS with Full Disk Encryption (FDE), enabling secure boot, and <em>automagically</em> unlocking the encrypted LUKS device(s) during the boot process with keys enrolled into the motherboard&#x27;s TPM2 chip.</p>
<p>Now, I should state that I didn&#x27;t come up with this guide alone. This guide is more of an amalgamation, based pretty heavily upon a lot of other guides and resource materials out there scattered across the net. As such, I encourage you to check out the reference materials<sup><a href="#user-content-fn-full-disk-encryption---nixos-wiki" id="user-content-fnref-full-disk-encryption---nixos-wiki" data-footnote-ref="true" aria-describedby="footnote-label">2</a></sup><sup>, </sup><sup><a href="#user-content-fn-installing-nixos-with-flakes-and-lvm-on-luks" id="user-content-fnref-installing-nixos-with-flakes-and-lvm-on-luks" data-footnote-ref="true" aria-describedby="footnote-label">3</a></sup><sup>, </sup><sup><a href="#user-content-fn-installing-nixos-with-full-disk-encryption" id="user-content-fnref-installing-nixos-with-full-disk-encryption" data-footnote-ref="true" aria-describedby="footnote-label">4</a></sup><sup>, </sup><sup><a href="#user-content-fn-a-modern-and-secure-desktop-setup---guides" id="user-content-fnref-a-modern-and-secure-desktop-setup---guides" data-footnote-ref="true" aria-describedby="footnote-label">5</a></sup><sup>, </sup><sup><a href="#user-content-fn-installation-of-nixos-with-encrypted-root" id="user-content-fnref-installation-of-nixos-with-encrypted-root" data-footnote-ref="true" aria-describedby="footnote-label">6</a></sup><sup>, </sup><sup><a href="#user-content-fn-how-to-install-nixos-with-full-disk-encryption-fde-using-luks2" id="user-content-fnref-how-to-install-nixos-with-full-disk-encryption-fde-using-luks2" data-footnote-ref="true" aria-describedby="footnote-label">7</a></sup><sup>, </sup><sup><a href="#user-content-fn-lanzaboote-docs-quick-start" id="user-content-fnref-lanzaboote-docs-quick-start" data-footnote-ref="true" aria-describedby="footnote-label">8</a></sup><sup>, </sup><sup><a href="#user-content-fn-nixos-manual" id="user-content-fnref-nixos-manual" data-footnote-ref="true" aria-describedby="footnote-label">9</a></sup> I used, linked in the footnotes below.</p>
<h3 id="what-is-nixos">What is NixOS?</h3>
<p>NixOS is a operating system (OS) based on Nix, a purely functional package management system. Nix (the package manager) treats packages like how values are treated in purely functional programming languages, like Haskell, and stores the resultant packages in the <em>Nix store</em> (<code>/nix/store</code>). Such packages are built from <em>Nix Expressions</em>, written in the purely functional <code>Nix</code> language.<sup><a href="#user-content-fn-how-nix-works" id="user-content-fnref-how-nix-works" data-footnote-ref="true" aria-describedby="footnote-label">10</a></sup></p>
<p>In NixOS specifically, the whole OS is built by the Nix package manager, from a Nix expression that declares the entire system&#x27;s configuration, typically in a <code>configuration.nix</code> file. Because everything is declared in a purely functional manner, a new configuration cannot overwrite previous configurations. This results in reliable, atomic upgrades, (which can be atomically rolled back) and reproducible system configurations.<sup><a href="#user-content-fn-how-nix-works" id="user-content-fnref-how-nix-works-2" data-footnote-ref="true" aria-describedby="footnote-label">10</a></sup></p>
<details details-warn="true"><summary>[WARN]: Non-FHS Compliance</summary><p>The immediate implications of the <em>Nix store</em> is that Nix, and by extension NixOS, are non Filesystem Hierarchy Standard (FHS) compliant. This creates many immediate benefits (listed above), but also some new challenges too, such as running precompiled binaries<sup><a href="#user-content-fn-nixos-wiki--packaging-binaries" id="user-content-fnref-nixos-wiki--packaging-binaries" data-footnote-ref="true" aria-describedby="footnote-label">11</a></sup>.</p><p>While there&#x27;s workarounds<sup><a href="#user-content-fn-nix-ld-a-clean-solution" id="user-content-fnref-nix-ld-a-clean-solution" data-footnote-ref="true" aria-describedby="footnote-label">12</a></sup><sup>, </sup><sup><a href="#user-content-fn-nixos-wiki--packaging-binaries" id="user-content-fnref-nixos-wiki--packaging-binaries-2" data-footnote-ref="true" aria-describedby="footnote-label">11</a></sup> and escape hatches<sup><a href="#user-content-fn-nixos-wiki--steam" id="user-content-fnref-nixos-wiki--steam" data-footnote-ref="true" aria-describedby="footnote-label">13</a></sup> for the latter, If this guide is how you experience NixOS for the first time, then I should warn you about it&#x27;s most common pain point, before you sink some time into this project. As such, please consider if non-FHS compliance is a deal breaker for you, before wiping your SSD.</p></details>
<h3 id="who-is-the-guide-for">Who is the guide for?</h3>
<p>This guide is for anyone interested in using NixOS<sup><a href="#user-content-fn-how-nix-works" id="user-content-fnref-how-nix-works-3" data-footnote-ref="true" aria-describedby="footnote-label">10</a></sup>, in a relatively secure fashion, with <em>modern conveniences</em> like using an onboard TPM2 chip to decrypt LUKS devices during boot up. Additionally, the level of technical knowledge in this guide, assumes a somewhat decent familiarity with Linux systems, at least to the point where one might feel comfortable enough to perform a manual install of either Arch Linux<sup><a href="#user-content-fn-installation-guide-archwiki" id="user-content-fnref-installation-guide-archwiki" data-footnote-ref="true" aria-describedby="footnote-label">14</a></sup> or Gentoo<sup><a href="#user-content-fn-gentoo-amd64-guide" id="user-content-fnref-gentoo-amd64-guide" data-footnote-ref="true" aria-describedby="footnote-label">15</a></sup>.</p>
<p>While we won&#x27;t be installing Arch or Gentoo today, we are going to be making heavy use of the command line and TUI based text editors as we perform a manual install of NixOS. If that&#x27;s something you&#x27;re up for, then this guide is written for you.</p>
<p>On the other hand, if this is your first exposure to Linux or even Unix based operating systems, let alone NixOS, then I encourage you to consider <em>skimming</em> through this guide, instead of actually performing any of the steps contained within it. That way, you can at least discover if anything I talk about today seems interesting to you, even if it seems beyond your current comfort level of technical expertise. Because, who knows, you might just find you&#x27;ve been bitten by the &#x27;Nix bug before you know it! Also, should that happen, I highly recommend trying a traditional Linux distro like <a href="https://fedoraproject.org/workstation/">fedora workstation</a>, just to get your feet wet, before diving headfirst into something that&#x27;s far less user-friendly.</p>
<h3 id="goals-of-this-guide">Goals of this Guide</h3>
<p>By the end of this article, my hope is that you’ll be able to:</p>
<ol>
<li>Install NixOS with Full Disk Encryption.</li>
<li>Use a <code>flake.nix</code> to configure your NixOS system.</li>
<li>Install and configure <code>Lanzaboote</code> to enable secure boot on your machine.</li>
<li>Use <code>systemd-cryptenroll</code> to unlock your LUKS devices via your motherboard&#x27;s TPM chip.</li>
<li>Use <code>home-manager</code> to configure your shell and <code>git</code>/<code>gh</code>.</li>
</ol>
<h2 id="part-01-from-zero-to-nixos">Part 01: From Zero to NixOS</h2>
<h3 id="prerequisites">Prerequisites</h3>
<ol>
<li>A machine with a UEFI motherboard</li>
<li>A machine with a TPM2 chip (This is only needed for the <em>Unlock LUKS via TPM2</em> part of the guide, which uses <code>tpm2-tss</code> to enable <code>systemd-cryptenroll</code> to enroll your LUKS keys into the TPM chip.<sup><a href="#user-content-fn-systemd-cryptenroll" id="user-content-fnref-systemd-cryptenroll" data-footnote-ref="true" aria-describedby="footnote-label">16</a></sup>)</li>
<li>A bootable USB (4 GB minimum) with the <a href="https://nixos.org/download/#nixos-iso">Minimal NixOS ISO</a><sup><a href="#user-content-fn-minimal-iso" id="user-content-fnref-minimal-iso" data-footnote-ref="true" aria-describedby="footnote-label">17</a></sup> loaded onto it</li>
</ol>
<h3 id="booting-the-installer">Booting the installer</h3>
<ol>
<li>Repeatedly mash <code>F2</code> or <code>F10</code> or <code>DEL</code> or whichever key let’s you into your system’s UEFI/Bios.</li>
<li>Disable secure boot (if enabled).</li>
<li>Disable the CSM/Legacy Support (if enabled/applicable).
<ol>
<li><strong>WARNING: If this was enabled, any legacy hardware you might have (e.g., a GTX 660 with a VBIOS lacking UEFI support) will immediately become incompatible/stop working (until you re-enable CSM/Legacy Support). If you continue, you acknowledge that you&#x27;re comfortable with removing/losing any incompatible hardware from your current system.</strong></li>
</ol>
</li>
<li>Move the NixOS USB to the highest boot priority (if applicable)</li>
<li>Save your changes, and boot into your NixOS USB.</li>
<li>Once you&#x27;ve reached GRUB, feel free to either load the installer from the USB itself, or copy it to the RAM. You can also choose <em>&quot;no modeset&quot;</em> if you&#x27;re having issues (usually graphical) reaching the installer&#x27;s <code>TTY</code>.</li>
</ol>
<details details-critical="true"><summary>[CRITICAL]: Secure boot requires UEFI!</summary><p>Installing NixOS in UEFI mode is critical to enabling secure boot in <a href="#part-02-secure-boot-with-lanzaboote">part 02</a>. Most modern motherboards/hardware supports UEFI by default. Still yet, you might want to ensure that the Compatibility Support Module (CSM) is disabled if your motherboard UEFI/BIOS has that option.</p></details>
<h3 id="connect-to-the-internet">Connect to the Internet</h3>
<p>Before we proceed with the installation, it’s probably a good idea to make sure you can access the internet way before making any drastic changes (partitioning, formatting, etc.) that would affect your currently installed OS.</p>
<p>So, plug in an ethernet cable, or follow the steps below to setup WiFi, then try to ping a website: e.g., <code>ping google.com</code>. If you can see packets coming back, hit <code>ctrl+c</code> to stop the pinging, then proceed to <a href="#partitioning">partitioning</a>.</p>
<p>Otherwise, stop here, and start troubleshooting. If you still can’t connect to the internet, it’s possible your network adapter is just too new, and thus isn’t supported by the Linux kernel at this time.</p>
<h4 id="wifi-setup">WiFi Setup</h4>
<p>First, make sure your wireless network adapter is actually enabled. You can see which adapters are available with <code>ip link</code></p>
<pre><code class="language-sh">[nixos@nixos:&#x27;&#x27;]$ ip link
1: lo: &lt;LOOPBACK,UP,LOWER_UP&gt; mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: enp: &lt;NO-CARRIER,BROADCAST,MULTICAST,UP&gt; mtu 1500 qdisc fq_codel state DOWN mode DEFAULT group default qlen 1000
 link/ether 55:a5:b5:c5:d5:f5 brd ff:ff:ff:ff:ff:ff
3: wlp: &lt;BROADCAST,MULTICAST&gt; mtu 1500 qdisc noop state DOWN mode DORMANT group default qlen 1000
 link/ether f5:d5:c5:b5:af:f5 brd ff:ff:ff:ff:ff:ff
</code></pre>
<p>If your WiFi adapter is <em>DOWN</em> like mine (wlp) just run:</p>
<pre><code class="language-console"># ip link set &lt;your-interface&gt; up
</code></pre>
<p>If that results in an error:</p>
<pre><code class="language-console">RTNETLINK answers: Operation not possible due to RF-kill
</code></pre>
<p>Please see the note on RF-kill Unblocking below, otherwise skip to <a href="#getting-connected-with-wpa_supplicant">Getting Connected with <code>wpa_supplicant</code></a>.</p>
<details><summary>[INFO]: RF-kill Unblocking</summary><p>First, run <code>rfkill</code> to investigate what interface is blocked.</p><pre><code class="language-sh">[nixos@nixos:&#x27;&#x27;]$ rfkill
ID TYPE      DEVICE               SOFT      HARD
 0 wlan      ideapad_wlan      blocked unblocked
 1 bluetooth ideapad_bluetooth blocked unblocked
 2 bluetooth hci0              blocked unblocked
 3 wlan      phy0              blocked unblocked
</code></pre><p>In my case, my <code>wlan</code> interface is “soft” blocked. So, running <code># rfkill unblock wlan</code> unblocks it.</p><pre><code class="language-sh">[nixos@nixos:&#x27;&#x27;]$ sudo rfkill unblock wlan
ID TYPE      DEVICE                 SOFT      HARD
 0 wlan      ideapad_wlan      unblocked unblocked
 1 bluetooth ideapad_bluetooth   blocked unblocked
 2 bluetooth hci0                blocked unblocked
 3 wlan      phy0              unblocked unblocked
</code></pre><p>Then running <code>ip link set wlp up</code> results in no error.</p><pre><code class="language-sh">[nixos@nixos:&#x27;&#x27;]$ sudo ip link set wlp up

[nixos@nixos:&#x27;&#x27;]$ _
</code></pre></details>
<h5 id="getting-connected-with-wpa_supplicant">Getting Connected with <code>wpa_supplicant</code></h5>
<p>With the WiFi adapter actually available, we can now continue (mostly) from the official NixOS manual<sup><a href="#user-content-fn-nixos-manual" id="user-content-fnref-nixos-manual-2" data-footnote-ref="true" aria-describedby="footnote-label">9</a></sup> for getting connected to the internet run:</p>
<pre><code class="language-console"># systemctl start wpa_supplicant
</code></pre>
<p>Then bring up the CLI with: <code>wpa_cli</code>. From there, you can scan for networks:</p>
<pre><code class="language-sh">&gt; scan
OK
&gt; scan_results
bssid / frequency / signal level / flags / ssid
1a:2b:34:56:70    5555  -53 [WPA2-PSK+SAE-CCMP][WPS][ESS] myhomenetwork
...
</code></pre>
<p>Then add, set, and enable your network:</p>
<pre><code class="language-sh">&gt; add_network
0
&gt; set_network 0 ssid &quot;myhomenetwork&quot;
OK
&gt; set_network 0 psk &quot;mypassword&quot;
OK
&gt; set_network 0 key_mgmt WPA-PSK
OK
&gt; enable_network 0
OK
</code></pre>
<p>Alternatively for an enterprise network:</p>
<pre><code class="language-sh">&gt; add_network
0
&gt; set_network 0 ssid &quot;eduroam&quot;
OK
&gt; set_network 0 identity &quot;myname@example.com&quot;
OK
&gt; set_network 0 password &quot;mypassword&quot;
OK
&gt; set_network 0 key_mgmt WPA-EAP
OK
&gt; enable_network 0
OK
</code></pre>
<p>If everything went okay after the last command, you’ll something like:</p>
<pre><code class="language-sh">...
&gt; enable_network 0
OK
&lt;3&gt;CTRL-EVENT-SCAN-STARTED
&lt;3&gt;CTRL-EVENT-SCAN-RESULTS
&lt;3&gt;SME: Trying to authenticate with 1a:2b:34:56:70 (SSID=&#x27;myhomenetwork&#x27; freq=5555 MHz)
&lt;3&gt;Associated with 1a:2b:34:56:70
&lt;3&gt;CTRL-EVENT-SUBNET-STATUS-UPDATE status=0
&lt;3&gt;WPA: Key negotiation completed with 1a:2b:34:56:70  [PTK=CCMP GTK=CCMP]
&lt;3&gt;CTRL-EVENT-CONNECTED - Connection to 1a:2b:34:56:70 completed [id=0 id_str=]
&gt;
</code></pre>
<p>Then all you have to do is run <code>quit</code> to return to the shell, and we can proceed with the next step.</p>
<h3 id="partitioning">Partitioning</h3>
<details open="" details-critical="true"><summary>[CRITICAL]: Partitioning will result in DATA LOSS!</summary><p>Please make sure to have backed up anything and everything that is important to you, before proceeding through this section!</p></details>
<p>Now that we’re absolutely positive we can connect to the internet, we’re going to use <code>fdisk</code> to partition the drive we want to install NixOS on. Running the ensuing <code>fdisk</code> commands will create both a boot partition and a LVM partition. The LVM partition will hold both a root partition and a swap partition.</p>
<p>We’ll use the <code>fdisk</code> CLI to perform the partitioning part of the process. But first, we can also use it to list our disks via <code># fdisk -l</code>, so we can get the exact location of the disk we want to partition.</p>
<pre><code class="language-sh">[nixos@nixos:&#x27;&#x27;]$ sudo fdisk -l
Disk /dev/loop0: 1021.89 MiB, 1071525888 bytes, 2092824 sectors

Disk /dev/nvme0n1: 476.94 GiB, 512110190592 bytes, 1000215216 sectors
Disk model: SAMSUNG M2VLB512
Units: sectors of 1 ** 512 = 512 butes
Sector Size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: gpt
Disk identifier: 09515620-9D73-11EF-BE8C-0800200C9A66

Device             Start        End   Sectors   Size Type
/dev/nvme0n1p1      2048     206847    204800   100M EFI System
/dev/nvme0n1p2    206848     239615     32768    16M Microsoft reserved
/dev/nvme0n1p3    239616  998639615 998400000 476.1G Microsoft basic data
/dev/nvme0n1p4 998639616 1000212479   1572864   768M Windows recovery environment

...
</code></pre>
<p>Once we&#x27;ve found our disk, we&#x27;ll pass it&#x27;s location as an input into <code>fdisk</code> to perform the actual partitioning steps:</p>
<pre><code class="language-console"># fdisk /dev/your-disk-to-partition
</code></pre>
<p>In my case the command looks like: <code># fdisk /dev/nvme0n1</code>.</p>
<details details-critical="true"><summary>[CRITICAL]: Be sure <em>your commands</em> reference <em>your drive to partition!</em></summary><p>Many of the commands featured in this guide refer to <code>/dev/nvme0n1</code> as an <strong>example</strong>. However, You might have <em>your</em> drive to partition on <code>/dev/sda</code> for instance. So, to avoid any unnecessary heartache, please make sure the commands you&#x27;re running are on the drive you actually want to partition.</p></details>
<h4 id="create-a-empty-gpt-table">Create a Empty GPT Table</h4>
<p>To begin, we’ll clean up any old partitions by creating a empty GPT partition table (<code>g</code>):</p>
<pre><code class="language-sh">Command (m for help): g
Created a new GPT disklabel (GUID: 5213d2a2-a040-44c4-ba6a-02787812777e).

Command (m for help):
</code></pre>
<p>To verify this, you can print (<code>p</code>) out the new partition table, which should now be empty:</p>
<pre><code class="language-sh">Command (m for help): p
Disk /dev/nvme0n1: 476.94 GiB, 512110190592 bytes, 1000215216 sectors
Disk model: SAMSUNG M2VLB512
Units: sectors of 1 ** 512 = 512 butes
Sector Size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: gpt
Disk identifier: 5213d2a2-a040-44c4-ba6a-02787812777e

Command (m for help):
</code></pre>
<details details-warn="true"><summary>[WARN]: Empty partition table !== Secure erase</summary><p>Creating an empty partition table doesn&#x27;t securely erase the data on your disk. If that&#x27;s something you&#x27;d like to perform, perhaps ATA Security Erase<sup><a href="#user-content-fn-secure-erase" id="user-content-fnref-secure-erase" data-footnote-ref="true" aria-describedby="footnote-label">18</a></sup> might be something worth looking into.</p></details>
<h4 id="create-the-efi-system-partition-esp">Create the EFI System Partition (ESP)</h4>
<p>We can then create the ESP like so:</p>
<ol>
<li><code>n</code></li>
<li>(partition number; default) <code>enter</code></li>
<li>(first sector: default) <code>enter</code></li>
<li>(last sector) <code>+1G</code></li>
<li>(remove signature if applicable) <code>Y</code></li>
<li><code>t</code> (change partition type)</li>
<li>(partition type or alias) <code>1</code> (EFI)</li>
</ol>
<pre><code class="language-sh">Command (m for help): n
First sector (2048-1000214527), default 2048):
Last sector, +sectors or +size{K,M,G,T,P} (2048-1000214527, default 1000215216): +1G

Created a new partition 1 of type &#x27;Linux filesystem&#x27; and of size 1 GiB.

Command (m for help): t
Partition type or alias (type L to list all): 1
Changed type of partition &#x27;Linux filesystem&#x27; to &#x27;EFI&#x27;.

Command (m for help):
</code></pre>
<h4 id="create-the-lvm-partition">Create the LVM Partition</h4>
<p>Next, we’ll add our LVM partition:</p>
<ol>
<li><code>n</code></li>
<li>(partition number: default) <code>enter</code></li>
<li>(first sector: default) <code>enter</code></li>
<li>(last sector: default) <code>enter</code></li>
<li>(remove signature if applicable) <code>Y</code></li>
<li><code>t</code> (change partition type)</li>
<li>(partition number: (1, 2, default 2)) <code>enter</code></li>
<li>(partition type or alias:) <code>44</code> (Linux LVM)</li>
</ol>
<pre><code class="language-sh">Command (m for help): n
Partition number (1, 2, default 2):
First sector (2099200-1000214527), default 2099200):
Last sector, +sectors or +size{K,M,G,T,P} (2048-1000215216, default 1000215216):

Created a new partition 2 of type &#x27;Linux filesystem&#x27; and of size 475.9 GiB.

Command (m for help): t
Partition number (1, 2, default 2):
Partition type or alias (type L to list all): 44
Changed type of partition &#x27;Linux filesystem&#x27; to &#x27;Linux LVM&#x27;.

Command (m for help):
</code></pre>
<h4 id="verifying-the-partitions">Verifying the Partitions</h4>
<p>If you print (<code>p</code>) out the partition table again, it&#x27;ll probably look similar to this:</p>
<pre><code class="language-sh">Command (m for help): p
Disk /dev/nvme0n1: 476.94 GiB, 512110190592 bytes, 1000215216 sectors
Disk model: SAMSUNG M2VLB512
Units: sectors of 1 ** 512 = 512 butes
Sector Size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: gpt
Disk identifier: 5213d2a2-a040-44c4-ba6a-02787812777e

Device           Start        End   Sectors   Size Type
/dev/nvme0n1p1    2048    2099199   2097152     1G EFI System
/dev/nvme0n1p2 2099200 1000214527 998115328 475.9G Linux LVM

Command (m for help):
</code></pre>
<p>if it looks right, then we can save (<code>w</code>) our changes and exit!</p>
<p>Now, running <code># lsblk</code> should look something like this:</p>
<pre><code class="language-sh">[nixos@nixos:&#x27;&#x27;]$ sudo lsblk
NAME        MAJ:MIN RM   SIZE  RO TYPE MOUNTPOINTS
...
nvme0n1      259:0    0  476.9G  0 disk
|-nvme0n1p1 259:3    0      1G  0 part
|-nvme0n1p2 259:4    0  475.9G  0 part

[nixos@nixos:&#x27;&#x27;]$
</code></pre>
<h3 id="encrypting-with-luks">Encrypting with LUKS</h3>
<details details-critical="true"><summary>[CRITICAL]: Running <code>luksFormat</code> will cause DATA LOSS!</summary><p>Assuming you skipped the partitioning step, please make sure to have backed up anything and everything that is important to you, before proceeding through this section!</p></details>
<p>We’ll be encrypting the LVM partition with LUKS (version 2). To do that, we just have to run the following command:</p>
<pre><code class="language-console"># cryptsetup -v -y --label=NIXLUKS luksFormat --type luks2 /dev/nvme0n1p2
</code></pre>
<p>The <code>-v</code> flag ensures a verbose output, <code>-y</code> ensures it’ll ask us for our encryption password twice to confirm, the label flag is simply assigned to <code>NIXLUKS</code> but you can call it whatever you want. <code>luksFormat</code> actually encrypts our disk, and the <code>--type</code> flag set to <code>luks2</code>.<sup><a href="#user-content-fn-cryptsetup" id="user-content-fnref-cryptsetup" data-footnote-ref="true" aria-describedby="footnote-label">19</a></sup> This is important because it&#x27;s both newer, and a necessary requirement to use <code>systemd-cryptenroll</code>, which enables enrolling your LUKS keys into the TPM2.<sup><a href="#user-content-fn-systemd-cryptenroll" id="user-content-fnref-systemd-cryptenroll-2" data-footnote-ref="true" aria-describedby="footnote-label">16</a></sup></p>
<pre><code class="language-sh">[nixos@nixos:&#x27;&#x27;]$ sudo cryptsetup -v -y --label=NIXLUKS luksFormat --type luks2 /dev/nvme0n1p2

WARNING!
========
This will overwrite data on /dev/nvme0n1p2 irrevocably.

Are you sure? (Type &#x27;yes&#x27; in capital letters): YES
Enter passphrase fpr /dev/nvme0n1p2:
verify passphrase:
Key slot 0 created.
Command successful.

[nixos@nixos:&#x27;&#x27;]$
</code></pre>
<h4 id="verifying-the-luks-device">Verifying the LUKS Device</h4>
<p>To verify the partition got encrypted, you can inspect the LUKS header using:</p>
<pre><code class="language-console"># cryptsetup luksDump /dev/nvme0n1p2
</code></pre>
<p>Which will give an output similar to this:</p>
<pre><code class="language-sh">[nixos@nixos:&#x27;&#x27;]$ sudo cryptsetup luksDump /dev/nvme0n1p2
LUKS header information
Version         2
...
UUID:           09515620-9D73-11EF-BE8C-0800200C9A66
label:          NIXLUKS
...
</code></pre>
<p>The most important thing to note is the version number and your label. If it’s “2”, and your label looks right, then we’re all good to go.</p>
<details details-warn="true"><summary>[WARN]: Back up your LUKS header!</summary><p>As a best practice, you probably should backup the LUKS header sometime later.<sup><a href="#user-content-fn-backup-luks-header" id="user-content-fnref-backup-luks-header" data-footnote-ref="true" aria-describedby="footnote-label">20</a></sup> You can do so with the following command:</p><pre><code class="language-console"># cryptsetup luksHeaderBackup /dev/nvme0n1p2 --header-backup-file /path/to/backup.dat
</code></pre></details>
<h4 id="opening-the-luks-device">Opening the LUKS Device</h4>
<p>Now, we’re going to open our encrypted LUKS partition and create a reference to it on <code>/dev/mapper/cryptroot</code> with:</p>
<pre><code class="language-console"># cryptsetup luksOpen /dev/nvme0n1p2 cryptroot
</code></pre>
<p>You can then check our mapped partition exists with: <code>ls /dev/mapper/cryptroot</code></p>
<p>This won&#x27;t return anything really (because there&#x27;s nothing in <code>cryptroot</code> yet), unless <code>cryptroot</code> doesn&#x27;t exist.</p>
<pre><code class="language-sh">[nixos@nixos:&#x27;&#x27;]$ ls /dev/mapper/cryptroot
/dev/mapper/cryptroot

[nixos@nixos:&#x27;&#x27;]$
</code></pre>
<h3 id="lvm-partitioning">LVM Partitioning</h3>
<p>Before creating the root and swap (and other possible) logical volumes (LVs), we&#x27;ll first need to create a Physical Volume on <code>cryptroot</code>, which can then be used to create a Logical Volume Group (LVG) named <code>lvmroot</code>.</p>
<pre><code class="language-console"># pvcreate /dev/mapper/cryptroot
</code></pre>
<pre><code class="language-console"># vgcreate lvmroot /dev/mapper/cryptroot
</code></pre>
<p>With that, we can create the <em>root</em> LV and the <em>swap</em> LV. You can of course create as many LVs as you want, such as a <em>home</em> LV. However, for my purposes, I’m going to keep it simple.</p>
<h4 id="create-the-swap-logical-volume">Create the <code>swap</code> Logical Volume</h4>
<p>We can create the swap LV on our root LVG (lvmroot) with the following command:</p>
<pre><code class="language-console"># lvcreate -L24G lvmroot -n swap
</code></pre>
<details><summary>[INFO]: <code>swap</code> space depends on the use case</summary><p>How much <code>swap</code> space you need is use case dependent. In some cases, it might not even be needed at all! As such, please see the linked <del>stackoverflow</del> <a href="https://askubuntu.com/questions/49109/i-have-16gb-ram-do-i-need-32gb-swap">askubuntu thread</a><sup><a href="#user-content-fn-i-have-16gb-ram--do-i-need-32gb-swap" id="user-content-fnref-i-have-16gb-ram--do-i-need-32gb-swap" data-footnote-ref="true" aria-describedby="footnote-label">21</a></sup> for more. For my own usecase, I’ve got 16 GB of ram on the laptop I&#x27;m using as the <del>test subject</del> <em>example</em> for this guide, and plan to use hibernate, so 16 * 1.5 = 24 GB.</p></details>
<h4 id="create-the-root-logical-volume">Create the <code>root</code> Logical Volume</h4>
<p>With the swap LV set, we can set the root LV to take up the rest of the available space:</p>
<pre><code class="language-console"># lvcreate -l 100%FREE lvmroot -n root
</code></pre>
<h3 id="formatting">Formatting</h3>
<p>This is a fairly standard procedure, the only thing of note here is adding labels, which makes mounting the partitions much easier.</p>
<details><summary>[INFO]: A volume label&#x27;s max length == 11 || 16 bytes</summary><p>The volume label string for the bootable, ESP partition (FAT32) has a maximum length of only 11 bytes<sup><a href="#user-content-fn-fat-osdev-wiki" id="user-content-fnref-fat-osdev-wiki" data-footnote-ref="true" aria-describedby="footnote-label">22</a></sup>. Both the ext4 and Swap partitions have a maximum volume label length of 16 bytes. <sup><a href="#user-content-fn-ext4-spec-global-structures-superblock-linux-kernel" id="user-content-fnref-ext4-spec-global-structures-superblock-linux-kernel" data-footnote-ref="true" aria-describedby="footnote-label">23</a></sup><sup>, </sup><sup><a href="#user-content-fn-man-swaplabel" id="user-content-fnref-man-swaplabel" data-footnote-ref="true" aria-describedby="footnote-label">24</a></sup>.</p></details>
<ol>
<li>
<p>Format the ESP partition:</p>
<pre><code class="language-console"># mkfs.fat -F 32 -n NIXBOOT /dev/nvme0n1p1
</code></pre>
</li>
<li>
<p>Format the Root LV:</p>
<pre><code class="language-console"># mkfs.ext4 -L NIXROOT /dev/mapper/lvmroot-root
</code></pre>
</li>
<li>
<p>Format the Swap LV (if you made one):</p>
<pre><code class="language-console"># mkswap -L NIXSWAP /dev/mapper/lvmroot-swap
</code></pre>
</li>
</ol>
<h3 id="mounting">Mounting</h3>
<p>Then we can mount the formatted partitions like so.</p>
<ol>
<li>
<p>Mount the Root LV:</p>
<pre><code class="language-console"># mount /dev/disk/by-label/NIXROOT /mnt
</code></pre>
</li>
<li>
<p>Create the ESP Directory:</p>
<pre><code class="language-console"># mkdir /mnt/boot
</code></pre>
</li>
<li>
<p>Mount the ESP:</p>
<pre><code class="language-console"># mount -o umask=0077 /dev/disk/by-label/NIXBOOT /mnt/boot
</code></pre>
</li>
<li>
<p>Turn on Swap:</p>
<pre><code class="language-console"># swapon -L NIXSWAP
</code></pre>
</li>
</ol>
<h4 id="verifying-the-mounts">Verifying the Mounts</h4>
<p>To verify everything is in the right place, running:</p>
<pre><code class="language-console"># lsblk -o name,label,size,type,mountpoints /dev/nvme0n1
</code></pre>
<p>should result in output similar to this:</p>
<pre><code class="language-sh">[nixos@nixos:&#x27;&#x27;]$ sudo lsblk -o name,label,size,type,mountpoints /dev/nvme0n1
NAME                LABEL     SIZE TYPE  MOUNTPOINTS
nvme0n1                     476.9G disk
|-nvme0n1p1         NIXBOOT     1G part  /mnt/boot
|-nvme0n1p2         NIXLUKS 475.9G part
   |-cryptroot              475.9G crypt
     |-lvmroot-swap NIXSWAP    24G lvm   [SWAP]
     |-lvmroot-root NIXROOT 451.9G lvm   /mnt

[nixos@nixos:&#x27;&#x27;]$
</code></pre>
<h3 id="installing-nixos">Installing NixOS</h3>
<p>We’ll first generate a minimal configuration file. Then we’ll edit it to support our LUKS encrypted partitions.</p>
<pre><code class="language-console"># nixos-generate-config --root /mnt
</code></pre>
<p>Then we can use Vim (or nano) prefixed with <code>sudo</code> to edit our generated config to mount our LUKS partition. If you forget to elevate your editor command, you won&#x27;t be able to save your changes—<em>don&#x27;t ask me how I know that.</em></p>
<pre><code class="language-console"># vim /mnt/etc/nixos/hardware-configuration
</code></pre>
<h4 id="hardware-configurationnix"><code>hardware-configuration.nix</code></h4>
<p>Our generated config currently looks something like this:</p>
<pre><code class="language-nix"># Do not modify this file! It was generated by ■nixos-generate-config■
# and may be overwritten by future invocations. Please make changes
# to /etc/nixos/configuration.nix instead.
{ config, lib, pkgs, modulesPath, ... }:

{
  imports =
    [ (modulesPath + &quot;/installer/scan/not-detected.nix&quot;)
    ];

  boot.initrd.availableKernerlModules = [&quot;xhci_pci&quot; &quot;ahci&quot; &quot;nvme&quot; &quot;usb_storage&quot; &quot;usbhid&quot; &quot;sd_mod&quot; ];
  boot.initrd.kernelModules = [ &quot;dm-snapshot&quot; ];
  boot.kernelModules = [ &quot;kvm-intel&quot; ];
  boot.extraModulePackages = [ ];

  fileSystems.&quot;/&quot; =
    {
      device = &quot;/dev/disk/by-uuid/f96d7825-6b61-452b-a6f9-456ad8ba308f&quot;;
      fsType = &quot;ext4&quot;;
    };

  fileSystems.&quot;/boot&quot; =
    {
      device = &quot;/dev/disk/by-uuid/6335-2377&quot;;
      fsType = &quot;vfat&quot;;
      options = [ &quot;fmask=0077&quot; &quot;dmask=0077&quot; ];
    };

  swapDevices =
    [{ device = &quot;/dev/disk/by-uuid/b0849f28-36bf-41c1-b58f-108f4a51442a&quot;; }
    ];

  # Enables DHCP on each ethernet and wireless interface. In case of scripted networking
  # (the default) this is the recommended approach. When using systemd-networkd it&#x27;s
  # still possible to use this option, but it&#x27;s recommended to use it in conjunction
  # with explicit per-interface declaration with `networking.interfaces.&lt;interface&gt;.useDHCP`.
  networking.useDHCP = lib.mkDefault true;

  nixpkgs.hostPlatform = lib.mkDefault &quot;x86_64-linux&quot;;
  hardware.cpu.intel.updatedMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware;
}
</code></pre>
<p>While it&#x27;s nearly perfect, it doesn&#x27;t take into account our LUKS device, and so we&#x27;ll have to disregard the ominous warnings to make some minor changes if we want our machine to be useable. In short, all we have to do is:</p>
<ul>
<li>Add <code>cryptd</code> to <code>boot.initrd.kernelModules</code>.</li>
<li>Define our LUKS partition as a LUKS device under <code>boot.initrd.luks.devices.&quot;cryptroot&quot;</code>.</li>
<li>Update our filesystem mounts to use “by-label” for ease of use / readability.</li>
</ul>
<p>This last change is optional, but I find it helpful for compatibility reasons:</p>
<ul>
<li>Set <code>hardware.enableAllFirmware</code> to <code>true</code>.</li>
</ul>
<pre><code class="language-nix"># Do not modify this file! It was generated by ■nixos-generate-config■
# and may be overwritten by future invocations. Please make changes
# to /etc/nixos/configuration.nix instead.
{config, lib, pkgs, modulesPath, ...}:

{
  imports =
    [ (modulesPath + &quot;/installer/scan/not-detected.nix&quot;)
    ];

  boot.initrd.availableKernerlModules = [&quot;xhci_pci&quot; &quot;ahci&quot; &quot;nvme&quot; &quot;usb_storage&quot; &quot;usbhid&quot; &quot;sd_mod&quot; ];
  boot.initrd.kernelModules = [ &quot;dm-snapshot&quot; &quot;cryptd&quot; ]; # add cryptd
  # Define our LUKS device
  boot.initrd.luks.devices.&quot;cryptroot&quot;.device = &quot;/dev/disk/by-label/NIXLUKS&quot;;
  boot.kernelModules = [ &quot;kvm-intel&quot; ];
  boot.extraModulePackages = [ ];

  # Use labels instead of UUIDs to mount our LVs
  fileSystems.&quot;/&quot; =
    {
      device = &quot;/dev/disk/by-label/NIXROOT&quot;;
      fsType = &quot;ext4&quot;;
    };

  fileSystems.&quot;/boot&quot; =
    {
      device = &quot;/dev/disk/by-label/NIXBOOT&quot;;
      fsType = &quot;vfat&quot;;
      options = [ &quot;fmask=0077&quot; &quot;dmask=0077&quot; ];
    };

  swapDevices =
    [{ device = &quot;/dev/disk/by-label/NIXSWAP&quot;; }
    ];

  # Enables DHCP on each ethernet and wireless interface. In case of scripted networking
  # (the default) this is the recommended approach. When using systemd-networkd it&#x27;s
  # still possible to use this option, but it&#x27;s recommended to use it in conjunction
  # with explicit per-interface declaration with `networking.interfaces.&lt;interface&gt;.useDHCP`.
  networking.useDHCP = lib.mkDefault true;

  nixpkgs.hostPlatform = lib.mkDefault &quot;x86_64-linux&quot;;
  hardware.enableAllFirmware = true; # Helps with compatibility
  hardware.cpu.intel.updatedMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware;
}
</code></pre>
<p>With our edits in place, we can save and exit the <code>hardware-configuration.nix</code> file and move on to <code>configuration.nix</code>.</p>
<details><summary>[INFO]: Thoughts on the <em>ominous warnings</em></summary><p>In all likelihood, this is the only time you&#x27;ll ever run <code>nixos-generate-config</code>, so feel free to make edits to <code>hardware-configuration.nix</code>. Just be aware that running <code>nixos-generate-config</code> again, will undo all the changes you&#x27;ve made to it.</p></details>
<h4 id="configurationnix"><code>configuration.nix</code></h4>
<p><code>configuration.nix</code> is where you&#x27;ll want to configure everything else about your machine (e.g., hostname, user accounts, networking, window managers, programs, etc.). As such, we&#x27;ll want to make some edits from the minimal configuration to something a lot more useable. Because, as it stands, the generated <code>configuration.nix</code> has most of what we need commented out (see: the code snippet below for more details).</p>
<details><summary>[INFO]: Default <code>configuration.nix</code></summary><pre><code class="language-nix"># Edit this configuration file to define what should be installed on
# your system. Help is available in the configuration.nix(5) man page, on
# https://search.nixos.org/options and in the NixOS manual (`nixos-help`).

{ config, lib, pkgs, ... }:

{
  imports =
    [ # Include the results of the hardware scan.
      ./hardware-configuration.nix
    ];

  # Use the systemd-boot EFI boot loader.
  boot.loader.systemd-boot.enable = true;
  boot.loader.efi.canTouchEfiVariables = true;

  # networking.hostName = &quot;nixos&quot;; # Define your hostname.
  # Pick only one of the below networking options.
  # networking.wireless.enable = true; # Enables wireless support via wpa_supplicant
  # networking.networkManager.enable = true; # Easiest to use and most distros use this by default.

  # Set your time zone.
  # time.timeZone = &quot;America/New_York&quot;;

  # Configure network proxy if necessary
  # networking.proxy.default = &quot;http://user:password@proxy:port/&quot;;
  # networking.proxy.noProxy = &quot;127.0.0.1,localhost,internal.domain&quot;;

  # Select internationalisation properties.
  # i18n.defaultLocale = &quot;en_US.UTF-8&quot;;
  # console = {
  # font = &quot;Lat2-Terminus16&quot;;
  #  keyMap = &quot;us&quot;;
  #  useXkbConfig = true; # use xkb.options in tty.
  # }

  # Enable the X11 windowing system.
  # services.xserver.enable = true;

  # Configure keymap in X11
  # services.xserver.xkb.layout = &quot;us&quot;;
  # services.xserver.xkb.options = &quot;eurosign:e,caps:escape&quot;;

  # Enable CUPS to print documents.
  # services.printing.enable = true;

  # Enable sound.
  # hardware.pulseaudio.enable = true;
  # OR
  # services.pipewire = {
  #  enable = true;
  #  pulse.enable = true;
  # }

  # Enable touchpad support (enabled default in most desktopManager).
  # services.libinput.enable = true;

  # Define a user account. Don&#x27;t forget to set a password with ■passwd■.
  # users.users.name = {
  #  isNormalUser = true;
  #  extraGroups = [ &quot;wheel&quot; ]; # enable ■sudo■ for the user.
  # }

  # List packages installed in system profile. To search, run:
  # $ nix search wget
  # environment.systemPackages = with pkgs; [
  #  vim # Do not forget to add an editor to edit configuration.nix! The Nano editor is also installed by default.
  #  wget
  # ];

  # Some programs need SUID wrappers, can be configured further or are
  # started in user sessions.
  # programs.mtr.enable = true;
  # programs.gnupg.agent = {
  #   enable = true;
  #   enableSSHSupport = true;
  # };

  # List services that you want to enable:

  # Enable the OpenSSH daemon.
  # services.openssh.enable = true;

  # Open ports in the firewall.
  # networking.firewall.allowedTCPPorts = [ ... ];
  # networking.firewall.allowedUDPPorts = [ ... ];
  # Or disable the firewall altogether.
  # networking.firewall.enable = false;

  # Copy the NixOS configuration file and link it from the resulting system
  # (/run/current-system/configuration.nix). This is useful in cage you
  # accidentally delete configuration.nix.
  # system.copySystemConfiguration = true;

  # The option defines the first version of NixOS you have installed on this particular machine.
  # and is used to maintain compatibillity with application data (e.g. databases) created on older NixOS versions.
  #
  # Most users should NEVER change this value after the initial install, for any reason,
  # even if you&#x27;ve upgraded your system to a new NixOS release.
  #
  # This value does NOT affect the Nixpkgs version your pacakges and OS are pulled from,
  # so changing it will NOT upgrade your system - see https://nixos.org/manual/nixos/stable/#sec-upgrading for how to actually do that.
  #
  # This value being lower than the current NixOS release does NOT mean your system is
  # out of date, out of support, or vulnerable.
  #
  # Do NOT change this value unless you have manually inspected all the changes it would make to your configuration,
  # and migrated your data accordingly.
  #
  #For more information, see `man configuration.nix` or https://nixos.org/manual/nixos/stable/options#opt-system.stateVersion .
  system.stateVersion = &quot;24.05&quot;; # Did you read the comment?
}
</code></pre></details>
<p>To make our changes, we can use <code>vim</code> or <code>nano</code> again to load <code>configuration.nix</code> into an editing buffer:</p>
<pre><code class="language-console"># vim /mnt/etc/nixos/configuration.nix
</code></pre>
<p>From there we&#x27;ll, uncomment out network manager, we&#x27;ll enable flakes, we&#x27;ll update our time zone, add an account, and anything else we might need.</p>
<h5 id="core-configuration">Core configuration</h5>
<p>The following configures/enables what I believe to be the most important features/options. You&#x27;ll be able to enable most of them simply by uncommenting them out (removing the <code>#</code> before them) and or modifying their default values.</p>
<h6 id="add-a-hostname">Add a hostname</h6>
<pre><code class="language-nix">networking.hostName = &quot;my-nixos-machine&quot;; # Define your hostname.
</code></pre>
<p>Your hostname will correlate with the host system defined in your <code>nixosConfiguration</code>. If you plan on sharing a single <code>flake.nix</code> between multiple NixOS machines, it&#x27;s probably for the best you change this to something unique—well, unique amongst the machine&#x27;s that have differing configurations.</p>
<h6 id="enable-network-manager">Enable network manager</h6>
<pre><code class="language-nix">networking.networkManager.enable = true;
</code></pre>
<p>Network Manager is both the easiest and (unsurprisingly) the default networking tool for most Distros. It&#x27;s also what I prefer using, but you might prefer <code>wpa_supplicant</code> instead.</p>
<h6 id="enable-flakes">Enable flakes</h6>
<pre><code class="language-nix">nix.settings.experimental-features = [&quot;nix-command&quot; &quot;flakes&quot;];
</code></pre>
<p>Flakes are one of my most favorite things about Nix. They&#x27;re incredibly versatile from setting up <a href="https://nixos-and-flakes.thiscute.world/nixos-with-flakes/modularize-the-configuration#modularize-your-nixos-configuration">modular, multi-machine configurations from a single <code>flake.nix</code></a><sup><a href="#user-content-fn-modularize-your-nixos-configuration" id="user-content-fnref-modularize-your-nixos-configuration" data-footnote-ref="true" aria-describedby="footnote-label">25</a></sup> to creating reproducible <a href="https://github.com/the-nix-way/dev-templates">development environments</a><sup><a href="#user-content-fn-the-nix-way-dev-templates" id="user-content-fnref-the-nix-way-dev-templates" data-footnote-ref="true" aria-describedby="footnote-label">26</a></sup>. In short, they&#x27;re quite handy.</p>
<h6 id="update-the-time-zone">Update the time zone</h6>
<pre><code class="language-nix">time.timeZone = &quot;America/Los_Angeles&quot;
</code></pre>
<p>I believe it&#x27;s also possible to update your time zone imperatively as well, if you&#x27;re Desktop Environment (i.e., Gnome, Plasma, etc.) allows it. As such, setting it from <code>configuration.nix</code> will thus establish it as the &quot;default&quot; global/system time zone.</p>
<h6 id="add-a-user-account-with-superuser-privileges-thats-also-in-the-networkmanager-group">Add a user account with <code>superuser</code> privileges, that&#x27;s also in the <code>networkmanager</code> group</h6>
<pre><code class="language-nix">users.users.mycoolusername = {
  isNormalUser = true;
  extraGroups = [&quot;wheel&quot; &quot;networkmanager&quot;];
};
</code></pre>
<p><code>superuser</code> privileges via the &quot;wheel&quot; group, gives your user (in this case a user named <code>mycoolusername</code>) access to <code>sudo</code>. If you tried to use <code>sudo</code> without being in the <code>wheel</code> group or being given explicit access somewhere in the <code>sudoers</code> file, well <a href="https://xkcd.com/838/">it becomes an &quot;incident&quot;, and gets reported ... somewhere</a><sup><a href="#user-content-fn-incident-xkcd" id="user-content-fnref-incident-xkcd" data-footnote-ref="true" aria-describedby="footnote-label">27</a></sup>.</p>
<p>Adding your user to the <code>networkmanager</code> group, while possibly optional, helps to ensure your user has the right permissions to use/configure <code>networkmanager</code>.</p>
<details><summary>[INFO]: <em>Dude, Where&#x27;s my password?</em></summary><p>If you&#x27;re wondering about the user password, that&#x27;s something we&#x27;ll configure at the very end, just before rebooting.</p></details>
<h6 id="enable-unfree-packages-optional">Enable unfree packages (optional)</h6>
<pre><code class="language-nix">nixpkgs.config.allowUnfree = true;
</code></pre>
<p>Allowing &quot;unfree&quot; packages, will give you access to closed-source/proprietary binaries/software, such as Nvidia&#x27;s proprietary Linux drivers. It&#x27;s really up to you whether you want to enable this feature or not.</p>
<h6 id="helpful-packagessoftware">Helpful Packages/Software</h6>
<pre><code class="language-nix">environment.systemPackages = with pkgs; [
  vim # Do not forget to add an editor to edit configuration.nix! The Nano editor is also installed by default.
  wget
  firefox
  chromium # swap for ungoogled-chromium if you prefer
];
</code></pre>
<p>At minimum, you&#x27;ll want to add a secondary terminal based editor like <code>vim</code>, and a browser like <code>firefox</code> or alternatively, <code>chromium</code> and or <code>ungoogled-chromium</code>.</p>
<h5 id="anything-else-you-might-want">Anything Else You Might Want</h5>
<p>Beyond the <a href="#core-configuration">Core configuration</a> above, I&#x27;ll leave it up to you to determine how you want to configure the rest of your machine. For example, you&#x27;ll probably want to choose a Desktop Environment (DE) or alternatively, configure a Window Manager (WM). I&#x27;ll leave the following suggestions to give you some ideas:</p>
<h6 id="add-a-desktop-environment-or-alternatively-a-window-manager">Add a Desktop Environment or, alternatively, a Window Manager</h6>
<p>In 2024, the two most popular DEs remain to be Gnome and Plasma. You can enable Gnome like this (see: <a href="https://wiki.nixos.org/wiki/GNOME">wiki.nixos.org/wiki/GNOME</a><sup><a href="#user-content-fn-nixos-wiki--gnome" id="user-content-fnref-nixos-wiki--gnome" data-footnote-ref="true" aria-describedby="footnote-label">28</a></sup>):</p>
<pre><code class="language-nix">services.xserver.enable = true;
services.xserver.displayManager.gdm.enable = true;
services.xserver.desktopManager.gnome.enable = true;
</code></pre>
<p>Or KDE Plasma like this (see: <a href="https://wiki.nixos.org/wiki/KDE">wiki.nixos.org/wiki/KDE</a><sup><a href="#user-content-fn-nixos-wiki--kde" id="user-content-fnref-nixos-wiki--kde" data-footnote-ref="true" aria-describedby="footnote-label">29</a></sup>)</p>
<pre><code class="language-nix">services.xserver.enable = true; # optional
services.displayManager.sddm.enable = true;
services.displayManager.sddm.wayland.enable = true;
services.desktopManager.plasma6.enable = true;
</code></pre>
<p>Alternatively you can configure a Window Manager, such as Sway, like this (see: <a href="https://wiki.nixos.org/wiki/Sway">wiki.nixos.org/wiki/Sway</a><sup><a href="#user-content-fn-nixos-wiki--sway" id="user-content-fnref-nixos-wiki--sway" data-footnote-ref="true" aria-describedby="footnote-label">30</a></sup>)</p>
<pre><code class="language-nix"># A very minimal Sway configuration
environment.systemPackages = with pkgs; [
  grim # screenshot functionality
  slurp # screenshot functionality
  wl-clipboard # wl-copy and wl-paste for copy/paste from stdin / stdout
  mako # notification system developed by swaywm maintainer
];

# Enable the gnome-keyring secrets vault.
# Will be exposed through DBus to programs willing to store secrets.
services.gnome.gnome-keyring.enable = true;

# enable sway window manager
programs.sway = {
  enable = true;
  wrapperFeatures.gtk = true;
};

services.xserver = {
  displayManager.gdm = {
    enable = true;
    wayland = true;
  };
};
</code></pre>
<details><summary>[INFO]: NixOS makes It&#x27;s painless to try out a new DE/WM</summary><p>The nice thing about NixOS is that you&#x27;re not stuck with a DE or WM. If you want to try something else, edit your config to accomodate whichever thing you want, run <code># nixos-rebuild switch</code>, and you&#x27;ll have a totally new desktop experience!</p></details>
<h6 id="enable-pipewire-sound">Enable PipeWire (sound)</h6>
<pre><code class="language-nix"># rtkit is optional but recommended
security.rtkit.enable = true;
services.pipewire = {
  enable = true; # if not already enabled
  alsa.enable = true;
  alsa.support32Bit = true;
  pulse.enable = true;
  # If you want to use JACK applications, uncomment this
  #jack.enable = true;
};
</code></pre>
<p>PipeWire is a modern replacement for Pulse Audio, and if you&#x27;re using Ardour or some other DAW, you&#x27;ll want to enable JACK as well (see: <a href="https://wiki.nixos.org/wiki/PipeWire">wiki.nixos.org/wiki/PipeWire</a><sup><a href="#user-content-fn-nixos-wiki--pipewire" id="user-content-fnref-nixos-wiki--pipewire" data-footnote-ref="true" aria-describedby="footnote-label">31</a></sup>). For configuring Bluetooth audio you&#x27;ll want to see: <a href="https://wiki.nixos.org/wiki/PipeWire#Bluetooth_Configuration">wiki.nixos.org/wiki/PipeWire#Bluetooth_Configuration</a><sup><a href="#user-content-fn-nixos-wiki--pipewire-bt" id="user-content-fnref-nixos-wiki--pipewire-bt" data-footnote-ref="true" aria-describedby="footnote-label">32</a></sup>.</p>
<h6 id="enable-bluetooth">Enable Bluetooth</h6>
<pre><code class="language-nix">hardware.bluetooth.enable = true; # enables support for Bluetooth
hardware.bluetooth.powerOnBoot = true; # powers up the default Bluetooth controller on boot
</code></pre>
<p>The above should enable Bluetooth, and if your DE doesn&#x27;t come with a Bluetooth manager GUI, you can additionally enable the <code>blueman</code> applet</p>
<pre><code class="language-nix">services.blueman.enable = true;
</code></pre>
<p>For more see: <a href="https://wiki.nixos.org/wiki/Bluetooth">wiki.nixos.org/wiki/Bluetooth</a><sup><a href="#user-content-fn-nixos-wiki--bluetooth" id="user-content-fnref-nixos-wiki--bluetooth" data-footnote-ref="true" aria-describedby="footnote-label">33</a></sup>.</p>
<h6 id="enable-cups--avahi">Enable CUPS + Avahi</h6>
<pre><code class="language-nix"># Enable CUPS to print documents.
services.printing.enable = true;
services.avahi = {
  enable = true;
  nssmdns4 = true;
  openFirewall = true;
};
</code></pre>
<p>Enabling CUPS will allow you to print things. Enabling Avahi will allow you to find printers on your network. For more see: <a href="https://wiki.nixos.org/wiki/Printing">wiki.nixos.org/wiki/Printing</a><sup><a href="#user-content-fn-nixos-wiki--printing" id="user-content-fnref-nixos-wiki--printing" data-footnote-ref="true" aria-describedby="footnote-label">34</a></sup>.</p>
<h6 id="use-an-alternative-shell-eg-zsh">Use an Alternative Shell (e.g., <code>zsh</code>)</h6>
<pre><code class="language-nix">programs.zsh.enable = true;
users.defaultUserShell = pkgs.zsh;
</code></pre>
<p>If you&#x27;ve been hacking away at the command line on a Mac in the last decade, chances are you&#x27;ve become attached to using <code>zsh</code> instead of <code>bash</code>. In that case, the above will change the default shell (<code>bash</code>) to something that feels much more at home (<code>zsh</code>). For more see: <a href="https://wiki.nixos.org/wiki/Zsh">wiki.nixos.org/wiki/Zsh</a><sup><a href="#user-content-fn-nixos-wiki--zsh" id="user-content-fnref-nixos-wiki--zsh" data-footnote-ref="true" aria-describedby="footnote-label">35</a></sup>.</p>
<p>Alternatively, you can use <a href="https://wiki.nixos.org/wiki/Fish"><code>fish</code></a><sup><a href="#user-content-fn-nixos-wiki--fish" id="user-content-fnref-nixos-wiki--fish" data-footnote-ref="true" aria-describedby="footnote-label">36</a></sup> or even <a href="https://wiki.nixos.org/wiki/Nushell"><code>nushell</code></a><sup><a href="#user-content-fn-nixos-wiki--nushell" id="user-content-fnref-nixos-wiki--nushell" data-footnote-ref="true" aria-describedby="footnote-label">37</a></sup>.</p>
<details><summary>[INFO]: Customizing the shell</summary><p>As for custom prompts like <a href="https://starship.rs/">starship</a><sup><a href="#user-content-fn-starship--cross--shell-prompt" id="user-content-fnref-starship--cross--shell-prompt" data-footnote-ref="true" aria-describedby="footnote-label">38</a></sup> and plugins like <a href="https://github.com/zsh-users/zsh-syntax-highlighting">zsh-syntax-highlighting</a><sup><a href="#user-content-fn-zsh-users-zsh-syntax-highlighting" id="user-content-fnref-zsh-users-zsh-syntax-highlighting" data-footnote-ref="true" aria-describedby="footnote-label">39</a></sup>, we&#x27;ll be using Home-Manager via the <code>flake.nix</code> in this guide <a href="#part-03-configuring-home-manager-abridged">later</a> to configure that.</p></details>
<h5 id="example-configurationnix">Example <code>configuration.nix</code></h5>
<details><summary>[INFO]: <code>configuration.nix</code> Example</summary><pre><code class="language-nix">{ config, lib, pkgs, ... }:

{
  imports =
    [ # Include the results of the hardware scan.
      ./hardware-configuration.nix
    ];

  # Use the systemd-boot EFI boot loader.
  boot.loader.systemd-boot.enable = true;
  boot.loader.efi.canTouchEfiVariables = true;

  networking.hostName = &quot;my-nixos-machine&quot;; # Define your hostname.
  networking.networkManager.enable = true; # Easiest to use and most distros use this by default.

  # Set your time zone.
  time.timeZone = &quot;America/Los_Angeles&quot;;

  # Enable Flakes
  nix.settings.experimental-features = [&quot;nix-command&quot; &quot;flakes&quot;];

  # Enable the Gnome Desktop Environment
  services.xserver.enable = true;
  services.xserver.displayManager.gdm.enable = true;
  services.xserver.desktopManager.gnome.enable = true;

  # Enable CUPS to print documents.
  services.printing.enable = true;
  services.avahi = {
    enable = true;
    nssmdns4 = true;
    openFirewall = true;
  };

  # Enable sound.
  security.rtkit.enable = true;
  services.pipewire = {
    enable = true; # if not already enabled
    alsa.enable = true;
    alsa.support32Bit = true;
    pulse.enable = true;
    # If you want to use JACK applications, uncomment this
    #jack.enable = true;
  };

  # Bluetooth
  hardware.bluetooth.enable = true; # enables support for Bluetooth
  hardware.bluetooth.powerOnBoot = true; # powers up the default Bluetooth controller on boot

  # Define a user account. Don&#x27;t forget to set a password with ■passwd■.
  users.users.mycoolusername = {
    isNormalUser = true;
    extraGroups = [&quot;wheel&quot; &quot;networkmanager&quot;];
  };

  # Default shell =&gt; ZSH
  programs.zsh.enable = true;
  users.defaultUserShell = pkgs.zsh;

  # Allow Unfree packages
  nixpkgs.config.allowUnfree = true;

  # List packages installed in system profile. To search, run:
  # $ nix search wget
  environment.systemPackages = with pkgs; [
    vim
    wget
    firefox
    chromium
  ];

  system.stateVersion = &quot;24.05&quot;; # Did you read the comment?
}
</code></pre></details>
<p>If you&#x27;re happy with the above, we can move on to the best part, the <code>flake.nix</code>.</p>
<h4 id="flakenix"><code>flake.nix</code></h4>
<p>Alright, we&#x27;re nearly at the finish line. The final piece of the (installation) puzzle is to create a <code>flake.nix</code> at <code>/mnt/etc/nixos</code>, in the same place where you&#x27;ve created the <code>hardware-configuration.nix</code> and <code>configuration.nix</code>, to bring everything together.</p>
<p>The <code>flake.nix</code> is where you&#x27;ll be able to add additional modules as inputs, such as <code>home-manager</code> and <code>lanzaboote</code>.</p>
<details><summary>[INFO]: Flakes are <em>cool.</em></summary><p>I&#x27;ll be honest, Nix flakes are far more versatile/feature-packed than I&#x27;ve let on. I <strong>highly recommend</strong> giving <a href="https://github.com/ryan4yin">ryan4yin&#x27;s</a> <a href="https://nixos-and-flakes.thiscute.world/preface">NixOS &amp; Flakes Book</a><sup><a href="#user-content-fn-nixos-and-flakes-book" id="user-content-fnref-nixos-and-flakes-book" data-footnote-ref="true" aria-describedby="footnote-label">40</a></sup> a read sometime.</p></details>
<p>We can define a minimal <code>flake.nix</code> like so:</p>
<pre><code class="language-nix">{
  description = &quot;A minimal flake.nix for a NixOS machine&quot;;
  inputs = {
    nixpkgs.url = &quot;github:NixOS/nixpkgs/nixos-unstable&quot;;
  };
  outputs = {self, nixpkgs, ... }@inputs: {
    nixosConfigurations = {
      my-nixos-machine = nixpkgs.lib.nixosSystem {
        system = &quot;x86_64-linux&quot;; # Assumes a standard x86 CPU
        modules = [./configuration.nix];
      };
    };
  };
}
</code></pre>
<details><summary>[INFO]: <em>Why <code>nixos-unstable</code>? Isn&#x27;t it unstable?</em></summary><p>I&#x27;m using <code>nixos-unstable</code> in this guide instead of the stable <code>nixos-24.11</code> for a few reasons. One is that <code>home-manager:master</code> tracks <code>nixos-unstable</code>. Two is that the nature of Nix makes rollbacks incredibly easy, and significantly cuts down the risk of using <code>nixos-unstable</code>. Finally, while the <code>nixos-unstable</code> <em>channel</em> is occasionally true to it&#x27;s namesake, it&#x27;s not as unstable or as bleeding-edge as something like Arch Linux. That&#x27;s due to the somewhat significant amount of automated build testing any given commit into <code>nixpkgs:master</code> needs to go through before being released as the latest <code>nixos-unstable</code> build/channel update.<sup><a href="#user-content-fn-nix-channels" id="user-content-fnref-nix-channels" data-footnote-ref="true" aria-describedby="footnote-label">41</a></sup></p></details>
<p>And with that, we can finally proceed to the very much anticipated install command.</p>
<h4 id="nixos-install"><code>nixos-install</code></h4>
<p>This is it, moment of truth time. To begin, we’ll change directories to our <code>nixos</code> directory:</p>
<pre><code class="language-sh">cd /mnt/etc/nixos
</code></pre>
<p>Then we’ll update our flake. However, because the minimal NixOS install doesn&#x27;t enable the <code>nix-command</code> nor <code>flakes</code>, we&#x27;ll need to append these features as flags to actually perform this step:</p>
<pre><code class="language-console"># nix --extra-experimental-features nix-command --extra-experimental-features flakes flake update
</code></pre>
<details><summary>Updating Your System with a <code>flake.nix</code></summary><p>Once NixOS is actually installed (and the <code>nix-command</code> and <code>flakes</code> features are both enabled), you can update your system <code>flake.nix</code> simply by running <code># nix flake update</code>. Doing so will update the <code>nixpkgs</code> input to the latest git commit for whichever channel you&#x27;ve configured (in our case that&#x27;s <code>nixos-unstable</code>). However, simply updating the flake doesn&#x27;t update the system itself. You still have to build an updated system configuration (and switch to it) by running <code># nixos-rebuild switch</code>.</p><p>Note: If you encounter a <code>flake.nix</code> in userspace, you typically don&#x27;t have to elevate the command.</p></details>
<p>If that presented no errors, your output should look something like this:</p>
<pre><code class="language-sh">[nixos@nixos:/mnt/etc/nixos]$ sudo nix --extra-experimental-features nix-command --extra-experimental-features flakes flake update
warning: creating lock file &#x27;/mnt/etc/nixos/flake.lock&#x27;

[nixos@nixos:/mnt/etc/nixos]$
</code></pre>
<details><summary>[INFO]: On <code>flake.lock</code>, &amp; overriding <code>flake.nix</code> inputs </summary><p>If you&#x27;re familiar with modern package managers for dev tooling (e.g., Cargo, NPM, yarn, Bun, etc), then you (conceptually) already understand what <code>flake.lock</code> does and <em>why</em>.</p><p>For everyone else, a <code>flake.lock</code> <em>locks</em> the commit hashes of your flakes&#x27; configured inputs, by writing it into the <code>flake.lock</code> file. In practice, this means you can alter your system configuration (i.e., configure a new option, add a new package, etc.), without having to upgrade your entire system/installed packages, even way into the future (assuming the features you&#x27;re changing/adding existed in said <em>locked</em> input commit hash).</p><p>The above is possible because <code>nixos-rebuild &lt;switch|reboot&gt;</code> will use the commit hashes of the inputs configured in the <code>flake.nix</code> found in the <code>flake.lock</code> file. In our case, following the prior <code>nix flake update</code>, running <code>nixos-rebuild switch</code> will use the commit hashes of our input (<code>NixOS/nixpkgs/nixos-unstable</code>) found in the newly created <code>flake.lock</code>, and generate a fresh system profile from it. This also means that if you share your <code>flake.nix</code> and <code>flake.lock</code> to a different machine, it <em>should</em> reproduce the exact same system configuration upon <code>nixos-rebuild switch</code> (so long as you don&#x27;t run <code>nix flake update</code> and alter the <code>flake.lock</code>).</p><p>Note: You can override/lock an input&#x27;s commit hash in your <code>flake.nix</code>. In fact, it&#x27;s sometimes necessary. For example, I once locked my <code>nixpkgs</code> to commit <a href="https://github.com/NixOS/nixpkgs/commit/5633bcff0c61">5633bcff0c61</a> because a package I wanted to use in later <code>nixos-unstable</code> builds was a bit too <code>unstable</code> (at least for a week or two).</p><pre><code class="language-diff">{
  inputs = {
-    nixpkgs.url = &quot;github:NixOS/nixpkgs/nixos-unstable&quot;;
+    nixpkgs.url = &quot;github:NixOS/nixpkgs/5633bcff0c61&quot;;
  };
}
</code></pre><p>hint-01: It&#x27;s somewhat unsafe, but totally possible to use PRs to <code>NixOS/nixpkgs</code> before they&#x27;re merged following the same technique/idea.</p><p>hint-02: You can find the status and commit hashes of the latest builds of all the different Nix channels at <a href="https://status.nixos.org/">status.nixos.org</a>. The build status and corresponding commits of <code>nixos-unstable</code> specifically, can be found at <a href="https://hydra.nixos.org/job/nixos/trunk-combined/tested#tabs-status">https://hydra.nixos.org/job/nixos/trunk-combined/tested#tabs-status</a>.</p></details>
<p>With a clean <code>flake update</code>, that&#x27;s the go-ahead to perform the magical install command.</p>
<pre><code class="language-console"># nixos-install --root /mnt --no-root-passwd --flake /mnt/etc/nixos#my-nixos-machine
</code></pre>
<p>Be warned though, this step could take a while (even assuming no compilation errors). As such, now&#x27;s probably a good time to stretch your legs and get some fresh air. You&#x27;ve probably been sitting here reading this guide for a while now, so, maybe relax your eyes, and go enjoy a refreshment of some kind (a cup of tea, coffee, etc.). That way, if there are any unexpected errors (likely syntax, like a forgotten semi-colon), you&#x27;ll at least be better prepared to deal with it...</p>
<p>...Alright, if everything did go according to plan you should see output like the following:</p>
<pre><code class="language-sh">[nixos@nixos:/mnt/etc/nixos]$ sudo nixos-install --root /mnt --no-root-passwd --flake /mnt/etc/nixos#my-nixos-machine
copying channel...
building the flake in path:/mnt/etc/nixos?lastModified=1729820879@narHash=sha256-xRGYbMOgJiuxxVKLtkxb0yi01srhw/NL652Uq/ghXXY%3D...
Installing the boot loader...
setting up /etc...
Initializing machine ID from random generator.
Created &quot;/boot/EFI&quot;.
Created &quot;/boot/EFI/systemd&quot;.
Created &quot;/boot/EFI/BOOT&quot;.
Created &quot;/boot/loader&quot;.
Created &quot;/boot/EFI/Linux&quot;.
Copied &quot;/nix/store/xg6f0c5pchmc2jq84s4np19jirnn90mn-systemd-256.6/lib/systemd/boot/efi/systemd-bootx64.efi&quot; to &quot;/boot/EFI/systemd/systemd-bootx64.efi&quot;.
Copied &quot;/nix/store/xg6f0c5pchmc2jq84s4np19jirnn90mn-systemd-256.6/lib/systemd/boot/efi/systemd-bootx64.efi&quot; to &quot;/boot/EFI/BOOT/BOOTX64.EFI&quot;.
Created EFI boot entry &quot;Linux Boot Manager&quot;.
Installation finished!

[nixos@nixos:/mnt/etc/nixos]$
</code></pre>
<h4 id="final-steps">Final Steps</h4>
<p><strong>VERY IMPORTANT:</strong> We still need to setup a password for our user account. We can do that like so:</p>
<pre><code class="language-console"># nixos-enter --root /mnt -c “passwd mycoolusername”
</code></pre>
<p>Once that&#x27;s all set, it&#x27;s finally time to reboot. You can do that safely<sup><a href="#user-content-fn-installing-nixos-with-flakes-and-lvm-on-luks" id="user-content-fnref-installing-nixos-with-flakes-and-lvm-on-luks-2" data-footnote-ref="true" aria-describedby="footnote-label">3</a></sup> like so:</p>
<pre><code class="language-console"># umount -R /mnt
# sudo swapoff -L NIXSWAP
# sudo vgchange -a n lvmroot
# sudo cryptsetup close /dev/mapper/cryptroot
# reboot
</code></pre>
<p>Or dangerously via a simple: <code>$ reboot</code></p>
<p>If everything went well, you’ll be greeted by LUKS asking for your passphrase.</p>
<pre><code class="language-sh">&lt;&lt;&lt; NixOS Stage 1 &gt;&gt;&gt;

loading module dm-snapshot...
loading module cryptd...
loading module dm_mod...
running udev...
Starting sytemd-udevd version 256.6
Passphrase for /dev/disk/by-label/NIXLUKS: _
</code></pre>
<p>If it went really well, it&#x27;ll accept your passphrase, and you&#x27;ll arrive at your Display Manager of choice (or a TTY if that&#x27;s what you configured), and you&#x27;ll be able to login to your user with the password you just set.</p>
<p>At this point you can either continue configuring/tweaking your new NixOS machine to your liking before continuing to the next section, or you can just keep pushing through, the choice is up to you.</p>
<h2 id="part-02-secure-boot-with-lanzaboote">Part 02: Secure Boot with Lanzaboote</h2>
<p><code>Lanzaboote</code> is a wonderful project that I’ve been happily using on my NixOS machines for about a year now without a single issue. Still yet, it’s always a good idea to back up any important data before setting it up (like, your Windows BitLocker Recovery Keys, in case you&#x27;re securely dual booting), and especially before updating <code>Lanzaboote</code> to the latest release.</p>
<p>In any event, we’ll be following along with the quick start guide <a href="https://github.com/nix-community/lanzaboote/blob/master/docs/QUICK_START.md">lanzaboote/docs/QUICK_START</a><sup><a href="#user-content-fn-lanzaboote-docs-quick-start" id="user-content-fnref-lanzaboote-docs-quick-start-2" data-footnote-ref="true" aria-describedby="footnote-label">8</a></sup> for this section.</p>
<p><strong>PLEASE BE ADVISED: THE QUICK START GUIDE LINKED ABOVE WILL PROVIDE YOU WITH FAR MORE ACCURATE/CURRENT INSTRUCTIONS THAN WHAT&#x27;S SHOWN HERE FOR THIS ARTICLE. THE FOLLOWING SECTIONS ABOUT LANZABOOTE SHOULD BE REGARDED AS SUGGESTIONS/COMMENTARY FOR EDUCATIONAL PURPOSES ONLY, RATHER THAN AS A TRUE INSTRUCTION MANUAL.</strong></p>
<h3 id="confirm-nixos-is-installed-in-uefi-mode">Confirm NixOS is installed in UEFI mode</h3>
<p>Assuming you met the prerequisites and you successfully installed NixOS into the UEFI, the output of <code>bootctl status</code> should look like this:</p>
<pre><code class="language-sh">❯ bootctl status
System:
     Firmware: UEFI 2.60 (INSYDE Corp. 22532)
Firmware Arch: x64
  Secure Boot: disabled (disabled)
 TPM2 Support: yes
 Measured UKI: no
 Boot into FW: supported

Current Boot Loader:
      Product: systemd-boot 256.6
...
</code></pre>
<p>If the firmware is <code>UEFI</code> and the current boot loader is <code>systemd-boot</code> we can continue.</p>
<h3 id="generate-the-keys">Generate the Keys</h3>
<p>We’ll need to use <code>sbctl</code> for this. You <em>could</em> add it to your system packages like so:</p>
<pre><code class="language-nix">{pkgs, ...}:
{
  environment.systemPackages = with; pkgs [
    # sbctl needed to generate keys
    sbctl
  ];
}
</code></pre>
<p>However, we want to just run it from an ephemeral nix shell via <code>$ nix-shell -p sbctl</code> (we&#x27;re going to declare this package in a module, as we&#x27;ll see).</p>
<p>Either way, once you have <code>sbctl</code> just run <code># sbctl create-keys</code> like so:</p>
<pre><code class="language-sh">❯ sudo sbctl create-keys
[sudo] password for mycoolusername:
Created Owner UUID fcdc9ade-1757-4fd6-8376-bc21c7f4d093
Creating secure boot keys...✓
Secure boot keys created!
</code></pre>
<h3 id="configure-the-lanzaboote-module-flake">Configure the Lanzaboote Module (Flake)</h3>
<p>The following is how I personally like to modularize my own NixOS flake based configuration. What we&#x27;ll do is amend our minimal flake from earlier, adding <code>lanzaboote</code> as an input, and we&#x27;ll enable it by adding it to our hosts module array. We&#x27;re also going to create a directory in our <code>/etc/nixos</code> folder, called <code>modules</code> that will contain a module I&#x27;m calling <code>lanza.nix</code>. This is what the flake should look like:</p>
<pre><code class="language-nix">{
  description = &quot;A minimal flake.nix for a SecureBoot-enabled NixOS machine&quot;;
  inputs = {
    nixpkgs.url = &quot;github:NixOS/nixpkgs/nixos-unstable&quot;;
    lanzaboote = {
      url = &quot;github:nix-community/lanzaboote/v0.4.1&quot;;
      inputs.nixpkgs.follows = &quot;nixpkgs&quot;;
    };
  };
  outputs = {self, nixpkgs, lanzaboote, ... }@inputs: {
    nixosConfigurations = {
      my-nixos-machine = nixpkgs.lib.nixosSystem {
        system = &quot;x86_64-linux&quot;; # Assumes a standard x86 CPU
        modules = [
         ./configuration.nix
         lanzaboote.nixosModules.lanzaboote
         ./modules/lanza.nix
        ];
      };
    };
  };
}
</code></pre>
<p>Of course, the <code>./modules/lanza.nix</code> doesn&#x27;t exist yet, so let&#x27;s create it and bring it up in our editor.</p>
<pre><code class="language-sh">sudo mkdir /etc/nixos/modules
sudo touch /etc/nixos/modules/lanza.nix
sudo vim /etc/nixos/modules/lanza.nix
</code></pre>
<p>Then we can create something like this:</p>
<pre><code class="language-nix">{pkgs, lib, ... }:

{
  environment.systemPackages = with pkgs; [
    # For debugging and troubleshooting Secure Boot.
    sbctl
  ];

  # Lanzaboote currently replaces the systemd-boot module.
  # This setting is usually set to true in configuration.nix
  # generated at installation time. So we force it to false
  # for now.
  boot.loader.systemd-boot.enable = lib.mkForce false;

  boot.lanzaboote = {
    enable = true;
    pkiBundle = &quot;/var/lib/sbctl&quot;;
  };
}
</code></pre>
<details open=""><summary>[INFO]: If you previously declared <code>pkgs.sbctl</code> in your <code>configuration.nix</code> ...</summary><p>Now might be a good time to remove it from your <code>configuration.nix</code> and redeclare it here.</p></details>
<h3 id="enable-lanzaboote">Enable Lanzaboote</h3>
<p>With the module configured, you should be cleared to run <code># nixos-rebuild switch</code>, which will result in <code>Lanzaboote</code> being installed/enabled. You can check everything went well with the output from <code># sbctl verify</code></p>
<pre><code class="language-sh">❯ sudo sbctl verify
Verifying file database and EFI images in /boot...
✓ /boot/EFI/BOOT/BOOTX64.EFI is signed
✓ /boot/EFI/Linux/nixos-generation-1.efi is signed
✓ /boot/EFI/Linux/nixos-generation-2.efi is signed
✗ /boot/EFI/nixos/kernel-linux-6.11.5.efi is not signed
✓ /boot/EFI/systemd/systemd-bootx64.efi is signed
</code></pre>
<p>If the output looks clean, we can enable secure boot.</p>
<blockquote>
<p>&quot;It is expected that the files ending with <code>bzImage.efi</code> are <strong>not</strong> signed.&quot; - The QUICK START guide for Lanzaboote</p>
</blockquote>
<h3 id="enabling-secure-boot">Enabling Secure Boot</h3>
<p>if you followed the beginning of this guide, secure boot is currently disabled. What we’re going to do now is turn it back on, but with <em>setup mode</em> enabled.</p>
<h4 id="enable-setup-mode">Enable Setup Mode</h4>
<p>My Lenovo laptop has this <em>setup mode</em>, and if you happen to have it as well, simply turn it on and proceed to the next step.</p>
<details><summary>[INFO]: Lenovo&#x27;s <em>setup mode</em> can be quircky</summary><p>On my particular Lenovo laptop, when I enable setup mode, it disables secure boot simultaneously. So, make sure to re-enable it either before you leave the UEFI/BIOS, or after enrolling the keys.</p></details>
<details><summary>[INFO]: Manually replicating <em>setup mode</em> in ASUS&#x27; UEFI</summary><p>If you, like me, also happen to have a machine with an ASUS motherboard, then we’ll have to take some extra steps.</p><ol>
<li>Enable secure boot</li>
<li>Install factory default keys (if they’re empty)</li>
<li>Delete every key <strong>except the Forbidden Signature Database (dbx)</strong>
<ul>
<li>This is typically the key at the bottom of the list</li>
</ul>
</li>
<li>Save changes and reboot</li>
</ol></details>
<h3 id="enroll-the-keys">Enroll the Keys</h3>
<p>Now that secure boot’s enabled (sorta), we can enroll the keys we generated earlier with vendor keys from Microsoft.</p>
<pre><code class="language-sh">$ sudo sbctl enroll-keys --microsoft
[sudo] password for lani:
Enrolling keys to EFI variables...
With vendor keys from microsoft...✓
Enrolled keys to the EFI variables!
</code></pre>
<p>And now, just reboot! Once you’re back in, you can verify secure boot is activated (user mode).</p>
<pre><code class="language-sh">$ bootctl status
System:
      Firmware: UEFI 2.60 (INSYDE Corp. 225332)
 Firmware Arch: x64
   Secure Boot: enabled (user)
  TPM2 Support: yes
  Measured UKI: yes
  Boot into FW: supported
</code></pre>
<h2 id="part-25-unlocking-luks-with-tpm2-using-systemd-cryptenroll">Part 2.5: Unlocking LUKS with TPM2 using <code>systemd-cryptenroll</code></h2>
<p>At the very beginning of this guide, I promised I’d show you how to auto decrypt your LUKS device with TPM2. So, to do that, we’re going to amend the <code>lanza.nix</code> module we created earlier, to both install <code>tpm2-tss</code> and to create a tiny script (<code>luksCryptenroller</code>) to leverage a handy tool included in systemd: <code>systemd-cryptenroll</code>. The command our script calls on our LUKS device (<code>NIXLUKS</code>) looks something like this:</p>
<pre><code class="language-console"># systemd-cryptenroll --wipe-slot=tpm2 --tpm2-device=auto --tpm2-pcrs=0+7 /dev/disk/by-label/NIXLUKS
</code></pre>
<p>According to the man pages for <code>systemd-cryptenroll</code><sup><a href="#user-content-fn-man-systemd-cryptenroll" id="user-content-fnref-man-systemd-cryptenroll" data-footnote-ref="true" aria-describedby="footnote-label">42</a></sup>, the flags on the command do the following:</p>
<ul>
<li><code>--wipe-slot=tpm2</code> clears out any previous keys bound to the slot.</li>
<li><code>--tpm2-device=auto</code> automatically grabs the default TPM2 chip (usually found on your motherboard).</li>
<li><code>--tpm2-pcrs=0+7</code> binds the keys to Platform Configuration Registers 0 (platform-code, i.e., system firmware) and 7 (secure-boot-policy, i.e., the secure boot <em>state</em>; changes when enabled/disabled, or firmware certificates changes).</li>
</ul>
<p>Then, we can implement said command into our <code>lanza.nix</code> module like this:</p>
<pre><code class="language-nix">{pkgs, lib, ... }:
let
  luksCryptenroller = pkgs.writeTextFile {
    name = &quot;luksCryptenroller&quot;;
    destination = &quot;/bin/luksCryptenroller&quot;;
    executable = true;

    # Note: You can hardcode additional LUKS devices like so:
    # text = let
    #   ...
    #   luksDevice02 = &quot;BEEGLUKS01&quot;;
    #   luksDevice03 = &quot;BEEGLUKS02&quot;;
    # in &#x27;&#x27;
    #   ...
    #   sudo systemd-cryptenroll --wipe-slot=tpm2 --tpm2-device=auto --tpm2-pcrs=0+7 /dev/disk/by-label/${luksDevice02}
    #   sudo systemd-cryptenroll --wipe-slot=tpm2 --tpm2-device=auto --tpm2-pcrs=0+7 /dev/disk/by-label/${luksDevice03}
    # &#x27;&#x27;;

    text = let
      luksDevice01 = &quot;NIXLUKS&quot;;
    in &#x27;&#x27;
      sudo systemd-cryptenroll --wipe-slot=tpm2 --tpm2-device=auto --tpm2-pcrs=0+7 /dev/disk/by-label/${luksDevice01}
    &#x27;&#x27;;
  };
in
{
  environment.systemPackages = [
    luksCryptenroller
    # For debugging and troubleshooting Secure Boot.
    pkgs.sbctl
    # Needed to use the TPM2 chip with `systemd-cryptenroll`
    pkgs.tpm2-tss
  ];

  # Lanzaboote currently replaces the systemd-boot module.
  # This setting is usually set to true in configuration.nix
  # generated at installation time. So we force it to false
  # for now.
  boot.loader.systemd-boot.enable = lib.mkForce false;

  boot.lanzaboote = {
    enable = true;
    pkiBundle = &quot;/var/lib/sbctl&quot;;
  };
}
</code></pre>
<p>I&#x27;ll admit, not the prettiest (or the DRYest) <code>bash</code> script in the world, but <em>hey</em>, it gets the job done. A far more <em>sophisticated</em> approach would probably store the LUKS devices in an array, and use a for loop to run through it, calling <code>systemd-cryptenroll</code> with the current/<em>ith</em> device in the array. I&#x27;ll leave that up to you to implement.</p>
<h3 id="enrolling-the-keys">Enrolling the keys</h3>
<p>This is rather straight forward just run <code>$ luksCryptenroller</code> (you&#x27;ll immediately be prompted for your <code>superuser</code> password). Then just enter your passphrase for each LUKS device, in the order you declared them in.</p>
<details><summary>[INFO]: Emergent properties of a <del>reused</del> <em>shared</em> LUKS passphrase</summary><p>Fun fact, if all your LUKS devices share the same passphrase, <code>systemd-cryptenroll</code> seems to just know to re-use it, rather than prompting you for it again. As to how that&#x27;s possible ... <em>well, I have no idea.</em> If by chance you happen to figure that out, I&#x27;d love to hear about it!</p></details>
<h2 id="part-03-configuring-home-manager-abridged">Part 03: Configuring Home Manager (Abridged)</h2>
<p><code>home-manager</code> is a very extensive utility for configuring the user (rather than the global/system) environment on a Nix/NixOS machine. It&#x27;s far too much to talk about in detail here, so I&#x27;ll leave you with the official <a href="https://nix-community.github.io/home-manager/">Home Manager Manual</a><sup><a href="#user-content-fn-home-manager-manual" id="user-content-fnref-home-manager-manual" data-footnote-ref="true" aria-describedby="footnote-label">43</a></sup>, a very handy tool: <a href="https://home-manager-options.extranix.com/">Home Manager Option Search</a><sup><a href="#user-content-fn-home-manager---option-search" id="user-content-fnref-home-manager---option-search" data-footnote-ref="true" aria-describedby="footnote-label">44</a></sup>, and finally the <a href="https://github.com/nix-community/home-manager/issues">nix-community/home-manager/issues</a><sup><a href="#user-content-fn-issues---nix-community-home-manager" id="user-content-fnref-issues---nix-community-home-manager" data-footnote-ref="true" aria-describedby="footnote-label">45</a></sup> page for when things go awry (It doesn&#x27;t happen that often, but I&#x27;ll admit weird bugs do happen on occasion).</p>
<p>As such, I&#x27;m going to very briefly walk you through configuring <code>home-manager</code> via further amending our <code>flake.nix</code>, and setting up the most important features I use it for (shell configuration, <code>git</code>/<code>gh</code> configuration, etc.).</p>
<h3 id="step-0---fontsnix">Step 0 - <code>fonts.nix</code></h3>
<p>Shells look best with monospaced fonts, especially <em>monospaced nerd-fonts</em>, so before we get ahead of ourselves, let&#x27;s create a <code>fonts.nix</code> in the systemwide space.</p>
<pre><code class="language-console"># touch /etc/nixos/modules/fonts.nix
</code></pre>
<p>And we&#x27;ll bring it into an editor with:</p>
<pre><code class="language-console"># vim /etc/nixos/modules/fonts.nix
</code></pre>
<p>To create the following:</p>
<pre><code class="language-nix"># modules/fonts.nix
{pkgs, ...}:
{
  fonts = {
    fontconfig = {
      enable = true;
    };
    packages = with pkgs; [
      noto-fonts
      noto-fonts-emoji-blob-bin
      noto-fonts-cjk-sans
      nerd-fonts._0xproto # personal fav monospaced font. However, you can use whatever monospaced nerd font you&#x27;d like.
      nerd-fonts.symbols-only
    ];
  };
}
</code></pre>
<p>As you&#x27;ll notice, this module enables a system wide <code>fontconfig</code> (you could also use home-manager to enable a user space one), and several common font packages, along with my favorite monospaced font (0xProto) packaged as a nerd-font.</p>
<p>We can then import it into <code>configuration.nix</code> like so:</p>
<pre><code class="language-nix"># configuration.nix
{ config, lib, pkgs, ... }:

{
  imports =
    [ # Include the results of the hardware scan.
      ./hardware-configuration.nix
      # system modules
      ./modules/fonts.nix
    ];

  ...
}
</code></pre>
<details><summary>[INFO]: On <em>where</em> to import modules within a <code>flake.nix</code></summary><p>You could import the above into the <code>flake.nix</code> module array as well, the snippet above just demonstrates that you can import it into your main <code>configuration.nix</code> file too. Inversely, you could move the <code>lanza.nix</code> module out of the <code>flake.nix</code> module array, and import it into the <code>configuration.nix</code> imports array instead as well.</p><p>This is possible because nested modules will ultimately wind up in the <code>flake.nix</code> module array anyway, so long as the <em>parent</em> modules (i.e., <code>configuration.nix</code>) containing such nested <em>child</em> modules (<code>fonts.nix</code>) are declared there.</p></details>
<p>We can then build the new configuration like so:</p>
<pre><code class="language-console"># nixos-rebuild switch
</code></pre>
<p>And with single <code># nixos-rebuild switch</code> we now have all the fonts we need to continue.</p>
<h3 id="configuring-home-manager-via-flakenix">Configuring Home Manager via <code>flake.nix</code></h3>
<p>This is rather straight forward (it&#x27;s one of the neat things about flakes!), just like we added <code>lanzaboote</code>, we&#x27;ll add <code>home-manager</code> as an input, and configure it as a module<sup><a href="#user-content-fn-home-manager-manual-flakes-nixos-module" id="user-content-fnref-home-manager-manual-flakes-nixos-module" data-footnote-ref="true" aria-describedby="footnote-label">46</a></sup>.</p>
<pre><code class="language-nix">{
  description = &quot;A minimal flake.nix for a SecureBoot-enabled NixOS machine, with Home Manager&quot;;
  inputs = {
    nixpkgs.url = &quot;github:NixOS/nixpkgs/nixos-unstable&quot;;
    lanzaboote = {
      url = &quot;github:nix-community/lanzaboote/v0.4.1&quot;;
      inputs.nixpkgs.follows = &quot;nixpkgs&quot;;
    };
    home-manager = {
      url = &quot;github:nix-community/home-manager&quot;;
      inputs.nixpkgs.follows = &quot;nixpkgs&quot;;
    };
  };
  outputs = {self, nixpkgs, lanzaboote, home-manager, ... }@inputs: {
    nixosConfigurations = {
      my-nixos-machine = nixpkgs.lib.nixosSystem {
        system = &quot;x86_64-linux&quot;; # Assumes a standard x86 CPU
        specialArgs = {
          inherit inputs; # this passes down the inputs
        };
        modules = [
          ./configuration.nix
          lanzaboote.nixosModules.lanzaboote
          ./modules/lanza.nix
          home-manager.nixosModules.home-manager
          {
            home-manager.useGlobalPkgs = true;
            home-manager.useUserPackages = true;
            home-manager.users.mycoolusername = import ./home;
            home-manager.extraSpecialArgs = inputs; # from the passed down input, we can pass these as args to `home.nix`
          }
        ];
      };
    };
  };
}
</code></pre>
<p>Then we&#x27;ll create our <code>home</code> directory, along with the module&#x27;s we&#x27;ll need:</p>
<pre><code class="language-sh">❯ sudo mkdir /etc/nixos/home
❯ sudo mkdir /etc/nixos/home/modules
❯ sudo mkdir /etc/nixos/home/modules/shell
❯ sudo touch /etc/nixos/home/default.nix
❯ sudo touch /etc/nixos/home/home-configuration.nix
❯ sudo touch /etc/nixos/home/modules/shell/default.nix
❯ sudo touch /etc/nixos/home/modules/shell/zsh.nix
❯ sudo touch /etc/nixos/home/modules/shell/starship.nix
❯ sudo touch /etc/nixos/home/modules/shell/git-gh.nix
</code></pre>
<p>The above is pretty verbose, so let&#x27;s walk through each module.</p>
<details><summary>[INFO]: Primer on <code>default.nix</code></summary><p>You probably noticed in our flake config this line:</p><pre><code class="language-nix">{
   home-manager.users.mycoolusername = import ./home;
}
</code></pre><p>As well as the two <code>default.nix</code> modules we created in <code>./home</code> and <code>./home/modules/shell</code>. So, what does it do? Well, it&#x27;s a module that primarily serves to imports other modules. As an added bonus, the way to import a <code>default.nix</code> module, is through referencing it&#x27;s parent directory, such as <code>home</code> or <code>home/modules/shell</code>.</p><p>This is a feature that becomes more useful, when you have modules that shouldn&#x27;t be imported by default. For example, you might decide to create modules for AMD GPUs (<code>modules/amd.nix</code>) and Nvidia GPUs (<code>modules/nvidia.nix</code>). Unless you want to install both sets of drivers across all your machines, you&#x27;ll probably want to manually import those on a system by system basis via the flake.nix, rather than importing both via a <code>default.nix</code> in a <code>modules/gpu</code> directory.</p></details>
<h4 id="homedefaultnix"><code>home/default.nix</code></h4>
<p>Continuing, let&#x27;s start with <code>home/default.nix</code>. Using a <code>superuser</code> privileged editor, you&#x27;ll want to assemble <code>home/default.nix</code> like this:</p>
<pre><code class="language-nix">{
  imports = [
    ./home-configuration.nix
    ./modules/shell
  ];
}
</code></pre>
<p>Inline with the spirit of <code>default.nix</code>, this simply imports a file called <code>./home-configuration.nix</code> (which we&#x27;ll configure next), and another <code>default.nix</code> which declares the default imports for the <code>shell</code> module.</p>
<h4 id="homehome-configurationnix"><code>home/home-configuration.nix</code></h4>
<p>And we can assemble <code>home-configuration.nix</code> like so:</p>
<pre><code class="language-nix">{
  home.username = &quot;mycoolusername&quot;;
  home.homeDirectory = &quot;/home/mycoolusername&quot;;
  home.stateVersion = &quot;24.05&quot;;
  programs.home-manager.enable = true;
}
</code></pre>
<p>The above configures Home Manager to act on <code>mycoolusername</code>&#x27;s home directory, and enables itself.</p>
<h4 id="homemodulesshelldefaultnix"><code>home/modules/shell/default.nix</code></h4>
<p>Another <code>default.nix</code> module, it&#x27;ll import modules used primarily within the terminal shell.</p>
<pre><code class="language-nix">{
  imports = [
    ./zsh.nix
    ./starship.nix
    ./git-gh.nix
  ];
}
</code></pre>
<h4 id="homemodulesshellzshnix"><code>home/modules/shell/zsh.nix</code></h4>
<pre><code class="language-nix">{config, pkgs, ...}: {
  home.packages = with pkgs; [
    pfetch-rs
  ];

  programs.zsh = {
    enable = true;
    autosuggestion.enable = true;
    syntaxHighlighting.enable = true;
    history = {
      size = 10000;
      path = &quot;${config.xdg.dataHome}/zsh/history&quot;;
    };
    initExtra = &#x27;&#x27;
    case $(tty) in
      (/dev/tty[1-9]);;
      (*)
        eval pfetch;;
    esac
    &#x27;&#x27;;
  };
}
</code></pre>
<p>The above configures <code>zsh</code> (which we declared and enabled as the default shell from <code>configuration.nix</code>), with some common plugins, a decent history size, and even runs <code>pfetch</code> every time you open a fresh shell. The little if/case statement in <code>initExtra</code> ensures <code>pfetch</code> doesn&#x27;t run in a bare <code>tty</code> (which, I&#x27;m sure you&#x27;re very familiar with if you followed <a href="#part-01-from-zero-to-nixos">Part 01: From Zero to NixOS</a> all the way through).</p>
<h4 id="homemodulesshellstarshipnix"><code>home/modules/shell/starship.nix</code></h4>
<pre><code class="language-nix">{
  programs.starship = {
    enable = true;
    settings = {
      add_newline = false;

      nix_shell = {
        symbol = &quot; &quot;;
      };

      git_status = {
        format = &quot;([\$all_status$ahead_behind\]($style) )&quot;;
        modified = &quot;󰇂 &quot;;
        ahead = &quot; $\{count}&quot;;
        conflicted = &quot;󱚝 &quot;;
        behind = &quot; $\{count}&quot;;
        diverged = &quot;󰹺   $\{ahead_count}  $\{behind_count}&quot;;
        up_to_date = &quot; &quot;;
        deleted = &quot;󰮉 &quot;;
        untracked = &quot;󱚠 &quot;;
        stashed = &quot; &quot;;
        staged = &quot; &quot;;
        style = &quot;bold blue&quot;;
      };
    };
  };
}
</code></pre>
<p>The above is my personal Starship Prompt configuration, and I hope it serves you well in your version control adventures (if you choose to implement this module).</p>
<h4 id="homemodulesshellgit-ghnix"><code>home/modules/shell/git-gh.nix</code></h4>
<pre><code class="language-nix">{
  programs.git = {
    enable = true;
    userName = &quot;mycoolgithubusername&quot;;
    userEmail = &quot;mycoolgithubacctemail@xample.com&quot;;
    extraConfig = {
      safe.directory = &quot;/etc/nixos&quot;;
      core.editor = &quot;nvim&quot;; # We didn&#x27;t cover this, but I trust you can setup neovim on your own, perhaps via nixvim ;3 (https://nix-community.github.io/nixvim/)
    };
  };
  programs.gh = {
    enable = true;
  };
}
</code></pre>
<h4 id="the-final-step-nixos-rebuild-switch">The Final Step: <code>nixos-rebuild switch</code></h4>
<p>With all the modules configured, it&#x27;s time to run <code># nixos-rebuild switch</code>, or <code># nixos-rebuild boot</code> if you want to cut down on the misc Nix profile generations (only drawback is you have to reboot). If everything compiled successfully, it&#x27;s now time to bask in the triumph of having accomplished a LOT today, complete with a base Home Manager configuration as icing on the cake.</p>
<details><summary>[INFO]: Cleaning up old Nix generations</summary><p>Eventually, you&#x27;ll want to clean out old <em>Nix generations</em> (created after each <code># nixos-rebuild switch</code>) since they can really start piling up, and monopololize your diskspace. You can do so (mildly dangerously) with <code># nix-collect-garbage -d</code> to delete <em>all</em> old generations of profiles. Or, for a much safer garbage collection, you can run <code># nix-collect-garbage --delete-older-than &lt;time_period&gt;</code> to remove things older than, say, 30 days, using <code>30d</code> as the time period. <sup><a href="#user-content-fn-nix-collect-garbage" id="user-content-fnref-nix-collect-garbage" data-footnote-ref="true" aria-describedby="footnote-label">47</a></sup></p><p>The former garbage collection command can also become <code>très dangereaux</code>, if your last config and current config are both broken in a way that you can no longer access the <code>TTY</code>, since all possible escape hatches (working generations) would be removed. This is probably one of the only ways you can truly <em>brick</em> a NixOS install.</p><p>In the event of such a <em>bricked</em> install, you can use the USB with the Minimal NixOS ISO you used earlier to attempt a repair. Once booted, just mount your drives, run <code>luksOpen</code> to unlock them, edit the borked config files, then follow the instructions on the NixOS Wiki page for <a href="https://wiki.nixos.org/wiki/Change_root">Change root</a> to run <code>nixos-enter</code>, so you can run <code>nixos-rebuild switch</code>.</p></details>
<h2 id="discussion">Discussion</h2>
<p>Welcome to the end of a very long <del>blog post</del> <em>guide</em>. If you completed the above, you have earned a very well deserved break and celebration of your accomplishments today (or however long it took to get through this guide).</p>
<p>What you&#x27;ve done is no small feat. In reaching the end of this guide, you&#x27;ve installed NixOS with Full Disk Encryption, enabled secure boot with <code>lanzaboote</code>, enabled your devices TPM2 chip to decrypt your LUKS devices during boot with <code>systemd-cryptenroller</code>, and finally configured a slick <code>zsh</code> shell with Starship Prompt, some plugins, and configured <code>git</code> and <code>gh</code>, ready to turn your <code>/etc/nixos</code> folder into a repo<sup><a href="#user-content-fn-other-useful-tips" id="user-content-fnref-other-useful-tips" data-footnote-ref="true" aria-describedby="footnote-label">48</a></sup>. <em>Pretty rad!</em></p>
<p>If you knew nothing about Nix when you first got here, well, I hope you were able to get the gist of it after reaching the end here, and picked up a few things along the way.</p>
<p>I also hope that in being your guide on this journey through the major circles of Nix, that you were able to overcome some of the steep learning curve Nix/NixOS is infamously known for. If however, I failed to help with that... Welp, I guess I&#x27;ll just have to update this post in accordance to your feedback, won&#x27;t I?</p>
<p>In any event, I hope you learned something, and if you were ever a chronic distrohopper<sup><a href="#user-content-fn-distrohopper" id="user-content-fnref-distrohopper-2" data-footnote-ref="true" aria-describedby="footnote-label">1</a></sup> like I was, I hope NixOS brings you the same peace of mind it brought me, serving as the final distro following a very long series of hops.</p>
<p>Finally, It&#x27;s just me writing/editing these articles, and they&#x27;re <em>quite long.</em> As such, it&#x27;s entirely possible I&#x27;ve <em>accidentally</em> a word or semi-colon or two. Should you find any errors in this guide, I&#x27;d really appreciate it if you either left a comment below explaining what&#x27;s wrong, <a href="https://github.com/laniakita/website/issues/new">opened an issue</a> on this site&#x27;s repo, or submitted a pull request to correct this article directly. Thank you.</p>
<p><em>PS, I&#x27;d love to hear your thoughts! Feel free to drop a comment or question below, or reach out to me on social media to let me know what you thought of this article!</em></p>
<section data-footnotes="true" class="footnotes"><h2 class="sr-only" id="footnote-label">Footnotes</h2>
<ol>
<li id="user-content-fn-distrohopper">
<p>Urban Dictionary: distrohopper [Internet]. Urban Dictionary. [cited 2025 Jan 4]. Available from: <a href="https://www.urbandictionary.com/define.php?term=distrohopper">https://www.urbandictionary.com/define.php?term=distrohopper</a> <a href="#user-content-fnref-distrohopper" data-footnote-backref="" aria-label="Back to reference 1" class="data-footnote-backref">↩</a> <a href="#user-content-fnref-distrohopper-2" data-footnote-backref="" aria-label="Back to reference 1-2" class="data-footnote-backref">↩<sup>2</sup></a></p>
</li>
<li id="user-content-fn-full-disk-encryption---nixos-wiki">
<p>Full Disk Encryption - NixOS Wiki [Internet]. NixOS Wiki. 2024 [cited 2025 Jan 3]. Available from: <a href="https://wiki.nixos.org/wiki/Full_Disk_Encryption">https://wiki.nixos.org/wiki/Full_Disk_Encryption</a> <a href="#user-content-fnref-full-disk-encryption---nixos-wiki" data-footnote-backref="" aria-label="Back to reference 2" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-installing-nixos-with-flakes-and-lvm-on-luks">
<p>Cîmpianu D. Installing NixOS with Flakes and LVM on LUKS [Internet]. Jadarma’s Blog. 2024 [cited 2025 Jan 3]. Available from: <a href="https://jadarma.github.io/blog/posts/2024/08/installing-nixos-with-flakes-and-lvm-on-luks/">https://jadarma.github.io/blog/posts/2024/08/installing-nixos-with-flakes-and-lvm-on-luks/</a> <a href="#user-content-fnref-installing-nixos-with-flakes-and-lvm-on-luks" data-footnote-backref="" aria-label="Back to reference 3" class="data-footnote-backref">↩</a> <a href="#user-content-fnref-installing-nixos-with-flakes-and-lvm-on-luks-2" data-footnote-backref="" aria-label="Back to reference 3-2" class="data-footnote-backref">↩<sup>2</sup></a></p>
</li>
<li id="user-content-fn-installing-nixos-with-full-disk-encryption">
<p>Schulke M. Installing NixOS with Full Disk Encryption [Internet]. Gist. 2021 [cited 2025 Jan 3]. Available from: <a href="https://gist.github.com/mara-schulke/43e2632ce73d94028f50f438037c1578">https://gist.github.com/mara-schulke/43e2632ce73d94028f50f438037c1578</a> <a href="#user-content-fnref-installing-nixos-with-full-disk-encryption" data-footnote-backref="" aria-label="Back to reference 4" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-a-modern-and-secure-desktop-setup---guides">
<p>Kovari BB. A Modern and Secure Desktop Setup - Guides [Internet]. NixOS Discourse. 2024 [cited 2025 Jan 3]. Available from: <a href="https://discourse.nixos.org/t/a-modern-and-secure-desktop-setup/41154">https://discourse.nixos.org/t/a-modern-and-secure-desktop-setup/41154</a> <a href="#user-content-fnref-a-modern-and-secure-desktop-setup---guides" data-footnote-backref="" aria-label="Back to reference 5" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-installation-of-nixos-with-encrypted-root">
<p>Vermaat M. Installation of NixOS with encrypted root [Internet]. Gist. 2016 [cited 2025 Jan 3]. Available from: <a href="https://gist.github.com/martijnvermaat/76f2e24d0239470dd71050358b4d5134">https://gist.github.com/martijnvermaat/76f2e24d0239470dd71050358b4d5134</a> <a href="#user-content-fnref-installation-of-nixos-with-encrypted-root" data-footnote-backref="" aria-label="Back to reference 6" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-how-to-install-nixos-with-full-disk-encryption-fde-using-luks2">
<p>Hong SZ. How to Install NixOS With Full Disk Encryption (FDE) using LUKS2, Detached LUKS Header, and A Separate Boot Partition on an USB/MicroSD Card [Internet]. Shen’s Essays. 2021 [cited 2025 Jan 3]. Available from: <a href="https://shen.hong.io/installing-nixos-with-encrypted-root-partition-and-seperate-boot-partition/">https://shen.hong.io/installing-nixos-with-encrypted-root-partition-and-seperate-boot-partition/</a> <a href="#user-content-fnref-how-to-install-nixos-with-full-disk-encryption-fde-using-luks2" data-footnote-backref="" aria-label="Back to reference 7" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-lanzaboote-docs-quick-start">
<p>Stecklina J, Lahfa R, nikstur. lanzaboote/docs/QUICK_START [Internet]. GitHub. 2024 [cited 2025 Jan 3]. Available from: <a href="https://github.com/nix-community/lanzaboote/blob/master/docs/QUICK_START.md">https://github.com/nix-community/lanzaboote/blob/master/docs/QUICK_START.md</a> <a href="#user-content-fnref-lanzaboote-docs-quick-start" data-footnote-backref="" aria-label="Back to reference 8" class="data-footnote-backref">↩</a> <a href="#user-content-fnref-lanzaboote-docs-quick-start-2" data-footnote-backref="" aria-label="Back to reference 8-2" class="data-footnote-backref">↩<sup>2</sup></a></p>
</li>
<li id="user-content-fn-nixos-manual">
<p>NixOS contributors. NixOS Manual [Internet]. Nix &amp; NixOS | Declarative builds and deployments. [cited 2025 Jan 3]. Available from: <a href="https://nixos.org/manual/nixos/unstable/">https://nixos.org/manual/nixos/unstable/</a> <a href="#user-content-fnref-nixos-manual" data-footnote-backref="" aria-label="Back to reference 9" class="data-footnote-backref">↩</a> <a href="#user-content-fnref-nixos-manual-2" data-footnote-backref="" aria-label="Back to reference 9-2" class="data-footnote-backref">↩<sup>2</sup></a></p>
</li>
<li id="user-content-fn-how-nix-works">
<p>NixOS contributors. How Nix Works | Nix &amp; NixOS [Internet]. [cited 2025 Jan 3]. Available from: <a href="https://nixos.org/guides/how-nix-works/">https://nixos.org/guides/how-nix-works/</a> <a href="#user-content-fnref-how-nix-works" data-footnote-backref="" aria-label="Back to reference 10" class="data-footnote-backref">↩</a> <a href="#user-content-fnref-how-nix-works-2" data-footnote-backref="" aria-label="Back to reference 10-2" class="data-footnote-backref">↩<sup>2</sup></a> <a href="#user-content-fnref-how-nix-works-3" data-footnote-backref="" aria-label="Back to reference 10-3" class="data-footnote-backref">↩<sup>3</sup></a></p>
</li>
<li id="user-content-fn-nixos-wiki--packaging-binaries">
<p>Packaging/Binaries - NixOS Wiki [Internet]. [cited 2025 Jan 3]. Available from: <a href="https://wiki.nixos.org/wiki/Packaging/Binaries">https://wiki.nixos.org/wiki/Packaging/Binaries</a> <a href="#user-content-fnref-nixos-wiki--packaging-binaries" data-footnote-backref="" aria-label="Back to reference 11" class="data-footnote-backref">↩</a> <a href="#user-content-fnref-nixos-wiki--packaging-binaries-2" data-footnote-backref="" aria-label="Back to reference 11-2" class="data-footnote-backref">↩<sup>2</sup></a></p>
</li>
<li id="user-content-fn-nix-ld-a-clean-solution">
<p>Thornhill JT and J. Nix-ld: A clean solution for issues with pre-compiled executables on NixOS [Internet]. 2022 [cited 2025 Jan 3]. Available from: <a href="https://blog.thalheim.io/2022/12/31/nix-ld-a-clean-solution-for-issues-with-pre-compiled-executables-on-nixos/">https://blog.thalheim.io/2022/12/31/nix-ld-a-clean-solution-for-issues-with-pre-compiled-executables-on-nixos/</a> <a href="#user-content-fnref-nix-ld-a-clean-solution" data-footnote-backref="" aria-label="Back to reference 12" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-nixos-wiki--steam">
<p>Steam - NixOS Wiki [Internet]. [cited 2025 Jan 3]. Available from: <a href="https://wiki.nixos.org/wiki/Steam#FHS_environment_only">https://wiki.nixos.org/wiki/Steam#FHS_environment_only</a> <a href="#user-content-fnref-nixos-wiki--steam" data-footnote-backref="" aria-label="Back to reference 13" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-installation-guide-archwiki">
<p>Installation guide - ArchWiki [Internet]. [cited 2025 Jan 3]. Available from: <a href="https://wiki.archlinux.org/title/Installation_guide">https://wiki.archlinux.org/title/Installation_guide</a> <a href="#user-content-fnref-installation-guide-archwiki" data-footnote-backref="" aria-label="Back to reference 14" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-gentoo-amd64-guide">
<p>Gentoo AMD64 Handbook - Gentoo wiki [Internet]. [cited 2025 Jan 4]. Available from: <a href="https://wiki.gentoo.org/wiki/Handbook:AMD64">https://wiki.gentoo.org/wiki/Handbook:AMD64</a> <a href="#user-content-fnref-gentoo-amd64-guide" data-footnote-backref="" aria-label="Back to reference 15" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-systemd-cryptenroll">
<p>systemd-cryptenroll - ArchWiki [Internet]. [cited 2025 Jan 3]. Available from: <a href="https://wiki.archlinux.org/title/Systemd-cryptenroll">https://wiki.archlinux.org/title/Systemd-cryptenroll</a> <a href="#user-content-fnref-systemd-cryptenroll" data-footnote-backref="" aria-label="Back to reference 16" class="data-footnote-backref">↩</a> <a href="#user-content-fnref-systemd-cryptenroll-2" data-footnote-backref="" aria-label="Back to reference 16-2" class="data-footnote-backref">↩<sup>2</sup></a></p>
</li>
<li id="user-content-fn-minimal-iso">
<p>Download | Nix &amp; NixOS [Internet]. [cited 2025 Jan 3]. Available from: <a href="https://nixos.org/download/#nixos-iso">https://nixos.org/download/#nixos-iso</a> <a href="#user-content-fnref-minimal-iso" data-footnote-backref="" aria-label="Back to reference 17" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-secure-erase">
<p>encryption - How to secure erase files and folders? - Information Security Stack Exchange [Internet]. [cited 2025 Jan 3]. Available from: <a href="https://security.stackexchange.com/questions/175968/how-to-secure-erase-files-and-folders">https://security.stackexchange.com/questions/175968/how-to-secure-erase-files-and-folders</a> <a href="#user-content-fnref-secure-erase" data-footnote-backref="" aria-label="Back to reference 18" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-cryptsetup">
<p>cryptsetup(8) - Linux man page [Internet]. [cited 2025 Jan 3]. Available from: <a href="https://linux.die.net/man/8/cryptsetup">https://linux.die.net/man/8/cryptsetup</a> <a href="#user-content-fnref-cryptsetup" data-footnote-backref="" aria-label="Back to reference 19" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-backup-luks-header">
<p>Upgrading and backing up your LUKS header - Infrastructure and Application Security [Internet]. [cited 2025 Jan 4]. Available from: <a href="https://krvtz.net/posts/upgrading-and-backing-up-your-luks-header.html">https://krvtz.net/posts/upgrading-and-backing-up-your-luks-header.html</a> <a href="#user-content-fnref-backup-luks-header" data-footnote-backref="" aria-label="Back to reference 20" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-i-have-16gb-ram--do-i-need-32gb-swap">
<p>Szelei T. I have 16GB RAM. Do I need 32GB swap? - Ask Ubuntu [Internet]. Ask Ubuntu. 2018 [cited 2025 Jan 3]. Available from: <a href="https://askubuntu.com/questions/49109/i-have-16gb-ram-do-i-need-32gb-swap">https://askubuntu.com/questions/49109/i-have-16gb-ram-do-i-need-32gb-swap</a> <a href="#user-content-fnref-i-have-16gb-ram--do-i-need-32gb-swap" data-footnote-backref="" aria-label="Back to reference 21" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-fat-osdev-wiki">
<p>FAT - OSDev Wiki [Internet]. [cited 2025 Jan 3]. Available from: <a href="https://wiki.osdev.org/FAT#FAT_32_2">https://wiki.osdev.org/FAT#FAT_32_2</a> <a href="#user-content-fnref-fat-osdev-wiki" data-footnote-backref="" aria-label="Back to reference 22" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-ext4-spec-global-structures-superblock-linux-kernel">
<p>Global Structures — The Linux Kernel documentation [Internet]. [cited 2025 Jan 3]. Available from: <a href="https://docs.kernel.org/filesystems/ext4/globals.html#super-block">https://docs.kernel.org/filesystems/ext4/globals.html#super-block</a> <a href="#user-content-fnref-ext4-spec-global-structures-superblock-linux-kernel" data-footnote-backref="" aria-label="Back to reference 23" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-man-swaplabel">
<p>swaplabel(8) - Linux manual page [Internet]. [cited 2025 Jan 3]. Available from: <a href="https://man7.org/linux/man-pages/man8/swaplabel.8.html">https://man7.org/linux/man-pages/man8/swaplabel.8.html</a> <a href="#user-content-fnref-man-swaplabel" data-footnote-backref="" aria-label="Back to reference 24" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-modularize-your-nixos-configuration">
<p>Yin R. Modularize Your NixOS Configuration [Internet]. NixOS &amp; Flakes Book. 2024 [cited 2025 Jan 3]. Available from: <a href="https://nixos-and-flakes.thiscute.world/nixos-with-flakes/modularize-the-configuration#modularize-your-nixos-configuration">https://nixos-and-flakes.thiscute.world/nixos-with-flakes/modularize-the-configuration#modularize-your-nixos-configuration</a> <a href="#user-content-fnref-modularize-your-nixos-configuration" data-footnote-backref="" aria-label="Back to reference 25" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-the-nix-way-dev-templates">
<p>Perkins L. the-nix-way/dev-templates [Internet]. The Nix Way; 2024 [cited 2025 Jan 3]. Available from: <a href="https://github.com/the-nix-way/dev-templates">https://github.com/the-nix-way/dev-templates</a> <a href="#user-content-fnref-the-nix-way-dev-templates" data-footnote-backref="" aria-label="Back to reference 26" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-incident-xkcd">
<p>Munroe R. Incident [Internet]. xkcd. [cited 2025 Jan 3]. Available from: <a href="https://xkcd.com/838/">https://xkcd.com/838/</a> <a href="#user-content-fnref-incident-xkcd" data-footnote-backref="" aria-label="Back to reference 27" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-nixos-wiki--gnome">
<p>GNOME - NixOS Wiki [Internet]. [cited 2025 Jan 3]. Available from: <a href="https://wiki.nixos.org/wiki/GNOME">https://wiki.nixos.org/wiki/GNOME</a> <a href="#user-content-fnref-nixos-wiki--gnome" data-footnote-backref="" aria-label="Back to reference 28" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-nixos-wiki--kde">
<p>KDE - NixOS Wiki [Internet]. [cited 2025 Jan 3]. Available from: <a href="https://wiki.nixos.org/wiki/KDE">https://wiki.nixos.org/wiki/KDE</a> <a href="#user-content-fnref-nixos-wiki--kde" data-footnote-backref="" aria-label="Back to reference 29" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-nixos-wiki--sway">
<p>Sway - NixOS Wiki [Internet]. [cited 2025 Jan 3]. Available from: <a href="https://wiki.nixos.org/wiki/Sway">https://wiki.nixos.org/wiki/Sway</a> <a href="#user-content-fnref-nixos-wiki--sway" data-footnote-backref="" aria-label="Back to reference 30" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-nixos-wiki--pipewire">
<p>PipeWire - NixOS Wiki [Internet]. [cited 2025 Jan 3]. Available from: <a href="https://wiki.nixos.org/wiki/PipeWire">https://wiki.nixos.org/wiki/PipeWire#Bluetooth_Configuration</a> <a href="#user-content-fnref-nixos-wiki--pipewire" data-footnote-backref="" aria-label="Back to reference 31" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-nixos-wiki--pipewire-bt">
<p>PipeWire - NixOS Wiki [Internet]. [cited 2025 Jan 3]. Available from: <a href="https://wiki.nixos.org/wiki/PipeWire#Bluetooth_Configuration">https://wiki.nixos.org/wiki/PipeWire#Bluetooth_Configuration</a> <a href="#user-content-fnref-nixos-wiki--pipewire-bt" data-footnote-backref="" aria-label="Back to reference 32" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-nixos-wiki--bluetooth">
<p>Bluetooth - NixOS Wiki [Internet]. [cited 2025 Jan 3]. Available from: <a href="https://wiki.nixos.org/wiki/Bluetooth">https://wiki.nixos.org/wiki/Bluetooth</a> <a href="#user-content-fnref-nixos-wiki--bluetooth" data-footnote-backref="" aria-label="Back to reference 33" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-nixos-wiki--printing">
<p>Printing - NixOS Wiki [Internet]. [cited 2025 Jan 3]. Available from: <a href="https://wiki.nixos.org/wiki/Printing">https://wiki.nixos.org/wiki/Printing</a> <a href="#user-content-fnref-nixos-wiki--printing" data-footnote-backref="" aria-label="Back to reference 34" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-nixos-wiki--zsh">
<p>Zsh - NixOS Wiki [Internet]. [cited 2025 Jan 3]. Available from: <a href="https://wiki.nixos.org/wiki/Zsh">https://wiki.nixos.org/wiki/Zsh</a> <a href="#user-content-fnref-nixos-wiki--zsh" data-footnote-backref="" aria-label="Back to reference 35" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-nixos-wiki--fish">
<p>fish - NixOS Wiki [Internet]. [cited 2025 Jan 3]. Available from: <a href="https://wiki.nixos.org/wiki/Fish">https://wiki.nixos.org/wiki/Fish</a> <a href="#user-content-fnref-nixos-wiki--fish" data-footnote-backref="" aria-label="Back to reference 36" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-nixos-wiki--nushell">
<p>Nushell - NixOS Wiki [Internet]. [cited 2025 Jan 3]. Available from: <a href="https://wiki.nixos.org/wiki/Nushell">https://wiki.nixos.org/wiki/Nushell</a> <a href="#user-content-fnref-nixos-wiki--nushell" data-footnote-backref="" aria-label="Back to reference 37" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-starship--cross--shell-prompt">
<p>Starship: Cross-Shell Prompt [Internet]. [cited 2025 Jan 3]. Available from: <a href="https://starship.rs/">https://starship.rs/</a> <a href="#user-content-fnref-starship--cross--shell-prompt" data-footnote-backref="" aria-label="Back to reference 38" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-zsh-users-zsh-syntax-highlighting">
<p>zsh-users/zsh-syntax-highlighting [Internet]. zsh-users; 2025 [cited 2025 Jan 3]. Available from: <a href="https://github.com/zsh-users/zsh-syntax-highlighting">https://github.com/zsh-users/zsh-syntax-highlighting</a> <a href="#user-content-fnref-zsh-users-zsh-syntax-highlighting" data-footnote-backref="" aria-label="Back to reference 39" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-nixos-and-flakes-book">
<p>Yin R. NixOS &amp; Flakes Book [Internet]. NixOS &amp; Flakes Book. 2024 [cited 2025 Jan 3]. Available from: <a href="https://nixos-and-flakes.thiscute.world/">https://nixos-and-flakes.thiscute.world/</a> <a href="#user-content-fnref-nixos-and-flakes-book" data-footnote-backref="" aria-label="Back to reference 40" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-nix-channels">
<p>Nix channels - NixOS Wiki [Internet]. [cited 2025 Jan 4]. Available from: <a href="https://nixos.wiki/wiki/Nix_channels">https://nixos.wiki/wiki/Nix_channels</a> <a href="#user-content-fnref-nix-channels" data-footnote-backref="" aria-label="Back to reference 41" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-man-systemd-cryptenroll">
<p>systemd-cryptenroll [Internet]. [cited 2025 Jan 4]. Available from: <a href="https://www.freedesktop.org/software/systemd/man/latest/systemd-cryptenroll.html">https://www.freedesktop.org/software/systemd/man/latest/systemd-cryptenroll.html</a> <a href="#user-content-fnref-man-systemd-cryptenroll" data-footnote-backref="" aria-label="Back to reference 42" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-home-manager-manual">
<p>Home Manager Manual [Internet]. [cited 2025 Jan 3]. Available from: <a href="https://nix-community.github.io/home-manager/">https://nix-community.github.io/home-manager/</a> <a href="#user-content-fnref-home-manager-manual" data-footnote-backref="" aria-label="Back to reference 43" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-home-manager---option-search">
<p>Snel P. Home Manager - Option Search [Internet]. [cited 2025 Jan 3]. Available from: <a href="https://home-manager-options.extranix.com/">https://home-manager-options.extranix.com/</a> <a href="#user-content-fnref-home-manager---option-search" data-footnote-backref="" aria-label="Back to reference 44" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-issues---nix-community-home-manager">
<p>Issues · nix-community/home-manager [Internet]. GitHub. [cited 2025 Jan 3]. Available from: <a href="https://github.com/nix-community/home-manager">https://github.com/nix-community/home-manager</a> <a href="#user-content-fnref-issues---nix-community-home-manager" data-footnote-backref="" aria-label="Back to reference 45" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-home-manager-manual-flakes-nixos-module">
<p>Nix Flakes: NixOS module - Home Manager Manual [Internet]. [cited 2025 Jan 3]. Available from: <a href="https://nix-community.github.io/home-manager/index.xhtml#sec-flakes-nixos-module">https://nix-community.github.io/home-manager/index.xhtml#sec-flakes-nixos-module</a> <a href="#user-content-fnref-home-manager-manual-flakes-nixos-module" data-footnote-backref="" aria-label="Back to reference 46" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-nix-collect-garbage">
<p>nix-collect-garbage - Nix Reference Manual [Internet]. [cited 2025 Jan 4]. Available from: <a href="https://nix.dev/manual/nix/2.25/command-ref/nix-collect-garbage.html">https://nix.dev/manual/nix/2.25/command-ref/nix-collect-garbage.html</a> <a href="#user-content-fnref-nix-collect-garbage" data-footnote-backref="" aria-label="Back to reference 47" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-other-useful-tips">
<p>Yin R. Other Useful Tips [Internet]. NixOS &amp; Flakes Book. 2024 [cited 2025 Jan 3]. Available from: <a href="https://nixos-and-flakes.thiscute.world/nixos-with-flakes/other-useful-tips#managing-the-configuration-with-git">https://nixos-and-flakes.thiscute.world/nixos-with-flakes/other-useful-tips#managing-the-configuration-with-git</a> <a href="#user-content-fnref-other-useful-tips" data-footnote-backref="" aria-label="Back to reference 48" class="data-footnote-backref">↩</a></p>
</li>
</ol>
</section>]]></content>
  </entry>
  <entry>
    <title>How I Broke (my own) Production &amp; How I Fixed it 4 Hours Later</title>
    <link rel="alternate" href="https://laniakita.com/blog/fixing-my-ci-an-adventure-with-turborepo-sst-and-bun"/>
    <id>https://laniakita.com/blog/fixing-my-ci-an-adventure-with-turborepo-sst-and-bun</id>
    <updated>2025-03-05T07:25:49.000Z</updated>
    <category term="/categories/meta" scheme="https://laniakita.com/categories/meta" label="Meta"/>
    <category term="/tags/turborepo" scheme="https://laniakita.com/tags/turborepo" label="Turborepo"/>
    <category term="/tags/sst" scheme="https://laniakita.com/tags/sst" label="SST"/>
    <category term="/tags/bun" scheme="https://laniakita.com/tags/bun" label="Bun"/>
    <content type="html"><![CDATA[<figure><img src="https://laniakita.com/images/non-oc/artem-horovenko-pipes.jpg" alt="Source: Artem Horovenko (via Unsplash). On the left side of the image, there are two adjacent pairs of large blue tubes, aligned vertically, and running from the left to the center of the image, featuring a convex-fairing, and a grate over their respective openings. In the background we can see more large blue tubes, curving left and right, with steel cages and catwalks adjacent to them. On the right side of the image, there are six large green tubes, placed vertically, and spaced tightly together, running from top to bottom of the image." /><figcaption>The internet is not something that you just dump something on. It's not a big truck. It's a series of tubes. - Sen. Ted Stevens (R-Alaska).</figcaption></figure> <p>Since I finally managed to break the CI/CD pipeline for this site, I&#x27;ve been thinking a lot about that infamous quote from the Senator for Alaska. Not for its accuracy (or lack thereof), but because in this one instance, I did, in fact, break (or jam) a <em>critical tube</em> that gets this site out to the internet. Though, I should admit that&#x27;s not what took this site offline for 4 hours (we&#x27;ll get to that part soon).</p>
<h2 id="the-situation">The Situation</h2>
<p>At around 0600 Zulu time on February 21st, I rebased this site&#x27;s production branch with main, and pushed two of my latest commits. One updated my <code>flake.nix</code> inputs (updated <code>flake.lock</code>), the other was a simple fix to the cards on the landing page. About 30 minutes later I received the following email.</p>
<p><img src="https://laniakita.com/images/oc/2025/03/production-action-failed-timeout-email.png" alt="Screenshot of a GitHub Action notification email on the status of the Production Build Action workflow run, that informed me that all jobs have failed. The info box states: laniakita is building for production / SST-Deploy-Production Failed in 30 minutes and 37 seconds" width="557" height="499"/></p>
<p>What followed was what I can only describe as <em>an adventure</em>.</p>

<p>That <em>skeet</em> now serves as both a historical record of what happened that Thursday night, and was the inspiration behind this very blog post.</p>
<h2 id="a-preliminary-investigation">A Preliminary Investigation</h2>
<p>Debugging is an <em>Art</em>. While I&#x27;m not the world&#x27;s greatest detective, I am pretty good at sniffing out the cause of the issue, and assembling a solution thereafter. So, let&#x27;s go through what I did.</p>
<h3 id="poi-01-timing-out--freezing">POI #01: Timing Out &amp; Freezing</h3>
<p>The first thing I did was look into the <a href="https://github.com/laniakita/website/actions/runs/13448074321/job/37577491756">workflow logs</a>, where I discovered our first Point of Interest (POI), the timeout error.</p>
<p><img src="https://laniakita.com/images/oc/2025/03/production-deploy-logs-41.png" alt="Screenshot of Production Build Action #41 at the Deploy step (point of failure). At line 1 collapses the next 11 lines into a single Run bun sst deploy --stage=production --verbose. Lines 12 - 35 showcase normal behavior for SST + Turborepo so far. Line 36 states `Error: The action &#x27;Deploy&#x27; has timed out after 30 minutes." width="873" height="644"/></p>
<p>Given my experience with the beta version of SSTv3 (<code>Ion</code>), an occasional timeout on <code>sst deploy</code> wasn&#x27;t entirely unexpected. It&#x27;s why I set <code>timeout-minutes: 30</code> on the <em>Deploy</em> step in the first place. So, I decided to give it another go.</p>
<p><img src="https://laniakita.com/images/oc/2025/03/prod-build-log-41-attmpt-2.png" alt="Screenshot of Production Build #41 attempt 02, showing a locked sst state error, failing the Deploy step" width="975" height="281"/></p>
<p>This gave a <em>Locked state error</em>, since the previous deploy attempt didn&#x27;t get to <em>gracefully</em> cancel deployment to AWS. So, following the error message&#x27;s guidance, I ran <code>sst unlock</code> from my terminal and tried again.</p>
<p><img src="https://laniakita.com/images/oc/2025/03/prod-build-log-41-attempt-3.png" alt="Screenshot of Production Build #41 attempt 03, getting stuck building @website/web." width="987" height="234"/></p>
<p><em>Huh.</em> I was audibly stunned. Watching this attempt go by, I was confounded why it was stuck at the same place as the first attempt. At about a minute and 23 seconds in, I pulled the plug.</p>
<p>While that might seem pre-emptive, compared to a <em>successful</em> production run up to this point, the process would be about half way done by now, so freezing up at line 39 was incredibly odd.</p>
<h3 id="troubleshooters-step-01-unplug-it-and-plug-it-back-in">Troubleshooter&#x27;s Step 01: Unplug it and Plug it Back in</h3>
<p>I was feeling puzzled, until I was suddenly struck with <em>genius</em>. I would simply perform the equivalent of <em>unplugging and plugging it back in again</em> via running <code>sst remove</code> and <code>sst deploy</code>, <em>magically</em> solving my problems! <strong><em>What could go wrong?</em></strong></p>
<p><img src="https://laniakita.com/images/oc/2025/03/prod-build-log-41-hang.png" alt="Screenshot of Production Build #41 attempt 04, showing the attempt at lines 51-53 to build @website/web, and the inevitable hang at lines 74 where it finished creating the MonitorMetricsSchedule scheduler" width="978" height="510"/></p>
<p>While I was grateful that <em>attempt 04</em> displayed some signs of life, at about 4 minutes into the Deploy however, it was clear something had <em>gone wrong</em>. So, I pulled the plug on this attempt too.</p>
<p>To make matters worse, because I did run <code>sst remove --stage=production</code>, my <strong>production</strong> site was now ripped from the internet. While this would be fine if I had either a backup to divert traffic to, or realized I could&#x27;ve just re-run the last passing production Build Action, I unfortunately did not. This was cause for some <em>light</em> panic.</p>
<details open=""><summary>Hubris: All in on Production</summary><p>If you notice the <a href="https://github.com/laniakita/website">repo</a> for this site today, there&#x27;s now two workflows I use: one for <em>dev</em> and one for <em>production</em>. However, it wasn&#x27;t like that before this happened.</p><p>Why? Well, one part of it was stinginess, another was avoiding the work to add a dev build banner into the nav when the URL !== productionUrl. The rest of it though? That was arrogance. My hubris.</p><p>However, that&#x27;s not to say I didn&#x27;t have a <em>dev</em> deployment stage. I setup a <em>dev</em> AWS account specifically to live test <em>radical</em> changes of this site, before I rebased them into production.</p><p>At the time, I figured that was good-enough, and a dev CI/CD would be something <em>nice to have</em>. I suppose I didn&#x27;t feel strongly enough then to follow through with a proper CI/CD to handle <em>main</em> for this little blog/portfolio thing. That was a foolish error on my part.</p></details>
<h3 id="troubleshooters-step-02-update-deps">Troubleshooter&#x27;s Step 02: Update Deps</h3>
<p>As I was reeling from terror, I decided to try an emergency dependencies update of Node.js and Turbo. I didn&#x27;t have any evidence that they weren&#x27;t the cause of the problems, but a cheap hail-mary attempt seemed worth a shot.</p>
<p><img src="https://laniakita.com/images/oc/2025/03/prod-build-log-42-hang.png" alt="Screenshot of Production Build #42, showing the same hang at lines 74-75 where it finishes creating the MonitorMetricsSchedule scheduler before getting stuck" width="982" height="216"/></p>
<p><em>No dice.</em> As a follow-up, I wondered if re-creating <code>bun.lock</code> might help, so I tried that too.</p>
<p><img src="https://laniakita.com/images/oc/2025/03/prod-build-log-43.png" alt="Screenshot of Production Build #43, stuck in the same place" width="984" height="93"/></p>
<p>As you can imagine, that didn&#x27;t work either.</p>
<h3 id="troubleshooters-step-03-pin-deps">Troubleshooter&#x27;s step 03: Pin Deps</h3>
<p>If updating dependencies wasn&#x27;t working, perhaps pinning them to an older version might help. I decided <code>sst</code> might be playing a role here, so I rolled it back, and ran the CI again.</p>
<p><img src="https://laniakita.com/images/oc/2025/03/prod-build-log-44-oops.png" alt="Screenshot of Production Build #44, showing a locked sst state error, (again) failing the Deploy step" width="982" height="73"/></p>
<p><em>Oops</em>. I correct the <em>Lock</em> once more, and sent through my final <em>hail mary</em> attempt for the evening.</p>
<p><img src="https://laniakita.com/images/oc/2025/03/prod-build-log-45-sigh.png" alt="Screenshot of Production Build #45, hanging once more at line 73-75, with the canceled message below it." width="979" height="87"/></p>
<p><em>Sigh</em>. I let out a pained sigh. <em>This is gonna require <strong>thinking</strong>, isn&#x27;t it?</em>, I whined to myself. So, I took a break for a couple of hours, and contemplated.</p>
<h2 id="approaching-a-solution">Approaching a Solution</h2>
<p>After my basic troubleshooting session brought me to somewhere worse than square one (no website), I started mulling over what I learned.</p>
<p>What I found peculiar, was that consecutive runs would hang around the same place. For whatever reason, attempting to build <code>@website/web</code> (this site), would lockup the Deploy step either right away (if the other functions don&#x27;t exist yet), or 75 lines in if my metrics monitoring functions need to be created. Either way, this pointed to something going wrong with <em>how</em> <code>@website/web</code> was being built.</p>
<h3 id="new-pois">New POIs</h3>
<p>This realization, narrowed things down to a few potential causes:</p>
<ul>
<li><code>@website/web</code> is being built, but something is failing to report that step finished (<code>sigterm</code>), freezing the Deploy step as a result.</li>
<li><code>@website/web</code> is failing to build in the CI runner for <em>reasons</em>.
<ul>
<li>Perhaps the <code>sst</code> CLI has a bug in reading the flag syntax?</li>
<li>Perhaps Ubuntu/Debian is being difficult with SST again (I think this was an issue during the beta).</li>
</ul>
</li>
<li>The Turborepo tasks for <code>@website/web</code> aren&#x27;t being executed properly.
<ul>
<li>A task that occurs before <code>build</code>, like <code>test</code>, might be <em>failing</em> to send a <code>sigterm</code>, or <code>bun</code>/<code>sst</code>/<code>turbo</code> is failing to pick it up.</li>
<li>In any event, the <code>build</code> task for <code>@website/web</code> gets stuck waiting to be executed, causing the hang.</li>
</ul>
</li>
</ul>
<p>So, I decided to go down this list from <em>ez</em> to <em>difficult</em> in trying to solve the problem.</p>
<h3 id="too-ez-check-your-syntax">Too EZ: Check your syntax</h3>
<p>While I can&#x27;t find the relevant issue now, I do remember coming across a closed issue in the <a href="https://github.com/sst/sst">sst</a> repo, detailing that running a command like <code>sst COMMAND --FLAG=VAR</code> was causing issues but <code>sst COMMAND --FLAG VAR</code> wasn&#x27;t. I believe I tried to run this locally, but that didn&#x27;t fix it, so I moved on.</p>
<h3 id="redefining-turborepos-tasks">Redefining Turborepo&#x27;s Tasks</h3>
<p>Armed with the possibility the <em>tasks</em> weren&#x27;t executing properly, I decided to remove the <code>prebuild</code> task as dependency of <code>test</code> from the <code>turbo.json</code> in both <code>@website/web</code> and <code>@website/showcase</code>. I also made <code>build</code> depend on other build tasks first (suggested by the <a href="https://turbo.build/repo/docs/crafting-your-repository/configuring-tasks#running-tasks-in-the-right-order">turborepo docs</a>)</p>
<pre><code class="language-diff">    &quot;test&quot;: {
-      &quot;dependsOn&quot;: [&quot;prebuild&quot;]
+      &quot;dependsOn&quot;: []
    },
    &quot;build&quot;: {
-      &quot;dependsOn&quot;: [&quot;test&quot;],
+      &quot;dependsOn&quot;: [&quot;test&quot;, &quot;^build&quot;],
</code></pre>
<p>I also changed the <code>buildCommand</code> (for both <code>web</code> and <code>showcase</code>) in my <code>sst.aws.Nextjs</code> function to specify bun specifically.</p>
<pre><code class="language-diff">
export const web = new sst.aws.Nextjs(&quot;Web&quot;, {
  path: &quot;apps/web&quot;,
-  buildCommand: &quot;turbo build:open-next&quot;,
+  buildCommand: &quot;bun run turbo build:open-next;&quot;,
  server: {
    runtime: &quot;nodejs22.x&quot;
  },

</code></pre>
<p>Then (locally) I held my breath and ran <code>sst deploy --stage production --verbose</code>. <em>Thank fuck!</em> I was overwhelmed with relief, when I saw things had gone smoothly, and everything was back online.</p>
<p>For a bit I even contemplated calling it here, but I knew that deploying locally and deploying in the CI were two very different things. However, having tasted this victory, I was confident I could coax a second from the CI runner.</p>
<p>So, with a foolish grin, I pulled the website down with <code>sst remove</code>, because I knew an even greater victory awaited me in the CI runner.</p>
<h2 id="overcoming-the-ci-runner">Overcoming the CI runner</h2>
<p>Since everything worked locally, I figured either it would <em>just work</em> in the CI runner, or if it didn&#x27;t, I could just further reduce the barriers to the build step like I did earlier. So, I rebased <em>main</em> onto <em>production</em> and pushed things through.</p>
<p><img src="https://laniakita.com/images/oc/2025/03/prod-build-log-46.png" alt="Screenshot of production build #46. Showing a very familiar problem (hanging)." width="984" height="763"/></p>
<p>Unsurprisingly, things did not <em>just work</em>. However, I did have an inkling of what I was doing this time around, so I got to work.</p>
<h3 id="experiment-01-isolating-the-tasks">Experiment 01: Isolating the Tasks</h3>
<p>From earlier, I altered the build commands back to what they were, in case that was causing a problem.</p>
<pre><code class="language-diff">
export const web = new sst.aws.Nextjs(&quot;Web&quot;, {
  path: &quot;apps/web&quot;,
-  buildCommand: &quot;bun run turbo build:open-next;&quot;,
+  buildCommand: &quot;turbo build:open-next&quot;,
  server: {
    runtime: &quot;nodejs22.x&quot;
  },

</code></pre>
<p>Then I altered the tasks array somewhat back to what it was (which failed locally, but this is science now). I set <code>prebuild</code> to depend on nothing, and made <code>test</code> depend on <code>prebuild</code> (as it was).</p>
<pre><code class="language-diff">  &quot;extends&quot;: [&quot;//&quot;],
  &quot;tasks&quot;: {
    &quot;prebuild&quot;: {
+      &quot;dependsOn&quot;: [],
      &quot;inputs&quot;: [&quot;$TURBO_DEFAULT$&quot;, &quot;content/**&quot;],
      &quot;outputs&quot;: [&quot;.contentlayer&quot;, &quot;.contentlayermini&quot;, &quot;.versionvault&quot;]
    },
    &quot;test&quot;: {
-      &quot;dependsOn&quot;: []
+      &quot;dependsOn&quot;: [&quot;prebuild&quot;]
    },
    &quot;build&quot;: {
      &quot;dependsOn&quot;: [&quot;test&quot;, &quot;^build&quot;],
</code></pre>
<p>This gave an interesting result.</p>
<p><img src="https://laniakita.com/images/oc/2025/03/prod-build-log-47.png" alt="Screenshot showing prebuild and test tasks completed, but getting stuck on MonitorMetricsSchedule again" width="977" height="917"/></p>
<p>While I wasn&#x27;t <em>enthusiastic</em> about what I saw, it was an interesting result. In hindsight, this might&#x27;ve worked, I think I just got nervous around the two-minute mark and assumed the worse. Still, I pressed on.</p>
<h3 id="experiment-02-simplifying-the-task-dependencies">Experiment 02: Simplifying the Task Dependencies</h3>
<p>I decided to do two things. I continued to tweak the build command, and then I did what I believed was the solution (simplifying the task deps) to start at the build task immediately.</p>
<pre><code class="language-diff">export const web = new sst.aws.Nextjs(&quot;Web&quot;, {
  path: &quot;apps/web&quot;,
-  buildCommand: &quot;turbo build:open-next&quot;,
+  buildCommand: &quot;turbo build:open-next;&quot;,
  server: {
    runtime: &quot;nodejs22.x&quot;
  }
</code></pre>
<pre><code class="language-diff">    &quot;test&quot;: {
-      &quot;dependsOn&quot;: [&quot;prebuild&quot;]
+      &quot;dependsOn&quot;: []
    },
    &quot;build&quot;: {
-      &quot;dependsOn&quot;: [&quot;test&quot;, &quot;^build&quot;],
+      &quot;dependsOn&quot;: [&quot;^build&quot;],
      &quot;outputs&quot;: [&quot;.next/**&quot;, &quot;public/dist/**&quot;, &quot;public/sw.js&quot;],
      &quot;inputs&quot;: [
        &quot;$TURBO_DEFAULT$&quot;,
</code></pre>
<p>This gave me the result I was working so hard towards.</p>
<p><img src="https://laniakita.com/images/oc/2025/03/prod-build-log-48-success.png" alt="Screenshot of production build #48, showcasing a successful run with all job steps complete." width="1047" height="613"/></p>
<p><em>I did it! I did the thing!</em> I was ecstatic for the rest of the evening, basking in victory. It&#x27;s just unfortunate this did not last into the next day when I setup the <em>dev</em> workflow. My attempt at integrating the lesson I had just learned.</p>
<h2 id="revenge-of-the-ci-runner">Revenge of the CI Runner</h2>
<p>In setting up the <em>dev</em> Action Workflow (about two weeks ago at the time of writing), I hit a painfully familiar snag—after hitting a few <em>unrelated</em> snags, of course.</p>
<p><img src="https://laniakita.com/images/oc/2025/03/a-new-adventure.png" alt="Screenshot of the process it took to get the dev action working." width="713" height="850"/></p>
<p>Let me preface with: this <em>adventure</em> wasn&#x27;t as bad as it looks. In order:</p>
<ul>
<li>The first five build attempts failed due to a misconfiguration of an AWS role.</li>
<li>The next two failed due to my <em>handle failed deploy</em> job on failure, failed to unlock the state (ironic).</li>
<li>The eighth run I canceled due to seeing a very familiar deploy printout</li>
<li>Runs nine and ten timed-out (just like before)</li>
<li>Run eleven (Dev Build Action #5 attempt 2) failed due to state being locked again</li>
<li>Run twelve (Dev Build Action #6) I canceled after it seemed like it was going to just hang.</li>
<li>Run thirteen (what a lucky number) succeeded as Dev Build Action #7.</li>
</ul>
<p>While I am going to explain what I changed, to make run thirteen a success, I do need to preface that my <em>changes</em> are apparently no longer necessary given the success of the most recent build action (#19). I&#x27;ll explain that towards the end.</p>
<h3 id="nuclear-option-sst-buildcommand-set-to-exit-0">Nuclear Option: SST buildCommand set to &quot;exit 0;&quot;</h3>
<p>After banging my head against the keyboard in frustration, I decided upon the <em>nuclear option</em>. Instead of letting <code>sst deploy</code> run the build command, it&#x27;s possible to just let <code>turbo</code> do it instead. This is very similar to the <a href="https://opennext.js.org/aws/config/nx">Nx Monorepo Configuration</a> guide in the <a href="https://opennext.js.org/">OpenNext</a> Docs.</p>
<p>In my root <code>turbo.json</code> I created a <em>deploy</em> task that depends on build.</p>
<pre><code class="language-diff">{
  &quot;$schema&quot;: &quot;https://turbo.build/schema.json&quot;,
  &quot;tasks&quot;: {
    &quot;build&quot;: {
      &quot;dependsOn&quot;: [&quot;^build&quot;]
    },
+    &quot;deploy&quot;: {
+      &quot;dependsOn&quot;: [&quot;build&quot;]
+    },
  }
}
</code></pre>
<p>In my root <code>package.json</code> I added <em>deploy</em> and <em>deploy:dev</em> to the scripts object.</p>
<pre><code class="language-diff">{
  &quot;scripts&quot;: {
    &quot;build&quot;: &quot;turbo run build&quot;,
    &quot;dev&quot;: &quot;turbo run dev&quot;,
    &quot;lint&quot;: &quot;turbo run lint&quot;,
    &quot;test&quot;: &quot;turbo run test&quot;,
    &quot;test:watch&quot;: &quot;turbo run test:watch&quot;,
+    &quot;deploy&quot;: &quot;turbo run deploy&quot;,
+    &quot;deploy:dev&quot;: &quot;turbo run deploy &amp;&amp; sst deploy --stage=dev --verbose&quot;
  },
}
</code></pre>
<p>In my <code>sst.aws.Nextjs</code> function I set the <code>buildCommand</code> to <code>exit 0;</code> (thus SST won&#x27;t handle the build process).</p>
<pre><code class="language-diff">export const web = new sst.aws.Nextjs(&quot;Web&quot;, {
  path: &quot;apps/web&quot;,
-  buildCommand: &quot;bun run build:open-next&quot;,
+  buildCommand: &quot;exit 0;&quot;,
</code></pre>
<p>Finally In the nested <code>turbo.json</code> of each application (<code>@website/web</code>, <code>@website/showcase</code>), I created a task called <em>deploy</em>, and set it dependent on the <em>build:open-next</em> task.</p>
<pre><code class="language-diff">{
  &quot;extends&quot;: [&quot;//&quot;],
  &quot;tasks&quot;: {
    &quot;build&quot;: {
      outputs&quot;: [&quot;.next/**&quot;, &quot;public/dist/**&quot;, &quot;public/sw.js&quot;],
      &quot;inputs&quot;: [&quot;$TURBO_DEFAULT$&quot;, &quot;next.config.*&quot;]
    },
    &quot;build:open-next&quot;: {
      &quot;dependsOn&quot;: [&quot;build&quot;],
      &quot;env&quot;: [&quot;OPEN_NEXT_VERSION&quot;, &quot;NEXT_PUBLIC_DEPLOYED_URL&quot;],
      &quot;outputs&quot;: [&quot;.open-next/**&quot;],
      &quot;inputs&quot;: [&quot;$TURBO_DEFAULT$&quot;, &quot;.open-next.config.ts&quot;]
    },
+    &quot;deploy&quot;: {
+      &quot;dependsOn&quot;: [&quot;build:open-next&quot;]
+    }
    }
  }
</code></pre>
<p>I also altered the <em>Dev</em> workflow to run <code>bun run deploy:dev</code> (the script in the root <code>package.json</code>) instead of <code>sst</code> directly.</p>
<pre><code class="language-diff">      # ROTFB
      - name: Deploy
-        run: bun sst deploy --stage dev --verbose
+        run: bun run deploy:dev
        timeout-minutes: 30
</code></pre>
<p>This worked perfectly.</p>
<p><img src="https://laniakita.com/images/oc/2025/03/dev-build-log-7-perfect.png" alt="Screenshot showing a perfect run from Dev Build Action #7. Every step has a check mark, finishing in just under three minutes." width="766" height="728"/></p>
<p>This worked <em>so perfectly</em>, I&#x27;ve had consistent deployments with it no problem over the last week and a half or so. There is one caveat, which leads us to our next section.</p>
<h4 id="dude-wheres-my-variables">Dude, where&#x27;s my variables?</h4>
<p>Bypassing SST&#x27;s <code>buildCommand</code>, like I did, <em>the rub</em> is that you lose out on having resources passed down to the application&#x27;s build environment. While the resources part can be solved with wrapping <code>turbo deploy</code> with <code>sst shell</code> i.e. <code>sst shell bun run turbo deploy</code>, that apparently does nothing for the environmental variables that you can define in a config.</p>
<pre><code class="language-ts">export const web = new sst.aws.Nextjs(&#x27;Web&#x27;, {
  path: &#x27;apps/web&#x27;,
  buildCommand: &#x27;exit 0;&#x27;,
  server: {
    runtime: &#x27;nodejs22.x&#x27;,
  },
  environment: {
    NEXT_PUBLIC_DEPLOYED_URL:
      $app.stage === &#x27;production&#x27; ? &#x27;https://laniakita.com&#x27; : `https://${$app.stage}.laniakita.com`,
  },
  domain: {
    name: $app.stage === &#x27;production&#x27; ? &#x27;laniakita.com&#x27; : `${$app.stage}.laniakita.com`,
    dns: sst.cloudflare.dns(),
  },
});
</code></pre>
<p>So, in the example above, a variable defined between lines 7-12 just doesn&#x27;t get passed down. While I&#x27;ve thought about some clever workarounds importing specific <code>.env</code> files based on a task, I decided to try something.</p>
<h2 id="going-full-circle">Going Full Circle</h2>
<p>Last night, I was brought <em>full circle</em>, when I tested the waters on returning to what I was doing weeks ago, before all these <em>shenanigans</em> took place. I used the <code>buildCommand</code>.</p>
<pre><code class="language-diff">export const web = new sst.aws.Nextjs(&quot;Web&quot;, {
  path: &quot;apps/web&quot;,
-  buildCommand: &quot;exit 0;&quot;,
+  buildCommand: &quot;turbo run build:open-next&quot;
</code></pre>
<p>Likewise, I edited the scripts in the root <code>package.json</code> once more.</p>
<pre><code class="language-diff">{
  &quot;scripts&quot;: {
    &quot;build&quot;: &quot;turbo run build&quot;,
    &quot;dev&quot;: &quot;turbo run dev&quot;,
    &quot;lint&quot;: &quot;turbo run lint&quot;,
    &quot;test&quot;: &quot;turbo run test&quot;,
    &quot;test:watch&quot;: &quot;turbo run test:watch&quot;,
    &quot;deploy&quot;: &quot;turbo run deploy&quot;,
-   &quot;deploy:dev&quot;: &quot;turbo run deploy &amp;&amp; sst deploy --stage=dev --verbose&quot;
-   &quot;deploy:production&quot;: &quot;turbo run deploy &amp;&amp; sst deploy --stage=production --verbose&quot;
+   &quot;deploy:dev&quot;: &quot;sst deploy --stage=dev --verbose&quot;,
+   &quot;deploy:production&quot;: &quot;sst deploy --stage=production --verbose&quot;

  },
}
</code></pre>
<p>I pushed everything from my local terminal, and was surprised to see a little green check mark against my hesitant commit message.</p>
<p><img src="https://laniakita.com/images/oc/2025/03/a-perfect-test.png" alt="Screenshot of my commit into main with the message: refactor: test if sst deploy command hangs in CI. A green check mark appears below it with a 1/1 informing me that it completed the workflow successfully" width="392" height="68"/></p>
<p>I then sent several more changes through, to test whether that was a fluke. It was not.</p>
<p><img src="https://laniakita.com/images/oc/2025/03/not-a-fluke.png" alt="Screenshot showing my latest commits all deployed perfectly, no hangups." width="688" height="382"/></p>
<p>Given everything <em>just works</em>, like it should. Like it did before, <strong>for months</strong>, leading up to this. I&#x27;ll admit to feeling a <em>little</em> vexed that evening, but more so just relieved.</p>
<h2 id="discussion">Discussion</h2>
<blockquote>
<p>This was an adventure. This was too much adventure. I want off Mr. Bone&#x27;s Wild Adventure.</p>
</blockquote>
<p><img src="https://laniakita.com/images/non-oc/mr-bones-ride-never-ends.jpg" alt="Screenshot of Mr. Bone&#x27;s Wild Ride, created by an anonymous Roller Coaster Tycoon 2 player. The front left side of the image features a dark gray sign with the phrase THE RIDE NEVER ENDS in red lettering, placed into a square of green grass, perpendicular to the roller coaster tracks to the left of it. On the right side of the image, a giant skeleton holding a large top hat, statically performs a tipping gesture to incoming riders." width="367" height="306"/></p>
<p>If the internet is like a series of tubes, then the <em>wild ride</em> I just endured can be blamed on a jammed tube, a criss-crossed tube, and a return tube.</p>
<p>In other words, this website failed to deploy to production, a <em>jam</em> in the CI tube, which culminated in the site going offline. Making some changes to how the site is processed through <em>the tube</em>, let it go through. Yet, those same changes weren&#x27;t sufficient for a <em>different tube</em>, and it <em>jammed</em> again. A <em>criss-crossing</em> if you will from failure-to-success-to-failure-to-success. Then, everything fell down the <em>return tube</em> (for reasons), and we went full circle with the website being able to go through the <em>dev tube</em> with the original build instructions.</p>
<p>Metaphorical tubes aside, I had originally intended this article to be somewhat instructional. I had <em>assumed</em> that this was clearly a configuration problem on my part, because a <em>poor craftsman blames their tools</em>. So, I wanted to <em>figure it out</em>, write it down, and paste my opinionated configuration guide to the internet for anyone else looking to configure Turborepo, Bun, Next.js and SST with a Github Action to handle deployments.</p>
<p>However, given my <em>experiment</em> last night, which demonstrated sequential, successful deployments were possible using the <strong>original, wrapped build commands</strong>... I&#x27;ve been given some pause by this whole thing, I&#x27;ll admit.</p>
<p>In the end, I&#x27;m left only more confused, than when I began.</p>]]></content>
  </entry>
  <entry>
    <title>How I Built a CMS, Using TypeScript, Bun, Drizzle, &amp; MDX (complete version)</title>
    <link rel="alternate" href="https://laniakita.com/blog/how-i-built-my-own-cms-complete"/>
    <id>https://laniakita.com/blog/how-i-built-my-own-cms-complete</id>
    <updated>2024-10-17T02:33:45.000Z</updated>
    <category term="/categories/meta" scheme="https://laniakita.com/categories/meta" label="Meta"/>
    <category term="/categories/backend" scheme="https://laniakita.com/categories/backend" label="Backend"/>
    <category term="/categories/technical-summary" scheme="https://laniakita.com/categories/technical-summary" label="Technical Summary"/>
    <category term="/tags/drizzle-orm" scheme="https://laniakita.com/tags/drizzle-orm" label="Drizzle ORM"/>
    <content type="html"><![CDATA[<figure><img src="https://laniakita.com/images/non-oc/a-custom-cms.jpg" alt="A parody, that features a main character, with a bun for a head and a blindfold featuring MDX across it. A blue box with TS on it, a black box with some rain drops, and a green moose appear to be background characters. A character with a W for a head, representing a standard CMS, is eating popcorn in the corner." /><figcaption>Your scientists were so preoccupied with whether they could, they didn't stop to think if they should - Ian Malcom</figcaption></figure> <p>This wasn&#x27;t the post I planned to write. Initially, I thought this post was going to be dedicated exclusively to my bespoke content management system. Where, I thought I’d wax poetic for a while about how <em>sometimes the best solutions, are tailor made.</em> However, I’ve since come back to my senses to realize that <strong><em>the best solution is the one with the smallest number of compromises</em></strong>, hand crafted be damned.</p>
<p>So, that’s where <code>contentlayer2</code><sup><a href="#user-content-fn-1" id="user-content-fnref-1" data-footnote-ref="true" aria-describedby="footnote-label">1</a></sup> (maintained fork of <code>contentlayer</code>) came in to the equation. it&#x27;s a library I stumbled upon that did exactly what I’d come up with, but in a better, more robust, and much more practical manner. In short, it&#x27;s <em>beautiful</em>.</p>
<p>In honesty, I was awestruck by contentlayer’s pitch video<sup><a href="#user-content-fn-2" id="user-content-fnref-2" data-footnote-ref="true" aria-describedby="footnote-label">2</a></sup>. Especially so, because I’d just gone through the whole process of pouring blood, sweat, and tears into my own content management system for this site. It really wasn&#x27;t long after that, that I swapped my whole custom solution out for it.</p>
<p>But, swallowed pride aside, creating my own content backend was quite a learning experience for me, so I wanted to talk about it. As such, I present to you my technical summary on my custom solution.</p>
<h2 id="introduction">Introduction</h2>
<p>I started my life in web working with traditional CMSes like WordPress<sup><a href="#user-content-fn-3" id="user-content-fnref-3" data-footnote-ref="true" aria-describedby="footnote-label">3</a></sup> and then briefly, Ghost<sup><a href="#user-content-fn-4" id="user-content-fnref-4" data-footnote-ref="true" aria-describedby="footnote-label">4</a></sup>. Later, I took the JAMstack<sup><a href="#user-content-fn-5" id="user-content-fnref-5" data-footnote-ref="true" aria-describedby="footnote-label">5</a></sup> hype train as far as it could go, fetching data from headless CMSes<sup><a href="#user-content-fn-6" id="user-content-fnref-6" data-footnote-ref="true" aria-describedby="footnote-label">6</a></sup> like Strapi<sup><a href="#user-content-fn-7" id="user-content-fnref-7" data-footnote-ref="true" aria-describedby="footnote-label">7</a></sup> and Contentful<sup><a href="#user-content-fn-8" id="user-content-fnref-8" data-footnote-ref="true" aria-describedby="footnote-label">8</a></sup>, and integrating it into Gatsby<sup><a href="#user-content-fn-9" id="user-content-fnref-9" data-footnote-ref="true" aria-describedby="footnote-label">9</a></sup> frontends. Then, sometime before building this site, I caught myself up on the latest headless CMSes people have been working with. I experimented with Payload CMS<sup><a href="#user-content-fn-10" id="user-content-fnref-10" data-footnote-ref="true" aria-describedby="footnote-label">10</a></sup> and Directus<sup><a href="#user-content-fn-11" id="user-content-fnref-11" data-footnote-ref="true" aria-describedby="footnote-label">11</a></sup>, before diving into off-the-shelf backends like Supabase<sup><a href="#user-content-fn-12" id="user-content-fnref-12" data-footnote-ref="true" aria-describedby="footnote-label">12</a></sup>, and PocketBase<sup><a href="#user-content-fn-13" id="user-content-fnref-13" data-footnote-ref="true" aria-describedby="footnote-label">13</a></sup> (my personal favorite).</p>
<p>However, something about all the options I weighed kept disappointing me in one way or another. So, after a day of feeling the frustration of yet another imperfect solution, I found myself one late evening going <em>&quot;Yeah, well, I&#x27;ll build my own CMS, with Blackjack, and Hookers! In fact, forget the CMS&quot;</em><sup><a href="#user-content-fn-14" id="user-content-fnref-14" data-footnote-ref="true" aria-describedby="footnote-label">14</a></sup>. So, that&#x27;s what I did.</p>
<div class="relative w-full overflow-hidden pt-[75%]"><iframe src="https://www.youtube-nocookie.com/embed/ubPWaDWcOLU" class="absolute inset-0 size-full" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerPolicy="strict-origin-when-cross-origin" allowFullScreen=""></iframe></div>
<p>My idea was pretty straightforward. Instead of a traditional user-friendly CMS, I based it on Astro&#x27;s<sup><a href="#user-content-fn-15" id="user-content-fnref-15" data-footnote-ref="true" aria-describedby="footnote-label">15</a></sup> model of content management (Content Collections<sup><a href="#user-content-fn-16" id="user-content-fnref-16" data-footnote-ref="true" aria-describedby="footnote-label">16</a></sup>) —leveraging <code>markdown</code><sup><a href="#user-content-fn-17" id="user-content-fnref-17" data-footnote-ref="true" aria-describedby="footnote-label">17</a></sup>, <code>yaml</code><sup><a href="#user-content-fn-18" id="user-content-fnref-18" data-footnote-ref="true" aria-describedby="footnote-label">18</a></sup>, and <code>jsx</code><sup><a href="#user-content-fn-19" id="user-content-fnref-19" data-footnote-ref="true" aria-describedby="footnote-label">19</a></sup> in <code>.mdx</code><sup><a href="#user-content-fn-20" id="user-content-fnref-20" data-footnote-ref="true" aria-describedby="footnote-label">20</a></sup> files to store and organize content—with my own twist, a database I can cache all that data i want into. The latter meant I could fetch and generate content live in production, in taking advantage of Next.js’ SSR features<sup><a href="#user-content-fn-21" id="user-content-fnref-21" data-footnote-ref="true" aria-describedby="footnote-label">21</a></sup>.</p>
<p>As such, the following was the strategy I&#x27;d come up with as I poured over documentation<sup><a href="#user-content-fn-22" id="user-content-fnref-22" data-footnote-ref="true" aria-describedby="footnote-label">22</a></sup> and the various Next.js markdown blog examples<sup><a href="#user-content-fn-23" id="user-content-fnref-23" data-footnote-ref="true" aria-describedby="footnote-label">23</a></sup><sup>, </sup><sup><a href="#user-content-fn-24" id="user-content-fnref-24" data-footnote-ref="true" aria-describedby="footnote-label">24</a></sup> Vercel<sup><a href="#user-content-fn-25" id="user-content-fnref-25" data-footnote-ref="true" aria-describedby="footnote-label">25</a></sup> has available:</p>
<ol>
<li>Scan the posts.</li>
<li>Process the data.</li>
<li>Store data into a SQLite database</li>
<li>Fetch data from the database.</li>
<li>Render the data.</li>
</ol>
<p>Sounds easy enough, right? Well, sorta. The largest hurdle to overcome was in the processing phase, when I decided simply fetching the featured Image <code>src</code><sup><a href="#user-content-fn-26" id="user-content-fnref-26" data-footnote-ref="true" aria-describedby="footnote-label">26</a></sup> wasn’t good enough. That&#x27;s because I wanted <code>srcset</code><sup><a href="#user-content-fn-27" id="user-content-fnref-27" data-footnote-ref="true" aria-describedby="footnote-label">27</a></sup> blurs and that’s when things got complicated. A smaller hurdle, was realizing I wasn’t fetching the image transformations properly. Investigating why is how I wound up learning about Next’s <code>loaderFile</code><sup><a href="#user-content-fn-28" id="user-content-fnref-28" data-footnote-ref="true" aria-describedby="footnote-label">28</a></sup> configuration option, but I digress as that’s somewhat specific to the fact I use a CDN<sup><a href="#user-content-fn-29" id="user-content-fnref-29" data-footnote-ref="true" aria-describedby="footnote-label">29</a></sup> (i.e., AWS CloudFront<sup><a href="#user-content-fn-30" id="user-content-fnref-30" data-footnote-ref="true" aria-describedby="footnote-label">30</a></sup>) to distribute this site (&amp; it’s assets).</p>
<p>Now, while my custom solution worked, it was, admittedly, flawed. First, because I really only implemented the first three letters of the CRUD<sup><a href="#user-content-fn-31" id="user-content-fnref-31" data-footnote-ref="true" aria-describedby="footnote-label">31</a></sup> acronym for this. Second, by the time I came up with a way to auto delete content I’d removed, well, that&#x27;s when I stumbled upon a rad little library called <code>contentlayer2</code>, as I&#x27;d mentioned before. That was the turning point which pretty much signaled the end of any further development on my custom solution.</p>
<p>Before that happened, I should mention why I wound up using Turso<sup><a href="#user-content-fn-32" id="user-content-fnref-32" data-footnote-ref="true" aria-describedby="footnote-label">32</a></sup>, a SQLite<sup><a href="#user-content-fn-33" id="user-content-fnref-33" data-footnote-ref="true" aria-describedby="footnote-label">33</a></sup> (libSQL<sup><a href="#user-content-fn-34" id="user-content-fnref-34" data-footnote-ref="true" aria-describedby="footnote-label">34</a></sup>) DBaaS<sup><a href="#user-content-fn-35" id="user-content-fnref-35" data-footnote-ref="true" aria-describedby="footnote-label">35</a></sup> for this project, since SQLite&#x27;s whole purpose is that it&#x27;s embeddable.</p>
<p>Now, I actually did try that; embedding the SQLite database right into my Next.js application, and leveraging Bun’s native SQLite driver<sup><a href="#user-content-fn-36" id="user-content-fnref-36" data-footnote-ref="true" aria-describedby="footnote-label">36</a></sup> in the process. However, when I was testing development builds of this site on Vercel, I learned there were some limitations<sup><a href="#user-content-fn-37" id="user-content-fnref-37" data-footnote-ref="true" aria-describedby="footnote-label">37</a></sup><sup>, </sup><sup><a href="#user-content-fn-38" id="user-content-fnref-38" data-footnote-ref="true" aria-describedby="footnote-label">38</a></sup><sup>, </sup><sup><a href="#user-content-fn-39" id="user-content-fnref-39" data-footnote-ref="true" aria-describedby="footnote-label">39</a></sup>.</p>
<p>The first issue was that, ephemeral storage in static functions makes SQLite impractical<sup><a href="#user-content-fn-37" id="user-content-fnref-37-2" data-footnote-ref="true" aria-describedby="footnote-label">37</a></sup>. Second, while a read-only implementation is possible<sup><a href="#user-content-fn-38" id="user-content-fnref-38-2" data-footnote-ref="true" aria-describedby="footnote-label">38</a></sup>, you are limited by the serverless function&#x27;s storage capacity<sup><a href="#user-content-fn-39" id="user-content-fnref-39-2" data-footnote-ref="true" aria-describedby="footnote-label">39</a></sup>. Finally, I couldn&#x27;t get read-only SQLite to work on dynamically rendered routes. In my testing, reading from the embedded SQLite database was only possible on routes generated using <code>generateStaticParams</code><sup><a href="#user-content-fn-40" id="user-content-fnref-40" data-footnote-ref="true" aria-describedby="footnote-label">40</a></sup>.</p>
<p>So, in thinking about the future, and not wanting to limit my project pre-emptively, a DBaaS seemed my only viable option. However, by the time I&#x27;d actually realized that, I&#x27;d already written my Drizzle ORM<sup><a href="#user-content-fn-41" id="user-content-fnref-41" data-footnote-ref="true" aria-describedby="footnote-label">41</a></sup> statements for SQLite, and I wasn&#x27;t in the mood for converting them into a different SQL format. So, of Drizzle&#x27;s SQLite compatible drivers<sup><a href="#user-content-fn-42" id="user-content-fnref-42" data-footnote-ref="true" aria-describedby="footnote-label">42</a></sup>, I wound up using Turso.</p>
<details><summary>Choosing Turso over Cloudflare&#x27;s D1</summary><p>I suppose I could’ve used Cloudflare’s D1<sup><a href="#user-content-fn-43" id="user-content-fnref-43" data-footnote-ref="true" aria-describedby="footnote-label">43</a></sup>, instead of Turso, but the moose mascot was so gosh darn cute! Couple that with the fact they have a wonderfully generous free tier<sup><a href="#user-content-fn-44" id="user-content-fnref-44" data-footnote-ref="true" aria-describedby="footnote-label">44</a></sup>, how could I say no?!</p></details>
<h2 id="setup">Setup</h2>
<p>Of the ORMs, I had narrowed it down to two options, Prisma<sup><a href="#user-content-fn-45" id="user-content-fnref-45" data-footnote-ref="true" aria-describedby="footnote-label">45</a></sup> or Drizzle ORM<sup><a href="#user-content-fn-41" id="user-content-fnref-41-2" data-footnote-ref="true" aria-describedby="footnote-label">41</a></sup>. I settled on Drizzle because I liked the focus on TypeScript, and I liked that you could write raw(ish) SQL as an escape hatch using its <em>magic SQL</em><sup><a href="#user-content-fn-46" id="user-content-fnref-46" data-footnote-ref="true" aria-describedby="footnote-label">46</a></sup> operator. I also really liked the idea that the knowledge gained in working with it, would be transferable, since one of their taglines is <em>if you know SQL, you know Drizzle</em>. I&#x27;m also just a sucker for companies that know how to execute self-deprecating advertising strategies<sup><a href="#user-content-fn-47" id="user-content-fnref-47" data-footnote-ref="true" aria-describedby="footnote-label">47</a></sup><sup>, </sup><sup><a href="#user-content-fn-48" id="user-content-fnref-48" data-footnote-ref="true" aria-describedby="footnote-label">48</a></sup>, so my heart was settled on using it.</p>
<h2 id="drizzle--turso">Drizzle + Turso</h2>
<p>Anyways, since I did end up using Drizzle, there was some configuration required before I could flesh out the rest of my solution. Aside from registering for Turso and generating my API keys, the first thing I had to do was setup a <code>drizzle.config.ts</code><sup><a href="#user-content-fn-49" id="user-content-fnref-49" data-footnote-ref="true" aria-describedby="footnote-label">49</a></sup> file to use with Turso.</p>
<pre><code class="language-typescript">// drizzle.config.ts
import type { Config } from &#x27;drizzle-kit&#x27;;
import { Resource } from &#x27;sst&#x27;;

dotenv.config();

export default {
  schema: &#x27;./src/lib/db/schema/*&#x27;,
  out: &#x27;./drizzle/migrations&#x27;,
  dialect: &#x27;sqlite&#x27;,
  driver: &#x27;turso&#x27;,
  dbCredentials: {
    url: Resource.TursoUrl.value,
    authToken: Resource.TursoAuth.value,
  },
} satisfies Config;
</code></pre>
<p>I loosely followed Turso&#x27;s guide on setting it up with Drizzle<sup><a href="#user-content-fn-50" id="user-content-fnref-50" data-footnote-ref="true" aria-describedby="footnote-label">50</a></sup>, then came up with the resultant code above. In sum:</p>
<ul>
<li>I&#x27;m importing multiple schemas using the wildcard glob<sup><a href="#user-content-fn-51" id="user-content-fnref-51" data-footnote-ref="true" aria-describedby="footnote-label">51</a></sup> pattern (<code>*</code>).</li>
<li>SQL migrations get written into the root <code>drizzle/</code> directory.</li>
<li>I&#x27;m using the SQLite flavor of Drizzle, in tandem with the Turso driver.</li>
<li>I load in my credentials for Turso using the <code>{ dbCredentials }</code> object.</li>
<li>There&#x27;s a type check which ensures that config object is in agreement with the <code>Config</code> type imported from <code>drizzle-kit</code>.</li>
</ul>
<p>Something I should note, is that instead of injecting the Turso credentials from a <code>.env</code> file using something like <code>process.env.TURSO_AUTH</code><sup><a href="#user-content-fn-52" id="user-content-fnref-52" data-footnote-ref="true" aria-describedby="footnote-label">52</a></sup>, I took advantage of SST&#x27;s<sup><a href="#user-content-fn-53" id="user-content-fnref-53" data-footnote-ref="true" aria-describedby="footnote-label">53</a></sup> <em>secret</em><sup><a href="#user-content-fn-54" id="user-content-fnref-54" data-footnote-ref="true" aria-describedby="footnote-label">54</a></sup> component instead. You&#x27;ll notice I tapped into the <code>{ Resource }</code><sup><a href="#user-content-fn-55" id="user-content-fnref-55" data-footnote-ref="true" aria-describedby="footnote-label">55</a></sup> object from <code>sst</code> using some simple dot notation<sup><a href="#user-content-fn-56" id="user-content-fnref-56" data-footnote-ref="true" aria-describedby="footnote-label">56</a></sup>. This really just avoids the hassle of passing around a <code>.env.local</code><sup><a href="#user-content-fn-57" id="user-content-fnref-57" data-footnote-ref="true" aria-describedby="footnote-label">57</a></sup> file between work machines / dev environments.</p>
<h2 id="schemas">Schemas</h2>
<p>Once I had that, then I just had to define some schemas/tables<sup><a href="#user-content-fn-58" id="user-content-fnref-58" data-footnote-ref="true" aria-describedby="footnote-label">58</a></sup> for my posts. This resulted in creating the following tables:</p>
<ul>
<li>authors</li>
<li>tagSlugs</li>
<li>featured_images</li>
<li>posts</li>
</ul>
<p>The idea was that the authors, featured_images, and tagSlugs tables would respectively create one-many<sup><a href="#user-content-fn-59" id="user-content-fnref-59" data-footnote-ref="true" aria-describedby="footnote-label">59</a></sup>, and many-many<sup><a href="#user-content-fn-59" id="user-content-fnref-59-2" data-footnote-ref="true" aria-describedby="footnote-label">59</a></sup> relations to the posts table. Something like this:</p>
<p><img src="https://laniakita.com/images/oc/2024/10/cms-relations-diagram.jpg" alt="Entity Relationship diagram showing 1:M (authors, featured_images), and M:M (tagSlugs). The latter made possible with a junction table (posts_to_tagSlugs)" width="1000" height="800"/></p>
<p>In keeping with SQLites Datatypes<sup><a href="#user-content-fn-60" id="user-content-fnref-60" data-footnote-ref="true" aria-describedby="footnote-label">60</a></sup> and what&#x27;s available in Drizzle<sup><a href="#user-content-fn-61" id="user-content-fnref-61" data-footnote-ref="true" aria-describedby="footnote-label">61</a></sup> Some commonalities between these tables (as we&#x27;ll see), is that I&#x27;m using a <code>string</code> for each rows <code>id</code> and primary key column, and a <code>integer</code> set to <code>timestamp</code> for the date column. That&#x27;s because the <code>id</code> is a <code>UUIDv4</code><sup><a href="#user-content-fn-62" id="user-content-fnref-62" data-footnote-ref="true" aria-describedby="footnote-label">62</a></sup> generated using Bun&#x27;s <code>crypto.randomUUID()</code><sup><a href="#user-content-fn-63" id="user-content-fnref-63" data-footnote-ref="true" aria-describedby="footnote-label">63</a></sup>, and the date is a <code>utc</code> string. The <code>rawStr</code> column, is simply the raw <code>utf8</code> string generated from reading the <code>.mdx</code> file into memory.</p>
<p>Of note, in the below schemas, I&#x27;m manually exporting their types. Unfortunately, at the time, I simply wasn&#x27;t aware of drizzle&#x27;s <code>$inferInsert</code><sup><a href="#user-content-fn-64" id="user-content-fnref-64" data-footnote-ref="true" aria-describedby="footnote-label">64</a></sup>, <code>$inferSelect</code><sup><a href="#user-content-fn-64" id="user-content-fnref-64-2" data-footnote-ref="true" aria-describedby="footnote-label">64</a></sup> functions to automatically generate types. So, that&#x27;s why I&#x27;m doing it <em>artisanally</em>, in case you were wondering.</p>
<pre><code class="language-typescript">// authors.ts

import { sqliteTable, text, integer } from &#x27;drizzle-orm/sqlite-core&#x27;;

export interface Authors {
  id: string;
  slug: string;
  date: Date;
  name: string;
  mastodon?: string;
  mastodonURL?: string;
  localKey: string;
  rawStr: string;
}

export const authors = sqliteTable(&#x27;authors&#x27;, {
  id: text(&#x27;id&#x27;).primaryKey(),
  slug: text(&#x27;slug&#x27;).unique().notNull(),
  date: integer(&#x27;date&#x27;, { mode: &#x27;timestamp&#x27; }),
  name: text(&#x27;name&#x27;),
  mastodon: text(&#x27;mastodon&#x27;),
  mastodonURL: text(&#x27;mastodon_url&#x27;),
  localKey: text(&#x27;local_key&#x27;),
  rawStr: text(&#x27;raw_str&#x27;),
});
</code></pre>
<pre><code class="language-typescript">// tagSlugs.ts

import { sqliteTable, text, integer } from &#x27;drizzle-orm/sqlite-core&#x27;;

export interface Tags {
  id: string;
  slug: string;
  date: Date;
  title: string;
  localKey: string;
  rawStr?: string;
}

export const tagSlugs = sqliteTable(&#x27;tagSlugs&#x27;, {
  id: text(&#x27;id&#x27;).primaryKey(),
  slug: text(&#x27;slug&#x27;).unique(),
  date: integer(&#x27;date&#x27;, { mode: &#x27;timestamp&#x27; }),
  title: text(&#x27;title&#x27;).unique(),
  localKey: text(&#x27;local_key&#x27;),
  rawStr: text(&#x27;raw_str&#x27;),
});
</code></pre>
<pre><code class="language-typescript">//featured-images.ts

import { integer, sqliteTable, text } from &#x27;drizzle-orm/sqlite-core&#x27;;

export interface FeaturedImages {
  id: string;
  slug: string;
  date: Date;
  title?: string;
  fileLocation: string;
  caption?: string;
  credit?: string;
  creditUrlText?: string;
  creditUrl?: string;
  altText: string;
  localKey: string;
  blur: string;
  height: number;
  width: number;
  rawStr: string;
}

export const featuredImages = sqliteTable(&#x27;featured_images&#x27;, {
  id: text(&#x27;id&#x27;).primaryKey(),
  slug: text(&#x27;slug&#x27;).unique().notNull(),
  date: integer(&#x27;date&#x27;, { mode: &#x27;timestamp&#x27; }),
  title: text(&#x27;title&#x27;),
  fileLocation: text(&#x27;file_location&#x27;),
  caption: text(&#x27;caption&#x27;),
  credit: text(&#x27;credit&#x27;),
  creditUrlText: text(&#x27;credit_url_text&#x27;),
  creditUrl: text(&#x27;credit_url&#x27;),
  localKey: text(&#x27;local_key&#x27;),
  altText: text(&#x27;alt_text&#x27;),
  blur: text(&#x27;blur&#x27;),
  height: integer(&#x27;height&#x27;),
  width: integer(&#x27;width&#x27;),
  rawStr: text(&#x27;raw_str&#x27;),
});
</code></pre>
<p>Since the <code>posts.ts</code> schema/table is the most complicated, being the one that integrates all the other tables, it&#x27;s important to talk about what&#x27;s happening. You&#x27;ll notice in the below, I&#x27;m importing the previously defined schemas <code>authors</code>, <code>tagSlugs</code>, and <code>featured_images</code>, and consuming them both as foreign keys and in relational tables.</p>
<pre><code class="language-typescript">// posts.ts

import { sqliteTable, text, primaryKey, integer } from &#x27;drizzle-orm/sqlite-core&#x27;;
import { relations } from &#x27;drizzle-orm&#x27;;
import { authors } from &#x27;./authors&#x27;;
import { tagSlugs } from &#x27;./tagSlugs&#x27;;
import { featuredImages } from &#x27;./featured-images&#x27;;

export const authorsRelations = relations(authors, ({ many }) =&gt; ({
  posts: many(posts),
}));

export const featuredImagesRelations = relations(featuredImages, ({ many }) =&gt; ({
  posts: many(posts),
}));

export interface Posts {
  id: string;
  slug: string;
  date: Date;
  tagSlugs: string[];
  author: string;
  headline: string;
  subheadline?: string;
  featuredImage: string;
  altCaption?: string;
  localKey: string;
  rawStr: string;
}

export const posts = sqliteTable(&#x27;posts&#x27;, {
  id: text(&#x27;id&#x27;).primaryKey(),
  authorId: text(&#x27;author_id&#x27;)
    .references(() =&gt; authors.id, { onUpdate: &#x27;cascade&#x27;, onDelete: &#x27;cascade&#x27; })
    .notNull(),
  date: integer(&#x27;date&#x27;, { mode: &#x27;timestamp&#x27; }),
  slug: text(&#x27;slug&#x27;).unique().notNull(),
  headline: text(&#x27;headline&#x27;).unique().notNull(),
  subheadline: text(&#x27;subheadline&#x27;),
  featuredImageId: text(&#x27;featured_image_id&#x27;).references(() =&gt; featuredImages.id, {
    onUpdate: &#x27;cascade&#x27;,
    onDelete: &#x27;cascade&#x27;,
  }),
  altCaption: text(&#x27;alt_caption&#x27;),
  localKey: text(&#x27;local_key&#x27;),
  rawStr: text(&#x27;raw_str&#x27;),
});

export const postsRelations = relations(posts, ({ one, many }) =&gt; ({
  author: one(authors, {
    fields: [posts.authorId],
    references: [authors.id],
  }),
  featuredImage: one(featuredImages, {
    fields: [posts.featuredImageId],
    references: [featuredImages.id],
  }),
  postToTags: many(postsToTags),
}));

export const postsToTags = sqliteTable(
  &#x27;posts_to_tagSlugs&#x27;,
  {
    postId: text(&#x27;post_id&#x27;)
      .notNull()
      .references(() =&gt; posts.id, { onUpdate: &#x27;cascade&#x27;, onDelete: &#x27;cascade&#x27; }),
    tagId: text(&#x27;tag_id&#x27;)
      .notNull()
      .references(() =&gt; tagSlugs.id, { onUpdate: &#x27;cascade&#x27;, onDelete: &#x27;cascade&#x27; }),
  },
  (t) =&gt; {
    return {
      pk: primaryKey({ columns: [t.postId, t.tagId] }),
    };
  },
);

export const postsToTagsRelations = relations(postsToTags, ({ one }) =&gt; ({
  tag: one(tagSlugs, {
    fields: [postsToTags.tagId],
    references: [tagSlugs.id],
  }),
  post: one(posts, {
    fields: [postsToTags.postId],
    references: [posts.id],
  }),
}));
</code></pre>
<p>In using them as foreign keys, I&#x27;m really just taking advantage of the cascade operations.</p>
<p>As far as relations go, I&#x27;m using drizzles relations<sup><a href="#user-content-fn-65" id="user-content-fnref-65" data-footnote-ref="true" aria-describedby="footnote-label">65</a></sup> feature, and creating relational tables for both <em>one-to-many</em> (<code>authors</code>, <code>featured_images</code>) and <em>many-to-many</em> (<code>tagSlugs</code>) relations. In truth, this was somewhat <em>extra</em>, since I had foreign keys available to me on <code>turso</code>, but I was curious to know what <code>drizzle</code> was capable of, and this is one such addition that <code>drizzle</code> brings to the table (no pun intended &gt;.&lt;), beyond (most) vanilla SQL functions.</p>
<h3 id="database-config">Database Config</h3>
<p>With all the schemas fleshed out the last thing to setup, was a connection to the remote DB on Turso<sup><a href="#user-content-fn-66" id="user-content-fnref-66" data-footnote-ref="true" aria-describedby="footnote-label">66</a></sup>, where I then imported the schemas directly into the client connection.</p>
<pre><code class="language-typescript">// turso-db.ts

import { drizzle } from &#x27;drizzle-orm/libsql&#x27;;
import { Resource } from &#x27;sst&#x27;;
import { createClient } from &#x27;@libsql/client&#x27;;
import * as authors from &#x27;./schema/authors&#x27;;
import * as tagSlugs from &#x27;./schema/tagSlugs&#x27;;
import * as featuredImages from &#x27;./schema/featured-images&#x27;;
import * as posts from &#x27;./schema/posts&#x27;;

dotenv.config();

const client = createClient({
  url: Resource.TursoUrl.value,
  authToken: Resource.TursoAuth.value,
});

export const maindb = drizzle(client, { schema: { ...authors, ...tagSlugs, ...featuredImages, ...posts } });
</code></pre>
<p>With that squared away, the final step was to push our schemas as tables to our database on <code>turso</code>, with a single command: <code>drizzle-kit push</code><sup><a href="#user-content-fn-67" id="user-content-fnref-67" data-footnote-ref="true" aria-describedby="footnote-label">67</a></sup>. Though, since I was importing credentials via SST, that command was more like: <code>sst shell bun run drizzle-kit push</code><sup><a href="#user-content-fn-68" id="user-content-fnref-68" data-footnote-ref="true" aria-describedby="footnote-label">68</a></sup>, but I digress.</p>
<h2 id="overview-of-my-cms">Overview of my CMS</h2>
<p>With the the database configured, the rest of the CMS could then be fleshed out. This process was in theory quite simple, following the outline from earlier. All I had to do was read content into memory, <em>process</em> them, and send it into the database via <code>insert</code> statements.</p>
<h3 id="scanning">Scanning</h3>
<p>In practice, this took the form of an extensive script I wrote that took advantage of Bun’s speedy File I/O APIs<sup><a href="#user-content-fn-69" id="user-content-fnref-69" data-footnote-ref="true" aria-describedby="footnote-label">69</a></sup>, to scan a folder that held all the blog content, which then read any Markdown and MDX files into memory as UTF-8 strings, sans any ignored ones.</p>
<p>From there I used <code>gray-matter</code><sup><a href="#user-content-fn-70" id="user-content-fnref-70" data-footnote-ref="true" aria-describedby="footnote-label">70</a></sup> to parse the stringified front matter, to then create an object to store both the front matter data and the raw string itself.</p>
<h3 id="processing">Processing</h3>
<p>Then I checked that data for a UUID before generating a fresh UUIDv4 using <code>Bun.crypto</code> which gets injected into the original file’s front matter, where it can then be read back into memory to update the data object.</p>
<p>I also performed a similar step for the post slug as well, where I checked the front matter for the existence of a <code>slug</code> before generating one from the file name. I also checked if the slug and file name match, and if not, the file name overrides the slug. This is injected into the original file as well, and like the above, read back into memory to update the working data object.</p>
<p>Then I had a whole Image processing step, which, admittedly, is a little more complicated than the other two steps (as we’ll see). In short, a function scans the front matter data for an <code>imageSrc</code>, then it copies the (relatively defined) image from the <code>content/assets</code> folder, into Next’s <code>/public</code> folder<sup><a href="#user-content-fn-14" id="user-content-fnref-14-2" data-footnote-ref="true" aria-describedby="footnote-label">14</a></sup>. Then it runs the image through <code>plaiceholder</code><sup><a href="#user-content-fn-71" id="user-content-fnref-71" data-footnote-ref="true" aria-describedby="footnote-label">71</a></sup> to generate a blurry placeholder image (in <code>base64</code><sup><a href="#user-content-fn-72" id="user-content-fnref-72" data-footnote-ref="true" aria-describedby="footnote-label">72</a></sup> format) and to also fetch the image dimensions (height, width). Finally, it appends all that data to the object I’d generated earlier.</p>
<h3 id="storing-data">Storing Data</h3>
<p>Then, with another script, I just pushed all the data to the SQLite database on Turso using <code>drizzle</code> statements to insert/update the database.</p>
<h3 id="fetching-data">Fetching Data</h3>
<p>Once all the data was loaded, I simply fetched it using <code>drizzle</code>’s query builder (mostly, some calls were proper <code>select</code> statements), and I took advantage of using the React <code>cache</code><sup><a href="#user-content-fn-73" id="user-content-fnref-73" data-footnote-ref="true" aria-describedby="footnote-label">73</a></sup> hook built into Next.js to memoize those DB calls.</p>
<details><summary>Prepping for ISR</summary><p>There wasn’t a real point to doing so, seeing as I never implemented ISR, so the post page data never really went stale. Still, I figured in the event I did implement it, it’d come in handy. Well, at least I know how it works now.</p></details>
<h3 id="rendering-data">Rendering Data</h3>
<p>Once I had the data, the only thing left to do was to use <code>mdx-bundler</code><sup><a href="#user-content-fn-74" id="user-content-fnref-74" data-footnote-ref="true" aria-describedby="footnote-label">74</a></sup> to process the raw MDX string, and I just loaded everything else into my front end.</p>
<h2 id="scanning-the-posts">Scanning the Posts</h2>
<p>Alright, the very first thing to do, was to ingest the files that actually had the content, and bring everything into memory. The below script puts into action exactly that. It’s a little complicated, but most of the complexity stems from the added processing steps. We&#x27;ll go through this, chunk by chunk, over the following sections, this is just to show you the final product, upfront.</p>
<h3 id="the-big-script-fetch-mdxts">The Big Script: <code>fetch-mdx.ts</code></h3>
<p>One thing to note, is that this is the raw script I was previously using, complete with <code>eslint</code> ignore directives, suggestions, and the comments I left for myself, to help explain to myself what’s going on.</p>
<p>Now, I did think about cleaning the script up, but I felt it was better to show you exactly what I was using right up until the switch to <code>contentlayer2</code>. Why? Perhaps for posterity, or perhaps I just find the code I wrote a while ago interesting due to its now foreign nature to myself.</p>
<details><summary>[INFO]: <code>fetch-mdx.ts</code></summary><pre><code class="language-typescript">// fetch-mdx.ts

#! /usr/bin/env bun
/* eslint-disable no-undef -- bun runtime will provide bun functions */
/* eslint-disable no-console -- doesn&#x27;t run in the browser, so this is fine */

import path from &#x27;node:path&#x27;;
import { readdir, access } from &#x27;node:fs/promises&#x27;;
import matter from &#x27;gray-matter&#x27;;
import { getPlaiceholder } from &#x27;plaiceholder&#x27;;

const isNonEmptyArrayOfStrings = (value: unknown): value is string[] =&gt; {
  return Array.isArray(value) &amp;&amp; value.length &gt; 0 &amp;&amp; value.every((item) =&gt; typeof item === &#x27;string&#x27;);
};

interface ConfigProps {
  // must be relative path from root project directory =&gt; &#x27;./content&#x27;
  contentFolder: string;
  // array of relative paths INSIDE the content folder [&#x27;./assets&#x27;]
  foldersToExclude?: string[];
  // array of literal file name/ext =&gt; [&#x27;README.md&#x27;]
  filesToExclude?: string[];
  // debug
  debug?: boolean;
  suppressErr?: boolean;
}

export const defaultConfig: ConfigProps = {
  contentFolder: &#x27;./content&#x27;,
  foldersToExclude: [&#x27;./assets&#x27;],
  filesToExclude: [&#x27;LICENSE&#x27;, &#x27;README.md&#x27;],
};

/*
 * @example batchFetchMDXPaths({config})
 * () =&gt; [&#x27;./content/blog/post0.mdx&#x27;, ..., &#x27;./content/blog/postN.md&#x27;]
 */
export const batchFetchMDXPaths = async ({
  contentFolder,
  foldersToExclude,
  filesToExclude,
  debug,
  suppressErr,
}: ConfigProps): Promise&lt;string[] | undefined&gt; =&gt; {
  try {
    const dir = await readdir(contentFolder, { recursive: true });

    const excludedFolders = foldersToExclude?.map((folder) =&gt; {
      const cleanedFolderPath = folder.replace(&#x27;./&#x27;, &#x27;&#x27;);

      return cleanedFolderPath;
    });

    //const absPath = path.resolve(path.join(process.cwd(), contentFolder))

    const fileArr = dir.map((item): string | undefined =&gt; {
      debug &amp;&amp; console.log(&#x27;logging raw path:&#x27;, item);

      if (excludedFolders?.some((folder) =&gt; item.startsWith(folder))) {
        debug &amp;&amp; console.log(&#x27;skipping &#x27;, item);
        return;
      }

      if (filesToExclude?.some((file) =&gt; item.endsWith(file))) {
        debug &amp;&amp; console.log(&#x27;ommitting &#x27;, item);
        return;
      }

      if (item.endsWith(&#x27;.mdx&#x27;) || item.endsWith(&#x27;md&#x27;)) {
        return `${contentFolder}/${item}`;
      }
      return undefined;
    });
    const filter1 = fileArr.filter((el) =&gt; el);
    // validate found paths
    const cwd = process.cwd();
    const validatedArr = await Promise.all(
      filter1.map(async (pathStr) =&gt; {
        try {
          await access(path.resolve(path.join(cwd, pathStr!)));
          return pathStr;
        } catch (err) {
          console.error(err);
        }
      }),
    );

    const finalArr = validatedArr.filter((el) =&gt; el);

    debug &amp;&amp; console.log(finalArr);

    if (isNonEmptyArrayOfStrings(finalArr)) return finalArr;
  } catch (err) {
    if (err instanceof Error &amp;&amp; &#x27;code&#x27; in err &amp;&amp; err.code === &#x27;ENOENT&#x27;) {
      !suppressErr &amp;&amp;
        console.error(
          &quot;Ooops! Couldn&#x27;t open that directory! Are you sure that folder is relative to root your project directory, i.e. &#x27;./src/content/posts&#x27;? &quot;,
          err,
        );
      return;
    }
    !suppressErr &amp;&amp; console.error(err);
  }
};

interface ImageUtilProps {
  fmatter: Record&lt;string, unknown&gt;;
  mdxPath: string;
  publicPath?: string;
  imageKey: string;
  debug?: boolean;
}

const newimageEmbedPath = async ({
  fmatter,
  mdxPath,
  publicPath,
  imageKey,
  debug,
}: ImageUtilProps): Promise&lt;string | undefined&gt; =&gt; {
  if (imageKey &amp;&amp; typeof imageKey === &#x27;string&#x27; &amp;&amp; imageKey in fmatter) {
    debug &amp;&amp; console.log(&#x27;found hero image with key&#x27;, imageKey);

    //const imageType = &#x27;type&#x27; in fmatter &amp;&amp; (fmatter.type as string);

    const currentImagePath = fmatter[imageKey];

    if (typeof currentImagePath !== &#x27;string&#x27;) return;

    //debug &amp;&amp; console.log(currentImagePath)

    const splitStr = mdxPath.split(&#x27;/&#x27;);
    // need to get parent path before mutating array with pop.
    const parentPath = splitStr.slice(0, splitStr.length - 1).join(&#x27;/&#x27;);
    //debug &amp;&amp; console.log(parentPath)

    // now we can get the mdx file name to use as the folder later.
    const mdxFile = splitStr.pop();
    if (!mdxFile) return;
    //const mdxFileSlug = mdxFile.split(&#x27;.&#x27;)[0];
    //debug &amp;&amp; console.log(mdxFileSlug)

    const imgToCopyFilePath = path.resolve(parentPath, currentImagePath);
    //debug &amp;&amp; console.log(imgToCopyFilePath)

    const publicCopyPath = `/public/${publicPath}/${currentImagePath.split(&#x27;/&#x27;).pop()}`;

    //debug &amp;&amp; console.log(publicCopyPath)

    const embedPublicCopyPath = `/${publicPath}/${currentImagePath.split(&#x27;/&#x27;).pop()}`;
    //debug &amp;&amp; console.log(embedPublicCopyPath)

    const pathToCheck = path.join(process.cwd(), publicCopyPath);
    //debug &amp;&amp; console.log(pathToCheck)
    const constPublicImgFile = Bun.file(pathToCheck);
    const imgFile = Bun.file(imgToCopyFilePath);

    /*
     * If the file isn&#x27;t in the public folder, then copy it.
     * If an image already exists in the public folder,
     * but the declared frontmatter image is
     * different (diff in size), then replace it.
     */
    const checkImg = await constPublicImgFile.exists();

    if (!checkImg) {
      debug &amp;&amp; console.log(&#x27;image not in public folder, copying ...&#x27;);
      await Bun.write(`${process.cwd()}${publicCopyPath}`, imgFile);
    } else if (constPublicImgFile.size !== imgFile.size) {
      debug &amp;&amp; console.log(&#x27;found image is different from public folder, copying ...&#x27;);
      await Bun.write(`${process.cwd()}${publicCopyPath}`, imgFile);
    } else {
      debug &amp;&amp; console.log(&#x27;image is the same, not copying&#x27;);
    }

    return embedPublicCopyPath;
  }
};

const imgMetaPlusBlurryPlaiceHolders = async ({ fmatter, mdxPath, imageKey, debug }: ImageUtilProps) =&gt; {
  if (imageKey &amp;&amp; imageKey in fmatter) {
    debug &amp;&amp; console.log(`using ${fmatter[imageKey] as string} to generate img data + blurs`);
    const currentImagePath = fmatter[imageKey];
    //console.log(currentImagePath)
    const splitStr = mdxPath.split(&#x27;/&#x27;);
    const parentPath = splitStr.slice(0, splitStr.length - 1).join(&#x27;/&#x27;);
    const imgToCopyFilePath = path.resolve(parentPath, currentImagePath as string);

    const imgFile = Bun.file(imgToCopyFilePath);
    const arrayBuf = await imgFile.arrayBuffer();
    const buf = Buffer.from(arrayBuf);

    const {
      base64,
      metadata: { height, width },
    } = await getPlaiceholder(buf);

    return { base64, height, width };
  }
};

const comboImageProcessing = async ({ fmatter, mdxPath, imageKey, publicPath, debug }: ImageUtilProps) =&gt; {
  if (imageKey &amp;&amp; imageKey in fmatter) {
    // need to do this first (the other function seems to mutate something)
    const imgBlurPlusMetaRes = await imgMetaPlusBlurryPlaiceHolders({ fmatter, mdxPath, imageKey, debug });
    const imgBlurData = imgBlurPlusMetaRes?.base64;
    const imgHeight = imgBlurPlusMetaRes?.height;
    const imgWidth = imgBlurPlusMetaRes?.width;

    const newImgPath = await newimageEmbedPath({ fmatter, mdxPath, imageKey, publicPath, debug });
    fmatter[imageKey] = newImgPath;
    return { ...fmatter, blur: imgBlurData, height: imgHeight, width: imgWidth };
  }
};

interface InjectionPointProps {
  fileArr: string[];
  strOfInterest: string;
  precisionPoint: number;
  debug?: boolean;
}

const getInjectionPoint = ({ fileArr, strOfInterest, precisionPoint, debug }: InjectionPointProps) =&gt; {
  const getPoint = fileArr.map((strLine, index): number | undefined =&gt; {
    // we&#x27;re going to look for the first &quot;---&quot; of the front matter
    // then inject. We can test that we&#x27;re not adding at the end by
    // checking if the key following injection is valid
    const keyGuess = fileArr[index + precisionPoint]?.split(&#x27;:&#x27;)[0];
    //console.log(keyGuess)
    //console.log(strLine.split(&#x27;:&#x27;)[0])
    const point = index + precisionPoint;
    if (strLine === strOfInterest &amp;&amp; keyGuess) {
      debug &amp;&amp; console.log(&#x27;inject at &#x27;, index + precisionPoint, &#x27;before &#x27;, keyGuess);

      return point;
    } else if (strLine.split(&#x27;:&#x27;)[0] === strOfInterest) {
      debug &amp;&amp; console.log(&#x27;found&#x27;, strOfInterest, &#x27;at&#x27;, index, &#x27;Injecting at&#x27;, point, &#x27;before&#x27;, keyGuess);
      return point;
    }
    return undefined;
  });
  const injectionPoint = getPoint.filter((el) =&gt; el)[0];
  return injectionPoint;
};

interface InjectionProps {
  rawFile: string;
  absFilePath: string;
  debug?: boolean;
}

const injectUUID = async ({ rawFile, absFilePath, debug }: InjectionProps) =&gt; {
  const uuid = crypto.randomUUID();
  const fileArr = rawFile.split(&#x27;\n&#x27;);

  // we need to search the file string to find out where
  // we can safely inject the uuid.

  const injectionPoint = getInjectionPoint({ fileArr, strOfInterest: &#x27;---&#x27;, precisionPoint: 1, debug });

  if (typeof injectionPoint !== &#x27;number&#x27;) return;
  fileArr.splice(injectionPoint, 0, `id: ${uuid}`);

  const finalString = fileArr.join(&#x27;\n&#x27;);

  const fileWritePath = absFilePath;

  debug &amp;&amp; console.log(`saving updated markdown file to `, fileWritePath);

  await Bun.write(fileWritePath, finalString);
  const newMatter = matter(finalString).data;

  // we&#x27;ll need to update the image path in memory, if it exists

  return newMatter;
};

const injectSlug = async ({ rawFile, absFilePath, debug }: InjectionProps) =&gt; {
  const fileArr = rawFile.split(&#x27;\n&#x27;);

  const fileWritePath = absFilePath;
  console.log(fileWritePath);
  const fileName = fileWritePath.split(&#x27;/&#x27;).pop();
  console.log(fileName);
  if (!fileName) return;
  const slug = fileName.split(&#x27;.&#x27;)[0];

  // assuming we have the id, we&#x27;ll inject it right after
  const injectionPoint = getInjectionPoint({ fileArr, strOfInterest: &#x27;id&#x27;, precisionPoint: 1, debug });

  if (typeof injectionPoint !== &#x27;number&#x27;) return;
  fileArr.splice(injectionPoint, 0, `slug: ${slug}`);

  const finalString = fileArr.join(&#x27;\n&#x27;);

  debug &amp;&amp; console.log(`saving updated markdown file to `, fileWritePath);

  await Bun.write(fileWritePath, finalString);
  const newMatter = matter(finalString).data;

  // we&#x27;ll need to update the image path in memory, if it exists

  return newMatter;
};


const comboInject = async ({ rawFile, absFilePath, debug }: InjectionProps) =&gt; {
  const uuid = crypto.randomUUID();
  const fileArr = rawFile.split(&#x27;\n&#x27;);

  const fileWritePath = absFilePath;
  //console.log(fileWritePath);
  const fileName = fileWritePath.split(&#x27;/&#x27;).pop();
  //console.log(fileName);
  if (!fileName) return;
  const slug = fileName.split(&#x27;.&#x27;)[0];

  // we need to search the file string to find out where
  // we can safely inject the uuid.

  const injectionPointId = getInjectionPoint({ fileArr, strOfInterest: &#x27;---&#x27;, precisionPoint: 1, debug });

  if (typeof injectionPointId !== &#x27;number&#x27;) return;
  fileArr.splice(injectionPointId, 0, `id: ${uuid}`);

  // i think we can just feed the current filarArr, can&#x27;t we?
  // assuming we have the id, we&#x27;ll inject it right after
  const injectionPointSlug = getInjectionPoint({ fileArr, strOfInterest: &#x27;id&#x27;, precisionPoint: 1, debug });

  if (typeof injectionPointSlug !== &#x27;number&#x27;) return;
  fileArr.splice(injectionPointSlug, 0, `slug: ${slug}`);

  debug &amp;&amp; console.log(`saving updated markdown file to `, fileWritePath);
  const finalString = fileArr.join(&#x27;\n&#x27;);

  await Bun.write(fileWritePath, finalString);
  const newMatter = matter(finalString).data;

  return newMatter;
};


const updateSlug = async ({ rawFile, absFilePath, debug }: InjectionProps) =&gt; {
  const fileArr = rawFile.split(&#x27;\n&#x27;);

  const fileWritePath = absFilePath;
  //console.log(fileWritePath);
  const fileName = fileWritePath.split(&#x27;/&#x27;).pop();
  //console.log(fileName);
  if (!fileName) return;
  const slug = fileName.split(&#x27;.&#x27;)[0];

  // assuming we have the id, we&#x27;ll inject it right after
  const injectionPoint = getInjectionPoint({ fileArr, strOfInterest: &#x27;slug&#x27;, precisionPoint: 0, debug });

  if (typeof injectionPoint !== &#x27;number&#x27;) return;
  fileArr.splice(injectionPoint, 1, `slug: ${slug}`);

  const finalString = fileArr.join(&#x27;\n&#x27;);

  debug &amp;&amp; console.log(`saving updated markdown file to `, fileWritePath);

  await Bun.write(fileWritePath, finalString);
  const newMatter = matter(finalString).data;

  // we&#x27;ll need to update the image path in memory, if it exists

  return newMatter;
};

interface MatterProcessorProps {
  frontMatter: Record&lt;string, unknown&gt;;
  absFilePath: string;
  mdxPath: string;
  rawFile: string;
  imageKey?: string;
  publicPath?: string;
  priorityConfig?: Record&lt;string, unknown&gt;;
  debug?: boolean;
}


const matterProcessor = async ({
  frontMatter,
  absFilePath,
  mdxPath,
  rawFile,
  imageKey,
  publicPath,
  priorityConfig,
  debug,
}: MatterProcessorProps): Promise&lt;Record&lt;string, unknown&gt; | undefined&gt; =&gt; {
  // I&#x27;ve made the DRY Principle sadge. I&#x27;m sorry.
  const fileNameWithExt = mdxPath.split(&#x27;/&#x27;).pop();
  const fileNameOnlyRaw = fileNameWithExt!.split(&#x27;.&#x27;);
  const fileNameOnly = fileNameOnlyRaw[0];
  const commonConfig = {
    absFilePath,
    mdxPath,
    rawFile,
    imageKey,
    publicPath,
    debug,
  };
  debug &amp;&amp; console.log(frontMatter);
  let priority;
  if (&#x27;type&#x27; in frontMatter &amp;&amp; priorityConfig &amp;&amp; (frontMatter.type as string) in priorityConfig) {
    debug &amp;&amp; console.log(&#x27;assigning&#x27;, frontMatter.type, &#x27;with priority&#x27;, priorityConfig[frontMatter.type as string]);
    priority = priorityConfig[frontMatter.type as string];
    debug &amp;&amp; console.log(priority);
  }

  if (!(&#x27;id&#x27; in frontMatter) &amp;&amp; !(&#x27;slug&#x27; in frontMatter)) {
    debug &amp;&amp; console.log(&#x27;no uuid or slug found, injecting...&#x27;);
    const newMatter = await comboInject({ absFilePath, rawFile, debug });

    if (!newMatter) return;

    const finalPass = await matterProcessor({
      ...commonConfig,
      frontMatter: newMatter,
    });

    const newFileRead = Bun.file(absFilePath);
    const newFileStr = await newFileRead.text();

    return { ...finalPass, priority, rawStr: newFileStr };
  } else if (!(&#x27;id&#x27; in frontMatter)) {
    debug &amp;&amp; console.log(&#x27;no uuid found, injecting...&#x27;);
    const newMatter = await injectUUID({ absFilePath, rawFile, debug });

    if (!newMatter) return;

    const finalPass = await matterProcessor({
      ...commonConfig,
      frontMatter: newMatter,
    });

    const newFileRead = Bun.file(absFilePath);
    const newFileStr = await newFileRead.text();

    return { ...finalPass, priority, rawStr: newFileStr };
  } else if (!(&#x27;slug&#x27; in frontMatter)) {
    debug &amp;&amp; console.log(&#x27;no slug found, injecting...&#x27;);
    const newMatter = await injectSlug({ absFilePath, rawFile });

    if (!newMatter) return;

    const finalPass = await matterProcessor({
      ...commonConfig,
      frontMatter: newMatter,
    });
    const newFileRead = Bun.file(absFilePath);
    const newFileStr = await newFileRead.text();

    return { ...finalPass, priority, rawStr: newFileStr };
  } else if (&#x27;slug&#x27; in frontMatter &amp;&amp; frontMatter.slug !== fileNameOnly) {
    console.log(&#x27;filename !== slug in frontmatter. fixing...&#x27;);
    const newMatter = await updateSlug({ absFilePath, rawFile });

    if (!newMatter) return;

    const finalPass = await matterProcessor({
      ...commonConfig,
      frontMatter: newMatter,
    });
    const newFileRead = Bun.file(absFilePath);
    const newFileStr = await newFileRead.text();

    return { ...finalPass, priority, rawStr: newFileStr };
  } else if (
    imageKey &amp;&amp;
    imageKey in frontMatter &amp;&amp;
    &#x27;id&#x27; in frontMatter &amp;&amp;
    &#x27;slug&#x27; in frontMatter &amp;&amp;
    frontMatter.slug === fileNameOnly
  ) {
    debug &amp;&amp; console.log(`uuid found in front matter with ${frontMatter.id as string}, not injecting`);
    debug &amp;&amp; console.log(`slug found in front matter with ${frontMatter.slug as string}, not injecting`);
    debug &amp;&amp; console.log(`found ${frontMatter[imageKey] as string}, processing image...`);
    const newMatter = await comboImageProcessing({ fmatter: frontMatter, mdxPath, imageKey, publicPath, debug });
    return { ...newMatter, priority, localKey: mdxPath, rawStr: rawFile };
  }

  return { ...frontMatter, priority, localKey: mdxPath, rawStr: rawFile };
};

interface BatchFetchFrontMatterProps extends ConfigProps {
  pathsArr?: string[];
  imageKey?: string;
  publicPath?: string; // i.e. &#x27;assets/images/blog/heros&#x27;
  priorityConfig?: Record&lt;string, number&gt;;
}


/*
 * @example batchFetchFrontMatter([pathsArr])
 * () =&gt; [{front matter post0}, ..., {front matter postN}]
 */
const batchFetchFrontMatter = async ({
  pathsArr,
  imageKey,
  publicPath,
  debug,
  suppressErr,
  priorityConfig,
}: BatchFetchFrontMatterProps) =&gt; {
  if (!pathsArr) return;
  const cwd = process.cwd();
  try {
    const metaArr = await Promise.all(
      pathsArr.map(async (mdxPath: string) =&gt; {
        const absFilePath = path.resolve(path.join(cwd, mdxPath));
        const readIntoMem = Bun.file(absFilePath);
        const rawFile = await readIntoMem.text();
        const frontMatter = matter(rawFile).data;
        const res = await matterProcessor({
          frontMatter,
          absFilePath,
          mdxPath,
          rawFile,
          imageKey,
          publicPath,
          priorityConfig,
          debug,
        });
        return res;
      }),
    );
    metaArr.sort((a, b) =&gt; {
      return a &amp;&amp; &#x27;priority&#x27; in a &amp;&amp; b &amp;&amp; &#x27;priority&#x27; in b ? (a.priority as number) - (b.priority as number) : 0;
    });
    return metaArr;
  } catch (err) {
    suppressErr &amp;&amp; console.error(err);
  }
};

export interface BatchFetchMain extends ConfigProps, BatchFetchFrontMatterProps {}

// main function
export const batchFetchMain = async (fetchConfig: BatchFetchMain) =&gt; {
  const validMdxPaths = await batchFetchMDXPaths(fetchConfig);
  const frontMatterArr = await batchFetchFrontMatter({
    ...fetchConfig,
    pathsArr: validMdxPaths!,
  });
  fetchConfig.debug &amp;&amp; console.log(frontMatterArr);
  return frontMatterArr;
};
</code></pre></details>
<p>Alright, there’s a lot going on here. Let&#x27;s start with the main function, and work our way through it, shall we?</p>
<h3 id="the-main-function-batchfetchmain">The Main Function: <code>batchFetchMain()</code></h3>
<pre><code class="language-typescript">// main function
export const batchFetchMain = async (fetchConfig: BatchFetchMain) =&gt; {
  const validMdxPaths = await batchFetchMDXPaths(fetchConfig);
  const frontMatterArr = await batchFetchFrontMatter({
    ...fetchConfig,
    pathsArr: validMdxPaths!,
  });
  fetchConfig.debug &amp;&amp; console.log(frontMatterArr);
  return frontMatterArr;
};
</code></pre>
<p>Looking at <code>batchFetchMain()</code>, you’ll see it takes a configuration object of type <code>BatchFetchMain</code>, which extends types <code>ConfigProps</code> and <code>BatchFetchFrontMatterProps</code>. This object defines where the content is and what to exclude. It resembles something like this:</p>
<pre><code class="language-typescript">// ConfigProps + BatchFetchFrontMatterProps = BatchFetchMain
interface BatchFetchMain {
  // must be relative path from root project directory =&gt; &#x27;./content&#x27;
  contentFolder: string;
  // array of relative paths INSIDE the content folder [&#x27;./assets&#x27;]
  foldersToExclude?: string[];
  // array of literal file name/ext =&gt; [&#x27;README.md&#x27;]
  filesToExclude?: string[];
  // debug
  debug?: boolean;
  suppressErr?: boolean;
  pathsArr?: string[];
  imageKey?: string;
  publicPath?: string; // i.e. &#x27;assets/images/blog/heros&#x27;
  priorityConfig?: Record&lt;string, number&gt;;
}
</code></pre>
<p>From there, it generates an array of valid <code>.mdx</code> paths using <code>batchFetchMDXPaths</code>, as defined by the configuration object.</p>
<h3 id="fetching-file-paths-batchfetchmdxpaths">Fetching file paths: <code>batchFetchMDXPaths()</code></h3>
<p><code>batchFetchMDXPaths</code> imports the following File I/O APIs:</p>
<pre><code class="language-typescript">import path from &#x27;node:path&#x27;;
import { readdir, access } from &#x27;node:fs/promises&#x27;;
</code></pre>
<p>And relies on the following function I found loving on stackoverflow <em>... somewhere</em>:</p>
<pre><code class="language-typescript">const isNonEmptyArrayOfStrings = (value: unknown): value is string[] =&gt; {
  return Array.isArray(value) &amp;&amp; value.length &gt; 0 &amp;&amp; value.every((item) =&gt; typeof item === &#x27;string&#x27;);
};
</code></pre>
<p>The resultant <code>batchFetchMDXPaths()</code> function could then be created:</p>
<pre><code class="language-typescript">/*
 * @example batchFetchMDXPaths({config})
 * () =&gt; [&#x27;./content/blog/post0.mdx&#x27;, ..., &#x27;./content/blog/postN.md&#x27;]
 */
export const batchFetchMDXPaths = async ({
  contentFolder,
  foldersToExclude,
  filesToExclude,
  debug,
  suppressErr,
}: ConfigProps): Promise&lt;string[] | undefined&gt; =&gt; {
  try {
    const dir = await readdir(contentFolder, { recursive: true });

    const excludedFolders = foldersToExclude?.map((folder) =&gt; {
      const cleanedFolderPath = folder.replace(&#x27;./&#x27;, &#x27;&#x27;);

      return cleanedFolderPath;
    });

    //const absPath = path.resolve(path.join(process.cwd(), contentFolder))

    const fileArr = dir.map((item): string | undefined =&gt; {
      debug &amp;&amp; console.log(&#x27;logging raw path:&#x27;, item);

      if (excludedFolders?.some((folder) =&gt; item.startsWith(folder))) {
        debug &amp;&amp; console.log(&#x27;skipping &#x27;, item);
        return;
      }

      if (filesToExclude?.some((file) =&gt; item.endsWith(file))) {
        debug &amp;&amp; console.log(&#x27;ommitting &#x27;, item);
        return;
      }

      if (item.endsWith(&#x27;.mdx&#x27;) || item.endsWith(&#x27;md&#x27;)) {
        return `${contentFolder}/${item}`;
      }
      return undefined;
    });
    const filter1 = fileArr.filter((el) =&gt; el);
    // validate found paths
    const cwd = process.cwd();
    const validatedArr = await Promise.all(
      filter1.map(async (pathStr) =&gt; {
        try {
          await access(path.resolve(path.join(cwd, pathStr!)));
          return pathStr;
        } catch (err) {
          console.error(err);
        }
      }),
    );

    const finalArr = validatedArr.filter((el) =&gt; el);

    debug &amp;&amp; console.log(finalArr);

    if (isNonEmptyArrayOfStrings(finalArr)) return finalArr;
  } catch (err) {
    if (err instanceof Error &amp;&amp; &#x27;code&#x27; in err &amp;&amp; err.code === &#x27;ENOENT&#x27;) {
      !suppressErr &amp;&amp;
        console.error(
          &quot;Ooops! Couldn&#x27;t open that directory! Are you sure that folder is relative to root your project directory, i.e. &#x27;./src/content/posts&#x27;? &quot;,
          err,
        );
      return;
    }
    !suppressErr &amp;&amp; console.error(err);
  }
};
</code></pre>
<p>Going through it, it recursively reads the contents of the given <code>contentFolder</code> using <code>readdir</code> and saves the result into an array of <code>fs.Dirent</code> objects I lovingly called <code>dir</code>:</p>
<pre><code class="language-typescript">...
  try {
    const dir = await readdir(contentFolder, { recursive: true });
    ...
  }
  catch (err) {
    ...
  }
...
</code></pre>
<p>Then it cleans up the paths of the <code>foldersToExclude</code> array from the config:</p>
<pre><code class="language-typescript">...
  try {
    ...
 const excludedFolders = foldersToExclude?.map((folder) =&gt; {
      const cleanedFolderPath = folder.replace(&#x27;./&#x27;, &#x27;&#x27;);

      return cleanedFolderPath;
    });
    ...
  }
  catch (err) {
    ...
  }
...
</code></pre>
<p>Then it assembles the actual array of paths:</p>
<pre><code class="language-typescript">...
  try {
 ...
    const fileArr = dir.map((item): string | undefined =&gt; {
      debug &amp;&amp; console.log(&#x27;logging raw path:&#x27;, item);

      if (excludedFolders?.some((folder) =&gt; item.startsWith(folder))) {
        debug &amp;&amp; console.log(&#x27;skipping &#x27;, item);
        return;
      }

      if (filesToExclude?.some((file) =&gt; item.endsWith(file))) {
        debug &amp;&amp; console.log(&#x27;ommitting &#x27;, item);
        return;
      }

      if (item.endsWith(&#x27;.mdx&#x27;) || item.endsWith(&#x27;md&#x27;)) {
        return `${contentFolder}/${item}`;
      }
      return undefined;
    });
    ...
  }
  catch (err) {
    ...
  }
...
</code></pre>
<p>Once it has those paths from the <code>fileArr</code> array, it filters any <code>undefined</code> paths:</p>
<pre><code class="language-typescript">...
  try {
    ...
    const filter1 = fileArr.filter((el) =&gt; el);
    ...
  }
  catch (err) {
    ...
  }
...
</code></pre>
<p>Then it validates the paths, by mapping through the <code>filter1</code> array, checking if the path is accessible using <code>access</code>, and returning the path if it is, and an error if it isn&#x27;t.</p>
<pre><code class="language-typescript">...
  try {
    ...
    // validate found paths
    const cwd = process.cwd();
    const validatedArr = await Promise.all(
      filter1.map(async (pathStr) =&gt; {
        try {
          await access(path.resolve(path.join(cwd, pathStr!)));
          return pathStr;
        } catch (err) {
          console.error(err);
        }
      }),
    );
    ...
  }
  catch (err) {
    ...
  }
...
</code></pre>
<p>Then it does one more filter pass for any undefined strings and finally returns the resultant array (<code>finalArr</code>) only if the array isn&#x27;t empty:</p>
<pre><code class="language-typescript">...
  try {
    ...
    const finalArr = validatedArr.filter((el) =&gt; el);

    debug &amp;&amp; console.log(finalArr);

    if (isNonEmptyArrayOfStrings(finalArr)) return finalArr;
  }
  catch (err) {
    ...
  }
...
</code></pre>
<p>If at any point during this whole process a catastrophic failure occurred, it would catch the error like so:</p>
<pre><code class="language-typescript">...
  try {
    ...
  }
  catch (err) {
    if (err instanceof Error &amp;&amp; &#x27;code&#x27; in err &amp;&amp; err.code === &#x27;ENOENT&#x27;) {
      !suppressErr &amp;&amp;
        console.error(
          &quot;Ooops! Couldn&#x27;t open that directory! Are you sure that folder is relative to root your project directory, i.e. &#x27;./src/content/posts&#x27;? &quot;,
          err,
        );
      return;
    }
    !suppressErr &amp;&amp; console.error(err);
  }
...
</code></pre>
<p>Once the <code>finalArr</code> is generated from <code>batchFetchMDXPaths</code>, the posts can then be read into memory, and move on to <em>processing</em> via <code>batchFetchFrontMatter()</code>.</p>
<h2 id="post-processing--content-data-generation">Post Processing &amp; Content Data Generation</h2>
<p>With all the data in memory, it became possible to actually <em>do something</em> with it. That&#x27;s where the pre-processing step (of the posts) came into play. Just like before, we&#x27;ll start with the main function (<code>batchFetchFrontMatter()</code>) and work our way through it.</p>
<h3 id="batch-processing-of-front-matter-batchfetchfrontmatter">Batch processing of front matter: <code>batchFetchFrontMatter()</code></h3>
<p>This function is responsible for performing all the processing of the post data, and returns a resultant array of assembled post objects. They get consumed in the <code>push-mdx.ts</code> script (which we&#x27;ll see later), that does the actual database insertions.</p>
<pre><code class="language-typescript">/*
 * @example batchFetchFrontMatter([pathsArr])
 * () =&gt; [{front matter post0}, ..., {front matter postN}]
 */
const batchFetchFrontMatter = async ({
  pathsArr,
  imageKey,
  publicPath,
  debug,
  suppressErr,
  priorityConfig,
}: BatchFetchFrontMatterProps) =&gt; {
  if (!pathsArr) return;
  const cwd = process.cwd();
  try {
    const metaArr = await Promise.all(
      pathsArr.map(async (mdxPath: string) =&gt; {
        const absFilePath = path.resolve(path.join(cwd, mdxPath));
        const readIntoMem = Bun.file(absFilePath);
        const rawFile = await readIntoMem.text();
        const frontMatter = matter(rawFile).data;
        const res = await matterProcessor({
          frontMatter,
          absFilePath,
          mdxPath,
          rawFile,
          imageKey,
          publicPath,
          priorityConfig,
          debug,
        });
        return res;
      }),
    );
    metaArr.sort((a, b) =&gt; {
      return a &amp;&amp; &#x27;priority&#x27; in a &amp;&amp; b &amp;&amp; &#x27;priority&#x27; in b ? (a.priority as number) - (b.priority as number) : 0;
    });
    return metaArr;
  } catch (err) {
    suppressErr &amp;&amp; console.error(err);
  }
};
</code></pre>
<p>Alright, with this deceptively simple overview, it&#x27;s time to work through it. So, the first thing to do is to map through the validated <code>pathsArr</code>, and get the absolute path to the current given path, using <code>path.resolve</code> with the <code>cwd</code> joined to the given <code>mdxPath</code> using <code>path.join</code>:</p>
<pre><code class="language-typescript">const batchFetchFrontMatter = async ({
  pathsArr,
  ...
}: BatchFetchFrontMatterProps) =&gt; {
  if (!pathsArr) return;
  const cwd = process.cwd();
  try {
    const metaArr = await Promise.all(
      pathsArr.map(async (mdxPath: string) =&gt; {
        const absFilePath = path.resolve(path.join(cwd, mdxPath));
        ...
      }),
    );
    ...
  } catch (err) {
 ...
  }
};
</code></pre>
<p>Then we can finally take advantage of Bun&#x27;s unique File I/O APIs (<code>Bun.file</code>) API, in reading the <code>.mdx</code> file into memory as a <code>UTF-8</code> string:</p>
<pre><code class="language-typescript">const batchFetchFrontMatter = async ({
  pathsArr,
  ...
}: BatchFetchFrontMatterProps) =&gt; {
  if (!pathsArr) return;
  const cwd = process.cwd();
  try {
      const metaArr = await Promise.all(
        pathsArr.map(async (mdxPath: string) =&gt; {
          const absFilePath = path.resolve(path.join(cwd, mdxPath));
          const readIntoMem = Bun.file(absFilePath);
          const rawFile = await readIntoMem.text();
        ...
        }),
      );
    ...
  } catch (err) {
 ...
  }
};
</code></pre>
<p>Then we can grab just the front matter using the <code>matter</code> function from <code>gray-matter</code>:</p>
<pre><code class="language-typescript">...
import matter from &#x27;gray-matter&#x27;;

const batchFetchFrontMatter = async ({
  pathsArr,
  ...
}: BatchFetchFrontMatterProps) =&gt; {
  if (!pathsArr) return;
  const cwd = process.cwd();
  try {
    const metaArr = await Promise.all(
      pathsArr.map(async (mdxPath: string) =&gt; {
        ...
        const rawFile = await readIntoMem.text();
        const frontMatter = matter(rawFile).data;
        ...
      }),
    );
    ...
  } catch (err) {
 ...
  }
};
</code></pre>
<p>Then it gets to the heart of the function, the <code>matterProcessor</code>, which is, <em>&quot;Ha, quite a doozy&quot;</em> (we&#x27;ll break it down next), and returns the result:</p>
<pre><code class="language-typescript">/*
 * @example batchFetchFrontMatter([pathsArr])
 * () =&gt; [{front matter post0}, ..., {front matter postN}]
 */
const batchFetchFrontMatter = async ({
  pathsArr,
  ...
}: BatchFetchFrontMatterProps) =&gt; {
  if (!pathsArr) return;
  const cwd = process.cwd();
  try {
    const metaArr = await Promise.all(
      pathsArr.map(async (mdxPath: string) =&gt; {
        ...
        const res = await matterProcessor({
          frontMatter,
          absFilePath,
          mdxPath,
          rawFile,
          imageKey,
          publicPath,
          priorityConfig,
          debug,
        });
        return res;
      }),
    );
    ...
  } catch (err) {
    ...
  }
};
</code></pre>
<h3 id="zooming-in-processing-with-matterprocessor">Zooming in: Processing with <code>matterProcessor()</code></h3>
<p>In brief, the posts get assigned a UUID and a slug as needed. Then, if they have a featured image, they get copied over to the public folder and a blurry <code>srcset</code> image gets generated and stored as a <code>base64</code> string into the in-memory data object, along with the image dimensions. Finally, all that data generated during this step, gets returned as an array of objects.</p>
<details><summary>[INFO]: <code>matterProcessor()</code></summary><pre><code class="language-typescript">interface MatterProcessorProps {
  frontMatter: Record&lt;string, unknown&gt;;
  absFilePath: string;
  mdxPath: string;
  rawFile: string;
  imageKey?: string;
  publicPath?: string;
  priorityConfig?: Record&lt;string, unknown&gt;;
  debug?: boolean;
}

const matterProcessor = async ({
  frontMatter,
  absFilePath,
  mdxPath,
  rawFile,
  imageKey,
  publicPath,
  priorityConfig,
  debug,
}: MatterProcessorProps): Promise&lt;Record&lt;string, unknown&gt; | undefined&gt; =&gt; {
  // I&#x27;ve made the DRY Principle sadge. I&#x27;m sorry.
  const fileNameWithExt = mdxPath.split(&#x27;/&#x27;).pop();
  const fileNameOnlyRaw = fileNameWithExt!.split(&#x27;.&#x27;);
  const fileNameOnly = fileNameOnlyRaw[0];
  const commonConfig = {
    absFilePath,
    mdxPath,
    rawFile,
    imageKey,
    publicPath,
    debug,
  };
  debug &amp;&amp; console.log(frontMatter);
  let priority;
  if (&#x27;type&#x27; in frontMatter &amp;&amp; priorityConfig &amp;&amp; (frontMatter.type as string) in priorityConfig) {
    debug &amp;&amp; console.log(&#x27;assigning&#x27;, frontMatter.type, &#x27;with priority&#x27;, priorityConfig[frontMatter.type as string]);
    priority = priorityConfig[frontMatter.type as string];
    debug &amp;&amp; console.log(priority);
  }

  if (!(&#x27;id&#x27; in frontMatter) &amp;&amp; !(&#x27;slug&#x27; in frontMatter)) {
    debug &amp;&amp; console.log(&#x27;no uuid or slug found, injecting...&#x27;);
    const newMatter = await comboInject({ absFilePath, rawFile, debug });

    if (!newMatter) return;

    const finalPass = await matterProcessor({
      ...commonConfig,
      frontMatter: newMatter,
    });

    const newFileRead = Bun.file(absFilePath);
    const newFileStr = await newFileRead.text();

    return { ...finalPass, priority, rawStr: newFileStr };
  } else if (!(&#x27;id&#x27; in frontMatter)) {
    debug &amp;&amp; console.log(&#x27;no uuid found, injecting...&#x27;);
    const newMatter = await injectUUID({ absFilePath, rawFile, debug });

    if (!newMatter) return;

    const finalPass = await matterProcessor({
      ...commonConfig,
      frontMatter: newMatter,
    });

    const newFileRead = Bun.file(absFilePath);
    const newFileStr = await newFileRead.text();

    return { ...finalPass, priority, rawStr: newFileStr };
  } else if (!(&#x27;slug&#x27; in frontMatter)) {
    debug &amp;&amp; console.log(&#x27;no slug found, injecting...&#x27;);
    const newMatter = await injectSlug({ absFilePath, rawFile });

    if (!newMatter) return;

    const finalPass = await matterProcessor({
      ...commonConfig,
      frontMatter: newMatter,
    });
    const newFileRead = Bun.file(absFilePath);
    const newFileStr = await newFileRead.text();

    return { ...finalPass, priority, rawStr: newFileStr };
  } else if (&#x27;slug&#x27; in frontMatter &amp;&amp; frontMatter.slug !== fileNameOnly) {
    console.log(&#x27;filename !== slug in frontmatter. fixing...&#x27;);
    const newMatter = await updateSlug({ absFilePath, rawFile });

    if (!newMatter) return;

    const finalPass = await matterProcessor({
      ...commonConfig,
      frontMatter: newMatter,
    });
    const newFileRead = Bun.file(absFilePath);
    const newFileStr = await newFileRead.text();

    return { ...finalPass, priority, rawStr: newFileStr };
  } else if (
    imageKey &amp;&amp;
    imageKey in frontMatter &amp;&amp;
    &#x27;id&#x27; in frontMatter &amp;&amp;
    &#x27;slug&#x27; in frontMatter &amp;&amp;
    frontMatter.slug === fileNameOnly
  ) {
    debug &amp;&amp; console.log(`uuid found in front matter with ${frontMatter.id as string}, not injecting`);
    debug &amp;&amp; console.log(`slug found in front matter with ${frontMatter.slug as string}, not injecting`);
    debug &amp;&amp; console.log(`found ${frontMatter[imageKey] as string}, processing image...`);
    const newMatter = await comboImageProcessing({ fmatter: frontMatter, mdxPath, imageKey, publicPath, debug });
    return { ...newMatter, priority, localKey: mdxPath, rawStr: rawFile };
  }

  return { ...frontMatter, priority, localKey: mdxPath, rawStr: rawFile };
};
</code></pre></details>
<p>Let&#x27;s chew through this one as well, shall we? First, we&#x27;re defining a configuration type for this that looks like this:</p>
<pre><code class="language-typescript">interface MatterProcessorProps {
  frontMatter: Record&lt;string, unknown&gt;;
  absFilePath: string;
  mdxPath: string;
  rawFile: string;
  imageKey?: string;
  publicPath?: string;
  priorityConfig?: Record&lt;string, unknown&gt;;
  debug?: boolean;
}
</code></pre>
<p>Then we&#x27;re consuming in the function props, which creates a local configuration object:</p>
<pre><code class="language-typescript">const matterProcessor = async ({
  frontMatter,
  absFilePath,
  mdxPath,
  rawFile,
  imageKey,
  publicPath,
  priorityConfig,
  debug,
}: MatterProcessorProps): Promise&lt;Record&lt;string, unknown&gt; | undefined&gt; =&gt; {
  // I&#x27;ve made the DRY Principle sadge. I&#x27;m sorry.
  const fileNameWithExt = mdxPath.split(&#x27;/&#x27;).pop();
  const fileNameOnlyRaw = fileNameWithExt!.split(&#x27;.&#x27;);
  const fileNameOnly = fileNameOnlyRaw[0];
  const commonConfig = {
    absFilePath,
    mdxPath,
    rawFile,
    imageKey,
    publicPath,
    debug,
  };
  debug &amp;&amp; console.log(frontMatter);
  ...
};

</code></pre>
<p>Then we&#x27;re assigning a priority to the current object&#x27;s type (correlates to the respective schemas/tables we defined earlier), if it exists in the <code>frontMatter</code> and the <code>configuration</code>:</p>
<pre><code class="language-typescript">const matterProcessor = async ({
  frontMatter,
  priorityConfig,
  debug,
  ...
}: MatterProcessorProps): Promise&lt;Record&lt;string, unknown&gt; | undefined&gt; =&gt; {
  ...
  let priority;
  if (&#x27;type&#x27; in frontMatter &amp;&amp; priorityConfig &amp;&amp; (frontMatter.type as string) in priorityConfig) {
    debug &amp;&amp; console.log(&#x27;assigning&#x27;, frontMatter.type, &#x27;with priority&#x27;, priorityConfig[frontMatter.type as string]);
    priority = priorityConfig[frontMatter.type as string];
    debug &amp;&amp; console.log(priority);
  }
  ...
};

</code></pre>
<p>Then we go through the rest of the function. Essentially, we&#x27;re walking through several different cases, and injecting/retrieving/copying data in a recursive fashion.</p>
<details><summary>Performance vs Readability</summary><p>A <code>switch</code> statement could&#x27;ve worked here, but I decided against it for some reason. I think it&#x27;s because I prioritized readability over the marginal performance gain of a <code>switch</code>.</p></details>
<p>case_0: If there&#x27;s no <code>id</code> or <code>slug</code> in the frontmatter, it injects them using <code>comboInject()</code> and as long as the result isn&#x27;t undefined, it recursively calls the <code>matterProcessor()</code> function, using the <code>commonConfig</code> object and the freshly created front matter, aptly labled <code>newMatter</code>. Once that&#x27;s done, it uses <code>Bun.file</code> to read the updated <code>.mdx</code> file, and assigns it to the returned <code>rawStr</code> key.</p>
<pre><code class="language-typescript">...
  if (!(&#x27;id&#x27; in frontMatter) &amp;&amp; !(&#x27;slug&#x27; in frontMatter)) {
    debug &amp;&amp; console.log(&#x27;no uuid or slug found, injecting...&#x27;);
    const newMatter = await comboInject({ absFilePath, rawFile, debug });

    if (!newMatter) return;

    const finalPass = await matterProcessor({
      ...commonConfig,
      frontMatter: newMatter,
    });

    const newFileRead = Bun.file(absFilePath);
    const newFileStr = await newFileRead.text();

    return { ...finalPass, priority, rawStr: newFileStr };
  }
...
</code></pre>
<h4 id="assigning-uuids--slugs">Assigning UUIDs &amp; Slugs</h4>
<h5 id="no-id-or-slug--comboinject">No ID or Slug =&gt; <code>comboInject()</code></h5>
<p><code>comboInject</code> is the function that handles this case, it looks like this:</p>
<pre><code class="language-typescript">const comboInject = async ({ rawFile, absFilePath, debug }: InjectionProps) =&gt; {
  const uuid = crypto.randomUUID();
  const fileArr = rawFile.split(&#x27;\n&#x27;);

  const fileWritePath = absFilePath;
  //console.log(fileWritePath);
  const fileName = fileWritePath.split(&#x27;/&#x27;).pop();
  //console.log(fileName);
  if (!fileName) return;
  const slug = fileName.split(&#x27;.&#x27;)[0];

  // we need to search the file string to find out where
  // we can safely inject the uuid.

  const injectionPointId = getInjectionPoint({ fileArr, strOfInterest: &#x27;---&#x27;, precisionPoint: 1, debug });

  if (typeof injectionPointId !== &#x27;number&#x27;) return;
  fileArr.splice(injectionPointId, 0, `id: ${uuid}`);

  // i think we can just feed the current filarArr, can&#x27;t we?
  // assuming we have the id, we&#x27;ll inject it right after
  const injectionPointSlug = getInjectionPoint({ fileArr, strOfInterest: &#x27;id&#x27;, precisionPoint: 1, debug });

  if (typeof injectionPointSlug !== &#x27;number&#x27;) return;
  fileArr.splice(injectionPointSlug, 0, `slug: ${slug}`);

  debug &amp;&amp; console.log(`saving updated markdown file to `, fileWritePath);
  const finalString = fileArr.join(&#x27;\n&#x27;);

  await Bun.write(fileWritePath, finalString);
  const newMatter = matter(finalString).data;

  return newMatter;
};
</code></pre>
<p>You&#x27;ll notice it generates the <code>uuid</code> (v4) with Bun&#x27;s <code>crypto.randomUUID()</code>, then it generates the <code>slug</code> by splitting and popping the absolute file path (<code>absFilePath</code>). This essentially means the file name, is the ultimate source of truth for the slug, and if the filename changes, the slug changes with it.</p>
<p>Then it uses <code>getInjectionPoint()</code> to find out where to inject the <code>uuid</code> and <code>slug</code>. That function looks like this:</p>
<pre><code class="language-typescript">interface InjectionPointProps {
  fileArr: string[];
  strOfInterest: string;
  precisionPoint: number;
  debug?: boolean;
}

const getInjectionPoint = ({ fileArr, strOfInterest, precisionPoint, debug }: InjectionPointProps) =&gt; {
  const getPoint = fileArr.map((strLine, index): number | undefined =&gt; {
    // we&#x27;re going to look for the first &quot;---&quot; of the front matter
    // then inject. We can test that we&#x27;re not adding at the end by
    // checking if the key following injection is valid
    const keyGuess = fileArr[index + precisionPoint]?.split(&#x27;:&#x27;)[0];
    //console.log(keyGuess)
    //console.log(strLine.split(&#x27;:&#x27;)[0])
    const point = index + precisionPoint;
    if (strLine === strOfInterest &amp;&amp; keyGuess) {
      debug &amp;&amp; console.log(&#x27;inject at &#x27;, index + precisionPoint, &#x27;before &#x27;, keyGuess);

      return point;
    } else if (strLine.split(&#x27;:&#x27;)[0] === strOfInterest) {
      debug &amp;&amp; console.log(&#x27;found&#x27;, strOfInterest, &#x27;at&#x27;, index, &#x27;Injecting at&#x27;, point, &#x27;before&#x27;, keyGuess);
      return point;
    }
    return undefined;
  });
  const injectionPoint = getPoint.filter((el) =&gt; el)[0];
  return injectionPoint;
};
</code></pre>
<p>Once it&#x27;s injected into the raw file <code>string</code> array (<code>fileArr</code>), it can be condensed back into a string, saved into the <code>.mdx</code> file using <code>Bun.file</code>, and the fresh front matter data <code>newMatter</code> can be returned.</p>
<p>The other two cases, case_1: no slug in <code>frontMatter</code> and case_2: no <code>id</code> in <code>frontMatter</code>, repeat the respective <code>comboInject</code> processes, but for their respective needs.</p>
<h5 id="no-id-injectuuid">No ID? =&gt;<code>injectUUID()</code></h5>
<p>Both <code>injectUUID()</code> and <code>injectSlug()</code>, rely on the below interface <code>InjectionProps</code>.</p>
<pre><code class="language-typescript">interface InjectionProps {
  rawFile: string;
  absFilePath: string;
  debug?: boolean;
}
</code></pre>
<p>Then, like it&#x27;s name, <code>injectUUID()</code>, takes the inputs from with the given props above, finds the place in the MDX string to inject it, and updates the file with the injected UUID.</p>
<pre><code class="language-typescript">const injectUUID = async ({ rawFile, absFilePath, debug }: InjectionProps) =&gt; {
  const uuid = crypto.randomUUID();
  const fileArr = rawFile.split(&#x27;\n&#x27;);

  // we need to search the file string to find out where
  // we can safely inject the uuid.

  const injectionPoint = getInjectionPoint({ fileArr, strOfInterest: &#x27;---&#x27;, precisionPoint: 1, debug });

  if (typeof injectionPoint !== &#x27;number&#x27;) return;
  fileArr.splice(injectionPoint, 0, `id: ${uuid}`);

  const finalString = fileArr.join(&#x27;\n&#x27;);

  const fileWritePath = absFilePath;

  debug &amp;&amp; console.log(`saving updated markdown file to `, fileWritePath);

  await Bun.write(fileWritePath, finalString);
  const newMatter = matter(finalString).data;

  // we&#x27;ll need to update the image path in memory, if it exists

  return newMatter;
};
</code></pre>
<h5 id="no-slug--injectslug">No Slug? =&gt; <code>injectSlug()</code></h5>
<p>As for handling just the slug, this does something similar, except it doesn&#x27;t generate a UUID to inject, it simply takes the file pathname, and creates a slug from that.</p>
<pre><code class="language-typescript">const injectSlug = async ({ rawFile, absFilePath, debug }: InjectionProps) =&gt; {
  const fileArr = rawFile.split(&#x27;\n&#x27;);

  const fileWritePath = absFilePath;
  console.log(fileWritePath);
  const fileName = fileWritePath.split(&#x27;/&#x27;).pop();
  console.log(fileName);
  if (!fileName) return;
  const slug = fileName.split(&#x27;.&#x27;)[0];

  // assuming we have the id, we&#x27;ll inject it right after
  const injectionPoint = getInjectionPoint({ fileArr, strOfInterest: &#x27;id&#x27;, precisionPoint: 1, debug });

  if (typeof injectionPoint !== &#x27;number&#x27;) return;
  fileArr.splice(injectionPoint, 0, `slug: ${slug}`);

  const finalString = fileArr.join(&#x27;\n&#x27;);

  debug &amp;&amp; console.log(`saving updated markdown file to `, fileWritePath);

  await Bun.write(fileWritePath, finalString);
  const newMatter = matter(finalString).data;

  // we&#x27;ll need to update the image path in memory, if it exists

  return newMatter;
};
</code></pre>
<h5 id="slug--filename--updateslug">Slug !== filename? =&gt; <code>updateSlug()</code></h5>
<p>and in case_03: <code>slug</code> in <code>frontMatter</code> <code>!==</code> to <code>fileNameOnly</code> the <code>updateSlug</code> function is used. It&#x27;s extremely similar to the above function.</p>
<pre><code class="language-typescript">const updateSlug = async ({ rawFile, absFilePath, debug }: InjectionProps) =&gt; {
  const fileArr = rawFile.split(&#x27;\n&#x27;);

  const fileWritePath = absFilePath;
  //console.log(fileWritePath);
  const fileName = fileWritePath.split(&#x27;/&#x27;).pop();
  //console.log(fileName);
  if (!fileName) return;
  const slug = fileName.split(&#x27;.&#x27;)[0];

  // assuming we have the id, we&#x27;ll inject it right after
  const injectionPoint = getInjectionPoint({ fileArr, strOfInterest: &#x27;slug&#x27;, precisionPoint: 0, debug });

  if (typeof injectionPoint !== &#x27;number&#x27;) return;
  fileArr.splice(injectionPoint, 1, `slug: ${slug}`);

  const finalString = fileArr.join(&#x27;\n&#x27;);

  debug &amp;&amp; console.log(`saving updated markdown file to `, fileWritePath);

  await Bun.write(fileWritePath, finalString);
  const newMatter = matter(finalString).data;

  // we&#x27;ll need to update the image path in memory, if it exists

  return newMatter;
};
</code></pre>
<p>In our final case, case_04: image (<code>imageKey</code>) declared in <code>frontMatter</code>, <code>id</code> and <code>slug</code> declared in <code>frontMatter</code>, and the <code>slug</code> matches <code>fileNameOnly</code>.</p>
<pre><code class="language-typescript">  else if (
    imageKey &amp;&amp;
    imageKey in frontMatter &amp;&amp;
    &#x27;id&#x27; in frontMatter &amp;&amp;
    &#x27;slug&#x27; in frontMatter &amp;&amp;
    frontMatter.slug === fileNameOnly
  ) {
    debug &amp;&amp; console.log(`uuid found in front matter with ${frontMatter.id as string}, not injecting`);
    debug &amp;&amp; console.log(`slug found in front matter with ${frontMatter.slug as string}, not injecting`);
    debug &amp;&amp; console.log(`found ${frontMatter[imageKey] as string}, processing image...`);
    const newMatter = await comboImageProcessing({ fmatter: frontMatter, mdxPath, imageKey, publicPath, debug });
    return { ...newMatter, priority, localKey: mdxPath, rawStr: rawFile };
  }
</code></pre>
<p>This relies on a function called <code>comboImageProcessing()</code>, which brings us to our next section.</p>
<h4 id="generating-image-data">Generating Image Data</h4>
<p>This step is somewhat intensive. In this step I&#x27;m generating the blurry <code>srcset</code> image using <code>plaiceholder</code> and grabbing the resultant image dimensions, as well as copying over the featured image into the public folder.</p>
<p>The latter strategy ensures that featured images can be served directly from <code>Next.js</code> itself. As well. it also keeps things consistent by replicating the functionality of a rehype plugin I currently use (<code>rehype-mdx-import-media</code> by Remco Haszing), which does the same thing, but for images contained in the content body of the <code>mdx</code> file, rather than the front matter.</p>
<p>Aside, we&#x27;ll begin our look at this process by understanding the main function here, <code>comboImageProcessing()</code>.</p>
<h5 id="comboimageprocessing"><code>comboImageProcessing()</code></h5>
<p>Similar to before, we have a interface for a generic configuration object.</p>
<pre><code class="language-typescript">interface ImageUtilProps {
  fmatter: Record&lt;string, unknown&gt;;
  mdxPath: string;
  publicPath?: string;
  imageKey: string;
  debug?: boolean;
}
</code></pre>
<p>Then we have the actual function <code>comboImageProcessing()</code>, that leverage inner functions to both generate the <code>srcset</code> and dimension data, and performs the copying step to the public folder.</p>
<pre><code class="language-typescript">const comboImageProcessing = async ({ fmatter, mdxPath, imageKey, publicPath, debug }: ImageUtilProps) =&gt; {
  if (imageKey &amp;&amp; imageKey in fmatter) {
    // need to do this first (the other function seems to mutate something)
    const imgBlurPlusMetaRes = await imgMetaPlusBlurryPlaiceHolders({ fmatter, mdxPath, imageKey, debug });
    const imgBlurData = imgBlurPlusMetaRes?.base64;
    const imgHeight = imgBlurPlusMetaRes?.height;
    const imgWidth = imgBlurPlusMetaRes?.width;

    const newImgPath = await newimageEmbedPath({ fmatter, mdxPath, imageKey, publicPath, debug });
    fmatter[imageKey] = newImgPath;
    return { ...fmatter, blur: imgBlurData, height: imgHeight, width: imgWidth };
  }
};
</code></pre>
<p>We can see how the blurry <code>srcset</code> and the image dimensions are obtained below.</p>
<h5 id="imgmetaplusblurryplaiceholders"><code>imgMetaPlusBlurryPlaiceHolders()</code></h5>
<p>This function finds the absolute image path from the relative path given in the front matter, and then reads it into memory as an <code>arrayBuffer</code>, leveraging Bun&#x27;s File I/O API. Then it transforms it from an <code>arrayBuffer</code> to simply a <code>Buffer</code>, which can be used as an input for <code>getPlaiceHolder()</code>. The latter is a function from <code>plaiceholder</code>, and it relies on <code>sharp</code> under the hood. It&#x27;s also the actual function that generates the blurry <code>srcset</code> and provides the image dimensions.</p>
<pre><code class="language-typescript">import { getPlaiceholder } from &#x27;plaiceholder&#x27;;

const imgMetaPlusBlurryPlaiceHolders = async ({ fmatter, mdxPath, imageKey, debug }: ImageUtilProps) =&gt; {
  if (imageKey &amp;&amp; imageKey in fmatter) {
    debug &amp;&amp; console.log(`using ${fmatter[imageKey] as string} to generate img data + blurs`);
    const currentImagePath = fmatter[imageKey];
    //console.log(currentImagePath)
    const splitStr = mdxPath.split(&#x27;/&#x27;);
    const parentPath = splitStr.slice(0, splitStr.length - 1).join(&#x27;/&#x27;);
    const imgToCopyFilePath = path.resolve(parentPath, currentImagePath as string);

    const imgFile = Bun.file(imgToCopyFilePath);
    const arrayBuf = await imgFile.arrayBuffer();
    const buf = Buffer.from(arrayBuf);

    const {
      base64,
      metadata: { height, width },
    } = await getPlaiceholder(buf);

    return { base64, height, width };
  }
};
</code></pre>
<h5 id="newimageembedpath"><code>newImageEmbedPath()</code></h5>
<p>With that out of the way, the final step is to copy the image from the <code>content</code> folder, to the public folder, which Next.js can use to serve the image. The below function both performs that step, as well as generates the new relative path (to the public folder), which will be saved into the in-memory post object.</p>
<pre><code class="language-typescript">const newimageEmbedPath = async ({
  fmatter,
  mdxPath,
  publicPath,
  imageKey,
  debug,
}: ImageUtilProps): Promise&lt;string | undefined&gt; =&gt; {
  if (imageKey &amp;&amp; typeof imageKey === &#x27;string&#x27; &amp;&amp; imageKey in fmatter) {
    debug &amp;&amp; console.log(&#x27;found hero image with key&#x27;, imageKey);

    //const imageType = &#x27;type&#x27; in fmatter &amp;&amp; (fmatter.type as string);

    const currentImagePath = fmatter[imageKey];

    if (typeof currentImagePath !== &#x27;string&#x27;) return;

    //debug &amp;&amp; console.log(currentImagePath)

    const splitStr = mdxPath.split(&#x27;/&#x27;);
    // need to get parent path before mutating array with pop.
    const parentPath = splitStr.slice(0, splitStr.length - 1).join(&#x27;/&#x27;);
    //debug &amp;&amp; console.log(parentPath)

    // now we can get the mdx file name to use as the folder later.
    const mdxFile = splitStr.pop();
    if (!mdxFile) return;
    //const mdxFileSlug = mdxFile.split(&#x27;.&#x27;)[0];
    //debug &amp;&amp; console.log(mdxFileSlug)

    const imgToCopyFilePath = path.resolve(parentPath, currentImagePath);
    //debug &amp;&amp; console.log(imgToCopyFilePath)

    const publicCopyPath = `/public/${publicPath}/${currentImagePath.split(&#x27;/&#x27;).pop()}`;

    //debug &amp;&amp; console.log(publicCopyPath)

    const embedPublicCopyPath = `/${publicPath}/${currentImagePath.split(&#x27;/&#x27;).pop()}`;
    //debug &amp;&amp; console.log(embedPublicCopyPath)

    const pathToCheck = path.join(process.cwd(), publicCopyPath);
    //debug &amp;&amp; console.log(pathToCheck)
    const constPublicImgFile = Bun.file(pathToCheck);
    const imgFile = Bun.file(imgToCopyFilePath);

    /*
     * If the file isn&#x27;t in the public folder, then copy it.
     * If an image already exists in the public folder,
     * but the declared frontmatter image is
     * different (diff in size), then replace it.
     */
    const checkImg = await constPublicImgFile.exists();

    if (!checkImg) {
      debug &amp;&amp; console.log(&#x27;image not in public folder, copying ...&#x27;);
      await Bun.write(`${process.cwd()}${publicCopyPath}`, imgFile);
    } else if (constPublicImgFile.size !== imgFile.size) {
      debug &amp;&amp; console.log(&#x27;found image is different from public folder, copying ...&#x27;);
      await Bun.write(`${process.cwd()}${publicCopyPath}`, imgFile);
    } else {
      debug &amp;&amp; console.log(&#x27;image is the same, not copying&#x27;);
    }

    return embedPublicCopyPath;
  }
};
</code></pre>
<h3 id="zooming-out">Zooming out</h3>
<p>Once it has all of that, it returns the assembled <em>processed</em> object:</p>
<pre><code class="language-typescript">const matterProcessor = async ({
  frontMatter,
  absFilePath,
  mdxPath,
  rawFile,
  imageKey,
  publicPath,
  priorityConfig,
  debug,
}: MatterProcessorProps): Promise&lt;Record&lt;string, unknown&gt; | undefined&gt; =&gt; {
  ...
  return { ...frontMatter, priority, localKey: mdxPath, rawStr: rawFile };
};
</code></pre>
<p>From there, we can zoom further back out to the parent function to notice we return the assembled <em>processed</em> object as the <code>res</code>, to generate the <code>metaArr</code> array, which is then sorted and returned according to the priority we defined earlier:</p>
<pre><code class="language-typescript">/*
 * @example batchFetchFrontMatter([pathsArr])
 * () =&gt; [{front matter post0}, ..., {front matter postN}]
 */
const batchFetchFrontMatter = async ({
  pathsArr,
  imageKey,
  publicPath,
  debug,
  suppressErr,
  priorityConfig,
}: BatchFetchFrontMatterProps) =&gt; {
  if (!pathsArr) return;
  const cwd = process.cwd();
  try {
    const metaArr = await Promise.all(
      pathsArr.map(async (mdxPath: string) =&gt; {
        const absFilePath = path.resolve(path.join(cwd, mdxPath));
        const readIntoMem = Bun.file(absFilePath);
        const rawFile = await readIntoMem.text();
        const frontMatter = matter(rawFile).data;
        const res = await matterProcessor({
          frontMatter,
          absFilePath,
          mdxPath,
          rawFile,
          imageKey,
          publicPath,
          priorityConfig,
          debug,
        });
        return res;
      }),
    );
    metaArr.sort((a, b) =&gt; {
      return a &amp;&amp; &#x27;priority&#x27; in a &amp;&amp; b &amp;&amp; &#x27;priority&#x27; in b ? (a.priority as number) - (b.priority as number) : 0;
    });
    return metaArr;
  } catch (err) {
    suppressErr &amp;&amp; console.error(err);
  }
};
</code></pre>
<p>With all that out of the way, we can finally zoom even further back to our main function:</p>
<pre><code class="language-typescript">export const batchFetchMain = async (fetchConfig: BatchFetchMain) =&gt; {
  const validMdxPaths = await batchFetchMDXPaths(fetchConfig);
  const frontMatterArr = await batchFetchFrontMatter({
    ...fetchConfig,
    pathsArr: validMdxPaths!,
  });
  fetchConfig.debug &amp;&amp; console.log(frontMatterArr);
  return frontMatterArr;
};
</code></pre>
<p>To notice we then return the <code>frontMatterArr</code> generated from the <code>batchFetchFrontMatter()</code>. With that, our walk through of the first part of the CMS (Scanning and Processing) is complete!</p>
<h2 id="inserting-content-data-into-the-db">Inserting Content Data into the DB</h2>
<p>Okay, so I&#x27;ve talked a lot about generating data, but where does it all go? Well, the remote database on Turso, of course! You can check out the below scripts to witness it in action! The first is a collection of insertion functions (<code>bun-db-funcs.ts</code>), the second is a helper function that imports the previous functions dynamically from the configuration object (<code>push-mdx.ts</code>), the final script integrates all of this into <code>db-gen.ts</code>, which is called by the <code>runner.ts</code> script which is called during <code>prebuild</code> (runs before <code>next build</code>).</p>
<h3 id="bun-db-funcsts"><code>bun-db-funcs.ts</code></h3>
<p>The below isn&#x27;t that complicated (thankfully). You&#x27;ll notice that I&#x27;m primarily integrating all the schemas/tables defined all the way at the beginning of this article, and leveraging drizzle&#x27;s statement builder to insert the data we generated into the database.</p>
<details><summary><p>[INFO]: <code>bun-db-funcs.ts</code></p></summary><pre><code class="language-typescript">// bun-db-funcs.ts

/* eslint-disable no-console -- we&#x27;re not in the browser, so this is fine. */
import { eq, and } from &#x27;drizzle-orm&#x27;;
//import { maindb } from &#x27;@/lib/db/bun-db&#x27;;
import { maindb } from &#x27;@/lib/db/turso-db&#x27;;
import { type Authors, authors } from &#x27;@/lib/db/schema/authors&#x27;;
import { type Tags, tagSlugs } from &#x27;@/lib/db/schema/tagSlugs&#x27;;
import { type Posts, posts, postsToTags } from &#x27;@/lib/db/schema/posts&#x27;;
import { type FeaturedImages, featuredImages } from &#x27;@/lib/db/schema/featured-images&#x27;;

export const insertAuthors = async (data: Authors): Promise&lt;void&gt; =&gt; {
  if (!data.id) {
    console.error(&#x27;no author id! did you forget something?&#x27;);
    return;
  }
  try {
    const authorData = data;
    console.log(authorData);

    // perform check should update
    const inserted = await maindb.query.authors.findFirst({
      where: eq(authors.id, authorData.id),
      columns: {
        id: true,
        slug: true,
        date: true,
        name: true,
        mastodon: true,
        mastodonURL: true,
        localKey: true,
        rawStr: true,
      },
    });

    const assembledData = {
      id: authorData.id,
      slug: authorData.slug,
      date: authorData.date,
      name: authorData.name,
      mastodon: authorData.mastodon,
      mastodonURL: authorData.mastodonURL,
      localKey: authorData.localKey,
      rawStr: authorData.rawStr,
    };

    if (JSON.stringify(assembledData) !== JSON.stringify(inserted)) {
      await maindb
        .insert(authors)
        .values(assembledData)
        .onConflictDoUpdate({
          target: authors.id,
          set: {
            slug: authorData.slug,
            date: authorData.date,
            name: authorData.name,
            mastodon: authorData.mastodon,
            mastodonURL: authorData.mastodonURL,
            localKey: authorData.localKey,
            rawStr: authorData.rawStr,
          },
        });
      console.log(&#x27;inserted&#x27;, authorData.name, &#x27;into db&#x27;);
    } else {
      console.log(&#x27;author&#x27;, authorData.name, &#x27;already exists&#x27;);
    }
  } catch (err) {
    console.error(&quot;Couldn&#x27;t insert author:&quot;, err);
  }
};

export const insertTags = async (data: Tags): Promise&lt;void&gt; =&gt; {
  if (!data.id) {
    console.error(&#x27;no tag id! did you forget something?&#x27;);
    return;
  }
  try {
    const tagData = data;

    const inserted = await maindb.query.tagSlugs.findFirst({
      where: eq(tagSlugs.id, tagData.id),
      columns: {
        id: true,
        slug: true,
        date: true,
        title: true,
        localKey: true,
        rawStr: true,
      },
    });

    const assembledData = {
      id: tagData.id,
      slug: tagData.slug,
      date: tagData.date,
      title: tagData.title,
      localKey: tagData.localKey,
      rawStr: tagData.rawStr,
    };

    if (JSON.stringify(assembledData) !== JSON.stringify(inserted)) {
      await maindb
        .insert(tagSlugs)
        .values(assembledData)
        .onConflictDoUpdate({
          target: tagSlugs.id,
          set: {
            slug: tagData.slug,
            date: tagData.date,
            title: tagData.title,
            localKey: tagData.localKey,
            rawStr: tagData.rawStr,
          },
        });

      console.log(&#x27;inserted&#x27;, tagData.title, &#x27;into db&#x27;);
    } else {
      console.log(&#x27;tag&#x27;, tagData.title, &#x27;already exists&#x27;);
    }
  } catch (err) {
    console.error(&quot;Couldn&#x27;t insert tagSlugs&quot;, err);
  }
};

export const insertFeaturedImages = async (data: FeaturedImages): Promise&lt;void&gt; =&gt; {
  if (!data.id) {
    console.error(&#x27;no image id! did you forget something?&#x27;);
    return;
  }
  try {
    const imgData = data;

    const inserted = await maindb.query.featuredImages.findFirst({
      where: eq(featuredImages.id, imgData.id),
      columns: {
        id: true,
        slug: true,
        date: true,
        fileLocation: true,
        caption: true,
        credit: true,
        creditUrlText: true,
        creditUrl: true,
        altText: true,
        localKey: true,
        blur: true,
        height: true,
        width: true,
        rawStr: true,
      },
    });

    const assembledData = {
      id: imgData.id,
      slug: imgData.slug,
      date: imgData.date,
      fileLocation: imgData.fileLocation,
      caption: imgData.caption,
      credit: imgData.credit ? imgData.credit : null,
      creditUrlText: imgData.creditUrlText ? imgData.credit : null,
      creditUrl: imgData.creditUrl ? imgData.creditUrl : null,
      altText: imgData.altText,
      localKey: imgData.localKey,
      blur: imgData.blur,
      height: imgData.height,
      width: imgData.width,
      rawStr: imgData.rawStr,
    };

    if (JSON.stringify(assembledData) !== JSON.stringify(inserted)) {
      await maindb
        .insert(featuredImages)
        .values(assembledData)
        .onConflictDoUpdate({
          target: featuredImages.id,
          set: {
            slug: imgData.slug,
            date: imgData.date,
            fileLocation: imgData.fileLocation,
            caption: imgData.caption,
            credit: imgData.credit,
            creditUrlText: imgData.creditUrlText,
            creditUrl: imgData.creditUrl,
            altText: imgData.altText,
            localKey: imgData.localKey,
            blur: imgData.blur,
            height: imgData.height,
            width: imgData.width,
            rawStr: imgData.rawStr,
          },
        });

      console.log(&#x27;inserted&#x27;, imgData.slug, &#x27;into db&#x27;);
    } else {
      console.log(&#x27;img&#x27;, imgData.slug, &#x27;already exists&#x27;);
    }
  } catch (err) {
    console.error(&quot;Couldn&#x27;t insert images:&quot;, err);
  }
};

export const insertPosts = async (data: Posts): Promise&lt;void&gt; =&gt; {
  if (!data.id) {
    console.error(&#x27;no post id! Did you forget something?&#x27;);
    return;
  }
  try {
    const postData = data;

    // sleep a second
    console.log(&#x27;let data load&#x27;);
    const sleep = (ms: number) =&gt;
      new Promise((r) =&gt; {
        setTimeout(r, ms);
      });
    await sleep(500);
    console.log(&quot;okay! let&#x27;s continue&quot;);

    /*
  if (!(featuredImageIdRes &amp;&amp; &#x27;id&#x27; in featuredImageIdRes)) {
    console.error(&#x27;Could not retrieve image id from slug! Did you forget something?&#x27;);
    return;
  }


  if (!(authorIdRes &amp;&amp; &#x27;id&#x27; in authorIdRes)) {
    console.error(&#x27;Could not retrieve author id from slug! Did you forget something?&#x27;);
    return;
  }
*/
    const getAuthorID = async (slugStr: string) =&gt; {
      const authorIdRes = await maindb.query.authors.findFirst({
        where: eq(authors.slug, slugStr),
        columns: {
          id: true,
        },
      });
      return authorIdRes;
    };
    const getImgId = async (slugStr: string) =&gt; {
      const featuredImageIdRes = await maindb.query.featuredImages.findFirst({
        where: eq(featuredImages.slug, slugStr),
        columns: {
          id: true,
        },
      });
      return featuredImageIdRes;
    };

    const inserted = await maindb.query.posts.findFirst({
      where: eq(posts.id, postData.id),
      columns: {
        id: true,
        authorId: true,
        slug: true,
        date: true,
        headline: true,
        subheadline: true,
        featuredImageId: true,
        altCaption: true,
        localKey: true,
        rawStr: true,
      },
    });

    const authorIdfuncRes = await getAuthorID(postData.author);
    const imgIdfuncRes = await getImgId(postData.featuredImage);

    const assembledData = {
      id: postData.id,
      authorId: authorIdfuncRes!.id,
      slug: postData.slug,
      date: postData.date,
      headline: postData.headline,
      subheadline: postData.subheadline,
      featuredImageId: imgIdfuncRes!.id,
      altCaption: postData.altCaption ? postData.altCaption : null,
      localKey: postData.localKey,
      rawStr: postData.rawStr,
    };

    if (JSON.stringify(assembledData) !== JSON.stringify(inserted)) {
      await maindb
        .insert(posts)
        .values(assembledData)
        .onConflictDoUpdate({
          target: posts.id,
          set: {
            authorId: authorIdfuncRes?.id,
            slug: postData.slug,
            date: postData.date,
            headline: postData.headline,
            subheadline: postData.subheadline,
            featuredImageId: imgIdfuncRes?.id,
            altCaption: postData.altCaption,
            localKey: postData.localKey,
            rawStr: postData.rawStr,
          },
        });
      console.log(&#x27;inserted&#x27;, postData.slug, &#x27;into db&#x27;);
    } else {
      console.log(&#x27;post&#x27;, postData.slug, &#x27;already exists&#x27;);
    }

    await Promise.all(
      postData.tagSlugs.map(async (tagSlug) =&gt; {
        const res = await maindb.query.tagSlugs.findFirst({
          where: eq(tagSlugs.slug, tagSlug),
          columns: {
            id: true,
          },
        });
        if (!(res &amp;&amp; &#x27;id&#x27; in res)) return;

        const insertedPostToTags = await maindb.query.postsToTags.findFirst({
          where: and(eq(postsToTags.tagId, res.id), eq(postsToTags.postId, postData.id)),
          columns: {
            tagId: true,
            postId: true,
          },
        });

        const assembledDataPostToTags = {
          tagId: res.id,
          postId: postData.id,
        };

        if (JSON.stringify(assembledDataPostToTags) !== JSON.stringify(insertedPostToTags)) {
          await maindb
            .insert(postsToTags)
            .values(assembledDataPostToTags)
            .onConflictDoUpdate({
              target: [postsToTags.postId, postsToTags.tagId],
              set: { postId: postData.id, tagId: res.id },
            });
          console.log(&#x27;associated&#x27;, tagSlug, &#x27;with&#x27;, postData.slug, &#x27;in db&#x27;);
        } else {
          console.log(
            tagSlug,
            &#x27;with id:&#x27;,
            res.id,
            &#x27;is already associated with \npost:&#x27;,
            postData.slug,
            &#x27;with id&#x27;,
            postData.id,
          );
        }
      }),
    );
    console.log(&#x27;all done!&#x27;);
  } catch (err) {
    console.error(&quot;Couldn&#x27;t insert posts&quot;, err);
  }
};
</code></pre></details>
<p>One thing you&#x27;ll probably notice, is that I&#x27;m assembling a temporary object within each insertion function (respective of the given table, e.g., <code>authors</code>), from what already exists in the database. By running this comparison, I&#x27;m ensuring that only new content is inserted into the database, or content that needs to be updated. As such, this drastically reduces the number of rows that need to be written.</p>
<details><summary>Turso limits</summary><p>Turso <strong><em>only</em></strong> gives 25 million row additions per month for free, so I decided it was important to keep as much of that 25 million as possible. You know, just in case.</p></details>
<p>Another thing you might notice is that I&#x27;ve commented out the <code>maindb</code> that&#x27;s imported from <code>@/lib/db/bun-db</code>. That database, is the local one that relied on Bun&#x27;s native SQLite driver. But, for the reasons I went over earlier, I wound up using Turso instead.</p>
<p>Finally, I wanted to point out the <code>sleep()</code> functions. Those were put in place, because I was having issues performing the <code>does exist</code> tests, as part of my attempt to reduce the numbers of row additions. My best guess, is that latency between the database, and my little program, was introducing false negatives.</p>
<p>For example, when I asked that database if there was content for the other tables that <code>posts</code> needed, it would say no, despite having just inserted that data. This would stop any posts from being inserted into <code>posts</code>. The <code>sleep()</code>, was a band-aid solution that gave the database enough time to reflect the newly inserted data, and successfully allowed for the rest of the script to execute, so posts could make their way to the <code>posts</code> table.</p>
<p>With that, you can then see how these functions might be integrated in the below <code>push-mdx.ts</code> script.</p>
<h3 id="push-mdxts"><code>push-mdx.ts</code></h3>
<p>The below is more or less a helper function, that integrates closely with a configuration object (which defines the functions to be imported, such as from the script above). This was essentially my attempt at creating a generic function, which others could use if they created their own functions to be imported and used.</p>
<pre><code class="language-typescript">// push-mdx.ts

#! /usr/bin/env bun
/* eslint-disable no-console -- bun is bun */
import { type BatchFetchMain, batchFetchMain } from &#x27;./fetch-mdx&#x27;;

interface DbFunctionsProps {
  dbFunctionModules: {
    insert: Record&lt;string, unknown&gt;;
  };
}

export const batchPushMain = async (fetchConfig: BatchFetchMain &amp; DbFunctionsProps): Promise&lt;void&gt; =&gt; {
  // get processed front matter array
  const matterRes = await batchFetchMain(fetchConfig);

  if (!matterRes) {
    fetchConfig.debug &amp;&amp; console.log(&#x27;Ooops, no data found!&#x27;);
    return;
  }

  // arr is sorted by priority so this should work:
  await Promise.all(
    matterRes.map(async (processedMDX): Promise&lt;void&gt; =&gt; {
      if (processedMDX &amp;&amp; &#x27;type&#x27; in processedMDX &amp;&amp; (processedMDX.type as string)) {
        const funcType = processedMDX.type as string;
        if (funcType in fetchConfig.dbFunctionModules.insert) {
          const insModRaw = fetchConfig.dbFunctionModules.insert[funcType] as Record&lt;string, string&gt;;
          const insModKeys = Object.keys(insModRaw);
          const insModStr = insModKeys[0]!;
          const insModPath = insModRaw[insModStr]!;
          if (insModStr &amp;&amp; insModPath) {
            /* eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- importing types would be a lot to ask for */
            const dbFuncs = await import(insModPath);
            /* eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access -- importing types would be a lot to ask for */
            await dbFuncs[insModStr](processedMDX);
          }
        }
      }
    }),
  );
};
</code></pre>
<p>What I&#x27;m most proud of here, is that I figured out how to dynamically import modules from the given string from the configuration object. It&#x27;s admittedly not pretty, as I import the functions based on their given declared <code>type</code>, but the defined functions were successfully imported by leveraging <code>Object.keys()</code>, and React&#x27;s dynamic import syntax. However, you&#x27;ll probably notice the eslint directives to disable typechecking on those functions. That&#x27;s simply because I didn&#x27;t know of a easy way to type them.</p>
<p>While the latter problem could&#x27;ve been solved by simply creating/importing those function types, this script was written at a point where I thought I could release this thing to the public as ready to use software. Thus, demanding people provide types with their functions seemed like a big ask, in my opinion. Though, I suppose expecting users to create their own database functions in the first place was probably a much greater ask. By that point, why even use something like this? But, I digress.</p>
<p>As well, you&#x27;ll notice I&#x27;m importing the main fetch function (<code>batchFetchMain()</code>), which again both fetches the posts to read, and performs various processing steps. If you&#x27;ll recall, many of those steps are admittedly quite <em>rigid</em>, or <em>inflexible</em>, which would create further problems if I ever did wind up generalizing this code based content management system.</p>
<p>Inherent problems aside, you can see how my generic script was integrated with the configuration object in the next section.</p>
<h3 id="db-gents"><code>db-gen.ts</code></h3>
<p>This is where the two scripts above are integrated into one main function that does everything. <code>batchPushMain()</code> is called with the configuration object, shamelessly called <code>laniConfig</code>.</p>
<pre><code class="language-typescript">// db-gen.ts

import { batchPushMain } from &#x27;./mdx-db/push-mdx&#x27;;

const laniConfig = {
  contentFolder: &#x27;./content&#x27;,
  foldersToExclude: [&#x27;./assets&#x27;],
  filesToExclude: [&#x27;README.md&#x27;, &#x27;LICENSE&#x27;],
  imageKey: &#x27;fileLocation&#x27;,
  publicPath: &#x27;assets/images/featured&#x27;,
  priorityConfig: {
    authors: 1,
    tagSlugs: 2,
    featuredImages: 3,
    posts: 4,
  },
  dbFunctionModules: {
    insert: {
      authors: { insertAuthors: &#x27;@/lib/bun-db-funcs&#x27; },
      tagSlugs: { insertTags: &#x27;@/lib/bun-db-funcs&#x27; },
      featuredImages: { insertFeaturedImages: &#x27;@/lib/bun-db-funcs&#x27; },
      posts: { insertPosts: &#x27;@/lib/bun-db-funcs&#x27; },
    },
  },
  debug: false,
};

const dbGen = async (): Promise&lt;void&gt; =&gt; {
  try {
    console.log(&#x27;trying to create db&#x27;);
    await batchPushMain(laniConfig);
    console.log(&#x27;db-gen successful&#x27;);
  } catch (err) {
    console.error(err);
  }
};

export default dbGen;
</code></pre>
<p>The point of this script, was to create a simple function that could be integrated into the <code>runner.ts</code> prebuild script—which I&#x27;ll cover a bit later—along with setting up a integrated configuration file for my CMS.</p>
<h2 id="fetching-data-from-the-db">Fetching Data from the DB</h2>
<p>What good would a CMS be if you couldn&#x27;t access any of the data you stored on to it? Pretty awful I&#x27;m betting. So, that&#x27;s why it was important to write the script below to get that data.</p>
<h3 id="node-db-funcsts"><code>node-db-funcs.ts</code></h3>
<p>One thing to point out, is there&#x27;s a switch over from <code>bun</code> to <code>node</code> functions, and that&#x27;s because the production build of a Next.js application runs on <code>node</code>. While it is possible to run the Next.js development server from <code>bun</code>, the production run time environment is strictly node, <em>for good reason</em>: Next.js often relies on the latest Node.js APIs that Bun simply hasn&#x27;t had time to implement, <em>yet</em>.</p>
<details><summary><p>[INFO]: <code>node-db-funcs.ts</code></p></summary><pre><code class="language-typescript">// node-db-funcs.ts

/* eslint-disable-next-line import/named -- monorepo issues */
import { cache } from &#x27;react&#x27;;
import &#x27;server-only&#x27;;
import { eq, desc, like, or, and } from &#x27;drizzle-orm&#x27;;
import { maindb } from &#x27;@/lib/db/turso-db&#x27;;
import { posts, postsToTags } from &#x27;@/lib/db/schema/posts&#x27;;
import { tagSlugs } from &#x27;@/lib/db/schema/tagSlugs&#x27;;

//import { maindb } from &#x27;@/lib/db/drizzle&#x27;;

export interface PostsToTagsItem {
  tag: {
    slug: string;
    title: string;
    id: string;
  };
}

export interface QueryPostMetaItem {
  id: string;
  date: Date;
  slug: string;
  headline: string;
  subheadline: string;
  author: {
    name: string;
  };
  featuredImage?: {
    fileLocation: string;
    altText: string;
    blur: string;
    height: number;
    width: number;
  };
  tagSlugs: {
    slug: string;
    title: string;
    id: string;
  }[];
  localKey: string;
}

export interface QueryPost extends QueryPostMetaItem {
  rawStr: string;
}

export const queryPostMetas = cache(async () =&gt; {
  const postRes = await maindb.query.posts.findMany({
    orderBy: [desc(posts.date)],
    columns: {
      authorId: false,
      featuredImageId: false,
      rawStr: false,
    },
    with: {
      author: {
        columns: {
          name: true,
        },
      },
      postToTags: {
        columns: {
          tagId: false,
          postId: false,
        },
        with: {
          tag: {
            columns: {
              slug: true,
              title: true,
              id: true,
            },
          },
        },
      },
      featuredImage: {
        columns: {
          fileLocation: true,
          altText: true,
          blur: true,
          height: true,
          width: true,
        },
      },
    },
  });
  const finalRes = postRes.map((post) =&gt; {
    const tagSlugsOne = post.postToTags.map((tagSlugsObj) =&gt; {
      const slug = tagSlugsObj.tag.slug;
      const title = tagSlugsObj.tag.title;
      const id = tagSlugsObj.tag.id;

      return { slug, title, id };
    });
    delete (post as unknown as { postToTags: Record&lt;string, unknown&gt; | undefined }).postToTags;
    return { ...post, tagSlugs: tagSlugsOne };
  });
  //console.dir(finalRes, { depth: null });
  return finalRes;
});

export const queryPostByIdForJustRawStr = cache(async (idStr: string) =&gt; {
  const resOne = await maindb.query.posts.findFirst({
    where: eq(posts.id, idStr),
    columns: {
      rawStr: true,
    },
  });
  return resOne;
});

export const queryPostByIdandSlugOrJustIdForJustRawStr = cache(
  async ({ idStr, slugStr }: { idStr: string; slugStr: string }) =&gt; {
    const postRes = await maindb.query.posts.findFirst({
      where: or(and(like(posts.id, `${idStr}%`), eq(posts.slug, slugStr)), like(posts.id, `${idStr}%`)),
      columns: {
        rawStr: true,
      },
    });
    return postRes;
  },
);

export interface PostQ extends QueryPostMetaItem {
  featuredImage: {
    fileLocation: string;
    altText: string;
    credit?: string;
    creditUrl?: string;
    creditUrlText?: string;
    caption?: string;
    height: number;
    width: number;
    blur: string;
  };
  altCaption?: string;
  rawStr: string;
}

export const queryPostMetaByIdandSlugOrJustId = cache(
  async ({ idStr, slugStr }: { idStr: string; slugStr: string }) =&gt; {
    const postRes = await maindb.query.posts.findFirst({
      where: or(
        and(like(posts.id, `${idStr}%`), eq(posts.slug, slugStr)),
        or(like(posts.id, `${idStr}%`), eq(posts.id, idStr)),
      ),
      columns: {
        authorId: false,
        featuredImageId: false,
      },
      with: {
        author: {
          columns: {
            name: true,
          },
        },
        postToTags: {
          columns: {
            tagId: false,
            postId: false,
          },
          with: {
            tag: {
              columns: {
                slug: true,
                title: true,
                id: true,
              },
            },
          },
        },
        featuredImage: {
          columns: {
            fileLocation: true,
            altText: true,
            credit: true,
            creditUrl: true,
            creditUrlText: true,
            caption: true,
            blur: true,
            height: true,
            width: true,
          },
        },
      },
    });

    if (!postRes) return;

    const tagSlugsMap = postRes.postToTags.map((tagSlugsObj) =&gt; {
      const slug = tagSlugsObj.tag.slug;
      const title = tagSlugsObj.tag.title;
      const id = tagSlugsObj.tag.id;
      return { slug, title, id };
    });

    delete (postRes as unknown as { postToTags: Record&lt;string, unknown&gt; | undefined }).postToTags;

    return { ...postRes, tagSlugs: tagSlugsMap };
  },
);

/*
    where: or(
      or(
        and(like(posts.id, `${idStr}%`), eq(posts.slug, slugStr)),
        or(like(posts.id, `${idStr}%`), eq(posts.id, idStr)),
      ),
      and(eq(posts.id, idStr), eq(posts.slug, slugStr)),
    ),
*/

export interface TagQ {
  id: string;
  slug: string;
  date: string;
  title: string;
  localKey: string;
  rawStr: string;
}

export const getAllTags = cache(async () =&gt; {
  const res = await maindb.select().from(tagSlugs);
  return res;
});

export const getTag = cache(async ({ idStr, slugStr }: { idStr: string; slugStr: string }) =&gt; {
  const idRes2 = await maindb.query.tagSlugs.findFirst({
    where: or(and(like(tagSlugs.id, `${idStr}%`), eq(tagSlugs.slug, slugStr)), like(tagSlugs.id, `${idStr}%`)),
  });
  return idRes2;
});

// type of metaItem arr
export const getPostsWithTagID = cache(async (tagIdStr: string) =&gt; {
  const idRes = await maindb.query.tagSlugs.findFirst({
    where: like(tagSlugs.id, `${tagIdStr}%`),
    columns: {
      id: true,
    },
  });

  if (!idRes?.id) return;

  const queryRes = await maindb.query.postsToTags.findMany({
    where: eq(postsToTags.tagId, idRes.id),
    columns: {
      postId: false,
      tagId: false,
    },
    with: {
      post: {
        columns: {
          id: true,
          slug: true,
        },
      },
    },
  });

  const postRes = await Promise.all(
    queryRes.map(async (post) =&gt; {
      const innerRes = await queryPostMetaByIdandSlugOrJustId({ idStr: post.post.id, slugStr: post.post.slug });
      return innerRes;
    }),
  );

  return postRes;
});
</code></pre></details>
<p>In this script, I&#x27;m primarily just using the Drizzle query builder to create functions to <code>select</code> data from the database, to be returned as assembled chunks (with types), for use around the website. The latter can be seen in the next section.</p>
<p>As well, I&#x27;m also making use of the <code>cache</code> function, that Next.js integrates from React. In theory, this meant that each call to the database was memoized, so I&#x27;d reduce the number of row reads to the database. In practice, all these posts were statically generated, so there was ultimately no point to doing things like this, other than for science, I suppose—which is quite important!</p>
<h2 id="putting-it-together">Putting it Together</h2>
<p>The last step was to ensure the <code>dbGen()</code> function ran before <code>next build</code>, which was accomplished simply by creating a script runner, and calling it in our <code>package.json</code>.</p>
<h3 id="runnerts"><code>runner.ts</code></h3>
<pre><code class="language-typescript">// runner.ts

/* eslint-disable no-console -- bun is bun */
import &#x27;dotenv/config&#x27;;
import dbGen from &#x27;./db-gen&#x27;;
import atomGen from &#x27;./atom-gen&#x27;;

const runScripts = async (): Promise&lt;void&gt; =&gt; {
  try {
    console.log(&#x27;trying to create database&#x27;);
    await dbGen();
    console.log(&#x27;dbGen success&#x27;);
    await atomGen();
    console.log(&#x27;atomGen success&#x27;);
  } catch (err) {
    console.error(err);
  }
};

try {
  await runScripts();
} catch (err) {
  console.error(err);
}
</code></pre>
<p>In the above, you&#x27;ll notice I&#x27;m calling the <code>dbGen()</code> function from earlier, as well as a function I didn&#x27;t cover called <code>atomGen()</code>. The latter is a script which generates the Atom Web Feed, and it relies on a library called <code>jstoxml</code>. I&#x27;ll likely cover how that all works in a later blog post.</p>
<p>Aside, in the below you can see how the <code>runner</code> script is called with <code>package.json</code>. One thing to notice is that I had to directly declare my <code>NODE_ENV</code> as <code>production</code>, otherwise I&#x27;d run into issues with Drizzle being unable to work properly. I&#x27;m unsure if it was a bug in <code>bun</code> or if this just how things are supposed to work.</p>
<pre><code class="language-json">// package.json

{
  ...,
  &quot;name&quot;: &quot;laniakita-web&quot;,
  &quot;scripts&quot;: {
 ...,
    &quot;prebuild&quot;: &quot;NODE_ENV=production bun ./src/scripts/runner.ts&quot;,
  },
}
</code></pre>
<p>With everything now in place, every time I ran <code>bun run build</code>, content would be scanned, processed, and fresh content would be inserted into the database, while old content would remain the same. I was then able to retrieve it using the functions I wrote in the previous section.</p>
<h2 id="rendering-content">Rendering Content</h2>
<p>So, now that we&#x27;ve seen how content get&#x27;s transformed into data, stored, then retrieved from a remote database on Turso, the final piece of the puzzle was to load it into the frontend. The below is the old source code for these posts, which demonstrates exactly that.</p>
<pre><code class="language-typescript">import type { Metadata, ResolvingMetadata } from &#x27;next&#x27;;
import { useMemo } from &#x27;react&#x27;;
import { getMDXComponent } from &#x27;mdx-bundler/client&#x27;;
import { redirect } from &#x27;next/navigation&#x27;;
import { PostHeader } from &#x27;@/app/blog/post-header&#x27;;
import {
  type PostQ,
  type QueryPostMetaItem,
  queryPostByIdandSlugOrJustIdForJustRawStr,
  queryPostMetaByIdandSlugOrJustId,
  queryPostMetas,
} from &#x27;@/lib/node-db-funcs&#x27;;
import { resMdxV3 } from &#x27;@/utils/mdxbundler-main&#x27;;
import BlogImageBlurServer from &#x27;@/app/blog/blog-image-blur-wrapper&#x27;;
import descriptionHelper from &#x27;@/utils/description-helper&#x27;;

export async function generateStaticParams() {
  const postMetas = (await queryPostMetas()) as unknown as QueryPostMetaItem[];
  return postMetas.map((meta) =&gt; ({
    id: meta.id.split(&#x27;-&#x27;).shift(),
    slug: meta.slug,
  }));
}

export async function generateMetadata(
  { params }: { params: { id: string; slug: string } },
  parent: ResolvingMetadata,
): Promise&lt;Metadata&gt; {
  const metaRes = (await queryPostMetaByIdandSlugOrJustId({
    idStr: params.id,
    slugStr: params.slug,
  })) as unknown as QueryPostMetaItem;
  const rawRes = await queryPostByIdandSlugOrJustIdForJustRawStr({ idStr: params.id, slugStr: params.slug });

  const findDescr = rawRes?.rawStr ? descriptionHelper(rawRes.rawStr) : [&quot;Lani&#x27;s Blog&quot;];
  const descr = findDescr ? findDescr.filter((el) =&gt; el) : &#x27;&#x27;;
  const previousImages = (await parent).openGraph?.images ?? [];
  const featuredImg = metaRes.featuredImage?.fileLocation;
  return {
    title: metaRes.headline,
    authors: [{ name: metaRes.author.name }],
    description: descr[0],
    openGraph: {
      title: metaRes.headline,
      description: descr[0],
      images: [featuredImg ? featuredImg : &#x27;&#x27;, ...previousImages],
    },
    twitter: {
      card: &#x27;summary&#x27;,
      title: metaRes.headline,
      description: descr[0],
      images: [featuredImg ? featuredImg : &#x27;&#x27;, ...previousImages],
    },
  };
}

export default async function BlogPostPage({ params }: { params: { id: string; slug: string } }) {
  const postQ = (await queryPostMetaByIdandSlugOrJustId({
    idStr: params.id,
    slugStr: params.slug,
  })) as unknown as PostQ;
  if (postQ.slug !== params.slug) {
    redirect(`/blog/posts/${params.id}/${postQ.slug}`);
  }
  const cwdFolderStrPre = postQ.localKey.split(&#x27;/&#x27;);
  const cwdFolderStr = cwdFolderStrPre.slice(0, cwdFolderStrPre.length - 1).join(&#x27;/&#x27;);
  const rawMDX = postQ.rawStr;
  if (!rawMDX) return;
  if (!cwdFolderStr) return;
  const resMdx = await resMdxV3(rawMDX, cwdFolderStr, params.id, &#x27;blog&#x27;);
  return (
    &lt;main className=&#x27;motion-safe:simple-color-trans -mb-0.5 min-h-full max-w-full bg-ctp-base dark:bg-ctp-midnight&#x27;&gt;
      {(resMdx as unknown) !== undefined &amp;&amp; (
        &lt;article id=&#x27;content&#x27; className=&#x27;flex size-full flex-col items-center justify-center&#x27;&gt;
          &lt;PostHeader dataObject={postQ} /&gt;

          &lt;div className=&#x27;flex min-h-full items-center justify-center px-10 py-6&#x27;&gt;
            &lt;div className=&#x27;prose-protocol-omega&#x27;&gt;
              &lt;MdxJsx code={resMdx.code} /&gt;
            &lt;/div&gt;
          &lt;/div&gt;
        &lt;/article&gt;
      )}
    &lt;/main&gt;
  );
}

export function MdxJsx({ code }: { code: string }) {
  const Component = useMemo(() =&gt; getMDXComponent(code), [code]);
  return &lt;Component components={{ img: BlogImageBlurServer }} /&gt;;
}
</code></pre>
<p>The above is sorta complicated on first look, but the important thing to notice is that I&#x27;m taking the retrieved raw <code>mdx</code> string, and feeding it into my <code>mdx-bundler</code> function. That function simultaneously configures <code>mdx-bundler</code> with all the plugins I use for these posts.</p>
<p>Et voila! A CMS I built all by myself, with data flowing from a database, directly into this Next.js application, to generate a blog on the internet.</p>
<h2 id="discussion--moving-to-contentlayer2">Discussion / Moving to Contentlayer2</h2>
<p>Alright, so after pouring all this time and energy into creating the above system, what went so wrong, that I felt the need to switch to <code>timlrx/contentlayer2</code>? Well, nothing major per se, it was just that by the time I&#x27;d written everything out, it felt overly complex, and a little half baked. I suppose once all the pieces were in place, and the high of finishing something I&#x27;d worked months on wore off, I quickly began to realize all it&#x27;s flaws.</p>
<p>For example, if you look over the <code>bun-db-funcs.ts</code>, you&#x27;ll notice I put little <code>sleep()</code> functions in between insertion steps. That was put in place, because <code>bun</code> is either too fast, or again, there&#x27;s too much latency for the operations to complete properly, or I&#x27;ve introduced a bug somehow with sloppy code (which honestly is more likely). In hindsight I could&#x27;ve modified those functions to be <code>transactions</code>, but by that point the novelty of the thing I&#x27;d just created had well worn off, and the slog of future maintenance, was beginning to creep in.</p>
<p>Likewise, I&#x27;d originally planned on creating a full blown CRUD application, which would&#x27;ve tied a admin dashboard directly into the <code>Next.js</code> application. That way I could edit and delete posts, simply through an admin panel, but this never fully materialized, beyond a little prototype I&#x27;d built in Svelte, before moving on to creating this site in Next.js. Why? I suppose I decided to prioritize getting content to a blog first, before creating any fancy GUIs that would&#x27;ve tied in an authentication framework, like Lucia, into the frontend application.</p>
<p>As such, once I&#x27;d come across <code>contentlayer2</code>, I was smitten. It was doing everything I was trying to do, but in a clean little package. There were no databases to configure. There was no worry about little pieces here and there throwing a wrench into the whole system. On the surface, it seemed like it just worked.</p>
<p>So, I dove in. I swallowed my pride, and I scrapped months of effort building my own CMS for something I can honestly say is better. However, because I did, maintenance on this site is now significantly less burdensome, and I&#x27;m far less worried about dependency updates breaking it.</p>
<p>Though, I will say that the time and energy I spent creating my own solution wasn&#x27;t all for naught. In fact, I still use the image processing functions I&#x27;d created earlier, inside of my <code>contentlayer</code> configuration. You can see how I&#x27;ve done this with the below code blocks. The first is my <code>contentlayer.config.ts</code> and the second is my recycled image processing functions (<code>image-process.ts</code>).</p>
<details><summary><p>[INFO]: <code>contentlayer.config.ts</code></p></summary><pre><code class="language-typescript">// contentlayer.config.ts

import { defineDocumentType, makeSource } from &#x27;contentlayer2/source-files&#x27;;
import remarkGfm from &#x27;remark-gfm&#x27;;
import rehypeSlug from &#x27;rehype-slug&#x27;;
import rehypeShiki from &#x27;@shikijs/rehype&#x27;;
import { rendererRich, transformerTwoslash } from &#x27;@shikijs/twoslash&#x27;;
import rehypeMdxImportMedia from &#x27;rehype-mdx-import-media&#x27;;
import { imageProcessor, FeaturedImageR1 } from &#x27;./src/lib/image-process&#x27;;
import jsxToHtml from &#x27;./src/lib/mdx-html&#x27;;

const CONTENT_DIR = &#x27;content&#x27;;

export const Project = defineDocumentType(() =&gt; ({
  name: &#x27;Project&#x27;,
  filePathPattern: &#x27;projects/**/*.yaml&#x27;,
  contentType: &#x27;data&#x27;,
  fields: {
    id: { type: &#x27;string&#x27;, required: true },
    date: { type: &#x27;date&#x27;, required: true },
    updated: { type: &#x27;date&#x27;, required: false },
    title: { type: &#x27;string&#x27;, required: true },
    tech: {
      type: &#x27;list&#x27;,
      of: { type: &#x27;string&#x27; },
    },
    imageSrc: { type: &#x27;string&#x27;, required: false },
    altText: { type: &#x27;string&#x27;, required: false },
    caption: { type: &#x27;string&#x27;, required: false },
    description: { type: &#x27;string&#x27;, required: true },
    blogPost: { type: &#x27;string&#x27;, required: false },
    link: { type: &#x27;string&#x27;, required: false },
  },
  computedFields: {
    url: {
      type: &#x27;string&#x27;,
      resolve: (project) =&gt; `/${project._raw.flattenedPath}`,
    },
    featured_image: {
      type: &#x27;json&#x27;,
      resolve: async (project): Promise&lt;FeaturedImageR1&gt; =&gt; {
        if (!project.imageSrc) return new FeaturedImageR1(false, &#x27;&#x27;, &#x27;&#x27;, 0, 0, &#x27;&#x27;, &#x27;&#x27;, &#x27;&#x27;, null);
        const data = await imageProcessor({
          contentDir: CONTENT_DIR,
          prefix: `${CONTENT_DIR}/${project._raw.flattenedPath}`,
          imgPath: project.imageSrc,
          debug: false,
        });

        const res = new FeaturedImageR1(
          true,
          data.src,
          data.base64,
          data.height,
          data.width,
          data.resized,
          project.altText ?? &#x27;&#x27;,
          project.caption ?? &#x27;&#x27;,
          data._debug ?? null,
        );

        return res;
      },
    },
  },
}));

export const Author = defineDocumentType(() =&gt; ({
  name: &#x27;Author&#x27;,
  filePathPattern: &#x27;authors/**/*.mdx&#x27;,
  contentType: &#x27;mdx&#x27;,
  fields: {
    name: { type: &#x27;string&#x27;, required: true },
    mastodon: { type: &#x27;string&#x27;, required: false },
    github: { type: &#x27;string&#x27;, required: false },
  },
  computedFields: {
    url: {
      type: &#x27;string&#x27;,
      resolve: (author) =&gt; `/${author._raw.flattenedPath}`,
    },
  },
}));

export const Page = defineDocumentType(() =&gt; ({
  name: &#x27;Page&#x27;,
  filePathPattern: &#x27;pages/**/*.mdx&#x27;,
  contentType: &#x27;mdx&#x27;,
  fields: {
    title: { type: &#x27;string&#x27;, required: true },
    description: { type: &#x27;string&#x27;, required: false },
    date: { type: &#x27;date&#x27;, required: false },
  },
  computedFields: {
    url: {
      type: &#x27;string&#x27;,
      resolve: (page) =&gt;
        `/${page._raw.flattenedPath.split(&#x27;/&#x27;).slice(1, page._raw.flattenedPath.split(&#x27;/&#x27;).length).join(&#x27;/&#x27;)}`,
    },
  },
}));

const Tag = defineDocumentType(() =&gt; ({
  name: &#x27;Tag&#x27;,
  filePathPattern: &#x27;tagSlugs/**/*.mdx&#x27;,
  contentType: &#x27;mdx&#x27;,
  fields: {
    id: { type: &#x27;string&#x27;, required: false },
    title: { type: &#x27;string&#x27;, required: true },
    slug: { type: &#x27;string&#x27;, required: false },
    date: { type: &#x27;date&#x27;, required: false },
  },
  computedFields: {
    url: {
      type: &#x27;string&#x27;,
      resolve: (tag) =&gt; `/${tag._raw.flattenedPath}`,
    },
  },
}));

const Category = defineDocumentType(() =&gt; ({
  name: &#x27;Category&#x27;,
  filePathPattern: &#x27;catSlugs/**/*.mdx&#x27;,
  contentType: &#x27;mdx&#x27;,
  fields: {
    id: { type: &#x27;string&#x27;, required: false },
    title: { type: &#x27;string&#x27;, required: true },
    slug: { type: &#x27;string&#x27;, required: false },
    date: { type: &#x27;date&#x27;, required: false },
  },
  computedFields: {
    url: {
      type: &#x27;string&#x27;,
      resolve: (category) =&gt; `/${category._raw.flattenedPath}`,
    },
  },
}));

export const Post = defineDocumentType(() =&gt; ({
  name: &#x27;Post&#x27;,
  filePathPattern: `posts/**/*.mdx`,
  contentType: &#x27;mdx&#x27;,
  fields: {
    id: { type: &#x27;string&#x27;, required: true },
    headline: { type: &#x27;string&#x27;, required: true },
    subheadline: { type: &#x27;string&#x27;, required: false },
    slug: { type: &#x27;string&#x27;, required: false },
    date: { type: &#x27;date&#x27;, required: true },
    updated: { type: &#x27;date&#x27;, required: false },
    author: { type: &#x27;string&#x27;, required: false },
    catSlugs: {
      type: &#x27;list&#x27;,
      of: Category,
    },
    tagSlugs: {
      type: &#x27;list&#x27;,
      of: Tag,
    },
    keywords: {
      type: &#x27;list&#x27;,
      of: { type: &#x27;string&#x27; },
    },
    imageSrc: { type: &#x27;string&#x27;, required: false },
    altText: { type: &#x27;string&#x27;, required: false },
    caption: { type: &#x27;string&#x27;, required: false },
  },
  computedFields: {
    html: {
      type: &#x27;string&#x27;,
      resolve: (post) =&gt; {
        const renderedMdx = jsxToHtml(post.body.code);
        return renderedMdx;
      },
    },
    url: {
      type: &#x27;string&#x27;,
      resolve: (post) =&gt; `/blog/${post.id.split(&#x27;-&#x27;).shift()}/${post._raw.flattenedPath.split(&#x27;/&#x27;).pop()}`,
    },
    featured_image: {
      type: &#x27;json&#x27;,
      resolve: async (post): Promise&lt;FeaturedImageR1&gt; =&gt; {
        if (!post.imageSrc) return new FeaturedImageR1(false, &#x27;&#x27;, &#x27;&#x27;, 0, 0, &#x27;&#x27;, &#x27;&#x27;, &#x27;&#x27;, null);
        const data = await imageProcessor({
          contentDir: CONTENT_DIR,
          prefix: `${CONTENT_DIR}/${post._raw.flattenedPath}`,
          imgPath: post.imageSrc,
          debug: false,
        });

        const res = new FeaturedImageR1(
          true,
          data.src,
          data.base64,
          data.height,
          data.width,
          data.resized,
          post.altText ?? &#x27;&#x27;,
          post.caption ?? &#x27;&#x27;,
          data._debug ?? null,
        );
        return res;
      },
    },
  },
}));

export default makeSource({
  contentDirPath: CONTENT_DIR,
  documentTypes: [Post, Category, Tag, Page, Project, Author],
  mdx: {
    remarkPlugins: [remarkGfm],
    rehypePlugins: [
      [
        rehypeShiki,
        {
          themes: {
            light: &#x27;catppuccin-latte&#x27;,
            dark: &#x27;catppuccin-mocha&#x27;,
          },

          transformers: [
            transformerTwoslash({
              explicitTrigger: true,
              renderer: rendererRich(),
            }),
          ],
        },
      ],
      rehypeMdxImportMedia,
      rehypeSlug,
    ],
    resolveCwd: &#x27;relative&#x27;,
    esbuildOptions(options) {
      options.outdir = `${process.cwd()}/public/assets/images/blog`;
      options.loader = {
        ...options.loader,
        &#x27;.png&#x27;: &#x27;file&#x27;,
        &#x27;.jpg&#x27;: &#x27;file&#x27;,
      };
      options.publicPath = `/assets/images/blog`;
      options.write = true;
      return options;
    },
  },
});
</code></pre></details>
<details><summary><p>[INFO]: <code>image-process.ts</code></p></summary><pre><code class="language-typescript">// image-process.ts

import { existsSync, copyFileSync, mkdirSync } from &#x27;node:fs&#x27;;
import { readFile, lstat } from &#x27;node:fs/promises&#x27;;
import path from &#x27;node:path&#x27;;
import { getPlaiceholder } from &#x27;plaiceholder&#x27;;
import sharp from &#x27;sharp&#x27;;

export interface DebugR1 {
  destination: string;
  status: {
    exists: boolean;
    existsInPublic: boolean;
  };
  didCopy: string;
  reason: string;
}

export class FeaturedImageR1 {
  hasImage: boolean;
  src: string;
  base64: string;
  height: number;
  width: number;
  resized: string;
  altText: string;
  caption: string;
  _debug: DebugR1 | null;

  constructor(
    hasImage: boolean,
    src: string,
    base64: string,
    height: number,
    width: number,
    resized: string,
    altText: string,
    caption: string,
    _debug: DebugR1 | null,
  ) {
    this.hasImage = hasImage;
    this.src = src;
    this.base64 = base64;
    this.height = height;
    this.width = width;
    this.resized = resized;
    this.altText = altText;
    this.caption = caption;
    this._debug = _debug;
  }
}

/**
 * A typeguarded version of `instanceof Error` for NodeJS.
 * author: Joseph JDBar Barron
 * {@link https://dev.to/jdbar}
 */

export function instanceOfNodeError&lt;T extends new (...args: unknown[]) =&gt; Error&gt;(
  value: Error,
  errorType: T,
): value is InstanceType&lt;T&gt; &amp; NodeJS.ErrnoException {
  return value instanceof errorType;
}

// check in relative assets folder &amp; public folder
const checkImgExists = (imgPath: string) =&gt; {
  let exists = false;

  // read image
  // 1. check if path exists
  if (existsSync(imgPath)) {
    exists = true;
  }

  return { exists };
};

const checkDuplicate = async (imgPathOne: string, imgPathTwo: string) =&gt; {
  let isDupe = 0;
  // WARNING: assumes both images exist
  const imgOne = await lstat(imgPathOne);
  const imgTwo = await lstat(imgPathTwo);
  if (imgOne.size === imgTwo.size) {
    isDupe = 1;
  }

  return isDupe;
};

interface Debug {
  destination: string;
  status: {
    exists: boolean;
    existsInPublic: boolean;
  };
  didCopy: string;
  reason: string;
}

interface ImageMoverRes {
  url: string;
  local: string;
  _meta: null | Debug;
}

const imageMover = async ({
  contentDir,
  prefix,
  imgPath,
  debug,
}: {
  contentDir: string;
  prefix: string;
  imgPath: string;
  debug?: boolean;
}): Promise&lt;ImageMoverRes&gt; =&gt; {
  if (debug) {
    console.debug(&#x27;image:&#x27;, imgPath);
    console.debug(&#x27;url:&#x27;, prefix);
  }
  /*
   * we essentially need to work backwards from
   * where the post is located (postParent), and the
   * location of the relative asset (imgPath).
   */
  const postParentRaw = prefix.split(&#x27;/&#x27;);
  postParentRaw.shift(); // removes CONTENT_DIR
  postParentRaw.pop(); // removes post filename
  const postParent = postParentRaw.join(&#x27;/&#x27;);
  const urlPath = path.join(postParent, imgPath); // location of the image in the public folder.
  const rawPath = path.resolve(path.join(contentDir, postParent, imgPath));
  const publicPath = path.resolve(path.join(&#x27;./public&#x27;, postParent, imgPath));

  if (debug) {
    console.debug(&#x27;rawPath:&#x27;, rawPath);
    console.debug(&#x27;publicPath:&#x27;, publicPath);
  }

  const statusOne = checkImgExists(rawPath);
  const statusTwo = checkImgExists(publicPath);
  const status = {
    exists: statusOne.exists,
    existsInPublic: statusTwo.exists,
  };

  let copied = 0;
  let isDupe = 0;

  // using sync functions, it&#x27;s faster for our purposes.
  const copier = (from: string, to: string) =&gt; {
    try {
      // important to make the directory, it won&#x27;t copy otherwise (ENOENT).
      mkdirSync(path.dirname(to), { recursive: true });
      copyFileSync(from, to);
      copied = 1;
      console.debug(&#x27;copied successfully to:&#x27;, to);
    } catch (err) {
      console.error(err);
    }
  };

  // if valid &amp; in public folder, check if new image is the same
  if (status.exists &amp;&amp; status.existsInPublic) {
    isDupe = await checkDuplicate(rawPath, publicPath);
    if (isDupe === 0) {
      copier(rawPath, publicPath);
    }
  }
  // if valid, but not in public folder, copy it
  if (status.exists &amp;&amp; !status.existsInPublic) {
    copier(rawPath, publicPath);
  }

  const result = {
    url: urlPath,
    local: rawPath,
    _meta: debug
      ? {
          destination: publicPath,
          status,
          didCopy: copied === 1 ? &#x27;copy&#x27; : &#x27;no copy&#x27;,
          reason: isDupe === 0 ? &#x27;original&#x27; : &#x27;duplicate&#x27;,
        }
      : null,
  };

  return result;
};

interface BlurRes {
  base64: string;
  height: number;
  width: number;
}

const imageBlurBase64 = async (imgPath: string): Promise&lt;BlurRes&gt; =&gt; {
  const imgFile = await readFile(imgPath);
  const {
    base64,
    metadata: { height, width },
  } = await getPlaiceholder(imgFile);
  return { base64, height, width };
};

const imageResize = async (imgPath: string) =&gt; {
  const imgFile = await readFile(imgPath);
  const { data } = await sharp(imgFile)
    .resize(1600, 900, { kernel: &#x27;lanczos3&#x27; })
    .toFormat(&#x27;jpeg&#x27;, { mozjpeg: true })
    .toBuffer({ resolveWithObject: true });

  const baseSixtyFour = `data:image/jpeg;base64,${data.toString(&#x27;base64&#x27;)}`;
  return baseSixtyFour;
};

export interface FeaturedImageRes extends BlurRes {
  src: string;
  resized: string;
  _debug: null | Debug;
}

export const imageProcessor = async ({
  contentDir,
  prefix,
  imgPath,
  debug,
}: {
  contentDir: string;
  prefix: string;
  imgPath: string;
  debug?: boolean;
}): Promise&lt;FeaturedImageRes&gt; =&gt; {
  try {
    const imgCopyRes = await imageMover({ contentDir, prefix, imgPath, debug });
    const blurRes = await imageBlurBase64(imgCopyRes.local);
    const resize64 = await imageResize(imgCopyRes.local);
    return { src: `/${imgCopyRes.url}`, ...blurRes, resized: resize64, _debug: debug ? imgCopyRes._meta : null };
  } catch (err) {
    console.error(err);
  }
  return { src: &#x27;&#x27;, base64: &#x27;&#x27;, height: 0, width: 0, resized: &#x27;&#x27;, _debug: null };
};
</code></pre></details>
<p>Granted, they&#x27;re a little modified—there&#x27;s an additional image down scaler too—but the base of it is there. Likewise, if I hadn&#x27;t sunk all this energy into my own solution in the first place, I&#x27;d never have been able to truly appreciate all the work that goes into CMSes and projects like <code>contentlayer</code>, nor would I be able to know their limitations so closely either.</p>
<p>Which, speaking of, <code>contentlayer</code> isn&#x27;t perfect, it&#x27;s far from it. Because it relies on <code>json</code> to store content, it creates significantly inflated application bundles. It&#x27;s sort of a common complaint<sup><a href="#user-content-fn-75" id="user-content-fnref-75" data-footnote-ref="true" aria-describedby="footnote-label">75</a></sup>. Now, is it a solution that works for something small like a blog, yes, and damn well too. But, for something more data intensive, like a news aggregator? Forget it. You&#x27;d be much better served creating either the monstrosity I did earlier, or using an off the shelf CMS, because at least those can scale.</p>
<p>With that said, <code>contentlayer</code> for a site like mine, just seems like the right tool for the job. At this scale, it&#x27;s as close to perfect as I&#x27;m going to get, and I&#x27;m incredibly appreciative of all the work behind it too.</p>
<section data-footnotes="true" class="footnotes"><h2 class="sr-only" id="footnote-label">Footnotes</h2>
<ol>
<li id="user-content-fn-1">
<p>Timothy. timlrx/contentlayer2 [Internet]. 2024 [cited 2024 Sep 13]. Available from: <a href="https://github.com/timlrx/contentlayer2">https://github.com/timlrx/contentlayer2</a> <a href="#user-content-fnref-1" data-footnote-backref="" aria-label="Back to reference 1" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-2">
<p>Contentlayer Makes Working with Content Easy for Developers [Internet]. 2022 [cited 2024 Sep 13]. Available from: <a href="https://www.youtube.com/watch?v=58Pj4a4Us7A">https://www.youtube.com/watch?v=58Pj4a4Us7A</a> <a href="#user-content-fnref-2" data-footnote-backref="" aria-label="Back to reference 2" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-3">
<p>Blog Tool, Publishing Platform, and CMS [Internet]. WordPress.org. 2024 [cited 2024 Oct 6]. Available from: <a href="https://wordpress.org/">https://wordpress.org/</a> <a href="#user-content-fnref-3" data-footnote-backref="" aria-label="Back to reference 3" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-4">
<p>Ghost: The best open source blog &amp; newsletter platform [Internet]. Ghost - The Professional Publishing Platform. [cited 2024 Oct 6]. Available from: <a href="https://ghost.org/">https://ghost.org/</a> <a href="#user-content-fnref-4" data-footnote-backref="" aria-label="Back to reference 4" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-5">
<p>What is JAMstack? [Internet]. [cited 2024 Oct 6]. Available from: <a href="https://www.cloudflare.com/learning/performance/what-is-jamstack/">https://www.cloudflare.com/learning/performance/what-is-jamstack/</a> <a href="#user-content-fnref-5" data-footnote-backref="" aria-label="Back to reference 5" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-6">
<p>Headless CMS: Everything you need to know [Internet]. [cited 2024 Oct 6]. Available from: <a href="https://hygraph.com/learn/headless-cms">https://hygraph.com/learn/headless-cms</a> <a href="#user-content-fnref-6" data-footnote-backref="" aria-label="Back to reference 6" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-7">
<p>Strapi - Open source Node.js Headless CMS 🚀 [Internet]. [cited 2024 Oct 6]. Available from: <a href="https://strapi.io/">https://strapi.io/</a> <a href="#user-content-fnref-7" data-footnote-backref="" aria-label="Back to reference 7" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-8">
<p>Content that takes you everywhere [Internet]. [cited 2024 Oct 6]. Available from: <a href="https://www.contentful.com/">https://www.contentful.com/</a> <a href="#user-content-fnref-8" data-footnote-backref="" aria-label="Back to reference 8" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-9">
<p>The Best React-Based Framework [Internet]. Gatsby. [cited 2024 Oct 6]. Available from: <a href="https://www.gatsbyjs.com/">https://www.gatsbyjs.com/</a> <a href="#user-content-fnref-9" data-footnote-backref="" aria-label="Back to reference 9" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-10">
<p>Payload: The fastest growing open-source headless CMS [Internet]. Payload. [cited 2024 Oct 6]. Available from: <a href="https://payloadcms.com">https://payloadcms.com</a> <a href="#user-content-fnref-10" data-footnote-backref="" aria-label="Back to reference 10" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-11">
<p>The Open Source Headless CMS (and More) [Internet]. [cited 2024 Oct 6]. Available from: <a href="https://directus.io/">https://directus.io/</a> <a href="#user-content-fnref-11" data-footnote-backref="" aria-label="Back to reference 11" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-12">
<p>Supabase | The Open Source Firebase Alternative [Internet]. Supabase. [cited 2024 Oct 6]. Available from: <a href="https://supabase.com/">https://supabase.com/</a> <a href="#user-content-fnref-12" data-footnote-backref="" aria-label="Back to reference 12" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-13">
<p>PocketBase - Open Source backend in 1 file [Internet]. [cited 2024 Oct 6]. Available from: <a href="https://pocketbase.io/">https://pocketbase.io/</a> <a href="#user-content-fnref-13" data-footnote-backref="" aria-label="Back to reference 13" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-14">
<p>Futurama - Build my own themepark, with blackjack, and hookers [Internet]. 2020 [cited 2024 Oct 6]. Available from: <a href="https://www.youtube.com/watch?v=ubPWaDWcOLU">https://www.youtube.com/watch?v=ubPWaDWcOLU</a> <a href="#user-content-fnref-14" data-footnote-backref="" aria-label="Back to reference 14" class="data-footnote-backref">↩</a> <a href="#user-content-fnref-14-2" data-footnote-backref="" aria-label="Back to reference 14-2" class="data-footnote-backref">↩<sup>2</sup></a></p>
</li>
<li id="user-content-fn-15">
<p>Astro [Internet]. Astro. [cited 2024 Oct 6]. Available from: <a href="https://astro.build/">https://astro.build/</a> <a href="#user-content-fnref-15" data-footnote-backref="" aria-label="Back to reference 15" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-16">
<p>Content Collections [Internet]. Docs. [cited 2024 Oct 6]. Available from: <a href="https://docs.astro.build/en/guides/content-collections/">https://docs.astro.build/en/guides/content-collections/</a> <a href="#user-content-fnref-16" data-footnote-backref="" aria-label="Back to reference 16" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-17">
<p>Daring Fireball: Markdown [Internet]. [cited 2024 Oct 6]. Available from: <a href="https://daringfireball.net/projects/markdown/">https://daringfireball.net/projects/markdown/</a> <a href="#user-content-fnref-17" data-footnote-backref="" aria-label="Back to reference 17" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-18">
<p>The Official YAML Web Site [Internet]. [cited 2024 Oct 6]. Available from: <a href="https://yaml.org/">https://yaml.org/</a> <a href="#user-content-fnref-18" data-footnote-backref="" aria-label="Back to reference 18" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-19">
<p>Writing Markup with JSX – React [Internet]. [cited 2024 Oct 6]. Available from: <a href="https://react.dev/learn/writing-markup-with-jsx">https://react.dev/learn/writing-markup-with-jsx</a> <a href="#user-content-fnref-19" data-footnote-backref="" aria-label="Back to reference 19" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-20">
<p>Wormer JO and T. Markdown for the component era [Internet]. MDX. 2017 [cited 2024 Oct 6]. Available from: <a href="https://mdxjs.com/">https://mdxjs.com/</a> <a href="#user-content-fnref-20" data-footnote-backref="" aria-label="Back to reference 20" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-21">
<p>Rendering: Server Components | Next.js [Internet]. [cited 2024 Oct 6]. Available from: <a href="https://nextjs.org/docs/app/building-your-application/rendering/server-components">https://nextjs.org/docs/app/building-your-application/rendering/server-components</a> <a href="#user-content-fnref-21" data-footnote-backref="" aria-label="Back to reference 21" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-22">
<p>Configuring: MDX | Next.js [Internet]. [cited 2024 Oct 6]. Available from: <a href="https://nextjs.org/docs/app/building-your-application/configuring/mdx">https://nextjs.org/docs/app/building-your-application/configuring/mdx</a> <a href="#user-content-fnref-22" data-footnote-backref="" aria-label="Back to reference 22" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-23">
<p>next.js/examples/blog-starter at canary · vercel/next.js [Internet]. GitHub. [cited 2024 Oct 6]. Available from: <a href="https://github.com/vercel/next.js/tree/canary/examples/blog-starter">https://github.com/vercel/next.js/tree/canary/examples/blog-starter</a> <a href="#user-content-fnref-23" data-footnote-backref="" aria-label="Back to reference 23" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-24">
<p>examples/solutions/blog at main · vercel/examples [Internet]. GitHub. [cited 2024 Oct 6]. Available from: <a href="https://github.com/vercel/examples/tree/main/solutions/blog">https://github.com/vercel/examples/tree/main/solutions/blog</a> <a href="#user-content-fnref-24" data-footnote-backref="" aria-label="Back to reference 24" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-25">
<p>Vercel: Build and deploy the best web experiences with the Frontend Cloud [Internet]. Vercel. [cited 2024 Oct 6]. Available from: <a href="https://vercel.com/home">https://vercel.com/home</a> <a href="#user-content-fnref-25" data-footnote-backref="" aria-label="Back to reference 25" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-26">
<p>HTMLImageElement: src property - Web APIs | MDN [Internet]. 2023 [cited 2024 Oct 6]. Available from: <a href="https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/src">https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/src</a> <a href="#user-content-fnref-26" data-footnote-backref="" aria-label="Back to reference 26" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-27">
<p>HTMLImageElement: srcset property - Web APIs | MDN [Internet]. 2024 [cited 2024 Oct 6]. Available from: <a href="https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/srcset">https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/srcset</a> <a href="#user-content-fnref-27" data-footnote-backref="" aria-label="Back to reference 27" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-28">
<p>Components: &lt;Image&gt; | Next.js [Internet]. 2024 [cited 2024 Sep 13]. Available from: <a href="https://nextjs.org/docs/app/api-reference/components/image#loaderfile">https://nextjs.org/docs/app/api-reference/components/image#loaderfile</a> <a href="#user-content-fnref-28" data-footnote-backref="" aria-label="Back to reference 28" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-29">
<p>What is a content delivery network (CDN)? | How do CDNs work? [Internet]. [cited 2024 Oct 6]. Available from: <a href="https://www.cloudflare.com/learning/cdn/what-is-a-cdn/">https://www.cloudflare.com/learning/cdn/what-is-a-cdn/</a> <a href="#user-content-fnref-29" data-footnote-backref="" aria-label="Back to reference 29" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-30">
<p>Content Delivery Network - Amazon CloudFront - AWS [Internet]. Amazon Web Services, Inc. [cited 2024 Oct 6]. Available from: <a href="https://aws.amazon.com/cloudfront/">https://aws.amazon.com/cloudfront/</a> <a href="#user-content-fnref-30" data-footnote-backref="" aria-label="Back to reference 30" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-31">
<p>What is CRUD? [Internet]. Codecademy. [cited 2024 Oct 6]. Available from: <a href="https://www.codecademy.com/article/what-is-crud">https://www.codecademy.com/article/what-is-crud</a> <a href="#user-content-fnref-31" data-footnote-backref="" aria-label="Back to reference 31" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-32">
<p>Turso — SQLite for Production [Internet]. Turso. 2024 [cited 2024 Sep 13]. Available from: <a href="https://turso.tech">https://turso.tech</a> <a href="#user-content-fnref-32" data-footnote-backref="" aria-label="Back to reference 32" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-33">
<p>About SQLite [Internet]. [cited 2024 Oct 6]. Available from: <a href="https://www.sqlite.org/about.html">https://www.sqlite.org/about.html</a> <a href="#user-content-fnref-33" data-footnote-backref="" aria-label="Back to reference 33" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-34">
<p>tursodatabase/libsql [Internet]. Turso Database; 2024 [cited 2024 Oct 6]. Available from: <a href="https://github.com/tursodatabase/libsql">https://github.com/tursodatabase/libsql</a> <a href="#user-content-fnref-34" data-footnote-backref="" aria-label="Back to reference 34" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-35">
<p>What Is Database as a Service (DBaaS)? | IBM [Internet]. 2021 [cited 2024 Oct 6]. Available from: <a href="https://www.ibm.com/topics/dbaas">https://www.ibm.com/topics/dbaas</a> <a href="#user-content-fnref-35" data-footnote-backref="" aria-label="Back to reference 35" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-36">
<p>SQLite – API | Bun Docs [Internet]. Bun. [cited 2024 Oct 6]. Available from: <a href="https://bun.sh/docs/api/sqlite">https://bun.sh/docs/api/sqlite</a> <a href="#user-content-fnref-36" data-footnote-backref="" aria-label="Back to reference 36" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-37">
<p>Rumzan I. Is SQLite supported in Vercel? [Internet]. [cited 2024 Oct 6]. Available from: <a href="https://vercel.com/guides/is-sqlite-supported-in-vercel">https://vercel.com/guides/is-sqlite-supported-in-vercel</a> <a href="#user-content-fnref-37" data-footnote-backref="" aria-label="Back to reference 37" class="data-footnote-backref">↩</a> <a href="#user-content-fnref-37-2" data-footnote-backref="" aria-label="Back to reference 37-2" class="data-footnote-backref">↩<sup>2</sup></a></p>
</li>
<li id="user-content-fn-38">
<p>Read-only sqlite · vercel · Discussion #1181 [Internet]. GitHub. [cited 2024 Oct 6]. Available from: <a href="https://github.com/orgs/vercel/discussions/1181">https://github.com/orgs/vercel/discussions/1181</a> <a href="#user-content-fnref-38" data-footnote-backref="" aria-label="Back to reference 38" class="data-footnote-backref">↩</a> <a href="#user-content-fnref-38-2" data-footnote-backref="" aria-label="Back to reference 38-2" class="data-footnote-backref">↩<sup>2</sup></a></p>
</li>
<li id="user-content-fn-39">
<p>Vercel Functions Limitations [Internet]. [cited 2024 Oct 6]. Available from: <a href="https://vercel.com/docs/functions/limitations">https://vercel.com/docs/functions/limitations</a> <a href="#user-content-fnref-39" data-footnote-backref="" aria-label="Back to reference 39" class="data-footnote-backref">↩</a> <a href="#user-content-fnref-39-2" data-footnote-backref="" aria-label="Back to reference 39-2" class="data-footnote-backref">↩<sup>2</sup></a></p>
</li>
<li id="user-content-fn-40">
<p>Functions: generateStaticParams | Next.js [Internet]. [cited 2024 Oct 6]. Available from: <a href="https://nextjs.org/docs/app/api-reference/functions/generate-static-params">https://nextjs.org/docs/app/api-reference/functions/generate-static-params</a> <a href="#user-content-fnref-40" data-footnote-backref="" aria-label="Back to reference 40" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-41">
<p>Drizzle ORM - next gen TypeScript ORM. [Internet]. 2024 [cited 2024 Sep 13]. Available from: <a href="https://orm.drizzle.team/">https://orm.drizzle.team/</a> <a href="#user-content-fnref-41" data-footnote-backref="" aria-label="Back to reference 41" class="data-footnote-backref">↩</a> <a href="#user-content-fnref-41-2" data-footnote-backref="" aria-label="Back to reference 41-2" class="data-footnote-backref">↩<sup>2</sup></a></p>
</li>
<li id="user-content-fn-42">
<p>Drizzle ORM - SQLite [Internet]. [cited 2024 Oct 6]. Available from: <a href="https://orm.drizzle.team/docs/get-started-sqlite">https://orm.drizzle.team/docs/get-started-sqlite</a> <a href="#user-content-fnref-42" data-footnote-backref="" aria-label="Back to reference 42" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-43">
<p>Overview | Cloudflare D1 docs [Internet]. Cloudflare Docs. [cited 2024 Oct 6]. Available from: <a href="https://developers.cloudflare.com/d1/">https://developers.cloudflare.com/d1/</a> <a href="#user-content-fnref-43" data-footnote-backref="" aria-label="Back to reference 43" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-44">
<p>Turso Database Pricing [Internet]. [cited 2024 Oct 6]. Available from: <a href="https://turso.tech/pricing">https://turso.tech/pricing</a> <a href="#user-content-fnref-44" data-footnote-backref="" aria-label="Back to reference 44" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-45">
<p>Prisma | Simplify working and interacting with databases [Internet]. Prisma. [cited 2024 Oct 6]. Available from: <a href="https://www.prisma.io">https://www.prisma.io</a> <a href="#user-content-fnref-45" data-footnote-backref="" aria-label="Back to reference 45" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-46">
<p>Drizzle ORM - Magic sql operator [Internet]. [cited 2024 Oct 6]. Available from: <a href="https://orm.drizzle.team/docs/sql">https://orm.drizzle.team/docs/sql</a> <a href="#user-content-fnref-46" data-footnote-backref="" aria-label="Back to reference 46" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-47">
<p>Kale V, Sayin E. Impressive insults: How do consumers respond to self‐deprecating advertisements? Psychology &amp; Marketing. 2024 Jul 20;41:2695–2710. <a href="#user-content-fnref-47" data-footnote-backref="" aria-label="Back to reference 47" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-48">
<p>Liu C, Gao J. What makes a self-deprecating advertisement more persuasive? The role of self-uncertainty. Asia Pacific Journal of Marketing and Logistics. 2023 Jul 11;36. <a href="#user-content-fnref-48" data-footnote-backref="" aria-label="Back to reference 48" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-49">
<p>Drizzle ORM - Config Reference [Internet]. [cited 2024 Oct 6]. Available from: <a href="https://orm.drizzle.team/kit-docs/config-reference">https://orm.drizzle.team/kit-docs/config-reference</a> <a href="#user-content-fnref-49" data-footnote-backref="" aria-label="Back to reference 49" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-50">
<p>Drizzle + Turso [Internet]. Turso. [cited 2024 Oct 6]. Available from: <a href="https://docs.turso.tech/sdk/ts/orm/drizzle">https://docs.turso.tech/sdk/ts/orm/drizzle</a> <a href="#user-content-fnref-50" data-footnote-backref="" aria-label="Back to reference 50" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-51">
<p>Glob Tool | DigitalOcean [Internet]. [cited 2024 Oct 6]. Available from: <a href="https://www.digitalocean.com/community/tools/glob">https://www.digitalocean.com/community/tools/glob</a> <a href="#user-content-fnref-51" data-footnote-backref="" aria-label="Back to reference 51" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-52">
<p>Node.js — How to read environment variables from Node.js [Internet]. [cited 2024 Oct 6]. Available from: <a href="https://nodejs.org/en/learn/command-line/how-to-read-environment-variables-from-nodejs">https://nodejs.org/en/learn/command-line/how-to-read-environment-variables-from-nodejs</a> <a href="#user-content-fnref-52" data-footnote-backref="" aria-label="Back to reference 52" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-53">
<p>What is SST [Internet]. SST. 2024 [cited 2024 Oct 6]. Available from: <a href="https://sst.dev/docs/">https://sst.dev/docs/</a> <a href="#user-content-fnref-53" data-footnote-backref="" aria-label="Back to reference 53" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-54">
<p>Secret [Internet]. SST. [cited 2024 Oct 6]. Available from: <a href="https://sst.dev/docs/component/secret/">https://sst.dev/docs/component/secret/</a> <a href="#user-content-fnref-54" data-footnote-backref="" aria-label="Back to reference 54" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-55">
<p>Linking [Internet]. SST. 2024 [cited 2024 Oct 6]. Available from: <a href="https://sst.dev/docs/linking/">https://sst.dev/docs/linking/</a> <a href="#user-content-fnref-55" data-footnote-backref="" aria-label="Back to reference 55" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-56">
<p>Property accessors - JavaScript | MDN [Internet]. 2024 [cited 2024 Oct 6]. Available from: <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Property_accessors">https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Property_accessors</a> <a href="#user-content-fnref-56" data-footnote-backref="" aria-label="Back to reference 56" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-57">
<p>Configuring: Environment Variables | Next.js [Internet]. [cited 2024 Oct 6]. Available from: <a href="https://nextjs.org/docs/app/building-your-application/configuring/environment-variables">https://nextjs.org/docs/app/building-your-application/configuring/environment-variables</a> <a href="#user-content-fnref-57" data-footnote-backref="" aria-label="Back to reference 57" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-58">
<p>Drizzle ORM - Schema [Internet]. [cited 2024 Oct 7]. Available from: <a href="https://orm.drizzle.team/docs/sql-schema-declaration">https://orm.drizzle.team/docs/sql-schema-declaration</a> <a href="#user-content-fnref-58" data-footnote-backref="" aria-label="Back to reference 58" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-59">
<p>Three Table Types Relationship (1:1, 1:n, m:n) [Internet]. [cited 2024 Oct 7]. Available from: <a href="https://www.relationaldbdesign.com/database-design/module6/three-relationship-types.php">https://www.relationaldbdesign.com/database-design/module6/three-relationship-types.php</a> <a href="#user-content-fnref-59" data-footnote-backref="" aria-label="Back to reference 59" class="data-footnote-backref">↩</a> <a href="#user-content-fnref-59-2" data-footnote-backref="" aria-label="Back to reference 59-2" class="data-footnote-backref">↩<sup>2</sup></a></p>
</li>
<li id="user-content-fn-60">
<p>Datatypes In SQLite [Internet]. [cited 2024 Oct 7]. Available from: <a href="https://www.sqlite.org/datatype3.html">https://www.sqlite.org/datatype3.html</a> <a href="#user-content-fnref-60" data-footnote-backref="" aria-label="Back to reference 60" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-61">
<p>Drizzle ORM - SQLite column types [Internet]. [cited 2024 Oct 7]. Available from: <a href="https://orm.drizzle.team/docs/column-types/sqlite">https://orm.drizzle.team/docs/column-types/sqlite</a> <a href="#user-content-fnref-61" data-footnote-backref="" aria-label="Back to reference 61" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-62">
<p>Davis KR, Peabody B, Leach P. Universally Unique IDentifiers (UUIDs) [Internet]. Internet Engineering Task Force; 2024 May. Report No.: RFC 9562. Available from: <a href="https://datatracker.ietf.org/doc/rfc9562">https://datatracker.ietf.org/doc/rfc9562</a> <a href="#user-content-fnref-62" data-footnote-backref="" aria-label="Back to reference 62" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-63">
<p>Crypto: randomUUID() method - Web APIs | MDN [Internet]. 2024 [cited 2024 Oct 7]. Available from: <a href="https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID">https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID</a> <a href="#user-content-fnref-63" data-footnote-backref="" aria-label="Back to reference 63" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-64">
<p>Drizzle ORM - Goodies [Internet]. [cited 2024 Oct 7]. Available from: <a href="https://orm.drizzle.team/docs/goodies">https://orm.drizzle.team/docs/goodies</a> <a href="#user-content-fnref-64" data-footnote-backref="" aria-label="Back to reference 64" class="data-footnote-backref">↩</a> <a href="#user-content-fnref-64-2" data-footnote-backref="" aria-label="Back to reference 64-2" class="data-footnote-backref">↩<sup>2</sup></a></p>
</li>
<li id="user-content-fn-65">
<p>Drizzle ORM - Query [Internet]. [cited 2024 Oct 7]. Available from: <a href="https://orm.drizzle.team/docs/rqb#declaring-relations">https://orm.drizzle.team/docs/rqb#declaring-relations</a> <a href="#user-content-fnref-65" data-footnote-backref="" aria-label="Back to reference 65" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-66">
<p>Drizzle ORM - Turso [Internet]. [cited 2024 Oct 7]. Available from: <a href="https://orm.drizzle.team/docs/connect-turso">https://orm.drizzle.team/docs/connect-turso</a> <a href="#user-content-fnref-66" data-footnote-backref="" aria-label="Back to reference 66" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-67">
<p>Drizzle ORM - <code>push</code> [Internet]. [cited 2024 Oct 7]. Available from: <a href="https://orm.drizzle.team/docs/drizzle-kit-push">https://orm.drizzle.team/docs/drizzle-kit-push</a> <a href="#user-content-fnref-67" data-footnote-backref="" aria-label="Back to reference 67" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-68">
<p>CLI [Internet]. SST. [cited 2024 Oct 7]. Available from: <a href="https://sst.dev/docs/reference/cli/">https://sst.dev/docs/reference/cli/</a> <a href="#user-content-fnref-68" data-footnote-backref="" aria-label="Back to reference 68" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-69">
<p>File I/O – API | Bun Docs [Internet]. Bun. 2024 [cited 2024 Sep 13]. Available from: <a href="https://bun.sh/docs/api/file-io">https://bun.sh/docs/api/file-io</a> <a href="#user-content-fnref-69" data-footnote-backref="" aria-label="Back to reference 69" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-70">
<p>Schlinkert J. jonschlinkert/gray-matter [Internet]. 2024 [cited 2024 Sep 13]. Available from: <a href="https://github.com/jonschlinkert/gray-matter">https://github.com/jonschlinkert/gray-matter</a> <a href="#user-content-fnref-70" data-footnote-backref="" aria-label="Back to reference 70" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-71">
<p>Plaiceholder [Internet]. 2023 [cited 2024 Sep 13]. Available from: <a href="https://plaiceholder.co/docs">https://plaiceholder.co/docs</a> <a href="#user-content-fnref-71" data-footnote-backref="" aria-label="Back to reference 71" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-72">
<p>Base64 - MDN Web Docs Glossary: Definitions of Web-related terms | MDN [Internet]. 2024 [cited 2024 Oct 7]. Available from: <a href="https://developer.mozilla.org/en-US/docs/Glossary/Base64">https://developer.mozilla.org/en-US/docs/Glossary/Base64</a> <a href="#user-content-fnref-72" data-footnote-backref="" aria-label="Back to reference 72" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-73">
<p>Building Your Application: Caching | Next.js [Internet]. [cited 2024 Oct 7]. Available from: <a href="https://nextjs.org/docs/app/building-your-application/caching">https://nextjs.org/docs/app/building-your-application/caching</a> <a href="#user-content-fnref-73" data-footnote-backref="" aria-label="Back to reference 73" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-74">
<p>Dodds KC. mdx-bundler: Compile and bundle your MDX files and their dependencies. FAST. [Internet]. 2021 [cited 2024 Sep 13]. Available from: <a href="https://github.com/kentcdodds/mdx-bundler">https://github.com/kentcdodds/mdx-bundler</a> <a href="#user-content-fnref-74" data-footnote-backref="" aria-label="Back to reference 74" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-75">
<p>Sdorra S. Contentlayer, MDX and the vercel edge function size limit | sdorra.dev [Internet]. A site about software development by Sebastian Sdorra. 2022 [cited 2024 Sep 29]. Available from: <a href="https://sdorra.dev/posts/2022-11-24-contentlayer-mdx-edge">https://sdorra.dev/posts/2022-11-24-contentlayer-mdx-edge</a> <a href="#user-content-fnref-75" data-footnote-backref="" aria-label="Back to reference 75" class="data-footnote-backref">↩</a></p>
</li>
</ol>
</section>]]></content>
  </entry>
  <entry>
    <title>Thoughts on Rebuilding my Website, Next.js 14.2+, &amp; SST</title>
    <link rel="alternate" href="https://laniakita.com/blog/thoughts-on-rebuilding-my-personal-website"/>
    <id>https://laniakita.com/blog/thoughts-on-rebuilding-my-personal-website</id>
    <updated>2024-09-03T23:46:58.000Z</updated>
    <category term="/categories/meta" scheme="https://laniakita.com/categories/meta" label="Meta"/>
    <category term="/categories/full-stack" scheme="https://laniakita.com/categories/full-stack" label="Full Stack"/>
    <category term="/tags/next-js" scheme="https://laniakita.com/tags/next-js" label="Next.js"/>
    <category term="/tags/sst" scheme="https://laniakita.com/tags/sst" label="SST"/>
    <content type="html"><![CDATA[<figure><img src="https://laniakita.com/images/oc/2024/08/sunset-no-14.2-by-lani-akita.png" alt="Abstract art of a conical gradient. Next.js represented by darker colors on the left, Ion/SST represented by lighter colors on the right." /><figcaption>The totality is not, as it were, a mere heap, but the whole is something besides the parts; there is a cause. - Aristotle</figcaption></figure> <details open=""><summary>September update</summary><p>Ion is now stable as SST v3! I&#x27;ve gone ahead and updated some stale parts of this article.</p><p>I&#x27;ve also since learned more about how Open Next works, and how SST deploys the bundle created by Open Next. You&#x27;ll see <del>strikethroughs</del> around my old statements.</p></details>
<p>This article was a long time coming, it&#x27;s also quite long. In some respects, this might feel like an article created from a few separate articles I stitched together, and perhaps it is. However, I felt it was important to talk about the primary technologies I used in developing/deploying this site, because they&#x27;re all pretty tightly integrated.</p>
<p>To elaborate, Next.js is the application framework I went with, and I deployed it with <del>Ion</del> SST. The latter is important because it uses OpenNext as a serverless adapter, which takes the output from <code>next build</code>, and splits it up into bundles that can deploy on specific AWS Lambda functions. This effectively modifies how a production Next.js application <del>is compiled</del> runs to ensure compatibility with AWS Lambda / Lambda@edge. In other words, modifying how it runs, modifies the behavior of the production site itself (a bit). So, I really wanted to talk about it.</p>
<p>As for the WebGL stuff, admittedly, that section could&#x27;ve gone into a separate article. However, learning how to create with those specific technologies was such a significant motivator for myself in rebuilding this site, that it was worth including.</p>
<h2 id="abstract">Abstract</h2>
<p>This article catalogs some of my thoughts on:</p>
<ul>
<li>Rebuilding this site</li>
<li>Developing with Next.js</li>
<li>Deploying with Ion/SST</li>
<li>Working with WebGL wrappers/libraries like Three.js &amp; React Three Fiber (a Three.js renderer for React).</li>
</ul>
<p>As well, I break down any last thoughts in the discussion section.</p>
<h2 id="introduction">Introduction</h2>
<p><a href="https://nextjs.org/">Next.js</a> is to <a href="https://react.dev/">React</a>, like a glass slipper is to <a href="https://en.wikipedia.org/wiki/Cinderella">Cinderella</a>; they fit perfectly together (usually). The same could be said of <a href="https://vercel.com/">Vercel</a>’s Cloud Platform and Next.js too, and yet, I used a tool called <a href="https://ion.sst.dev/docs/">Ion</a> to deploy this site on AWS instead. While I think Vercel is a fantastic choice for most use cases, especially for Next.js applications, something about Ion just caught my eye.</p>
<p>If you haven’t heard of it, Ion is the <a href="https://sst.dev/blog/moving-away-from-cdk.html#what-is-ion">upcoming v3</a> of <a href="https://sst.dev/">SST</a>, and it’s quite the Swiss Army knife of deployment tools. This is especially so with the switch from AWS <a href="https://aws.amazon.com/cdk/">CDK</a>/<a href="https://aws.amazon.com/cloudformation/">CFN</a> to <a href="https://www.pulumi.com/">Pulumi</a> &amp; <a href="https://www.terraform.io/">Terraform</a> providers to handle your Infrastructure as Code (IaC). In my use case, I used Ion’s <a href="https://ion.sst.dev/docs/component/aws/nextjs/"><code>Nextjs</code> component</a> which does a few very handy things in addition to some Next.js specific IaC which we’ll get to later.</p>
<p>Now, the main reason for rebuilding my personal site was simply because it no longer reflected my current skill set. Unlike the site you’re reading this on, my old site—nothing fancy, just a <a href="https://www.gatsbyjs.com/">Gatsby</a> frontend on <a href="https://aws.amazon.com/amplify/">Amplify</a>—from a few years back was much less <em>refined</em>. So, I decided to wipe the slate clean, but I had some goals in mind. I wanted this website to be modern, yet <em>ambitious</em>, with an accompanying codebase that was flexible (read: organized) enough for me to feed my lust for cutting-edge technologies for years to come.</p>
<p>While the latter condition was solved by simply setting this site up in a monorepo (I used <a href="https://turbo.build/">Turborepo</a> for that), everything else meant becoming quite adventurous with this project’s stack. I specifically chose technologies that would push me well beyond my comfort zone to both learn and to integrate. That ultimately meant leaving the ease and familiarity of <a href="https://svelte.dev/">Svelte</a> and <a href="https://kit.svelte.dev/">SvelteKit</a> to build this site with tools I’d once found overcomplicated and confusing—React and Next.js.</p>
<p>Likewise, it also meant learning a subfield of programming that I was once wholly intimidated by, graphics. In effect, I used <a href="https://threejs.org/">Three.js</a>, <a href="https://docs.pmnd.rs/react-three-fiber/getting-started/introduction">React Three Fiber</a>, and even some <a href="https://www.khronos.org/opengl/wiki/Core_Language_(GLSL)">GLSL</a> to add some fun WebGL-flare. This can be seen in the shader on the landing page, and also <a href="/work/bot-clicker">this mini-game</a> that almost became such a page. I also happened to stumble upon <a href="https://zustand-demo.pmnd.rs/">Zustand</a>, a lightweight state management system for React, which integrated with the latter very well.</p>
<details open=""><summary>September update</summary><p>I&#x27;m now using <a href="https://github.com/timlrx/contentlayer2"><code>timlrx/Contentlayer2</code></a> to handle the data layer. My bespoke solution was fun, but <code>contentlayer2</code> was a lot more practical. I&#x27;ll be writing about it in an upcoming blog post. But, I&#x27;ll still walk through the below in that article too, since creating your own content backend is quite a project.</p><p>Likewise, I&#x27;m using it&#x27;s integrated version of <code>mdx-bundler</code>, which is convenient in that it performs the <code>esbuild</code> step before <code>next build</code>. That means if I felt like it, these posts could be server rendered at the edge. That&#x27;s pretty cool.</p></details>
<p><del>Furthermore, I even abandoned the various headless CMSes I’ve grown comfortable with over the years, in favor of something I can call my own. In effect, I wrote a prebuild script to assemble my <em>data layer</em> which takes advantage of <a href="https://bun.sh/">Bun</a>’s speedy <a href="https://bun.sh/docs/api/file-io">File I/O API</a> to find and process MDX files with <a href="https://github.com/jonschlinkert/gray-matter">gray-matter</a>—and any contained featured images with <a href="https://plaiceholder.co/docs">plaiceholder</a>—saving the resultant array of objects into a SQLite-ish (<a href="https://github.com/tursodatabase/libsql">libSQL</a>) database on <a href="https://turso.tech/">Turso</a> via <a href="https://orm.drizzle.team/">Drizzle ORM</a> statements. I then fetch that data inside a server component to render it out in various ways, such as with <a href="https://github.com/kentcdodds/mdx-bundler"><code>mdx-bundler</code></a>.</del></p>
<p>I also wrote a <del>prebuild</del> <a href="https://nextjs.org/docs/app/building-your-application/routing/route-handlers">route handler</a> for the <a href="/atom.xml">blog’s web feed</a>. <del>That one</del> It fetches the blog posts from <code>contentlayer</code>, and integrates them into a purpose built object which is turned into an XML string with <a href="https://github.com/davidcalhoun/jstoxml"><code>jstoxml</code></a>. While I’d like to talk about this data layer portion further, it is, admittedly, a bit extensive. So, you’ll have to wait to read about in a later blog post.</p>
<p>Beyond that, I’m overall really happy with how everything turned out. While Next.js and Ion/SST aren’t without their flaws—something not too unexpected of bleeding-edge tools—the pairing still came together quite nicely (in tandem with everything else) to create something I’m quite proud of. Even if there’s still bugs (read: mistakes) I’d like to fix in the <a href="https://github.com/laniakita/website/tree/master/apps/laniakita-web">codebase</a>, the end result works like it should for the most part. Page load times are quite fast. Its Lighthouse scores are decent enough (admittedly, I’m sure there’s still more I could do to improve accessibility). There’s still plenty of room for me to grow and build on top of it. As such, I don’t regret developing this site with Next.js or shipping it with Ion/SST at all!</p>
<h2 id="what-i-like-about-nextjs">What I Like About Next.js</h2>
<p>Next.js is a batteries-included full stack framework that does much more than just offer bleeding-edge features from React’s canary branch. Its expansive range of APIs and configuration options is simply unparalleled as far as full stack frameworks for React go.</p>
<p>What I like most about Next.js, is its offering of convenient metadata generation tools, and the deep level of control you have over its various rendering, routing, and caching strategies. Not to mention the customizations you can make to its new compiler+bundler, SWC, too.</p>
<p>With features like the above, is it really a huge wonder as to why Next.js might be the most popular full stack framework in existence?</p>
<h3 id="app-router--server-components">App Router (&amp; Server Components)</h3>
<p>The <a href="https://nextjs.org/docs/app">App router</a> and <a href="https://react.dev/reference/rsc/server-components#">React Server Components</a> are probably old news at this point for most React devs, but it was new to me. So, on the off chance you’re unfamiliar with it, lemme give you a quick recap.</p>
<p>The App router is more than just a refreshed Pages router that includes the ability to co-locate components with a corresponding page/directory. It’s a major rewrite that enables functionality with React Server Components, an upcoming API in <a href="https://react.dev/blog/2024/04/25/react-19">React 19</a> that has already caused a monumental shift in the React landscape. The very nature of server components even solves the majority of security risks involved with React based applications (see: <a href="https://nextjs.org/blog/security-nextjs-server-components-actions">How to Think About Security in Next.js</a>). In short, server components are kind of a big deal.</p>
<p>Admittedly, I’m still pretty enthusiastic about server components, even after all this time fussing with them in building this site. In fact, their addition was a bit of a factor in luring me back over to the React side of things in the first place.</p>
<p>While they do create some frustrating scenarios, such as working out how to delicately <a href="https://nextjs.org/docs/app/building-your-application/rendering/composition-patterns#interleaving-server-and-client-components">interleave client and server components</a>, there’s just something neat about them. Perhaps there’s something to creating data heavy UI components server side, then shipping that to the client browser as pure markup, that just makes my brain happy.</p>
<p>Now, I’d like to shift our focus back to the App router, away from all this gushing over server components, however, that’s not exactly possible. One of the more prominent features enabled by this tight integration of server components with the App router is something called <a href="https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming">streaming</a>.</p>
<p>Streaming is a pretty big benefit of server components, and I believe it can improve the user experience by quite a bit. The largest benefit to streaming, in my opinion, is summarized nicely in the <a href="https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming#streaming-with-suspense">Next.js docs</a>:</p>
<blockquote>
<p>Streaming is particularly beneficial when you want to prevent long data requests from blocking the page from rendering as it can reduce the Time To First Byte (TTFB) and First Contentful Paint (FCP). It also helps improve Time to Interactive (TTI), especially on slower devices.</p>
</blockquote>
<p>I should mention however, that streaming isn’t a configuration constant, or a special component like the App router’s instant fallback <a href="https://nextjs.org/docs/app/api-reference/file-conventions/loading">loading UI</a> feature. Streaming is instead, a server-side rendering pattern that can be implemented on SSR pages <em>only</em>.</p>
<p>When implemented properly, streaming results in progressively sending components over to the client browser from fastest to slowest fetch request payload. This is done by simply wrapping each component found on such an SSR page with a <a href="https://react.dev/reference/react/Suspense"><code>&lt;Suspense /&gt;</code></a> boundary.</p>
<p>In effect, components that don’t fetch any data will load first, while components that do, will <em>stream-in</em> to the client browser in the order it takes to complete their respective fetch requests. As such, streaming creates a better browsing experience as the page won’t feel <em>stuck</em> as it loads in. Well, given you can ship enough of the UI for it to feel that way, but I digress.</p>
<p>While these blog posts aren’t SSR yet—ergo no streaming—I’ve prepared these post pages to do exactly that in using <a href="https://github.com/kentcdodds/mdx-bundler"><code>mdx-bundler</code></a>. It relies on <a href="https://esbuild.github.io/getting-started/#bundling-for-the-browser">esbuild</a> to render <code>mdx</code> strings live in production. As such, it really won’t be difficult to implement the streaming rendering pattern when I do. Especially since I already invested in the App router.</p>
<p>Overall, I&#x27;m quite satisfied in my decision to use the App router as my page routing model. Beyond the App routers integration of server components, it’s forward thinking-features like streaming that improve the user experience, gives me hope for the next-generation of web applications (is that why it’s called <em><ins>Next</ins>.js?</em>), and that makes me very happy.</p>
<h3 id="metadata--metadata-accessories">Metadata &amp; Metadata Accessories</h3>
<details open=""><summary>September update</summary><p>In addition to the below, I&#x27;ve implemented <code>next/og</code> on an API endpoint / route handler to generate OpenGraph/Twitter images on demand. You&#x27;ll notice these blog posts get the featured image if you share it, while blog posts without an image will resemble the images generated for a page like <a href="/credits/bot-clicker">credits/bot-clicker</a>. I&#x27;d originally intended to take advantage of the edge runtime for this, but the data I needed didn&#x27;t seem to be making it into the generated <code>Lambda@edge</code> bundle. So, these generate dynamically from a standard <code>Lambda</code> running the standard <code>node.js</code> runtime instead. You can see how I&#x27;m doing this here: <a href="https://github.com/laniakita/website/blob/master/apps/web/src/app/opengraph/%5B...path%5D/route.tsx">laniakita/website</a></p><p>This was quite a feat. It even led to me writing my own custom <code>middleware.ts</code> file. So, I&#x27;ll try to write a blog post for this later.</p></details>
<p>With Meta having developed and maintained React, and React having not-so-subtly championed Next.js as the premier framework for React (at least for the bleeding edge branch), then, you might deduce that Next.js would be great at generating and working with metadata. But is that true? Yes, yes it is.</p>
<p>To demonstrate, there’s quite a lot of <a href="https://nextjs.org/docs/app/building-your-application/optimizing/metadata">metadata APIs</a> included in Next.js by default. To give you an idea of how much, I put together the following list. It&#x27;s an overview of the metadata APIs I’m currently using for this site.</p>
<ul>
<li>From the <a href="https://nextjs.org/docs/app/api-reference/functions/generate-metadata"><code>generateMetadata</code> API</a>
<ul>
<li>To generate meta tagSlugs on simple static pages, I’m using the <a href="https://nextjs.org/docs/app/api-reference/functions/generate-metadata#the-metadata-object"><code>metadata</code> Object</a>.</li>
<li>To generate meta tagSlugs dynamically on dynamic routes I’m using the namesake <a href="https://nextjs.org/docs/app/api-reference/functions/generate-metadata#generatemetadata-function"><code>generateMetadata</code> function</a>.</li>
</ul>
</li>
<li>From the <a href="https://nextjs.org/docs/app/api-reference/file-conventions/metadata">Metadata files API</a>
<ul>
<li>To auto-generate the various icon meta tagSlugs, I’m using <a href="https://nextjs.org/docs/app/api-reference/file-conventions/metadata/app-icons">favicon, icon, apple-icon</a> file conventions.</li>
<li>To generate the robots.txt, I’m using a special <a href="https://nextjs.org/docs/app/api-reference/file-conventions/metadata/robots"><code>robots.ts</code></a> file.</li>
<li>To generate the <code>sitemap.xml</code>, I’m using the <a href="https://nextjs.org/docs/app/api-reference/functions/generate-sitemaps"><code>generateSitemaps</code> function from its namesake API</a>.</li>
</ul>
</li>
</ul>
<p>The <code>generateMetadata API</code> is quite handy, especially it’s namesake function. Even if the static <code>{metadata}</code> object is somewhat tedious to fill out, it’s highly preferred over the tedious nature of adding (and updating) each little meta tag in JSX. Likewise, the namesake <code>generateMetadata</code> function that gets used on dynamic routes, saves both my fingers and myself an even larger amount of time given the dynamic nature of the metadata.</p>
<p>The <code>generateMetadata</code> function can even be paired with the <a href="https://nextjs.org/docs/app/api-reference/functions/generate-static-params"><code>generateStaticParams</code> function API</a> on SSG dynamic routes as well. Funnily enough, it even works similarly to the <code>generateStaticParams</code> function too. The main difference of course between it and the latter, is that the returned data to map over is plugged into a <code>{metadata}</code> object, instead of the <code>{params}</code> object.</p>
<p>Aside, the Metadata files API has quite a lot of time saving utilities too. Beyond the <em>special files</em> that allow you to generate things like the <code>robots.txt</code> or <code>sitemap.xml</code> with functions similar to those in the <code>generateMetadata API</code>, the file-based metadata generation for icons is a feature that I really love.</p>
<p>Like the <code>generateMetadata API</code>, it too has saved me quite a lot of the mind-numbingly tedious grunt work in hard-coding the different icons into their respective meta tagSlugs. The difference (and best part) of course, is that it does this automatically without even an object! All you need to do is simply give your icon a corresponding <code>filename</code> to its file format, in accordance with the icon file conventions under the Metadata files API, et voilà! Essential icons automatically defined in your site’s header!</p>
<p>While these APIs go more in depth, metadata utilities are one of the crazy-cool <em>sleeper</em> features that Next.js provides. Features that just seem to be absent in other frameworks. I suppose this might be because metadata &amp; metadata tools don’t seem all that important in the Proof-of-Concept stage. However, given the ubiquity of metadata and its uses, metatagSlugs can add <em>A LOT</em> of polish to the production build of a web application, in my opinion.</p>
<p>Regardless of how you feel about SEO, metadata shows up everywhere. The little tagSlugs are used in way more places than just search engine algorithms. In the web browser alone, favicon tagSlugs show a site’s icon, title tagSlugs name the current tab, and minor tagSlugs like the author and a date show up in a browser’s reader view. Likewise, metadata and thus meta tagSlugs, are pretty much the sole determining factor in how a website will preview itself on social media.</p>
<p>For those reasons, I’m quite pleased with the suite of metadata tools Next.js provides OOTB. Especially because tools like these set Next.js worlds apart from other frameworks. While it’s not so complicated to just write your own in-house versions of these tools, having these <em>batteries</em> make Next.js an attractive option for myself and I imagine other <del>lazy</del> <strong><em>time efficient</em></strong> devs as well.</p>
<h3 id="server-actions-form-actions">Server Actions (Form Actions)</h3>
<p>Admittedly, I’ve not had a chance to play with the <a href="https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations">Server Actions API</a> too much aside from experiments with handling a user’s theme preference via cookies.</p>
<details><summary>Note: Handling theme prefs via cookies</summary><p>Speaking of, there’s a really wonderful <a href="https://www.mandalinedev.com/articles/color-themes-next-js">article by Mandaline</a> that talks about how to do exactly that. I happened to stumble across it in this <a href="https://github.com/vercel/next.js/discussions/53063">Next.js discussion thread</a> that summarizes the approaches to implementing a theme toggle on the latest versions of Next.js.</p><p>My solution was ultimately more traditional (why yes, I did just apply <code>suppressHydrationWarning</code> on the root <code>&lt;HTML /&gt;</code> element. <em>Thank you for noticing!</em> &gt;.&lt;), but handling it via cookies is quite a neat approach too (I can’t remember why I chose against it. Perhaps it had something to do with SSG?).</p></details>
<p>Aside, I should state my surprise to learn that native server side form handling didn’t really exist in Next.js until relatively recently with React 19. I say that because SvelteKit’s had this feature for a long while now (see: <a href="https://kit.svelte.dev/docs/form-actions"><em>form actions</em></a>). Granted, Next.js has had the option of creating a custom API endpoint for forms submitted client side for a long while too (see: <a href="https://nextjs.org/blog/next-14#forms-and-mutations">Next.js 14 announcement post that talks about this</a>), which I suppose gives you the same functionality. What’s really changed is that React, and by extension Next.js, offers a native API to handle this, which simplifies the process; a much welcome addition.</p>
<p>Regardless, server actions now being stable in Next.js 14 are a much-needed feature in my opinion, and I’m really grateful to have a Next.js equivalent to SvelteKit’s form actions. Likewise, I imagine that server actions likely make implementing an Authentication framework like <a href="https://lucia-auth.com/">Lucia</a> a little less complicated, which is quite a nice side effect to boot.</p>
<h3 id="have-rendering-your-way">Have Rendering Your Way</h3>
<p>The level of control you have over how you want to render routes/pages is really impressive. If you felt like it, you could statically generate (SSG) one route, and dynamically render (SSR) pages for another route. You could even do SSR on the edge for that route (or another one) if you felt like it. To do that, you just need to export a constant in your <code>page.tsx</code> or <code>layout.tsx</code>:</p>
<pre><code class="language-typescript">export const runtime = ‘edge’ // ‘nodejs’ (default) | ‘edge’
</code></pre>
<p>Now that I think about it, Next.js might be the only framework I’ve worked with that offers such a deep level of control over page rendering. I think the reason for this is that other frameworks (SvelteKit, Remix, Astro, etc.) have made SSR a first class (&amp; sometimes only) citizen. Granted adapters exist for SvelteKit (adapter-static), but it’s sorta an all or nothing decision, isn’t it?</p>
<details><summary>Note: Astro</summary><p>Apparently they’ve been doing <a href="https://astro.build/blog/hybrid-rendering/">Hybrid rendering since 2.0</a>. While the level of control isn’t as deep as Next’s, it’s more than good enough. I&#x27;m appreciative.</p></details>
<p>Aside, I find this quite a nifty feature of Next.js that is also pretty much unparalleled in other frameworks. I really appreciate the fact that I can render posts like this statically, and if I added it, I could render a <em>user dashboard</em> dynamically on request on the edge runtime, served at the edge. I could do all of that without it being an <em>all or nothing</em> decision. That’s just awesome!</p>
<p>As such, Next.js is in a league of its own in the rendering department. The level of control it offers is unmatched. If you’ve got a variety of content with different rendering needs, Next.js might just be the perfect framework for you.</p>
<h2 id="what-i-found-confusing-interesting-in-nextjs">What I Found <del>Confusing</del> <em>Interesting</em> in Next.js</h2>
<p>I won’t lie, the App router’s <a href="https://nextjs.org/docs/app/building-your-application/caching">caching mechanisms</a> are a <em>bit complicated.</em> You can learn more about how it works via this GitHub <a href="https://github.com/vercel/next.js/discussions/54075">discussion thread</a> in the main Next.js repo.</p>
<p>However, without reviewing it under a microscope, I don’t feel it&#x27;s possible for me to offer a nuanced opinion on it. I suppose I can point out that on SSG pages the <a href="https://nextjs.org/docs/app/building-your-application/caching#react-cache-function">React cache API</a> has been quite a wonderful feature. I use it to memoize calls to my DB, so that’s nice.</p>
<p>With that said, instead of griping about my ignorance, I’ll keep this section to something that’s a little odd for a mainstream framework like Next.js, <strong><em>undocumented features</em></strong>. Such mysterious quirks aren’t a bad thing <em>per se</em>, I imagine most software’s got a few, but Next.js likely has more than most.</p>
<p>Undoubtedly, these little mysteries are a result of Next’s bleeding-edge nature, since features are often added faster than the docs can be written. However, this does lead to some interesting scenarios. The most intriguing involving the configuration of the Next.js <a href="https://nextjs.org/docs/app/building-your-application/routing/middleware">middleware</a>.</p>
<h3 id="middleware--minimalmode">Middleware &amp; <code>minimalMode</code></h3>
<p>In brief, the Next.js <a href="https://nextjs.org/docs/app/building-your-application/routing/middleware">middleware</a> performs its namesake function, as a control layer that sits between client browser and the server. Typically, you’d extend the middleware by integrating it with the <a href="https://nextjs.org/docs/app/api-reference/functions/next-response">NextResponse API</a> to write logic that determines whether your site should produce a modified response based on an incoming request—e.g., when to allow a request for <a href="https://nextjs.org/docs/app/building-your-application/routing/middleware#cors">CORS</a>.</p>
<p>Beyond that, The middleware set to an undocumented configuration called <a href="https://github.com/vercel/next.js/discussions/29801"><code>minimalMode</code></a> is the <em>secret sauce</em> which enables Next.js to deploy <em>correctly</em> onto Vercel’s serverless platform. <a href="https://open-next.js.org/faq#will-my-nextjs-app-behave-the-same-as-it-does-on-vercel">The OpenNext FAQs</a> discuss this secret middleware configuration further. They explain a bit about how Vercel builds a Next.js application, how the middleware gets separated out in the minimalMode configuration to be deployed at the edge, and how that process relies on Vercel’s own proprietary infra.</p>
<p>While most developers working with Next.js (and by extension Vercel) have no need to spare a thought to this undocumented configuration, I found it an intriguing feature of Next.js nonetheless. Admittedly, that might have something to do with how I decided to <em>deploy</em> this Next.js application, but I digress.</p>
<h3 id="debug-flags">Debug Flags</h3>
<p>I’ll admit, secret debug flags aren’t as exciting as speculating on <code>minimalMode</code> and the mysteries surrounding Vercel’s proprietary infrastructure. However, this hidden flag may still come in handy during a late-night debugging session. So, let’s talk about it.</p>
<p>A while ago I’d found <a href="https://martincapodici.com/2023/06/08/nextjs-undocumented-features/">this post</a> by <a href="https://martincapodici.com/">Martin Capodici</a> on their blog that describes how they had uncovered an undocumented debug flag used to print out helpful diagnostics for the router cache in Next.js. All you need to do is set is <code>NEXT_PRIVATE_DEBUG_CACHE=1</code>, and the caching diagnostics are yours.</p>
<p>Like I said, it’s not the coolest secret flag in the world, but I did find something cool when I was reading up on it. Interestingly, I stumbled upon a rad little package called <a href="https://caching-tools.github.io/next-shared-cache/"><code>@neshca/cache-handler</code></a> that just so happens to describe the behavior of <code>NEXT_PRIVATE_DEBUG_CACHE</code> in a little more detail via their <a href="https://caching-tools.github.io/next-shared-cache/troubleshooting">troubleshooting guide</a>.</p>
<p>Granted, I don’t have a need for such a utility, but if you’re hosting a Next.js application on a distributed system like a k8s cluster, this might be the package for you. <code>@neshca/cache-handler</code> solves the cache validation issues borne from multiple instances of the same Next.js application, by letting you replace the default Next.js cache with a shared cache via special <a href="https://caching-tools.github.io/next-shared-cache/redis">cache handlers</a>. Cache Handlers for <a href="https://redis.io/docs/latest/develop/">redis</a> seem to be provided OOTB.</p>
<p>Fun caching tools aside, extra debugging information is always handy. So, it’s nice that Next.js has these flags built-in, even if they’re frustratingly lacking in documentation.</p>
<h2 id="what-i-like-about-sstion">What I Like about SST/Ion</h2>
<p>The Ion flavor of SST is <strong><em>rad</em></strong>. I am enamored with its endless utility, Ion may as well be a Swiss Army Knife in my DevOps toolbox. In integrating the Pulumi engine into Ion, there’s a wide range of Pulumi (&amp; even Terraform) providers available OOTB in the form of components, letting you configure an application’s Infrastructure as Code (IaC) with ease.</p>
<p>Likewise, Ion provides a CLI in the form of <code>sst</code>, which does much more than just deploy your infra. Depending on your <code>sst.config.ts</code>, the <code>sst</code> CLI provides a wrapper around your application’s <code>dev</code> and <code>build</code> scripts to inject resources defined and linked in your IaC, right into your application. This occurs in both your local dev environment and of course on your production infra upon <code>sst deploy</code>; it’s quite wonderful.</p>
<h3 id="iac-is-rad">IaC is Rad</h3>
<p>I’m a huge nerd, I get that, but something about declaring infra from a source controlled config file, and spinning it up from the command line just makes my brain happy. It probably scratches the same itch that Nix/NixOS does.</p>
<p>If I had to reason why I’m so delighted by IaC, I imagine the reproducibility aspect is likely the largest contributing factor. There’s something inherently comforting about the fact that I can just copy my IaC files over to a new project (with fresh API keys). Equally, it’s nice I could even roll back my infra in the same project (based on the config from a previous commit), instantly creating the same backend infra as before.</p>
<p>While of course infra requirements differ between projects, it’s really nice to have a base configuration that I can use as a boilerplate in my other projects. If only because it saves me the step of rewriting many of the same infra declarations.</p>
<p>Likewise, the fact I can save even more time by skipping (imperative) setup hell—endlessly clicking through menus, double-checking everything looks good, then getting reset because I accidentally skipped a required field—is a literal godsend for me. So, the time savings factor (a direct benefit of IaC) gets a huge plus from me too.</p>
<h3 id="pulumi--its-terraform-bridge">Pulumi (&amp; it’s Terraform Bridge)</h3>
<p>The Ion flavor of SST uses Pulumi providers (&amp; by extension Terraform providers) as components, that allow you to define your applications IaC. What’s nice is that you don’t even need a Pulumi account for this, as the <a href="https://sst.dev/blog/moving-away-from-cdk.html#how-does-ion-work">SST team integrated Pulumi’s engine right into Ion’s CLI</a>.</p>
<h3 id="linked-resources">Linked Resources</h3>
<p>It’s important to reiterate that Ion isn’t simply a different flavor of Terraform, as SST themselves <a href="https://sst.dev/blog/moving-away-from-cdk.html#what-ion-is-not">pointed out in that same blog post</a>. While Ion gives you the provider components, it goes quite a many steps further. One such step is in allowing you to integrate your defined infra/resources directly into your application through a concept called <a href="https://ion.sst.dev/docs/linking/">linking</a>.</p>
<p>All you need to do is link your defined resources back to your application in its <code>sst.config.ts</code> file. Once that’s done, you can access them anywhere in your application! No other IaC tool to my knowledge does that, making Ion quite unique in that regard.</p>
<h3 id="open-next">Open Next</h3>
<p>The <code>Nextjs</code> component relies on OpenNext in compiling a Next.js application. It’s an open source serverless adapter that makes deploying a Next.js application via serverless functions outside Vercel even possible with Ion/SST. OpenNext even attempts to achieve feature-parity with a Next.js app deployed on Vercel’s serverless platform, via an architecture based on a <a href="https://open-next.js.org/inner_workings/architecture">combination of AWS services</a> and performing slight modifications to how the Next.js <a href="https://open-next.js.org/faq">middleware is bundled and run</a>.</p>
<p>In deploying to AWS, I found OpenNext to be a lovely adapter for my Next.js application. Everything <em>just worked</em> (mostly, we&#x27;ll get into that). The best part was that I didn’t need to make any <a href="https://developers.cloudflare.com/pages/framework-guides/nextjs/deploy-a-nextjs-site/#edge-runtime">tough decisions regarding runtimes either</a>.</p>
<p>The latter is because <a href="https://docs.aws.amazon.com/lambda/latest/dg/lambda-nodejs.html">Node.js is thankfully available in a Lambda function</a>, unlike in a Cloudflare worker. While Workers offer <a href="https://developers.cloudflare.com/workers/runtime-apis/nodejs/#nodejs-compatibility">partial support</a> for Node.js APIs, due to their <a href="https://blog.cloudflare.com/introducing-cloudflare-workers">nature of being Edge functions</a>, it’s unlikely Next.js will ever work 100% the same as it would on Vercel or even on AWS.</p>
<p>Runtimes aside, the only thing I got snagged on with OpenNext was the fact that <code>sharp</code> (needed for <code>plaiceholder</code>) isn’t included in the final build.</p>
<details open=""><summary>September update</summary><p>Sharp is <strong>always</strong> excised from the Open Next bundle. The best way to re-add it is to simply install it into server bundle before deploying it. There&#x27;s two ways of doing it.</p><ul>
<li>You can either install it into the bundle using <code>npm install --arch=x64 --platform=linux --libc=glibc --prefix=&quot;.open-next/server-functions/default&quot; sharp</code>.</li>
<li>You can copy the version of sharp (&amp; it&#x27;s dependencies) you already have in your <code>node_modules/</code> directory over into the server bundle <code>cp -r ./node_modules/sharp ./open-next/server-functions/default/</code>.</li>
</ul><p>Since you&#x27;ll need to do this on every compilation/deploy, you&#x27;ll probably want to write a script to perform one of the methods above during the build process. You can see how I&#x27;m doing thing via the source code repo for this site (<a href="https://github.com/laniakita/website/tree/master/apps/web">laniakita/website</a>) for an example, or you can jump down to <a href="#transforms-lambda-layer">Transforms (Lambda Layers)</a>.</p></details>
<p><del>Despite the fact I <a href="https://open-next.js.org/v2/common_issues/bundle_size#sharp">included it as a dependency</a>, <code>plaiceholder</code> just couldn’t find <code>sharp</code>.</del></p>
<p><del>As a workaround, I used a <a href="https://docs.aws.amazon.com/lambda/latest/dg/chapter-layers.html">Lambda Layer</a> with <code>sharp</code> installed to it.</del></p>
<p>Then I just matched the <code>sharp</code> version on the layer, with the one in my <code>package.json</code>, using the <code>SHARP_VERSION</code> env. I injected that right before running <code>open-next build</code>.</p>
<p>Minor gripe aside, I’m extremely grateful to the people who’ve created OpenNext, and all the wonderful people who continue to maintain it. While the little adapter might not give you the complete <em>Vercel experience</em> 100% of the time (how could it? <a href="https://open-next.js.org/faq#will-my-nextjs-app-behave-the-same-as-it-does-on-vercel">Vercel uses their own proprietary infra</a>) it’s pretty damn close, and that’s incredible. Overall, I’m quite satisfied using OpenNext, and I won’t hesitate to use it in deploying future Next.js projects.</p>
<h3 id="sst-console">SST Console</h3>
<p>In honesty, I feel deploying things the <em>hard way</em> has meant forgoing some of the <em>luxuries</em> offered by managed serverless-deployment services like Vercel. That’s why it’s really wonderful the SST team found a way to offer the most important <em>luxury</em> of all, the console.</p>
<p>Granted, the SST <a href="https://console.sst.dev">console</a> is a little slow (though that’s probably more an AWS issue), it was incredibly helpful in debugging errors in production. Without it, I’d be digging through the logs of the various Lambda Functions, trying to get a glimpse of what was causing my 500 internal server errors. So, I’m very, very, thankful to the SST team for not only creating their own console, but offering it with a very generous free tier to boot.</p>
<h2 id="things-id-appreciate-in-the-upcoming-stable-release-of-ionsst">Things I&#x27;d Appreciate in the Upcoming Stable Release of Ion/SST</h2>
<details open=""><summary>September update</summary><p><strong>Ion is now stable as SST v3!</strong></p><p>I&#x27;ve gone ahead and struck through what&#x27;s no longer an issue. I&#x27;m extremely grateful to the SST team for all their hardwork.</p></details>
<p>Ion is an utterly amazing tool in my full stack toolbox. <del><strong>It’s also in beta</strong>, so</del> There’s also <del>expectedly</del> some sharp edges. As such, the following sections run through some of the things I felt could use a bit of polish. As well, there are notes about some things that might help you/your team if you’re looking to work with this super rad, but undoubtedly bleeding-edge tool, too.</p>
<p><del><strong><em>Note: Ion is still in beta (as of <time dateTime="2024-07-21T07:37:41.000Z">7/21/24</time>), and isn’t stable yet. By the time it is, it’ll just be called SST v3. So, if you’re reading this from the future, it’s entirely likely that everything I’m about to be salty about has been fixed. Reader discretion is advised.</em></strong></del></p>
<h3 id="docs">Docs</h3>
<p><del>The Ion docs are still a work in progress (as is Ion itself), so I really can’t fault the SST team too much here. However, it would be nice if some of the examples/guides from the old V2 docs could be migrated/converted over to the Ion docs site. For example, the <a href="https://docs.sst.dev/setting-up-aws"><em>configuring AWS section</em></a> from the old docs could probably be dropped into the Ion docs without too much editing.</del></p>
<details open=""><summary>September update</summary><p>I believe the below is still an <a href="https://github.com/sst/ion/issues/520">open issue</a>. However, I&#x27;ve not tried to deploy without setting the <code>CLOUDFLARE_DEFAULT_ACCOUNT_ID</code> since I&#x27;ve encountered this issue.</p></details>
<p><del>Beyond simple migrations,</del> There’s <del>also</del> a few features/behaviors that seem to be missing documentation. Notably, the guide on <a href="https://ion.sst.dev/docs/custom-domains/#cloudflare">using Cloudflare as a custom domain provider</a> fails to mention that in addition to the <code>CLOUDFLARE_API_TOKEN</code>, it’s really important to set the <code>CLOUDFLARE_DEFAULT_ACCOUNT_ID</code> variable too. Without the latter var set, you’ll likely find <code>sst deploy</code> breaks, resulting in a vague set of error messages from the Go compiler.</p>
<aside><p>Honestly, I think it’s rad the <code>sst</code> CLI uses Go, even if this is how I learned that fact.</p></aside>
<h3 id="transforms-lambda-layer">Transforms (Lambda Layer)</h3>
<details open=""><summary>September update</summary><p>I&#x27;m no longer using Lamda layers to handle sharp. Currently I just run a script to copy it from my root <code>node_modules/</code> into the bundle. This is primarily a workaround to the fact that I&#x27;ve not figured out how to just run <code>npm install --arch=x64 --platform=linux --libc=glibc --prefix=&#x27;.open-next/server-functions/default&#x27; sharp</code> in my monorepo, without getting caught up in a registry timeout / 404 error for the local packages that don&#x27;t exist in the <code>npm</code> registry. Likewise, <code>bun</code> doesn&#x27;t support <code>npm</code>&#x27;s <code>--prefix</code> flag.</p><p>So, I decided to just write a brute force script that copies <code>sharp</code> and it&#x27;s dependencies into the output bundle from <code>open-next</code>, specifically into the default server function. A smarter script would grab the named deps that would result from a call to <code>npm i sharp</code>, then figure out which ones to install then copy over. However, Sharp seem&#x27;s pretty stable, I was tired, and I just wanted something that worked immediately and I hard-coded the paths. So, have a gander at my silly little script.</p><pre><code class="language-ts">/*
 * The bun shebang probably isn&#x27;t necessary (no Bun specific APIs).
 * But, it ensures I get Bun&#x27;s versions of the Node filesystem APIs.
 */
#! /usr/bin/env bun

import { cp, mkdir } from &#x27;node:fs/promises&#x27;;
import { join } from &#x27;node:path&#x27;;

const cwd = process.cwd();
const monoRepoCorrection = &#x27;../../&#x27;;
const node_modules = join(cwd, monoRepoCorrection, &#x27;./node_modules&#x27;);
const openNextServerDefault = join(cwd, &#x27;.open-next/server-functions/default&#x27;);

const sharpInstall = `sharp`;

const sharpDeps = [&#x27;color&#x27;, &#x27;detect-libc&#x27;, &#x27;semver&#x27;];
const colorDeps = [&#x27;color-convert&#x27;, &#x27;color-string&#x27;];
const colorStringDeps = [&#x27;color-name&#x27;, &#x27;simple-swizzle&#x27;];
// color-convert depends on color-name

const pkgsToCopy = [sharpInstall, ...sharpDeps, ...colorDeps, ...colorStringDeps];

const t0 = performance.now();

export default async function copySharp() {
  try {
    console.info(&#x27;copying&#x27;, sharpInstall, &#x27;to:&#x27;, openNextServerDefault);
    const pkgPaths = pkgsToCopy.map((pkg) =&gt; {
      const from = `${node_modules}/${pkg}`;
      const to = `${openNextServerDefault}/node_modules/${pkg}`;
      return {
        source: from,
        dest: to,
      };
    });
    for await (const pkg of pkgPaths) {
      console.info(&#x27;creating dirs from&#x27;, pkg.source, &#x27;to:&#x27;, pkg.dest);
      await mkdir(pkg.dest, { recursive: true });
    }
    for await (const copied of pkgPaths) {
      console.info(&#x27;copying&#x27;, copied.source, &#x27;to:&#x27;, copied.dest);
      await cp(copied.source, copied.dest, { recursive: true });
    }

    console.info(&#x27;finished in &#x27;, performance.now() - t0, `ms`);
  } catch (err) {
    console.error(err);
  }
}

await copySharp();
</code></pre><p>I&#x27;ve set it up in my <code>turbo.json</code> so it runs immediately after <code>SHARP_VERSION=0.33.5 bunx open-next build</code>.</p><pre><code class="language-json">{
  &quot;extends&quot;: [&quot;//&quot;],
  &quot;tasks&quot;: {
    &quot;build:open-next&quot;: {
      &quot;env&quot;: [&quot;OPEN_NEXT_VERSION&quot;, &quot;NEXT_PUBLIC_DEPLOYED_URL&quot;],
      &quot;outputs&quot;: [
        &quot;.open-next/**&quot;,
        &quot;!.open-next/cache/**&quot;,
        &quot;public/dist/**&quot;,
        &quot;public/sw.js&quot;,
        &quot;.contentlayer&quot;,
        &quot;.contentlayermini&quot;
      ],
      &quot;inputs&quot;: [&quot;$TURBO_DEFAULT$&quot;, &quot;.env&quot;, &quot;.env.local&quot;, &quot;.env.development&quot;, &quot;.env.production&quot;]
    },
    &quot;copy-sharp&quot;: {
      &quot;inputs&quot;: [&quot;$TURBO_DEFAULT$&quot;, &quot;.env&quot;, &quot;.env.local&quot;, &quot;.env.development&quot;, &quot;.env.production&quot;, &quot;.open-next/**&quot;],
      &quot;outputs&quot;: [&quot;.open-next/server-functions/default/**&quot;],
      &quot;dependsOn&quot;: [&quot;build:open-next&quot;]
    }
  }
}
</code></pre></details>
<p><del>For whatever reasons, I just couldn’t replicate the functionality of the old SST v2 transforms (a couple of months ago), to declaratively define and create a Lambda Layer from my <code>sst.config.ts</code> / project repo. What I could do is link an existing (imperatively created) Lambda layer, but I couldn’t figure out how to create it from the config itself.</del></p>
<p><del>The reason I even looked into this in the first place, was because OpenNext doesn’t include the <code>sharp</code> module in the production build. So, the only way to have those blurry placeholders you see generate dynamically, was by creating a Lambda Layer running Node.js with the sharp module loaded into it (there’s more to this story, but that will be another article).</del></p>
<p><del>However, in trying to replicate <a href="https://docs.sst.dev/constructs/AstroSite#using-an-image-processing-layer-like-sharp">this guide from the old docs</a>, (whilst converting things as best I could to account for Pulumi’s aws-classic provider, inline with <a href="https://www.pulumi.com/registry/packages/aws/api-docs/lambda/layerversion/">their docs</a>), I found things would deploy, but I never received any sort of error message, nor did it create the Lambda Layer. This was very confusing to me.</del></p>
<details><summary>Deprecated code snippet 1</summary><pre><code class="language-ts"> transform: {
    server: (args) =&gt; {
      args.nodejs = {
        esbuild: {
          external: [“sharp”],
        },
      };
      args.layers = [
       new aws.lambda.LayerVersion(‘MySharp’, {
         layerName: “lambdaSharp”,
         code: new $util.asset.FileArchive(‘./layers/sharp’),
       })
      ];
    },
  },
</code></pre></details>
<p><del>&gt; IIRC, In addition to defining the layer like this, I’m fairly certain I tried a dot notation access of the output ARN string, but that didn’t work either.</del></p>
<p><del>I later came across <a href="https://github.com/sst/ion/issues/422">this probably relevant issue</a>, (which thankfully appears to be <a href="https://github.com/sst/ion/pull/423">fixed</a> now), but I eventually gave up and just defined it manually in the end, setting the <code>transform.server.layers</code> to the generated ARN.</del></p>
<details><summary>Deprecated code snippet 2</summary><pre><code class="language-ts"> transform: {
    server: (args) =&gt; {
      args.nodejs = {
        esbuild: {
          external: [“sharp”],
        },
      };
      args.layers = [‘arn:aws:lambda:us-west-1:555555555:layer:WebSharp:1’];
    },
  },
</code></pre></details>
<h3 id="aws-sso-integration">AWS SSO Integration</h3>
<details open=""><summary>September update</summary><p>This has been fixed. I&#x27;m very happy.</p></details>
<p><del>This I think is still an <a href="https://github.com/sst/ion/issues/284">open issue</a>, that will probably get fixed eventually, but it’s something I should point out anyway (at least if you’re running into issues). Because, theoretically, you should be able to configure the SSO profile directly in the <code>sst.config.ts</code>, <a href="https://docs.sst.dev/setting-up-aws#configure-sst">like so</a>.</del></p>
<p><del>However, as it stands, I have to preface my <code>sst</code> commands with an environmental variable set to my SSO profile of choice with <code>AWS_PROFILE=$MY_SSO_PROFILE</code>, to get things working/deploying properly. If I don’t, I run into the errors pointed out in that issue thread I linked earlier. While it’s not a horrible workaround, it is a little annoying. Granted I could likely export this from my <code>.zshrc</code> config, or even a <code>.env</code> file, but having it <em>just work</em> in the <code>sst.config.ts</code> feels like it would be ideal.</del></p>
<h3 id="nix-compatibility">Nix Compatibility</h3>
<details open=""><summary>September update</summary><p>This is still technically an issue, but I&#x27;ve since realized you can just run <code>sst</code> from the package manager, i.e., <code>bun run sst $COMMAND</code>. However, since I already went through the effort, I&#x27;ve continued to just run it from a distrobox container.</p></details>
<p>I’ll admit, I’m a fairly niche user, but Nix compatibility would be hella cool. While there’s actually <a href="https://github.com/NixOS/nixpkgs/pull/300478">an open PR in the nixpkgs repo</a> to add <code>sst</code> to nixpkgs, due to the nature of the <code>sst</code> CLI, compatibility with Nix is a little clunky. However, there’s <a href="https://github.com/sst/ion/issues/143">an open issue in the Ion repo</a> discussing a possible solution that could be implemented on SST’s end to make it <em>just work</em> with Nix, so perhaps <code>sst</code> will be fully compatible with Nix one day.</p>
<p>In any event, your best bet when working with SST applications on NixOS for now, is likely going to be to run the <code>sst</code> CLI from a distrobox container. However, you could probably get the binaries working via <code>steam-run</code> if I’m honest, but I haven’t tried it yet.</p>
<h2 id="working-with-threejs--react-three-fiber">Working with Three.js, &amp; React Three Fiber</h2>
<p><a href="https://threejs.org/">Three.js</a> is a WebGL wrapper written in JavaScript, and <a href="https://docs.pmnd.rs/react-three-fiber/getting-started/introduction">React Three Fiber</a> is a React renderer for three.js. Also, it is absolutely the coolest thing I’ve ever managed to learn in all my years spent developing for the web.</p>
<p>Initially, I was going to try to create most of this site in it. This would mean the majority of elements you’d interact with, would be contained entirely in the three.js canvas! Sorta like how <a href="https://workspaceupdates.googleblog.com/2021/05/Google-Docs-Canvas-Based-Rendering-Update.html">Google renders Google Docs</a>. The only thing that stopped me, was when I realized what this would mean in reality: long load times, no server components, no SSR.</p>
<p>Now, for Google Docs or anything that falls firmly in the <em>software</em> category, those supposed <em>drawbacks</em>, are just the baseline expectation. No one expects something so heavy to load instantly. However, for something much closer to a traditional website, like a blog? <em>Oh, those are drawbacks.</em></p>
<p>As such, I made the tough decision to be much more selective about where and how I use the Three.js canvas. The landing page I felt was an important place to demonstrate my knowledge of both it &amp; GLSL shaders, so that’s what I put there. However, because it is the landing page, I did my best to create as simple a scene as possible; shaving bandwidth down as much as I could. Because originally I was going to do <a href="/work/bot-clicker">something much heavier</a>, but shifted gears once I’d realized <em>how heavy</em>.</p>
<p>However, because this site renders its content much more traditionally, I was able to make heavy use of server components, experimenting with their weirdness to my hearts content.</p>
<p>Aside, it’s important to state it’s possible to mix vanilla Three.js with React Three Fiber. Of course, doing so defeats the purpose of the latter a tad bit, but occasionally I’ve found it useful to use a <a href="https://docs.pmnd.rs/react-three-fiber/api/objects#putting-already-existing-objects-into-the-scene-graph">primitive object</a> every now and then.</p>
<h3 id="integrating-with-nextjs">Integrating with Next.js</h3>
<p>When I first began this project, I took some inspiration from the <a href="https://github.com/pmndrs/react-three-next">pmndrs/react-three-next</a> boilerplate, even basing my <code>next.config.mjs</code> off it. It’s actually how I discovered you can chain plugins with an accumulator function.</p>
<p>The most interesting thing about the boilerplate is it uses <a href="https://github.com/pmndrs/tunnel-rat">pmndrs/tunnel-rat</a> to create an alternative <a href="https://github.com/pmndrs/drei?tab=readme-ov-file#view"><code>&lt;View /&gt;</code></a> component from <a href="https://github.com/pmndrs/drei">pmndrs/drei</a>. In testing, I couldn’t really figure out a benefit for doing this. My hunch is that the tunnel-rat method predates some changes to the <code>&lt;View /&gt;</code> component which might’ve complicated things in Next.js.</p>
<p>Regardless, the <code>&lt;View /&gt;</code> component from either method works with the Next.js App router just fine. The main benefit of cutting up the canvas like this, is that you don’t need to wait for it to load in again between pages, granted you’ve wrapped those pages with a component that provides a canvas. The only slowdowns you’d see, would result from loading models. Ideally, those models should be lazy loaded/dynamically imported with <a href="https://nextjs.org/docs/pages/building-your-application/optimizing/lazy-loading">next/dynamic</a>.</p>
<p>As well, if you’re working with WebGL in a Next.js application, you’re going to want to put <code>use client</code> at the top of the pages/components that make use of it. WebGL relies heavily on a client’s hardware, especially WebGPU. While you can SSR pages that import client components featuring these elements, Next.js/React won’t compile if you try to use these APIs directly in a server component for obvious reasons.</p>
<h3 id="paper-cuts-with-safari">Paper Cuts with Safari</h3>
<p>Originally, I was going to make heavy use of the <code>&lt;View /&gt;</code> component. The <strong>only</strong> thing that stopped me from doing so, was when I realized how <em>ungraceful</em> it looked in Safari. You see, if the <code>&lt;View /&gt;</code> doesn’t take up the entire page, the <code>&lt;View /&gt;</code> component starts to jitter as you scroll up or down. Here&#x27;s an <a href="https://github.com/pmndrs/drei/issues/1890">open issue</a> demonstrating this behavior.</p>
<p>According to this <a href="https://github.com/pmndrs/react-three-next/issues/124#issuecomment-1508105739">comment</a>, this behavior occurs because Safari doesn’t sync scroll events with the <a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame"><code>window.requestAnimationFrame()</code></a> API. This would help to explain why only Safari seems to experience this issue.</p>
<p>One solution to this is to use a virtual-scroller like <a href="https://lenis.darkroom.engineering/">Lenis</a>. It&#x27;s just that, I&#x27;ve got some reservations about that solution.</p>
<p>While, I think virtual-scrollers like Lenis look and feel great on desktop browsers, especially with a mouse scroll wheel, touch based Safari interactions with Lenis are another story. While I can&#x27;t comment on how something like Lenis <em>feels</em> on a touch-based Android device running Chrome (Chromium) or Firefox (Gecko), I can talk about how I&#x27;m not enthusiastic about it on Safari (WebKit) for iOS.</p>
<p>Due to the nature of Safari, there are some <a href="https://github.com/darkroomengineering/lenis?tab=readme-ov-file#limitations">limitations</a> to using Lenis as a solution. While yes, it does fix the jitters, the fact that fps is suddenly capped to 60, and then an abysmal 30 fps if power saving mode is engaged, doesn&#x27;t thrill me.</p>
<p>Sure, a virtual-scroller is better than jitters, I won&#x27;t argue that fact. However, on mobile Safari at least, virtual-scrollers result in a scrolling experience that I honestly find irritating. When I tested Lenis, I found I couldn&#x27;t <em>flick</em> up or down a page readily without it stopping in its tracks, nor could I scroll in either direction at a consistent speed. You can even replicate this behavior on the <a href="https://lenis.darkroom.engineering/">Lenis</a> website itself.</p>
<p>My eventual solution, was to just do away with the <code>&lt;View /&gt;</code> component, and embed the canvas directly. I did this after investigating <a href="https://sketchfab.com/3d-models/popular">Sketchfab</a>&#x27;s website, where I realized they solved the <em>jitter</em> problem by not using <code>&lt;View /&gt;</code> components at all, as they simply embed the canvas into whatever model-preview you&#x27;re currently hovering over.</p>
<p>Although, that solution is quite traditional, it worked for my purposes however, so that&#x27;s what I went with. Nevertheless, If you are creating a complete Three.js <em>experience</em>, then I&#x27;d suggest using Lenis. I say this despite my reservations, because Lenis or another virtual-scroller are really your only options.</p>
<h2 id="my-favorite-react-state-management-solution-zustand">My Favorite React State-Management Solution: Zustand</h2>
<p><img src="https://laniakita.com/images/non-oc/bear.jpg" alt="zustand’s mascot, an adorable bear" width="1200" height="600"/></p>
<p>Created by the <a href="https://pmnd.rs/">Poimandres dev collective</a> (Pmndrs)—the same collective behind React Three Fiber &amp; the React Three ecosystem—<a href="https://zustand-demo.pmnd.rs/">Zustand</a> is a featherweight alternative to <a href="https://react-redux.js.org/">React Redux</a>, and I love the little state-management solution dearly.</p>
<aside><p>How could you not!? It’s got a cute bear as a mascot!</p></aside>
<p>It’s incredibly minimal (feeling more like an extension of React’s Context API), which makes learning it, and integrating it rather painless.</p>
<p>In addition, because it comes from Poimandres, it integrates superbly into a <code>react-three/fiber</code> scene, with minimal performance impact. You can see it in action on their demo site, or you can see how I used it for my <em>toy</em>-clicker game, <a href="/work/bot-clicker">bot-clicker</a>, on its project page.</p>
<p>However, integrating Zustand with React Three isn’t the only way to use it, of course. The theme toggle in the navbar actually relies on it. While I’m persisting the theme state via the localStorage API, rather than with Zustand’s <a href="https://docs.pmnd.rs/zustand/integrations/persisting-store-data">Persist middleware</a>, it’s kept in memory via a zustand store.</p>
<p>To pass this state around, a provider component wraps the navbar &amp; theme toggle, as well as any other component that needs this context. In effect, state can be sent from the sliding switch, to any of the wrapped components.</p>
<p>While, the primary function of the sliding switch adds or removes the <em>dark</em> class on the root HTML element—this is <a href="https://tailwindcss.com/docs/dark-mode">used by tailwind CSS</a> to change colors from light to dark and vice-versa—it’s utility in being used in a Zustand store allows me to modify non-css elements too, like a Three.js scene to reflect the updated theme state.</p>
<p>Overall, I’m thrilled by the existence of Zustand, especially having gotten used to the ease of <a href="https://svelte.dev/docs/svelte-store">Svelte’s store API</a>, that makes state management a <em>breeze</em>. So, it was really nice to have found this minimalist, but really powerful, alternative for React as well.</p>
<h2 id="discussion">Discussion</h2>
<h3 id="overall-thoughts-on-nextjs">Overall Thoughts on Next.js</h3>
<p>Next.js 14 is the current major version of Next.js, having released close to a year ago (<time dateTime="2023-10-26T02:00:00.000Z">10/26/23</time>), with the latest minor update (non-canary) just this past week (14.2.5). Now, having last used Next.js when it was at version 11?, developing with Next 14 has admittedly been quite the learning experience. Even so, I’m overall quite satisfied with how everything turned out.</p>
<p>Granted, the most common complaint you’ll find about Next.js is usually in reference to the cognitive load it puts on developers—a result of its breadth of features, customizability, and the level of control you have over its various APIs (caching, routing, rendering, etc.)—and I don’t disagree with that assessment. Next.js has a lot of moving parts, ergo it’s a lot to learn. Additionally, it really didn’t help that googling the various Next.js APIs and clicking on what pops up will typically take you to the old Pages router version instead of the newer App router alternative/equivalent API. The latter is a little better nowadays, but still not perfect (guess Vercel’s SEO game was just too good).</p>
<p>Furthermore, I’ll even admit to knowing that I don’t know everything about Next.js. For example, I’ve got a rough idea of its caching model, but it’s not as complete as I’d like. However, this isn’t because I think the caching model is overcomplicated, it’s more so just been a lack of need to dive so deeply into it (<em>perhaps exploring it in-depth would make a good blog post?</em>).</p>
<p>Ignorance aside, once I managed to get a good enough mental-model of Next.js in my head, working with it was pretty smooth. At this point, I find most of the added thinking is spent in the optimization and testing departments.</p>
<p>In sum, it’s my opinion that Next’s key strengths more than make up for its drawbacks. What I like the most about Next.js is just how, well, <em>next</em> it is. It integrates unreleased features from React (which, albeit annoying sometimes for compatibility, but I understand why), it’s also incredibly forward-thinking in the features it provides, like <em>streaming</em>. Not to mention the level of control you have over even minute details like the precise runtime for a page route. Additionally, it’s one of the most <em>batteries-included</em> frameworks I’ve used as far as JavaScript/TypeScript frameworks go, and that’s a huge plus too. Oh, and it makes TypeScript a first-class citizen to top it all off, which makes my brain happy.</p>
<h3 id="overall-thoughts-on-ionsst">Overall Thoughts on Ion/SST</h3>
<p>Ion might still be in beta, but once I was able to get things going, it&#x27;s been a really solid deployment tool. Ion’s major defining difference from its predecessors, and leading reason why I chose beta software over the stable SST v2, is that it uses Pulumi and Terraform providers to provision your IaC, instead of Amazon’s CDK/CFN. The fact that your app’s architecture can be made up from services provided by a whole host of different cloud service providers instead of just the services offered by AWS, was a major selling point to me.</p>
<p>The only real drawback to Ion/SST that I can think of, is you’ll <a href="https://docs.sst.dev/going-to-production#deploy-from-git">need to set up your own CI/CD pipeline</a> with something like <a href="https://github.com/features/actions">GitHub Actions</a> or <a href="https://circleci.com/">Circle CI</a>. Well, unless of course you want to run <code>sst deploy</code> manually after every commit, then don&#x27;t let me stop you. If configuring that sounds like a drag, SST conveniently provides a CI/CD service called <a href="https://seed.run/">seed</a>, which should <em>just work</em> with an Ion/SST codebase OOTB.</p>
<p>Overall, I’m incredibly pleased with using SST/Ion to deploy Next.js applications to AWS (CloudFront, Lambda, S3, etc.). Even though Ion isn’t quite stable yet (it’ll be renamed SST v3 by then), I’m still quite satisfied with it. While it can’t replace all the tools in my DevOps toolbox (nor should it), it’s definitely going to get some heavy usage.</p>
<h3 id="conclusion">Conclusion</h3>
<p>This site is a Next.js 14 application, and with the help of Ion/SST + OpenNext, it gets deployed via serverless AWS Lambda functions, with integrated S3 buckets (&amp; other AWS Services), where it’s then distributed onto CloudFront’s CDN for your enjoyment.</p>
<p>While both Next.js and Ion/SST have their flaws, they’re both fantastic tools in my full stack toolbox. Their strengths more than make up for their perceived weaknesses, and I&#x27;ll happily use this combination again going forward.</p>
<p>In addition, there&#x27;s a whole slew of neat tools and technologies contained in this sites stack that I really enjoyed learning and building with. It&#x27;s quite likely I&#x27;ll find a use for these tools and libraries in later projects as well.</p>
<p>Overall, I couldn&#x27;t be happier with how this site came out, and I&#x27;m excited to see how both it and I, will evolve over time.</p>
<p>Finally, if you have any questions, or want to share your thoughts on this piece, or would like to submit corrections, please either reach out to me on social media, leave a comment below, open an <a href="https://github.com/laniakita/website/issues/new/choose">issue</a>, or submit a PR to the <a href="https://github.com/laniakita/website">repo</a> directly. Whichever way, I’d love to hear your thoughts, and I appreciate (constructive) feedback, thank you.</p>]]></content>
  </entry>
  <entry>
    <title>Bot Clicker</title>
    <link rel="alternate" href="https://laniakita.com/blog/bot-clicker"/>
    <id>https://laniakita.com/blog/bot-clicker</id>
    <updated>2024-09-01T23:46:58.000Z</updated>
    <category term="/categories/technical-summary" scheme="https://laniakita.com/categories/technical-summary" label="Technical Summary"/>
    <content type="html"><![CDATA[<figure><img src="https://laniakita.com/images/oc/2024/05/bot-clicker-pre-release-v05082024.png" alt="Screenshot of a development build of Bot Clicker, before being officially released to the public." /><figcaption>Just click on 'em! That'll show those bots whose boss.</figcaption></figure> <details open=""><summary>Link to Bot Clicker &amp; its source code</summary><p><strong>Link: <em><a href="https://showcase.laniakita.com/work/bot-clicker">Bot Clicker</a></em></strong><br/>
<strong>Source Code: <em><a href="https://github.com/laniakita/website/tree/main/apps/showcase/src/app/work/bot-clicker">laniakita/bot-clicker</a></em></strong></p></details>
<h2 id="tldr">TLDR</h2>
<p>Bot Clicker is a satirical mini-game I made for the landing page where you click on robots to make the clicker counter go up, and in return, they puff up and make enlightening sounds and social commentary.</p>
<p>To briefly summarize Bot Clickers internals, it uses a combination of Three.js + Three.js component libraries and zustand from the Poimandres developer collective. Zustand is used to both add and store the total clicker points, as well as send that data to the overlayed clicker counter. The counter total is also an input parameter in function that calculates game/bot movement speed for added &quot;eXtReMe EnTeRtAiNmEnT vAlUe!&quot;.</p>
<p>The actual robot models were provided by Oscar Creativo, and the sounds were provided by FilmCow (voices) and Atelier Magicae (musical sounds). For more details on those amazing Artists, please visit the <a href="/credits/bot-clicker">&quot;Bot Clicker&quot; credits</a> page.</p>
<h2 id="introduction">Introduction</h2>
<p>I kinda created Bot Clicker by accident. Originally, I was just trying to make something visually interesting for my landing page. But, one thing led to another, and I wound up expanding on the <a href="https://codesandbox.io/s/2ycs3">Flying Bananas example</a> from the react three fiber docs to create this toy &quot;game&quot; that&#x27;s also somewhat a nod to the iconic <a href="https://en.wikipedia.org/wiki/Cow_Clicker">Cow Clicker</a>, created by Ian Bogost.</p>
<p>I&#x27;m putting game in quotes, mostly because I think this enters into uncharted territory as to where we draw the line as to what exactly is and isn&#x27;t a game. While I&#x27;m not saying Bot Clicker is a great game, it&#x27;s not at all, it does however blur the lines a bit. On the whole, there does happen to be an objective (click the robots) and some sort of stimulating reward (line go up/hear fun sounds) for completing that objective. And if that&#x27;s your definition of a game, then that&#x27;s what Bot Clicker is, and if it isn&#x27;t, well it isn&#x27;t. I will use the term players instead of users though, since it&#x27;s not exactly a tool either.</p>
<p>While I didn&#x27;t clone Cow Clicker&#x27;s mechanics that make players earn the &quot;clicks&quot; to click on the bots, I also don&#x27;t feel like that&#x27;s the point I&#x27;m trying to make with Bot Clicker (2024 is also a very different time period than the early 2010s). So, whatever that point might be, is really on the players to conclude, and perhaps it should be (Rembrandt did famously leave the hands of the people in his portraits &quot;unfinished&quot; so viewers would &quot;complete&quot; his paintaings with their minds eye).</p>
<p>As far as technicals go, once I had actually stumbled onto the ideas that led to Bot Clicker, I felt there were two hard requirements I had to keep in mind.</p>
<ol>
<li>Bot clicker needed to load quickly (no long loading screens).</li>
<li>Bot Clicker needed somewhat high fidelity graphics (i.e. post-processing shaders) with a decent framerate to boot.</li>
</ol>
<p>Admitedly the second goal wasn&#x27;t that challenging to achieve since many optimization implementations. Still, there were some tiny hills left for me to meander over to get things running as well as they do.</p>
<p>Beyond that, everything else was relatively straight forward. Especially since I could recycle some of the logic for the moving, rotating, teleporting from the example to use for the robots. Which also helped to point me in the right direction for some of the optimization steps I decided to implement. Once I had things moving and in place, the last part was to just build the rest of &quot;Bot Clicker&quot;. This is detailed in the <a href="#rest-of-the-bot-clicker-rotfbc">Rest of the Bot Clicker</a> section.</p>
<h2 id="loading-fast">Loading Fast</h2>
<p>To accomplish the first goal (since Bot Clicker is a web game, after all), it was necessary to compress the robot model from megabytes to mere kilobytes. This was accomplished using gltfjsx, a handy cli tool for running both draco compressions and doing the tedious work of mapping out the transformed model into typed react-three/fiber components.</p>
<p>Because the model is rigged and animated (makes instancing challenging), my best option was implementing a Level of Detail (LOD) which was thankfully provided by Three. This meant compressing the model&#x27;s textures into three different resolutions: 512x, 256x, and 128x, for respective distances of close, medium, and far. Using the tool above, I was able to compress the 7.7 MiB glTF model into a 393.9 KiB .glb, 228.5 KiB .glb, and a 181.1 KiB .glb.</p>
<p>As I didn&#x27;t want to make things too data intensive on slower mobile connections, only the latter two textures display on mobile screen sizes. This means 256x model is used for close distances and the 128x model for any distance beyond that. This has the added side effect of increasing framerates, which I&#x27;ll get to in the next section.</p>
<h2 id="optimizations">Optimizations</h2>
<p>Since it was important to me to make use of post-processing shaders (I chose to use pmndrs&#x27; bloom, which is an implementation of <a href="https://www.froyok.fr/blog/2021-12-ue4-custom-bloom">Léna Piquet&#x27;s custom bloom for UEv4</a>), whilst maintaining a decent framerate with WebGL, optimizations were somewhat necessary. The most significant of those optimizations was making use of Three&#x27;s LOD implementation (which react-three/drei conveniently provides a light wrapper for as the &quot;Detailed&quot; component) with heavily reduced texture resolutions. The rest of the optimizations were mostly tweaks to the canvas setting (turning off anti-aliasing, flat tone mapping, etc.)</p>
<h3 id="level-of-detail-lod">Level of Detail (LOD)</h3>
<p>Before settling on LOD, I did initially want to instance the model (why waste 80 draw calls on 80 identical meshes?), and I did with a high-resolution (1024x) non-animated one during the proof-of-concept phase. However, it was only after I moved to a more complicated animated model (the robot you see above), did I learn that Three (to my current knowledge) doesn&#x27;t yet support animation clips on instanced meshes. So, while I&#x27;m sure workarounds do exist to make such a thing possible, I felt just using Three&#x27;s LOD implementation was a good compromise, even if it meant settling for lower-resolution textures on the meshes. Coincidentally, optimizing with LOD was the same decision the banana example made, though I&#x27;m sure it was for very different reasons, but I digress.</p>
<p>As well, as I mentioned in a previous section, Three&#x27;s LOD implementation takes at least two models to setup the rendering that switches between them depending on their distance from the camera. So, I chose to compress the model&#x27;s textures three separate times in 512x, 256x, and 128x resolutions to feed the LOD implementation. While I could&#x27;ve added a 1024x resolution to be the closest to the camera, in testing I found it both increased load times (iirc the .glb was about 1.8 MiBs) and weaker devices just chugged frames trying to render it, so I decided against.</p>
<p>I also created two seperate LOD components that reactively render based on the size of a players screen. Non-mobile screen sizes get the LOD component with all three resolutions, and mobile screens get the LOD component with only last two resolutions.</p>
<p>My reasoning for separating the LOD into separate components is two fold. Firstly, for faster load times on slow data connections (which i mentioned previously). Secondly, to accomodate the limitations of mobile phone processors, especially the ones onboard devices with ultra high resolution displays. I found doing things this way, rather than using a single LOD to rule them all, seemed to provide better performance (i.e. higher-framerates) on mobile devices, so the separate LODs made it into the final game.</p>
<h2 id="rest-of-the-bot-clicker-rotfbc">Rest of the Bot Clicker (ROTFBC)</h2>
<p>Like I said, I could recycle some of the logic from the earlier example to create most of the bones for what would become Bot Clicker. The rest of it was just a small matter of swapping out the model, adding some sounds, adding a few functions, and writing some extra logic. So in no particular order, this is what I did:</p>
<ol>
<li>Used robots instead of bananas</li>
<li>Used Next.js instead of a SPA React app</li>
<li>Added a few optimizations of my own. For example there&#x27;s only 30 bots on mobile devices, while there&#x27;s 60 bots on desktop. Also, LOD is used/implemented a bit differently.</li>
<li>Added some functions to load &amp; randomize the sounds when a bot is clicked.</li>
<li>Added functions that scale the robot up on a successful click (and scale back down after a 2 second period).</li>
<li>Implemented zustand to keep track of the clicks (I deliberatly chose to only count clicks of scaled bots to discourage click spamming the same bot).</li>
<li>Added functions that make movement speed dependent on the total number of clicks.</li>
<li>Added (drei&#x27;s) stars and the logic that make them move.</li>
<li>Used a bloom post-processing shader instead of the depth of field shader.</li>
<li>Implemented React Three A11y to prevent the bots from moving for those who have reduced motion on.</li>
<li>Added a menu with buttons that showcase a warning before you play/enter bot clicker. Also features the actual button that &quot;soft&quot; navigates you to the url (this uses Next&#x27;s useRouter hook) that enables you to play bot clicker.</li>
<li>Wrote logic for a bright hemisphere light when out of the game, and logic that adds a postional light and removes/greatly decreases the hemisphere light in the game.</li>
<li>Wrote logic that keeps bot movement very limited on the actual landing page, but brings them to life when the game is actually entered.</li>
<li>Wrote logic that ensures the stars, post-processing, and bot animations only get loaded when the game is actually enterered.</li>
<li>Added a very dark and blurry &quot;safety&quot; div to cover the bots when out of the game.</li>
<li>Wrote logic that ensures bots only &quot;react&quot; to clicks when engaged in the game (clicks actually fall right through the blurry div despite my best efforts, so this was what I came up with).
I&#x27;m sure there&#x27;s more but that&#x27;s most of the steps I took to make the rest of the Bot Clicker. ^-^</li>
</ol>
<h3 id="borrowed-logic">Borrowed Logic</h3>
<p>Borrowed from the example, there&#x27;s a few neat functions inside the useFrame hook: one that spreads out the bots to their positions, a second one that moves them upwards, a third one that spins them around, and a final fourth function that triggers once a bot goes beyond the top of a players screen so it can teleport them a little below the bottom edge of the players screen.</p>
<h3 id="keeping-track-of-the-number-of-clicked-bots-with-zustand">Keeping Track of the Number of Clicked Bots, with Zustand</h3>
<p>Bot Clicker wouldn&#x27;t be a game at all without the <del>score</del> clicker counter (truly the most essential feature of a game), so it was mission critical to find a way to keep track of how many bots a player &quot;successfully&quot; clicks on. What I decided to do was create the counter as an HTML overlay, to ensure positioning/aligning it with the other HTML elements was trivial in comparision to having it stored in the canvas. However, since it&#x27;s not in the canvas, I needed to create a globally shared context store, so the state value could be both incremented and passed between components. So I used Zustand, which is similar to Redux but much lighter (and also made by the same dev collective behind react three fiber).</p>
<p>So, when a player clicks on a non-scaled-up bot, a callback function triggers a separate function call to increment by one the stored value in the global click-counter store. Fun fact: for these stores to work in the components that call them, they need to be wrapped in that stores context provider component.</p>
<p>As for the counter itself, it&#x27;s really just a string (styled with tailwindcss) with a padStart() method attached to the value returned from the global zustand click-counter store.</p>
<h3 id="sounds">Sounds</h3>
<p>The actual audio is loaded on demand with Three&#x27;s AudioLoader. As well, since there&#x27;s two sets of sounds (one enum stores locations of the noises from FilmCow, the other enum stores the locations of noises from Atelier Magicae) I wrote a function so there&#x27;s roughly a 50% chance (Math.random is pseudo-random afterall) of getting a sound from either library, and then various probabilities of the specific sound you hear from the randomly selected library.</p>
<h3 id="stars">Stars</h3>
<p>A function in the useFrame hook rotates the stars (which come from the drei library) around so it felt like you were spinning with the robots too in outer space.</p>
<h3 id="accessibility">Accessibility</h3>
<p>You&#x27;ll also notice the large epilepsy warning for Bot Clicker, which I&#x27;m not 100% sure warrants it, but I felt it was best to err on the side of caution anyway. This is because once the bots move fast enough the reflection from the spotlight in the up-close bot might appear as a flicker, which is probably not good for someone with photosensitive epilepsy. This was partially the reason why I disabled the bot animations (bottom spinner thing with lights might look like a flicker) and bright post-processing bloom effect (they make the bot eyes flicker a bit) and the stars too (they twinkle), and significantly slowed down the bot movement speed when you aren&#x27;t actively engaged in the game. For extra caution I also added a darkened-blurry div on top the canvas. Which I realized also looked sorta cool.</p>
<p>Disabling such extra stuff really helps with out-of-game performance too! As I really don&#x27;t want to crash anyone&#x27;s browser for simply visiting my website if the game happens to be too heavy for their device. I&#x27;m also not sure the game will even load if your devices browser isn&#x27;t compatible with the JS version used by Three, so hopefully older devices won&#x27;t have too much issue visiting the home page (aside from not being able to play Bot Clicker, which some might even view as a plus).</p>
<p>The other thing I did was make use of react-three/a11y to stop the bots from moving around if a user does happen to have &quot;prefer reduced motion&quot; enabled in their browser. This way, even if they don&#x27;t actually play the game they&#x27;ll remain static behind the blurred div I layered on top the canvas.</p>
<h2 id="conclusion">Conclusion</h2>
<p>Bot Clicker is really just a toy for visitors to my site to play with, that also happens to live on the landing page. While the technical details of Bot Clicker aren&#x27;t too exciting (it&#x27;s literally just a background image, but more interesting and interactive), it does however inadvertantly posit some philosophical questions and I hope it gives you at least something to think about.</p>
<p>While a part of me does wish I could&#x27;ve made Bot Clicker into something with even a teency bit of substance, I felt it was already pushing the limits on acceptable bandwidth useage for a landing page. So, levels and backgrounds and different models/textures (even physics), likely wouldn&#x27;t be appreciated by most of the people who come here. I imagine most people just want to read some got dang&#x27; <del>Hotdogs</del> articles on how to build stuff.</p>]]></content>
  </entry>
  <entry>
    <title>Welcome to Yet Another Dev Blog</title>
    <link rel="alternate" href="https://laniakita.com/blog/welcome-to-yet-another-dev-blog"/>
    <id>https://laniakita.com/blog/welcome-to-yet-another-dev-blog</id>
    <updated>2024-05-28T01:49:37.000Z</updated>
    <category term="/categories/meta" scheme="https://laniakita.com/categories/meta" label="Meta"/>
    <content type="html"><![CDATA[<figure><img src="https://laniakita.com/images/oc/2024/05/yadb.png" alt="Rounded vertical rectangle resembling a text file icon, with the top right of the rectangle folded inward to resemble a page from a book that's been dog-eared. At the bottom is the text YADB.mdx" /><figcaption>MDX is like markdown, but Xtreem!!! I mean, what else could it possibly stand for? Xylophone?</figcaption></figure> <p>Hello internet, my name is Lani, and welcome to the official launch of laniakita.com 🎉, my over-engineered (oops 🤗) website and blog. This is where I’ll be showcasing some of my work, as well as my thoughts on developing sites/apps for the modern web (&amp; more, probably).</p>
<p>While I’m aware dev blogs are a dime-a-dozen these days, I figured that writing down my technical knowledge and sharing it with the void of the internet was a good practice still yet. One, because it forces me to think quite a bit deeper on the topics I’m writing about, and two, because someone out there might find my little notes interesting or possibly even useful. Hopefully that’s you in the latter case, but if not, well, there&#x27;s more blogs than just this one out on the internet.</p>
<h2 id="a-brief-technical-summary">A Brief Technical Summary</h2>
<p>While I sometimes love nerdy clichés a bit too much for my own good, I&#x27;m unfortunately not going to delve into too much detail on this <del>monstrosity</del> site for now (you&#x27;ll have to stay tuned for that). What I can offer you, is a little sneak peek, so here&#x27;s the (extended) TLDR from that upcoming deep dive:</p>
<details open=""><summary>Technical Summary</summary><p>This is a website and (using <a href="https://serwist.pages.dev/">serwist</a>) a <a href="https://web.dev/explore/progressive-web-apps">Progressive Web App (PWA)</a> written in <a href="https://www.typescriptlang.org/">TypeScript</a> and built with <a href="https://nextjs.org/docs">Next.js</a>/<a href="https://react.dev/reference/react">React</a>. Certain elements rely on <a href="https://docs.pmnd.rs/zustand/getting-started/introduction">zustand</a> for state management. WebGL elements rely on any combo of <a href="https://threejs.org/">Three.js</a> + <a href="https://docs.pmnd.rs/react-three-fiber/getting-started/introduction">React Three Fiber</a> + <a href="https://github.com/pmndrs/drei">React Three Drei</a> + <a href="https://docs.pmnd.rs/a11y/introduction">React Three A11y</a>. Styles rely on <a href="https://tailwindcss.com/docs/installation">tailwindcss</a> with the <a href="https://github.com/catppuccin/tailwindcss">Catppuccin for TailwindCSS</a> plugin (with my own tweaks) for the color palette. <a href="https://postcss.org/">PostCSS</a> and its <a href="https://github.com/postcss/autoprefixer">autoprefixer</a> plugin provide CSS vendor prefixes. <a href="https://github.com/0xType/0xProto">0xProto</a> is used as the primary monospace font, and <a href="https://fonts.google.com/specimen/Inter">Inter</a> is the primary sans-serif font. Icons are provided by <a href="https://github.com/phosphor-icons/core">Phosphor</a> and <a href="https://github.com/FortAwesome/Font-Awesome">Font Awesome</a>.</p><p>The written portion primarily exists as <a href="https://mdxjs.com/">MDX</a> files, which are rendered with either <a href="https://github.com/vercel/next.js/tree/canary/packages/next-mdx">@Next/mdx</a> or <a href="https://github.com/kentcdodds/mdx-bundler">MDX-Bundler</a> and styled via <a href="https://github.com/tailwindlabs/tailwindcss-typography">tailwind&#x27;s typography plugin</a>. My own custom script runs (prior to <code>next build</code>) some of these files through a pre-processing step to generate UUIDs, slugs, and (some) image metadata, so they can be cached into a remote <a href="https://www.sqlite.org/index.html">SQLite database</a> for convenient sorting/searching/retrieval. This relies on a combination of <a href="https://bun.sh/docs">Bun</a>, <a href="https://github.com/jonschlinkert/gray-matter">gray-matter</a>, <a href="https://plaiceholder.co/docs">plaice-holder</a>, and <a href="https://orm.drizzle.team/">Drizzle ORM</a> with the <a href="https://orm.drizzle.team/docs/get-started-sqlite#turso">Turso driver</a>.</p><p>The RSS feed relies on <a href="https://github.com/davidcalhoun/jstoxml">JSTOXML</a> to process a generated object tree (which in this case was crafted to comply with <a href="https://www.rssboard.org/rss-specification">RSS 2.0 specifications</a>) into a string. The result is saved into an XML file with Bun, and served via <a href="https://nextjs.org/docs/app/building-your-application/optimizing/static-assets">Next&#x27;s public folder</a>.</p><p>Metadata is generated via <a href="https://nextjs.org/docs/app/api-reference/functions/generate-metadata">Next&#x27;s generateMetadata API</a>, while misc data files (e.g. the sitemap.xml) are generated using the <a href="https://nextjs.org/docs/app/api-reference/file-conventions/metadata">Metadata Files API</a>.</p></details>
<p>Exciting I know, but you&#x27;ll just have to be patient until then.</p>
<h2 id="a-brief-site-map">A Brief Site Map</h2>
<p>Aside, this site is broken down into a few sections. There&#x27;s the blog, the project gallery, and then the about/contact/info pages.</p>
<p>For the blog section, posts like this one will get added probably bi-weekly to maybe monthly, and you can keep up with them by subscribing to the RSS feed.</p>
<p>In the project gallery, you&#x27;ll find some stuff I&#x27;ve built with an accompanying technical summary (when new projects get added, I&#x27;ll make sure to add a new blog post that links to it/its write-up).</p>
<p>About the info pages, well, those are just info pages. You can learn more about me, or learn how to contact me, I suppose. But Beyond that? There&#x27;s just not much else to them.</p>
<h2 id="the-future">The Future</h2>
<p>If you haven&#x27;t noticed, some things are still a bit rough around the edges (like the orthographic camera&#x27;s aspect ratio on the landing page), but I do plan on smoothing those out in the coming months (the carpenter&#x27;s <strong>house</strong> is never finished, after all).</p>
<p>As well, there&#x27;s still some features I&#x27;d like to get around to implementing too, like the utterances commenting system, once this site is finally open-sourced. While I did contemplate creating my own bespoke system (this would involve some SSO implementation with <a href="https://github.com/lucia-auth/lucia">Lucia auth</a>, and probably a DBaaS provider), I felt that taking advantage of what already exists would be better (a wild statement coming from me, I know). This is because you don&#x27;t need to integrate an account with a random application (just utterances itself, which is well established), or you can just comment on GitHub directly.</p>
<p>Also, I&#x27;m going to keep adding content, like my backlog of projects to the project gallery, as well as writing more posts and publishing those onto the blog page somewhat regularly.</p>
<p>Aside from all that, you&#x27;ll just have to stick around to find out what happens next. Subscribing to the <a href="/atom.xml">web feed</a> will make that easy for you. ^-^</p>]]></content>
  </entry>
</feed>