<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Roger Rajaratnam</title>
    <description>The latest articles on DEV Community by Roger Rajaratnam (@sourcier).</description>
    <link>https://dev.to/sourcier</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3877016%2Fe616827e-3f77-4299-a5fe-2503dc341bce.jpeg</url>
      <title>DEV Community: Roger Rajaratnam</title>
      <link>https://dev.to/sourcier</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sourcier"/>
    <language>en</language>
    <item>
      <title>Adding reading times to an Astro blog</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Thu, 24 Sep 2026 17:09:08 +0000</pubDate>
      <link>https://dev.to/sourcier/adding-reading-times-to-an-astro-blog-31nj</link>
      <guid>https://dev.to/sourcier/adding-reading-times-to-an-astro-blog-31nj</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/reading-time-astro" rel="noopener noreferrer"&gt;Adding reading times to an Astro blog&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Series: Part of &lt;a href="https://sourcier.uk/blog/how-this-blog-was-built" rel="noopener noreferrer"&gt;How this blog was built&lt;/a&gt; — twenty posts on every decision that shaped this site.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Reading times set expectations before someone commits to an article. A "2 min&lt;br&gt;
read" label on a blog card tells you it's a quick technique post. "14 min read"&lt;br&gt;
tells you to save it for later. Both are useful signals — and they're trivial&lt;br&gt;
to add once you know which package to reach for.&lt;/p&gt;

&lt;p&gt;This post covers adding reading times to both blog listing cards and individual&lt;br&gt;
post pages in Astro, using the &lt;code&gt;reading-time&lt;/code&gt; npm package from the&lt;br&gt;
&lt;a href="https://docs.astro.build/en/recipes/reading-time/" rel="noopener noreferrer"&gt;official Astro recipe&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  The package
&lt;/h2&gt;

&lt;p&gt;Astro's documentation recommends the &lt;a href="https://www.npmjs.com/package/reading-time" rel="noopener noreferrer"&gt;&lt;code&gt;reading-time&lt;/code&gt;&lt;/a&gt;&lt;br&gt;
package. It accepts a string of text, counts the words, and returns a result&lt;br&gt;
object including a &lt;code&gt;.text&lt;/code&gt; property formatted as &lt;code&gt;"5 min read"&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm add reading-time
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Usage is a single function call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;readingTime&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reading-time&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readingTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// result.text → "5 min read"&lt;/span&gt;
&lt;span class="c1"&gt;// result.minutes → 4.7&lt;/span&gt;
&lt;span class="c1"&gt;// result.words → 940&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;.text&lt;/code&gt; property is what gets displayed — it rounds to the nearest minute&lt;br&gt;
and appends "min read".&lt;/p&gt;
&lt;h2&gt;
  
  
  Where reading times appear
&lt;/h2&gt;

&lt;p&gt;This blog shows reading time in two places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Blog cards&lt;/strong&gt; — after the publication date, separated by a middle dot: &lt;code&gt;14 Apr 2026 · 5 min read&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Post page hero&lt;/strong&gt; — in the meta bar between the date and the tag pills&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgICBCT0RZWyJwb3N0LmJvZHkgKHJhdyBNYXJrZG93bikiXQogICAgUlRbInJlYWRpbmdUaW1lKGJvZHkpLnRleHRcbic1IG1pbiByZWFkJyJdCiAgICBCT0RZIC0tPiBSVAoKICAgIHN1YmdyYXBoIGNhcmRzWyJCbG9nIGxpc3RpbmcgcGFnZXMiXQogICAgICAgIEJHWyJCbG9nR3JpZC5hc3Ryb1xuaW5kZXguYXN0cm8iXQogICAgICAgIEJQWyJCbG9nUG9zdC5hc3Ryb1xuKGNhcmQgY29tcG9uZW50KSJdCiAgICAgICAgQkcgLS0-fHJlYWRpbmdUaW1lIHByb3B8IEJQCiAgICBlbmQKCiAgICBzdWJncmFwaCBwb3N0WyJJbmRpdmlkdWFsIHBvc3QgcGFnZXMiXQogICAgICAgIElEWyJibG9nL1tpZF0uYXN0cm8iXQogICAgICAgIE1MWyJNYXJrZG93blBvc3RMYXlvdXQuYXN0cm8iXQogICAgICAgIFBIWyJQYWdlSGVyby5hc3RybyJdCiAgICAgICAgSUQgLS0-fHJlYWRpbmdUaW1lIHByb3B8IE1MCiAgICAgICAgTUwgLS0-fHJlYWRpbmdUaW1lIHByb3B8IFBICiAgICBlbmQKCiAgICBSVCAtLT4gQkcKICAgIFJUIC0tPiBJRA" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgICBCT0RZWyJwb3N0LmJvZHkgKHJhdyBNYXJrZG93bikiXQogICAgUlRbInJlYWRpbmdUaW1lKGJvZHkpLnRleHRcbic1IG1pbiByZWFkJyJdCiAgICBCT0RZIC0tPiBSVAoKICAgIHN1YmdyYXBoIGNhcmRzWyJCbG9nIGxpc3RpbmcgcGFnZXMiXQogICAgICAgIEJHWyJCbG9nR3JpZC5hc3Ryb1xuaW5kZXguYXN0cm8iXQogICAgICAgIEJQWyJCbG9nUG9zdC5hc3Ryb1xuKGNhcmQgY29tcG9uZW50KSJdCiAgICAgICAgQkcgLS0-fHJlYWRpbmdUaW1lIHByb3B8IEJQCiAgICBlbmQKCiAgICBzdWJncmFwaCBwb3N0WyJJbmRpdmlkdWFsIHBvc3QgcGFnZXMiXQogICAgICAgIElEWyJibG9nL1tpZF0uYXN0cm8iXQogICAgICAgIE1MWyJNYXJrZG93blBvc3RMYXlvdXQuYXN0cm8iXQogICAgICAgIFBIWyJQYWdlSGVyby5hc3RybyJdCiAgICAgICAgSUQgLS0-fHJlYWRpbmdUaW1lIHByb3B8IE1MCiAgICAgICAgTUwgLS0-fHJlYWRpbmdUaW1lIHByb3B8IFBICiAgICBlbmQKCiAgICBSVCAtLT4gQkcKICAgIFJUIC0tPiBJRA" alt="Mermaid diagram" width="615" height="681"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Diagram fallback for Dev.to. View the canonical article for the full version: &lt;a href="https://sourcier.uk/blog/reading-time-astro" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/reading-time-astro&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  Blog cards
&lt;/h2&gt;

&lt;p&gt;The computation happens in the listing components — &lt;code&gt;BlogGrid.astro&lt;/code&gt; for paginated&lt;br&gt;
blog pages and &lt;code&gt;index.astro&lt;/code&gt; for the homepage recent-posts grid. Both import&lt;br&gt;
the package and pass the result down to &lt;code&gt;BlogPost.astro&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
import readingTime from 'reading-time';
---

{posts.map((post) =&amp;gt; (
  &amp;lt;BlogPost
    title={post.data.title}
    description={post.data.description}
    url={`/blog/${post.id}`}
    cover={post.data.cover}
    pubDate={post.data.pubDate}
    readingTime={readingTime(post.body ?? '').text}
  /&amp;gt;
))}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;BlogPost.astro&lt;/code&gt; receives &lt;code&gt;readingTime&lt;/code&gt; as an optional prop and appends it to&lt;br&gt;
the existing date string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
const { description, title, subTitle, url, cover, pubDate, draft, readingTime } =
  Astro.props;
---

{formattedDate &amp;amp;&amp;amp; (
  &amp;lt;p class="card-meta"&amp;gt;
    {formattedDate}{readingTime &amp;amp;&amp;amp; ` · ${readingTime}`}
  &amp;lt;/p&amp;gt;
)}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Making &lt;code&gt;readingTime&lt;/code&gt; optional means the card degrades gracefully in any context&lt;br&gt;
where it isn't passed — no template changes needed elsewhere.&lt;/p&gt;
&lt;h2&gt;
  
  
  Post pages
&lt;/h2&gt;

&lt;p&gt;For individual post pages, the computation lives in &lt;code&gt;blog/[id].astro&lt;/code&gt; alongside&lt;br&gt;
the existing &lt;code&gt;render()&lt;/code&gt; call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
import readingTime from 'reading-time';

const { post } = Astro.props;
const { Content, headings } = await render(post);
const postReadingTime = readingTime(post.body ?? '').text;
---

&amp;lt;MarkdownPostLayout
  frontmatter={post.data}
  postId={post.id}
  headings={headings}
  readingTime={postReadingTime}
&amp;gt;
  &amp;lt;Content /&amp;gt;
&amp;lt;/MarkdownPostLayout&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;MarkdownPostLayout.astro&lt;/code&gt; destructures the new prop and passes it straight&lt;br&gt;
through to &lt;code&gt;PageHero&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const { frontmatter, postId, headings = [], readingTime } = Astro.props;

// ...

&amp;lt;PageHero
  title={frontmatter.title}
  date={formattedDate ?? undefined}
  readingTime={readingTime}
  tags={frontmatter.tags}
  {/* ... */}
/&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In &lt;code&gt;PageHero.astro&lt;/code&gt;, the reading time sits between the date and the tags divider.&lt;br&gt;
The condition that guards the meta bar is extended to include &lt;code&gt;readingTime&lt;/code&gt;, and&lt;br&gt;
the divider condition is updated to account for either date or reading time being&lt;br&gt;
present:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{(author || date || readingTime || (tags &amp;amp;&amp;amp; tags.length &amp;gt; 0)) &amp;amp;&amp;amp; (
  &amp;lt;div class="page-hero__meta"&amp;gt;
    {author &amp;amp;&amp;amp; &amp;lt;span class="page-hero__author"&amp;gt;By {author}&amp;lt;/span&amp;gt;}
    {date &amp;amp;&amp;amp; &amp;lt;span class="page-hero__date"&amp;gt;{date}&amp;lt;/span&amp;gt;}
    {readingTime &amp;amp;&amp;amp; (
      &amp;lt;span class="page-hero__reading-time"&amp;gt;{readingTime}&amp;lt;/span&amp;gt;
    )}
    {(date || readingTime) &amp;amp;&amp;amp; tags &amp;amp;&amp;amp; tags.length &amp;gt; 0 &amp;amp;&amp;amp; (
      &amp;lt;span class="page-hero__divider" aria-hidden="true" /&amp;gt;
    )}
    {/* tags... */}
  &amp;lt;/div&amp;gt;
)}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that in place, every post page shows its reading time in the hero meta&lt;br&gt;
bar next to the date, and the divider between the meta bar and the tags&lt;br&gt;
appears whenever either one is present, so tags never end up floating with&lt;br&gt;
no separator when a post has no author or date set.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;reading-time&lt;/code&gt; package does the hard part: counting words and formatting&lt;br&gt;
the label. The rest of the work was just making sure that one value flowed&lt;br&gt;
cleanly from &lt;code&gt;post.body&lt;/code&gt;, through the listing pages and the post-page render,&lt;br&gt;
down to the two places a reader actually sees it: the card and the hero.&lt;/p&gt;

</description>
      <category>astro</category>
      <category>engineering</category>
      <category>frontend</category>
    </item>
    <item>
      <title>Automating Dev.to cross-posting from an Astro blog</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Tue, 22 Sep 2026 09:45:21 +0000</pubDate>
      <link>https://dev.to/sourcier/automating-devto-cross-posting-from-an-astro-blog-dii</link>
      <guid>https://dev.to/sourcier/automating-devto-cross-posting-from-an-astro-blog-dii</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/crosspost-devto-astro" rel="noopener noreferrer"&gt;Automating Dev.to cross-posting from an Astro blog&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Series: Part of &lt;a href="https://sourcier.uk/blog/how-this-blog-was-built" rel="noopener noreferrer"&gt;How this blog was built&lt;/a&gt;: documenting every decision that shaped this site.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Dev.to has a large audience that won't find your personal blog through search.&lt;br&gt;
Cross-posting gets your content in front of them, but only if you set up the&lt;br&gt;
canonical URL correctly. Skip that step and you risk training search engines to&lt;br&gt;
prefer the Dev.to copy over your own site.&lt;/p&gt;

&lt;p&gt;This post walks through the cross-posting script this blog uses: a single Node.js&lt;br&gt;
file that reads frontmatter from your markdown posts, converts a few&lt;br&gt;
blog-specific features into Dev.to-friendly markdown, builds an article payload,&lt;br&gt;
shows a preview, and asks for confirmation before publishing or updating.&lt;/p&gt;
&lt;h2&gt;
  
  
  How cross-posting and canonical URLs work
&lt;/h2&gt;

&lt;p&gt;When you publish the same content to two URLs, search engines need a signal for&lt;br&gt;
which one to treat as authoritative. The canonical URL (&lt;code&gt;&amp;lt;link rel="canonical"&amp;gt;&lt;/code&gt;)&lt;br&gt;
provides that signal.&lt;/p&gt;

&lt;p&gt;Dev.to supports a &lt;code&gt;canonical_url&lt;/code&gt; field on its API. When set, Dev.to renders it&lt;br&gt;
as the canonical link in the page &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;, pointing back to your blog. Search&lt;br&gt;
engines follow that signal and credit your domain for the content, not Dev.to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never cross-post without setting &lt;code&gt;canonical_url&lt;/code&gt;.&lt;/strong&gt; Without it, Dev.to's version&lt;br&gt;
may outrank your own.&lt;/p&gt;
&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgICBTQ1JJUFRbInBucG0gY3Jvc3Nwb3N0OmRldnRvXG4oc2NyaXB0cy9jcm9zc3Bvc3QtZGV2dG8uanMpIl0KICAgIFNDUklQVCAtLT4gTElTVFsiUmVhZCBjb2xsZWN0aW9ucy9wb3N0cy9cbkxpc3QgcHVibGlzaGVkLCBub24tZnV0dXJlIHBvc3RzIl0KICAgIExJU1QgLS0-IFBJQ0tbIlByb21wdDogc2VsZWN0IGEgcG9zdCJdCiAgICBQSUNLIC0tPiBGTVsiUGFyc2UgZnJvbnRtYXR0ZXJcbih0aXRsZSwgZGVzY3JpcHRpb24sIHRhZ3MpIl0KICAgIEZNIC0tPiBCT0RZWyJOb3JtYWxpc2UgbWFya2Rvd24gYm9keVxubWVybWFpZCwgU1ZHcywgc2VyaWVzIGNhbGxvdXRzLCBpbWFnZXMiXQogICAgQk9EWSAtLT4gUFJFVklFV1siQ29uc29sZSBwcmV2aWV3Il0KICAgIFBSRVZJRVcgLS0-IExPT0tVUFsiRmluZCBleGlzdGluZyBEZXYudG8gYXJ0aWNsZVxuYnkgY2Fub25pY2FsX3VybCJdCiAgICBMT09LVVAgLS0-IERFQ0lERXsiRXhpc3RpbmcgYXJ0aWNsZT8ifQogICAgREVDSURFIC0tPnwibm8ifCBDT05GSVJNX0NSRUFURXsiQ29uZmlybSBjcmVhdGU_In0KICAgIERFQ0lERSAtLT58InllcyJ8IENPTkZJUk1fQUNUSU9OeyJVcGRhdGUsIGNyZWF0ZSwgb3IgY2FuY2VsPyJ9CiAgICBDT05GSVJNX0NSRUFURSAtLT58InllcyJ8IEFQSVsiUE9TVC9QVVQgdG8gRGV2LnRvIEFQSVxud2l0aCBjYW5vbmljYWxfdXJsIHNldCJdCiAgICBDT05GSVJNX0FDVElPTiAtLT4gQVBJCiAgICBDT05GSVJNX0NSRUFURSAtLT58Im5vInwgRVhJVFsiRXhpdCJdCiAgICBDT05GSVJNX0FDVElPTiAtLT58ImNhbmNlbCJ8IEVYSVQKICAgIEFQSSAtLT4gUkVTVUxUWyJMb2cgcHVibGlzaGVkL3VwZGF0ZWQgVVJMICsgYXJ0aWNsZSBJRCJd" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgICBTQ1JJUFRbInBucG0gY3Jvc3Nwb3N0OmRldnRvXG4oc2NyaXB0cy9jcm9zc3Bvc3QtZGV2dG8uanMpIl0KICAgIFNDUklQVCAtLT4gTElTVFsiUmVhZCBjb2xsZWN0aW9ucy9wb3N0cy9cbkxpc3QgcHVibGlzaGVkLCBub24tZnV0dXJlIHBvc3RzIl0KICAgIExJU1QgLS0-IFBJQ0tbIlByb21wdDogc2VsZWN0IGEgcG9zdCJdCiAgICBQSUNLIC0tPiBGTVsiUGFyc2UgZnJvbnRtYXR0ZXJcbih0aXRsZSwgZGVzY3JpcHRpb24sIHRhZ3MpIl0KICAgIEZNIC0tPiBCT0RZWyJOb3JtYWxpc2UgbWFya2Rvd24gYm9keVxubWVybWFpZCwgU1ZHcywgc2VyaWVzIGNhbGxvdXRzLCBpbWFnZXMiXQogICAgQk9EWSAtLT4gUFJFVklFV1siQ29uc29sZSBwcmV2aWV3Il0KICAgIFBSRVZJRVcgLS0-IExPT0tVUFsiRmluZCBleGlzdGluZyBEZXYudG8gYXJ0aWNsZVxuYnkgY2Fub25pY2FsX3VybCJdCiAgICBMT09LVVAgLS0-IERFQ0lERXsiRXhpc3RpbmcgYXJ0aWNsZT8ifQogICAgREVDSURFIC0tPnwibm8ifCBDT05GSVJNX0NSRUFURXsiQ29uZmlybSBjcmVhdGU_In0KICAgIERFQ0lERSAtLT58InllcyJ8IENPTkZJUk1fQUNUSU9OeyJVcGRhdGUsIGNyZWF0ZSwgb3IgY2FuY2VsPyJ9CiAgICBDT05GSVJNX0NSRUFURSAtLT58InllcyJ8IEFQSVsiUE9TVC9QVVQgdG8gRGV2LnRvIEFQSVxud2l0aCBjYW5vbmljYWxfdXJsIHNldCJdCiAgICBDT05GSVJNX0FDVElPTiAtLT4gQVBJCiAgICBDT05GSVJNX0NSRUFURSAtLT58Im5vInwgRVhJVFsiRXhpdCJdCiAgICBDT05GSVJNX0FDVElPTiAtLT58ImNhbmNlbCJ8IEVYSVQKICAgIEFQSSAtLT4gUkVTVUxUWyJMb2cgcHVibGlzaGVkL3VwZGF0ZWQgVVJMICsgYXJ0aWNsZSBJRCJd" alt="Mermaid diagram" width="537" height="1674"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Diagram fallback for Dev.to. View the canonical article for the full version: &lt;a href="https://sourcier.uk/blog/crosspost-devto-astro" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/crosspost-devto-astro&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  Setting up
&lt;/h2&gt;

&lt;p&gt;Get an API key from &lt;a href="https://dev.to/settings/extensions"&gt;dev.to/settings/extensions&lt;/a&gt;&lt;br&gt;
under &lt;strong&gt;DEV Community API Keys&lt;/strong&gt;. Add it, along with your site's public URL, to&lt;br&gt;
&lt;code&gt;.env&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;DEVTO_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your_key_here
&lt;span class="nv"&gt;SITE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://sourcier.uk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script reads &lt;code&gt;.env&lt;/code&gt; automatically if it exists, using Node 20.12's built-in&lt;br&gt;
&lt;code&gt;process.loadEnvFile&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Running it
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm crosspost:devto
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The script lists published, non-future posts sorted newest-first and prompts you&lt;br&gt;
to pick one from a dropdown built with &lt;code&gt;@inquirer/prompts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;? Select a post to cross-post: (Use arrow keys)
❯ reactions-netlify-blobs-astro
  dark-light-theme-toggle
  comments-system
  ...

─────────────────────────────────────────
  Title        : Adding emoji reactions to your Astro blog with Netlify Blobs
  Canonical URL: https://sourcier.uk/blog/reactions-netlify-blobs-astro
  Tags         : astro, netlify, engineering, frontend
  Description  : How to add emoji reactions…
  Mermaid      : 1 converted (image mode)
  Code fences  : 3 normalised
  Series notes : 0 normalised
  SVG images   : 2 converted to links
  Original link: prepended
  Body length  : 4821 chars
─────────────────────────────────────────

Checking for existing Dev.to article by canonical URL…
No existing article found, will create a new one.

Cross-post to Dev.to? [y/N]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a matching article already exists (found by &lt;code&gt;canonical_url&lt;/code&gt;), the script&lt;br&gt;
offers a choice instead: update the existing article, create a new one, or&lt;br&gt;
cancel. Pass &lt;code&gt;--update&lt;/code&gt; to force update mode, or &lt;code&gt;--update &amp;lt;article-id&amp;gt;&lt;/code&gt; to&lt;br&gt;
target a specific article without the canonical URL lookup.&lt;/p&gt;
&lt;h2&gt;
  
  
  How frontmatter is parsed
&lt;/h2&gt;

&lt;p&gt;The script reads &lt;code&gt;collections/posts/&amp;lt;slug&amp;gt;/index.md&lt;/code&gt; directly, no Astro build&lt;br&gt;
needed. Frontmatter is parsed with a lightweight regex approach rather than a&lt;br&gt;
full YAML library, which keeps the script dependency-free.&lt;/p&gt;

&lt;p&gt;Tags are normalised for Dev.to's format: lowercase, alphanumeric only, maximum 4.&lt;br&gt;
The &lt;code&gt;#&lt;/code&gt; prefix Dev.to adds to tags in its UI is handled by the API automatically.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;normaliseTags&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;tags&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;a-z0-9&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hyphenated tags like &lt;code&gt;web-performance&lt;/code&gt; become &lt;code&gt;webperformance&lt;/code&gt;. That's a Dev.to&lt;br&gt;
constraint: their tag system doesn't support hyphens.&lt;/p&gt;
&lt;h2&gt;
  
  
  Making image URLs absolute
&lt;/h2&gt;

&lt;p&gt;Markdown posts reference images with paths like &lt;code&gt;/post-images/slug/image.webp&lt;/code&gt;&lt;br&gt;
or &lt;code&gt;./image.webp&lt;/code&gt;. Dev.to renders these relative to &lt;code&gt;dev.to&lt;/code&gt;, not your domain,&lt;br&gt;
so they'd break.&lt;/p&gt;

&lt;p&gt;The script converts both patterns to absolute URLs before sending to the API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;makeImagesAbsolute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;markdown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;markdown&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\(\/&lt;/span&gt;&lt;span class="sr"&gt;post-images&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;siteBase&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/post-images/`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\(\.\/([^&lt;/span&gt;&lt;span class="sr"&gt;)&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\.(&lt;/span&gt;&lt;span class="sr"&gt;png|jpg|jpeg|gif|webp|svg&lt;/span&gt;&lt;span class="se"&gt;))\)&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;`(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;siteBase&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/post-images/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/$1)`&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Converting mermaid diagrams to images
&lt;/h2&gt;

&lt;p&gt;Dev.to doesn't run the client-side mermaid library this blog uses, so a raw&lt;br&gt;
&lt;code&gt;&lt;/code&gt;`&lt;code&gt;mermaid&lt;/code&gt; fence would just render as an unstyled code block. The script&lt;br&gt;
renders the diagram as an image instead, using the free&lt;br&gt;
&lt;a href="https://mermaid.ink" rel="noopener noreferrer"&gt;mermaid.ink&lt;/a&gt; rendering service: the diagram source is&lt;br&gt;
base64url-encoded into an image URL, with a note linking back to the canonical&lt;br&gt;
article for the interactive version.&lt;/p&gt;

&lt;p&gt;`&lt;code&gt;&lt;/code&gt;javascript&lt;br&gt;
function mermaidFallback(markdown, canonicalUrl) {&lt;br&gt;
  const diagram = markdown.trim();&lt;br&gt;
  const encoded = toBase64Url(diagram);&lt;br&gt;
  const imageUrl = &lt;code&gt;https://mermaid.ink/img/${encoded}&lt;/code&gt;;&lt;/p&gt;

&lt;p&gt;return [&lt;br&gt;
    &lt;code&gt;![Mermaid diagram](${imageUrl})&lt;/code&gt;,&lt;br&gt;
    "",&lt;br&gt;
    &lt;code&gt;&amp;gt; Diagram fallback for Dev.to. View the canonical article for the full version: ${canonicalUrl}&lt;/code&gt;,&lt;br&gt;
  ].join("\n");&lt;br&gt;
}&lt;br&gt;
&lt;code&gt;&lt;/code&gt;`&lt;/p&gt;

&lt;p&gt;Set &lt;code&gt;DEVTO_MERMAID_MODE=code&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt; to fall back to the raw code fence&lt;br&gt;
instead, if you'd rather readers copy the diagram source.&lt;/p&gt;

&lt;h2&gt;
  
  
  Converting SVG wireframes to PNG
&lt;/h2&gt;

&lt;p&gt;SVG images (used for wireframes and mockups on this blog) aren't reliably&lt;br&gt;
supported by Dev.to's renderer. The script swaps the &lt;code&gt;.svg&lt;/code&gt; extension for &lt;code&gt;.png&lt;/code&gt;&lt;br&gt;
in the image URL, pointing at the PNG fallback that &lt;code&gt;post-images:copy&lt;/code&gt; already&lt;br&gt;
generates for every SVG, and adds a note linking back to the original:&lt;/p&gt;

&lt;p&gt;`&lt;code&gt;&lt;/code&gt;javascript&lt;br&gt;
function svgFallback(alt, svgUrl, canonicalUrl) {&lt;br&gt;
  const pngUrl = swapSvgExtension(svgUrl);&lt;/p&gt;

&lt;p&gt;return [&lt;br&gt;
    &lt;code&gt;![${escapeMarkdownLinkText(alt)}](${pngUrl})&lt;/code&gt;,&lt;br&gt;
    "",&lt;br&gt;
    &lt;code&gt;&amp;gt; Diagram fallback for Dev.to. View the canonical article for the original SVG: ${canonicalUrl}&lt;/code&gt;,&lt;br&gt;
  ].join("\n");&lt;br&gt;
}&lt;br&gt;
&lt;code&gt;&lt;/code&gt;`&lt;/p&gt;

&lt;h2&gt;
  
  
  Normalising series callouts and adding the original-post link
&lt;/h2&gt;

&lt;p&gt;This blog marks series posts with a &lt;code&gt;&amp;lt;div class="series-callout"&amp;gt;&lt;/code&gt; element,&lt;br&gt;
which Dev.to would strip as unknown HTML. The script converts it into a plain&lt;br&gt;
markdown blockquote, and prepends every article body with a link back to the&lt;br&gt;
canonical post, since not everyone reads the canonical URL in the article&lt;br&gt;
footer:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;`javascript&lt;br&gt;
function buildOriginalPostLine(title, canonicalUrl) {&lt;br&gt;
  return `&amp;gt; Original post: [${escapeMarkdownLinkText(title)}](${canonicalUrl})`;&lt;br&gt;
}&lt;br&gt;
`&lt;/code&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Updating an already cross-posted article
&lt;/h2&gt;

&lt;p&gt;Re-running &lt;code&gt;pnpm crosspost:devto&lt;/code&gt; and selecting a post that's already on Dev.to&lt;br&gt;
triggers a lookup by &lt;code&gt;canonical_url&lt;/code&gt; against your Dev.to articles. If found, you&lt;br&gt;
can update it in place rather than accidentally creating a duplicate:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;`javascript&lt;br&gt;
async function findExistingArticleByCanonical(canonicalUrl) {&lt;br&gt;
  const target = normaliseUrl(canonicalUrl);&lt;br&gt;
  // paginate through /api/articles/me/all and match on canonical_url&lt;br&gt;
}&lt;br&gt;
`&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;This means editing a published post on your blog and re-running the script is&lt;br&gt;
enough to keep the Dev.to copy in sync, no need to track article IDs manually.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to check after publishing
&lt;/h2&gt;

&lt;p&gt;Even with these conversions, Dev.to doesn't render everything your blog does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Code block titles&lt;/strong&gt;: &lt;code&gt;title="filename.ts"&lt;/code&gt; attributes in fenced code blocks
don't render on Dev.to. The code itself is fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expressive code features&lt;/strong&gt; like line highlighting or diff markers may not
survive the normal Dev.to markdown renderer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fullscreen image expand buttons&lt;/strong&gt; are added by this blog's layout at
runtime, so they won't appear on Dev.to. That's expected: readers can still
click through to the canonical article.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After publishing, open the article on Dev.to and do a quick visual check,&lt;br&gt;
especially for any mermaid diagrams or SVG wireframes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Full code listing
&lt;/h2&gt;

&lt;p&gt;`&lt;code&gt;&lt;/code&gt;js&lt;/p&gt;

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

&lt;p&gt;// Cross-posts a blog post to Dev.to with a canonical URL pointing back to sourcier.uk.&lt;br&gt;
// Prepends an original-post link and converts unsupported SVG embeds into PNG fallbacks.&lt;br&gt;
// Reads post content from collections/posts//index.md.&lt;br&gt;
//&lt;br&gt;
// Usage:&lt;br&gt;
//   node scripts/crosspost-devto.js&lt;br&gt;
//   node scripts/crosspost-devto.js --update&lt;br&gt;
//   node scripts/crosspost-devto.js --update &lt;br&gt;
//&lt;br&gt;
// Required environment variables (set in .env or shell):&lt;br&gt;
//   DEVTO_API_KEY  - API key from &lt;a href="https://dev.to/settings/extensions"&gt;https://dev.to/settings/extensions&lt;/a&gt; -&amp;gt; DEV Community API Keys&lt;br&gt;
//   SITE_URL       - public URL of the site, e.g. &lt;a href="https://sourcier.uk" rel="noopener noreferrer"&gt;https://sourcier.uk&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;import { select, confirm } from "@inquirer/prompts";&lt;br&gt;
import { readFileSync, readdirSync, existsSync } from "fs";&lt;br&gt;
import { join, resolve } from "path";&lt;/p&gt;

&lt;p&gt;const root = resolve(new URL(".", import.meta.url).pathname, "..");&lt;/p&gt;

&lt;p&gt;const envFile = join(root, ".env");&lt;br&gt;
if (existsSync(envFile) &amp;amp;&amp;amp; typeof process.loadEnvFile === "function") {&lt;br&gt;
  process.loadEnvFile(envFile);&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;const apiKey = process.env.DEVTO_API_KEY;&lt;br&gt;
const siteBase = (process.env.SITE_URL ?? "&lt;a href="https://sourcier.uk%22).replace(" rel="noopener noreferrer"&gt;https://sourcier.uk").replace(&lt;/a&gt;&lt;br&gt;
  /\/$/,&lt;br&gt;
  "",&lt;br&gt;
);&lt;br&gt;
const mermaidMode = (process.env.DEVTO_MERMAID_MODE ?? "image").toLowerCase();&lt;/p&gt;

&lt;p&gt;function parseCliArgs(argv) {&lt;br&gt;
  let explicitArticleId = null;&lt;br&gt;
  let updateOnly = false;&lt;/p&gt;

&lt;p&gt;for (let i = 0; i &amp;lt; argv.length; i += 1) {&lt;br&gt;
    const arg = argv[i];&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if (arg === "--help" || arg === "-h") {
  console.log("Usage:");
  console.log("  node scripts/crosspost-devto.js");
  console.log("  node scripts/crosspost-devto.js --update");
  console.log(
    "  node scripts/crosspost-devto.js --update &amp;lt;devto-article-id&amp;gt;",
  );
  process.exit(0);
}

if (arg === "--update") {
  updateOnly = true;
  const maybeId = argv[i + 1];
  if (maybeId &amp;amp;&amp;amp; !maybeId.startsWith("--")) {
    explicitArticleId = Number(maybeId);
    i += 1;
  }
  continue;
}

console.error(`Error: Unknown argument '${arg}'.`);
console.error("Use --help to see supported options.");
process.exit(1);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;return { explicitArticleId, updateOnly };&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;const { explicitArticleId, updateOnly } = parseCliArgs(process.argv.slice(2));&lt;/p&gt;

&lt;p&gt;if (!apiKey) {&lt;br&gt;
  console.error("Error: DEVTO_API_KEY environment variable is required.");&lt;br&gt;
  console.error("Get one at: &lt;a href="https://dev.to/settings/extensions%22"&gt;https://dev.to/settings/extensions"&lt;/a&gt;);&lt;br&gt;
  process.exit(1);&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;if (explicitArticleId !== null &amp;amp;&amp;amp; !Number.isInteger(explicitArticleId)) {&lt;br&gt;
  console.error("Error: --update  must be a valid integer.");&lt;br&gt;
  process.exit(1);&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;// -- Frontmatter parser --------------------------------------------------------&lt;/p&gt;

&lt;p&gt;function parseFrontmatter(content) {&lt;br&gt;
  const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);&lt;br&gt;
  if (!match) return {};&lt;br&gt;
  const yaml = match[1];&lt;br&gt;
  const result = {};&lt;/p&gt;

&lt;p&gt;for (const line of yaml.split(/\r?\n/)) {&lt;br&gt;
    const m = line.match(/^(\w+):\s*["'&amp;gt;]?(.&lt;em&gt;?)["']?\s&lt;/em&gt;$/);&lt;br&gt;
    if (m) result[m[1]] = m[2].trim();&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;const descBlock = yaml.match(&lt;br&gt;
    /^description:\s*&amp;gt;-?\r?\n((?:[ \t]+.+\r?\n?)*)/m,&lt;br&gt;
  );&lt;br&gt;
  if (descBlock) {&lt;br&gt;
    result.description = descBlock[1]&lt;br&gt;
      .split(/\r?\n/)&lt;br&gt;
      .map((l) =&amp;gt; l.trim())&lt;br&gt;
      .filter(Boolean)&lt;br&gt;
      .join(" ");&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;// Parse tags array: tags: ["tag1", "tag2"]&lt;br&gt;
  const tagsMatch = yaml.match(/^tags:\s*[(.+?)]/m);&lt;br&gt;
  if (tagsMatch) {&lt;br&gt;
    result.tags = tagsMatch[1]&lt;br&gt;
      .split(",")&lt;br&gt;
      .map((t) =&amp;gt; t.trim().replace(/^["']|["']$/g, ""));&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;return result;&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;function stripFrontmatter(content) {&lt;br&gt;
  return content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "").trim();&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;// Dev.to tags: lowercase alphanumeric only, max 4&lt;br&gt;
function normaliseTags(tags = []) {&lt;br&gt;
  return tags&lt;br&gt;
    .map((t) =&amp;gt; t.toLowerCase().replace(/[^a-z0-9]/g, ""))&lt;br&gt;
    .filter(Boolean)&lt;br&gt;
    .slice(0, 4);&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;function escapeMarkdownLinkText(value) {&lt;br&gt;
  return String(value ?? "")&lt;br&gt;
    .replace(/\/g, "\\")&lt;br&gt;
    .replace(/[/g, "\[")&lt;br&gt;
    .replace(/]/g, "\]");&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;function makeUrlAbsolute(url) {&lt;br&gt;
  if (!url) return url;&lt;br&gt;
  if (/^https?:\/\//i.test(url)) return url;&lt;br&gt;
  if (url.startsWith("/")) return &lt;code&gt;${siteBase}${url}&lt;/code&gt;;&lt;br&gt;
  return url;&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;function buildOriginalPostLine(title, canonicalUrl) {&lt;br&gt;
  return &lt;code&gt;&amp;gt; Original post: [${escapeMarkdownLinkText(title)}](${canonicalUrl})&lt;/code&gt;;&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;function swapSvgExtension(url) {&lt;br&gt;
  return url.replace(/.svg(?=(?:\?[^)]*)?$)/i, ".png");&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;// Replace relative /post-images/ paths and ./image references with absolute URLs&lt;br&gt;
function makeImagesAbsolute(markdown, slug) {&lt;br&gt;
  return markdown&lt;br&gt;
    .replace(/(\/post-images\//g, &lt;code&gt;(${siteBase}/post-images/&lt;/code&gt;)&lt;br&gt;
    .replace(&lt;br&gt;
      /(.\/([^)]+.(png|jpg|jpeg|gif|webp|svg)))/g,&lt;br&gt;
      &lt;code&gt;(${siteBase}/post-images/${slug}/$1)&lt;/code&gt;,&lt;br&gt;
    );&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;function stripHtmlTags(str) {&lt;br&gt;
  let result = str;&lt;br&gt;
  let prev;&lt;br&gt;
  do {&lt;br&gt;
    prev = result;&lt;br&gt;
    result = result.replace(/&amp;lt;[^&amp;gt;]*&amp;gt;?/g, "");&lt;br&gt;
  } while (result !== prev);&lt;br&gt;
  return result;&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;function normaliseSeriesCallout(line) {&lt;br&gt;
  const match = line.match(&lt;br&gt;
    /^&lt;/p&gt;
&lt;span&gt;([^&amp;lt;]+)&amp;lt;\/span&amp;gt;&lt;span&gt;([\s\S]*?)&amp;lt;\/span&amp;gt;&amp;lt;\/div&amp;gt;$/,&lt;br&gt;
  );

&lt;p&gt;if (!match) return null;&lt;/p&gt;

&lt;p&gt;const label = match[1].trim();&lt;br&gt;
  const text = stripHtmlTags(&lt;br&gt;
    match[2].replace(&lt;br&gt;
      /&lt;a href="([^"&gt;([\s\S]*?)&amp;lt;\/a&amp;gt;/g,&lt;br&gt;
      (_, href, linkText) =&amp;gt;&lt;br&gt;
        &lt;code&gt;[${escapeMarkdownLinkText(stripHtmlTags(linkText))}](${makeUrlAbsolute(href)})&lt;/code&gt;,&lt;br&gt;
    ),&lt;br&gt;
  )&lt;br&gt;
    .replace(/\s+/g, " ")&lt;br&gt;
    .trim();&lt;/a&gt;&lt;/p&gt;
&lt;a href="([^"&gt;

&lt;p&gt;return &lt;code&gt;&amp;gt; ${label}: ${text}&lt;/code&gt;;&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;function toBase64Url(value) {&lt;br&gt;
  return Buffer.from(value, "utf8")&lt;br&gt;
    .toString("base64")&lt;br&gt;
    .replace(/+/g, "-")&lt;br&gt;
    .replace(/\//g, "_")&lt;br&gt;
    .replace(/=+$/g, "");&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;function normaliseFenceLanguage(info) {&lt;br&gt;
  const token = info.trim().split(/\s+/)[0] ?? "";&lt;br&gt;
  return /^[a-z0-9#+.-]+$/i.test(token) ? token.toLowerCase() : "";&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;function mermaidFallback(markdown, canonicalUrl) {&lt;br&gt;
  const diagram = markdown.trim();&lt;br&gt;
  if (!diagram) return "";&lt;/p&gt;

&lt;p&gt;if (mermaidMode === "code") {&lt;br&gt;
    return [&lt;br&gt;
      &lt;code&gt;&amp;gt; Mermaid diagram is available in the canonical article: ${canonicalUrl}&lt;/code&gt;,&lt;br&gt;
      "",&lt;br&gt;
      "&lt;code&gt;&lt;/code&gt;&lt;code&gt;mermaid",&lt;br&gt;
      diagram,&lt;br&gt;
      "&lt;/code&gt;&lt;code&gt;&lt;/code&gt;",&lt;br&gt;
    ].join("\n");&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;const encoded = toBase64Url(diagram);&lt;br&gt;
  const imageUrl = &lt;code&gt;https://mermaid.ink/img/${encoded}&lt;/code&gt;;&lt;/p&gt;

&lt;p&gt;return [&lt;br&gt;
    &lt;code&gt;![Mermaid diagram](${imageUrl})&lt;/code&gt;,&lt;br&gt;
    "",&lt;br&gt;
    &lt;code&gt;&amp;gt; Diagram fallback for Dev.to. View the canonical article for the full version: ${canonicalUrl}&lt;/code&gt;,&lt;br&gt;
  ].join("\n");&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;function svgFallback(alt, svgUrl, canonicalUrl) {&lt;br&gt;
  const pngUrl = swapSvgExtension(svgUrl);&lt;/p&gt;

&lt;p&gt;return [&lt;br&gt;
    &lt;code&gt;![${escapeMarkdownLinkText(alt)}](${pngUrl})&lt;/code&gt;,&lt;br&gt;
    "",&lt;br&gt;
    &lt;code&gt;&amp;gt; Diagram fallback for Dev.to. View the canonical article for the original SVG: ${canonicalUrl}&lt;/code&gt;,&lt;br&gt;
  ].join("\n");&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;function normaliseSvgImage(line, canonicalUrl) {&lt;br&gt;
  const match = line.match(/^![([^]]&lt;em&gt;)](([^)\s]+.svg(?:\?[^)]&lt;/em&gt;)?))$/i);&lt;br&gt;
  if (!match) return null;&lt;/p&gt;

&lt;p&gt;const alt = match[1].trim() || "Diagram";&lt;br&gt;
  const url = match[2];&lt;/p&gt;

&lt;p&gt;return svgFallback(alt, url, canonicalUrl);&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;function normaliseMarkdownForDevto(markdown, canonicalUrl, title) {&lt;br&gt;
  const lines = markdown.split(/\r?\n/);&lt;br&gt;
  const output = [];&lt;br&gt;
  const stats = {&lt;br&gt;
    mermaidBlocks: 0,&lt;br&gt;
    codeFencesNormalised: 0,&lt;br&gt;
    seriesCalloutsNormalised: 0,&lt;br&gt;
    svgImagesConverted: 0,&lt;br&gt;
  };&lt;/p&gt;

&lt;p&gt;let inFence = false;&lt;br&gt;
  let fenceMarker = "";&lt;br&gt;
  let fenceInfo = "";&lt;br&gt;
  let fenceLines = [];&lt;/p&gt;

&lt;p&gt;for (const line of lines) {&lt;br&gt;
    if (!inFence) {&lt;br&gt;
      const open = line.match(/^(`{3,})(.*)$/);&lt;br&gt;
      if (!open) {&lt;br&gt;
        const seriesCallout = normaliseSeriesCallout(line);&lt;br&gt;
        if (seriesCallout) {&lt;br&gt;
          stats.seriesCalloutsNormalised += 1;&lt;br&gt;
          output.push(seriesCallout);&lt;br&gt;
          continue;&lt;br&gt;
        }&lt;/p&gt;

&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    const svgImage = normaliseSvgImage(line, canonicalUrl);
    if (svgImage) {
      stats.svgImagesConverted += 1;
      output.push(svgImage);
      continue;
    }

    output.push(line);
    continue;
  }

  inFence = true;
  fenceMarker = open[1];
  fenceInfo = open[2] ?? "";
  fenceLines = [];
  continue;
}

const trimmed = line.trim();
const isFenceClose =
  trimmed.startsWith(fenceMarker) &amp;amp;&amp;amp;
  /^`+$/.test(trimmed) &amp;amp;&amp;amp;
  trimmed.length &amp;gt;= fenceMarker.length;

if (!isFenceClose) {
  fenceLines.push(line);
  continue;
}

const rawCode = fenceLines.join("\n");
const language = normaliseFenceLanguage(fenceInfo);

if (language === "mermaid") {
  stats.mermaidBlocks += 1;
  output.push(mermaidFallback(rawCode, canonicalUrl));
} else {
  if (fenceInfo.trim()) stats.codeFencesNormalised += 1;
  output.push(language ? `\`\`\`${language}` : "
&lt;/code&gt;&lt;/pre&gt;



&lt;pre class="highlight plaintext"&gt;&lt;code&gt;      output.push(rawCode);
      output.push("```

");
    }

    inFence = false;
    fenceMarker = "";
    fenceInfo = "";
    fenceLines = [];
  }

  if (inFence) {
    output.push(`${fenceMarker}${fenceInfo}`);
    output.push(...fenceLines);
  }

  return {
    markdown: `${buildOriginalPostLine(title, canonicalUrl)}\n\n${output.join("\n").trimStart()}`,
    stats,
  };
}

function loadPost(slug) {
  const filePath = join(root, "collections", "posts", slug, "index.md");
  let raw;
  try {
    raw = readFileSync(filePath, "utf8");
  } catch {
    throw new Error(`Post not found: collections/posts/${slug}/index.md`);
  }
  const fm = parseFrontmatter(raw);
  if (!fm.title) throw new Error(`No title found in frontmatter for: ${slug}`);

  const title = fm.title.replace(/^["']|["']$/g, "");
  const canonicalUrl = `${siteBase}/blog/${slug}`;
  const markdownBody = makeImagesAbsolute(stripFrontmatter(raw), slug);
  const normalised = normaliseMarkdownForDevto(
    markdownBody,
    canonicalUrl,
    title,
  );

  return {
    title,
    description: fm.description || "",
    tags: normaliseTags(fm.tags),
    draft: fm.draft === "true",
    pubDate: fm.pubDate ? new Date(fm.pubDate) : null,
    body: normalised.markdown,
    canonicalUrl,
    transformStats: normalised.stats,
  };
}

function listPostIds() {
  const postsDir = join(root, "collections", "posts");
  return readdirSync(postsDir, { withFileTypes: true })
    .filter((d) =&amp;gt; d.isDirectory())
    .map((d) =&amp;gt; {
      try {
        const content = readFileSync(
          join(postsDir, d.name, "index.md"),
          "utf8",
        );
        const fm = parseFrontmatter(content);
        return {
          id: d.name,
          pubDate: fm.pubDate ? new Date(fm.pubDate) : new Date(0),
          isDraft: fm.draft === "true",
          isFuture: fm.pubDate ? new Date(fm.pubDate) &amp;gt; new Date() : false,
        };
      } catch {
        return null;
      }
    })
    .filter((p) =&amp;gt; p &amp;amp;&amp;amp; !p.isDraft &amp;amp;&amp;amp; !p.isFuture)
    .sort((a, b) =&amp;gt; b.pubDate - a.pubDate)
    .map((p) =&amp;gt; p.id);
}

function normaliseUrl(value) {
  return String(value || "").replace(/\/$/, "");
}

function buildArticlePayload(post) {
  return {
    article: {
      title: post.title,
      body_markdown: post.body,
      published: true,
      canonical_url: post.canonicalUrl,
      description: post.description,
      tags: post.tags,
    },
  };
}

async function findExistingArticleByCanonical(canonicalUrl) {
  const target = normaliseUrl(canonicalUrl);
  let page = 1;
  const perPage = 100;

  while (true) {
    const res = await fetch(
      `https://dev.to/api/articles/me/all?page=${page}&amp;amp;per_page=${perPage}`,
      {
        headers: {
          "api-key": apiKey,
        },
      },
    );

    const data = await res.json();

    if (!res.ok) {
      throw new Error(
        `Unable to list Dev.to articles (${res.status}): ${JSON.stringify(data)}`,
      );
    }

    if (!Array.isArray(data) || data.length === 0) return null;

    const existing = data.find((article) =&amp;gt; {
      const canonical = normaliseUrl(article.canonical_url);
      return canonical &amp;amp;&amp;amp; canonical === target;
    });

    if (existing) return existing;
    if (data.length &amp;lt; perPage) return null;

    page += 1;
  }
}

// -- Select post ----------------------------------------------------------------

const postIds = listPostIds();
if (postIds.length === 0) {
  console.log("\nNo published posts found.");
  process.exit(0);
}

const slug = await select({
  message: "Select a post to cross-post:",
  choices: postIds.map((id) =&amp;gt; ({ value: id })),
}).catch(() =&amp;gt; process.exit(0));

let post;
try {
  post = loadPost(slug);
} catch (err) {
  console.error(`Error: ${err.message}`);
  process.exit(1);
}

// -- Preview ---------------------------------------------------------------------

console.log("\n─────────────────────────────────────────");
console.log(`  Title        : ${post.title}`);
console.log(`  Canonical URL: ${post.canonicalUrl}`);
console.log(`  Tags         : ${post.tags.join(", ") || "(none)"}`);
console.log(
  `  Description  : ${post.description.slice(0, 80)}${post.description.length &amp;gt; 80 ? "…" : ""}`,
);
console.log(
  `  Mermaid      : ${post.transformStats.mermaidBlocks} converted (${mermaidMode} mode)`,
);
console.log(
  `  Code fences  : ${post.transformStats.codeFencesNormalised} normalised`,
);
console.log(
  `  Series notes : ${post.transformStats.seriesCalloutsNormalised} normalised`,
);
console.log(
  `  SVG images   : ${post.transformStats.svgImagesConverted} converted to links`,
);
console.log("  Original link: prepended");
console.log(`  Body length  : ${post.body.length} chars`);
console.log("─────────────────────────────────────────\n");

// -- Detect existing Dev.to article -----------------------------------------

let existingArticle = null;

if (explicitArticleId !== null) {
  existingArticle = { id: explicitArticleId };
  console.log(
    `--update ${explicitArticleId} provided - forcing update mode.\n`,
  );
} else {
  console.log("Checking for existing Dev.to article by canonical URL…");
  try {
    existingArticle = await findExistingArticleByCanonical(post.canonicalUrl);
  } catch (err) {
    console.error(`Error: ${err.message}`);
    process.exit(1);
  }
  if (existingArticle) {
    console.log(
      `Found existing article: https://dev.to/articles/${existingArticle.id}\n`,
    );
  } else {
    console.log("No existing article found - will create a new one.\n");
  }
}

const isUpdate = Boolean(existingArticle?.id);

if (updateOnly &amp;amp;&amp;amp; !isUpdate) {
  console.error(
    "Error: --update was set, but no existing Dev.to article was found for this canonical URL.",
  );
  console.error("Re-run without --update to create a new article instead.");
  process.exit(1);
}

// -- Confirm action ------------------------------------------------------------

let proceed;

if (explicitArticleId !== null || updateOnly) {
  const ok = await confirm({
    message: `Update Dev.to article ${existingArticle.id}?`,
    default: false,
  }).catch(() =&amp;gt; process.exit(0));
  if (!ok) {
    console.log("Aborted.");
    process.exit(0);
  }
  proceed = "update";
} else if (isUpdate) {
  proceed = await select({
    message: `An existing article was found. What would you like to do?`,
    choices: [
      { name: "Update the existing article", value: "update" },
      { name: "Create a new article", value: "create" },
      { name: "Cancel", value: "cancel" },
    ],
  }).catch(() =&amp;gt; process.exit(0));
} else {
  const ok = await confirm({
    message: "Cross-post to Dev.to?",
    default: false,
  }).catch(() =&amp;gt; process.exit(0));
  proceed = ok ? "create" : "cancel";
}

if (proceed === "cancel") {
  console.log("Aborted.");
  process.exit(0);
}

const doUpdate = proceed === "update";

// -- CREATE or UPDATE via Dev.to API --------------------------------------------

const endpoint = doUpdate
  ? `https://dev.to/api/articles/${existingArticle.id}`
  : "https://dev.to/api/articles";
const method = doUpdate ? "PUT" : "POST";

console.log(`\n${doUpdate ? "Updating" : "Posting"} on Dev.to…`);

const res = await fetch(endpoint, {
  method,
  headers: {
    "Content-Type": "application/json",
    "api-key": apiKey,
  },
  body: JSON.stringify(buildArticlePayload(post)),
});

const data = await res.json();

if (!res.ok) {
  console.error(
    `\nDev.to API error (${res.status}):`,
    JSON.stringify(data, null, 2),
  );
  process.exit(1);
}

console.log(`\n✓ ${doUpdate ? "Updated" : "Published"}! View at: ${data.url}`);
console.log("  Dev.to article ID:", data.id);
console.log(
  "\nNote: Mermaid and expressive code fences were normalised for Dev.to compatibility.",
);
console.log(
  "Review the Dev.to article to confirm formatting. Original-post link and canonical URL are set correctly.",
);


&lt;/code&gt;&lt;/pre&gt;



&lt;/a&gt;&lt;/span&gt;&lt;/span&gt;

</description>
      <category>astro</category>
      <category>engineering</category>
      <category>devto</category>
      <category>automation</category>
    </item>
    <item>
      <title>Adding emoji reactions to your Astro blog with Netlify Blobs</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Thu, 17 Sep 2026 16:09:14 +0000</pubDate>
      <link>https://dev.to/sourcier/adding-emoji-reactions-to-your-astro-blog-with-netlify-blobs-84</link>
      <guid>https://dev.to/sourcier/adding-emoji-reactions-to-your-astro-blog-with-netlify-blobs-84</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/reactions-netlify-blobs-astro" rel="noopener noreferrer"&gt;Adding emoji reactions to your Astro blog with Netlify Blobs&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Comments ask a lot of a reader. Reactions ask almost nothing, a single click&lt;br&gt;
and a second of intent. That asymmetry is what makes them worth adding: most&lt;br&gt;
readers who found your post useful will never write a comment, but many of them&lt;br&gt;
will click a heart or a lightbulb if you make it easy enough.&lt;/p&gt;

&lt;p&gt;This post covers the full implementation on this blog: a Netlify Blobs store&lt;br&gt;
for persistence, a serverless function for the API, and an Astro component that&lt;br&gt;
handles optimistic updates, localStorage deduplication, and a pop animation that&lt;br&gt;
makes the whole thing feel alive.&lt;/p&gt;
&lt;h2&gt;
  
  
  Architecture overview
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgICBzdWJncmFwaCBjbGllbnRbIkJyb3dzZXIiXQogICAgICAgIENPTVBbIlJlYWN0aW9ucyBjb21wb25lbnRcbihBc3RybyArIFRTKSJdCiAgICAgICAgTFNbImxvY2FsU3RvcmFnZVxuKHBlci1wb3N0IHJlYWN0aW9uIHNldCkiXQogICAgICAgIENPTVAgLS0-fCJmZXRjaCBvbiBtb3VudCJ8IEFQSQogICAgICAgIENPTVAgLS0-fCJQT1NUIG9uIGNsaWNrInwgQVBJCiAgICAgICAgQ09NUCA8LS0-fCJwZXJzaXN0IHZvdGVkIHJlYWN0aW9ucyJ8IExTCiAgICBlbmQKICAgIHN1YmdyYXBoIG5ldGxpZnlbIk5ldGxpZnkiXQogICAgICAgIEFQSVsiLy5uZXRsaWZ5L2Z1bmN0aW9ucy9yZWFjdGlvbnMiXQogICAgICAgIEJMT0JbIk5ldGxpZnkgQmxvYnMgc3RvcmVcbidyZWFjdGlvbnMnIl0KICAgICAgICBBUEkgPC0tPnwiZ2V0L3NldCBKU09OInwgQkxPQgogICAgZW5k" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgICBzdWJncmFwaCBjbGllbnRbIkJyb3dzZXIiXQogICAgICAgIENPTVBbIlJlYWN0aW9ucyBjb21wb25lbnRcbihBc3RybyArIFRTKSJdCiAgICAgICAgTFNbImxvY2FsU3RvcmFnZVxuKHBlci1wb3N0IHJlYWN0aW9uIHNldCkiXQogICAgICAgIENPTVAgLS0-fCJmZXRjaCBvbiBtb3VudCJ8IEFQSQogICAgICAgIENPTVAgLS0-fCJQT1NUIG9uIGNsaWNrInwgQVBJCiAgICAgICAgQ09NUCA8LS0-fCJwZXJzaXN0IHZvdGVkIHJlYWN0aW9ucyJ8IExTCiAgICBlbmQKICAgIHN1YmdyYXBoIG5ldGxpZnlbIk5ldGxpZnkiXQogICAgICAgIEFQSVsiLy5uZXRsaWZ5L2Z1bmN0aW9ucy9yZWFjdGlvbnMiXQogICAgICAgIEJMT0JbIk5ldGxpZnkgQmxvYnMgc3RvcmVcbidyZWFjdGlvbnMnIl0KICAgICAgICBBUEkgPC0tPnwiZ2V0L3NldCBKU09OInwgQkxPQgogICAgZW5k" alt="Mermaid diagram" width="618" height="498"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Diagram fallback for Dev.to. View the canonical article for the full version: &lt;a href="https://sourcier.uk/blog/reactions-netlify-blobs-astro" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/reactions-netlify-blobs-astro&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The client side is purely progressive: if the function call fails, the optimistic&lt;br&gt;
increment still shows. On the next page load, &lt;code&gt;fetchCounts&lt;/code&gt; corrects it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Setting up Netlify Blobs
&lt;/h2&gt;

&lt;p&gt;Netlify Blobs is a managed key-value store included on all Netlify plans, no&lt;br&gt;
database to provision, no connection strings to manage. When deployed, it works&lt;br&gt;
with zero configuration. For local development, the explicit fallback is&lt;br&gt;
&lt;code&gt;NETLIFY_SITE_ID&lt;/code&gt; plus either &lt;code&gt;NETLIFY_AUTH_TOKEN&lt;/code&gt; or &lt;code&gt;NETLIFY_PAT&lt;/code&gt;&lt;br&gt;
in &lt;code&gt;.env&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;NETLIFY_PAT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;           &lt;span class="c"&gt;# personal access token — use NETLIFY_PAT, not NETLIFY_ACCESS_TOKEN&lt;/span&gt;
                       &lt;span class="c"&gt;# (Netlify auto-injects NETLIFY_ACCESS_TOKEN at runtime with a limited&lt;/span&gt;
                       &lt;span class="c"&gt;# site-scoped machine token, which overwrites any value you set)&lt;/span&gt;
&lt;span class="nv"&gt;NETLIFY_SITE_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;       &lt;span class="c"&gt;# visible in Site configuration → General → Site ID&lt;/span&gt;
&lt;span class="c"&gt;# Optional: NETLIFY_AUTH_TOKEN=  # use this only if you want a separate Blobs token&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install the package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm add @netlify/blobs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The serverless function
&lt;/h2&gt;

&lt;p&gt;The function lives at &lt;code&gt;netlify/functions/reactions.ts&lt;/code&gt; and handles GET (fetch&lt;br&gt;
counts), POST (record a reaction), and OPTIONS (CORS preflight):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;HandlerEvent&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@netlify/functions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;connectLambda&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;getStore&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@netlify/blobs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;REACTIONS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;heart&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fire&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bulb&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;clap&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// Matches slugs like "deploying-astro-netlify" — prevents path traversal&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SLUG_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;a-z0-9-&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CORS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Access-Control-Allow-Origin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SITE_URL&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Access-Control-Allow-Methods&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;GET, POST, OPTIONS&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Access-Control-Allow-Headers&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getReactionsStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HandlerEvent&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;blobs&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;blobs&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;?.[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-nf-site-id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;connectLambda&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;getStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reactions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;siteID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NETLIFY_SITE_ID&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NETLIFY_AUTH_TOKEN&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NETLIFY_PAT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;siteID&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;getStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reactions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;siteID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;getStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reactions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HandlerEvent&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;blobs&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;httpMethod&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;OPTIONS&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;204&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CORS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;postId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;queryStringParameters&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;postId&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;SLUG_RE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;postId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CORS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Invalid post ID&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getReactionsStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;httpMethod&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;GET&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;postId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;CORS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;httpMethod&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;reaction&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;{}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CORS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Invalid JSON&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
      &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reaction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reaction&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;REACTIONS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reaction&lt;/span&gt; &lt;span class="k"&gt;as &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;REACTIONS&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CORS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Invalid reaction&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
      &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;postId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
    &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;reaction&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;reaction&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;postId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;CORS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;405&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CORS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Method not allowed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth noting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;SLUG_RE&lt;/code&gt; regex is the security boundary: it ensures &lt;code&gt;postId&lt;/code&gt; can only be
an alphanumeric slug, preventing any path traversal or injection into the store key.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;CORS&lt;/code&gt; headers and the &lt;code&gt;OPTIONS&lt;/code&gt; branch exist because the reactions
widget can be embedded and fetched from more than one origin during local
development. &lt;code&gt;SITE_URL&lt;/code&gt; narrows the allowed origin in production.&lt;/li&gt;
&lt;li&gt;In Netlify's Lambda compatibility mode, &lt;code&gt;connectLambda(event)&lt;/code&gt; has to run
before &lt;code&gt;getStore&lt;/code&gt;. Without it, the Blobs client has no runtime context and
throws the missing &lt;code&gt;siteID, token&lt;/code&gt; error.&lt;/li&gt;
&lt;li&gt;The explicit &lt;code&gt;NETLIFY_SITE_ID&lt;/code&gt; plus token fallback keeps the function usable
in local scripts and any path where Netlify hasn't injected that context.&lt;/li&gt;
&lt;li&gt;The POST handler wraps &lt;code&gt;JSON.parse&lt;/code&gt; in a try/catch so a malformed request
body returns a clean 400 instead of an unhandled exception.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;store.get&lt;/code&gt; returns &lt;code&gt;null&lt;/code&gt; if the key doesn't exist, so &lt;code&gt;?? {}&lt;/code&gt; handles the
cold-start case cleanly.&lt;/li&gt;
&lt;li&gt;The function returns the full updated counts on POST so the client can sync
without a second GET, one round trip per reaction.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Astro component
&lt;/h2&gt;

&lt;p&gt;The component has three responsibilities: load counts on mount, post reactions on&lt;br&gt;
click, and preserve which reactions the current user has already given.&lt;/p&gt;
&lt;h3&gt;
  
  
  Preventing duplicate votes
&lt;/h3&gt;

&lt;p&gt;There's no authentication on this blog, so duplicate prevention is localStorage-based.&lt;br&gt;
Each post gets a key of &lt;code&gt;reactions:&amp;lt;slug&amp;gt;&lt;/code&gt; holding a JSON array of reaction keys&lt;br&gt;
the user has already clicked. On mount, this set is read back and used to restore&lt;br&gt;
the active button state.&lt;/p&gt;

&lt;p&gt;This means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Clearing localStorage clears the deduplication state, by design.&lt;/li&gt;
&lt;li&gt;It doesn't prevent someone from opening a private window. That's an acceptable
tradeoff for a zero-auth reaction system.&lt;/li&gt;
&lt;li&gt;It does prevent the common case: a page refresh or return visit triggering
accidental double-counting.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  Optimistic updates
&lt;/h3&gt;

&lt;p&gt;When a button is clicked, the count increments immediately in the UI before the&lt;br&gt;
API call returns. This makes the interaction feel instant. Once the API responds,&lt;br&gt;
the displayed counts are replaced with the server truth, which will be identical&lt;br&gt;
in the normal case and correct in the rare case of a race condition.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;postReaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HTMLButtonElement&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reaction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reaction&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reacted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reaction&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// already voted&lt;/span&gt;

  &lt;span class="nx"&gt;reacted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reaction&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;saveReacted&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// persist to localStorage immediately&lt;/span&gt;

  &lt;span class="c1"&gt;// Optimistic increment — no waiting for the network&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;countEl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;querySelector&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;HTMLElement&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.reactions__count&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;countEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;countEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/.netlify/functions/reactions?post=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;postId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;reaction&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="c1"&gt;// Sync all buttons with server truth&lt;/span&gt;
    &lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;querySelectorAll&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;HTMLButtonElement&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.reactions__btn&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;updateButton&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reaction&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The pop animation
&lt;/h3&gt;

&lt;p&gt;A small &lt;code&gt;@keyframes&lt;/code&gt; animation triggers on click for tactile feedback:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight scss"&gt;&lt;code&gt;&lt;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="nt"&gt;btn-pop&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nt"&gt;0&lt;/span&gt;&lt;span class="nv"&gt;%&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nt"&gt;40&lt;/span&gt;&lt;span class="nv"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="mi"&gt;.22&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nt"&gt;70&lt;/span&gt;&lt;span class="nv"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="mi"&gt;.94&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nt"&gt;100&lt;/span&gt;&lt;span class="nv"&gt;%&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The class is added, a reflow is forced with &lt;code&gt;void btn.offsetWidth&lt;/code&gt;, then the class&lt;br&gt;
is re-added, this restarts the animation even if the same button is clicked twice&lt;br&gt;
quickly. &lt;code&gt;prefers-reduced-motion&lt;/code&gt; is respected by removing the transition on the&lt;br&gt;
emoji element.&lt;/p&gt;

&lt;h2&gt;
  
  
  Placement: hero and page menu, not a standalone block
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;Reactions&lt;/code&gt; component takes &lt;code&gt;variant&lt;/code&gt; and &lt;code&gt;compact&lt;/code&gt; props so the same&lt;br&gt;
markup and script can adapt to two very different contexts without duplicating&lt;br&gt;
logic:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Post hero.&lt;/strong&gt; &lt;code&gt;&amp;lt;Reactions postId={postId} variant="hero" compact /&amp;gt;&lt;/code&gt; sits
in the engagement row directly under the title, next to the share button.
The &lt;code&gt;hero&lt;/code&gt; variant visually hides the heading and shrinks the buttons into a
row of compact pills, so reacting is the first thing a reader can do, before
they've committed to reading the whole article.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Page menu.&lt;/strong&gt; &lt;code&gt;&amp;lt;Reactions postId={postId} compact /&amp;gt;&lt;/code&gt; sits inside the
floating page menu panel, alongside the table of contents, share, and
support sections. This keeps reacting reachable from anywhere while
scrolling, without competing for space with the article body.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both placements pass &lt;code&gt;compact&lt;/code&gt;, which switches the buttons to a horizontal&lt;br&gt;
pill layout and hides the text label, leaving only the emoji and count. The&lt;br&gt;
&lt;code&gt;hero&lt;/code&gt; variant additionally visually hides the heading for screen-reader-only&lt;br&gt;
context, since the surrounding UI already makes the purpose clear.&lt;/p&gt;

&lt;p&gt;This spreads the interaction across two low-friction entry points instead of&lt;br&gt;
funnelling it through a single comments-adjacent widget.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the counts look like when empty
&lt;/h2&gt;

&lt;p&gt;On first load before any reactions, counts display &lt;code&gt;—&lt;/code&gt; rather than &lt;code&gt;0&lt;/code&gt;. This is a&lt;br&gt;
deliberate choice: &lt;code&gt;0&lt;/code&gt; signals "nobody found this useful", which is discouraging&lt;br&gt;
for a new post. A dash is neutral and avoids that framing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Local development
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;connectLambda(event)&lt;/code&gt; in place, &lt;code&gt;netlify dev&lt;/code&gt; injects the Blobs context&lt;br&gt;
for Lambda compatibility functions and runs the store in local sandbox mode.&lt;br&gt;
If that context isn't available, the &lt;code&gt;NETLIFY_SITE_ID&lt;/code&gt; plus token fallback still&lt;br&gt;
lets the function connect manually.&lt;/p&gt;

&lt;p&gt;That keeps normal local development isolated from production while still making&lt;br&gt;
the function portable outside Netlify's request pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Full code listing
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Stores and retrieves emoji reaction counts for blog posts using Netlify Blobs.&lt;/span&gt;
&lt;span class="c1"&gt;//&lt;/span&gt;
&lt;span class="c1"&gt;// GET  /.netlify/functions/reactions?post=&amp;lt;slug&amp;gt;  → { heart: N, fire: N, bulb: N, clap: N }&lt;/span&gt;
&lt;span class="c1"&gt;// POST /.netlify/functions/reactions?post=&amp;lt;slug&amp;gt;  body: { reaction: "heart" }  → updated counts&lt;/span&gt;
&lt;span class="c1"&gt;//&lt;/span&gt;
&lt;span class="c1"&gt;// Local dev: requires NETLIFY_SITE_ID plus either NETLIFY_AUTH_TOKEN or&lt;/span&gt;
&lt;span class="c1"&gt;// NETLIFY_PAT in .env&lt;/span&gt;
&lt;span class="c1"&gt;// Production: works automatically with no extra config&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;HandlerEvent&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@netlify/functions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;connectLambda&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;getStore&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@netlify/blobs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;REACTIONS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;heart&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fire&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bulb&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;clap&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// Matches slugs like "deploying-astro-netlify" — prevents path traversal&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SLUG_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;a-z0-9-&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CORS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Access-Control-Allow-Origin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SITE_URL&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Access-Control-Allow-Methods&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;GET, POST, OPTIONS&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Access-Control-Allow-Headers&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getReactionsStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HandlerEvent&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;blobs&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;blobs&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;?.[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-nf-site-id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// eslint-disable-next-line @typescript-eslint/no-explicit-any&lt;/span&gt;
    &lt;span class="nf"&gt;connectLambda&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;getStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reactions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;siteID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NETLIFY_SITE_ID&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NETLIFY_AUTH_TOKEN&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NETLIFY_PAT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;siteID&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;getStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reactions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;siteID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;getStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reactions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HandlerEvent&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;blobs&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;httpMethod&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;OPTIONS&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;204&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CORS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;postId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;queryStringParameters&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;postId&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;SLUG_RE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;postId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CORS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Invalid post ID&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getReactionsStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;httpMethod&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;GET&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;postId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;CORS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;httpMethod&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;reaction&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;{}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CORS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Invalid JSON&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
      &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reaction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reaction&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;REACTIONS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reaction&lt;/span&gt; &lt;span class="k"&gt;as &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;REACTIONS&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CORS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Invalid reaction&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
      &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;postId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
    &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;reaction&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;reaction&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;postId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;CORS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;405&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CORS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Method not allowed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
interface Props {
  postId: string;
  variant?: "default" | "hero";
  compact?: boolean;
}
const { postId, variant = "default", compact = false } = Astro.props;
const heading =
  variant === "hero" ? "Was this useful?" : "Did you find this useful?";

const reactions = [
  { key: "heart", emoji: "❤️", label: "Love it" },
  { key: "fire", emoji: "🔥", label: "On fire" },
  { key: "bulb", emoji: "💡", label: "Insightful" },
  { key: "clap", emoji: "👏", label: "Helpful" },
] satisfies { key: string; emoji: string; label: string }[];
---

&amp;lt;div
  class:list={[
    "reactions",
    {
      "reactions--hero": variant === "hero",
      "reactions--compact": compact,
    },
  ]}
  data-post-id={postId}
  aria-label="Post reactions"
&amp;gt;
  &amp;lt;p class="reactions__heading"&amp;gt;{heading}&amp;lt;/p&amp;gt;
  &amp;lt;div class="reactions__buttons" role="group" aria-label="React to this post"&amp;gt;
    {
      reactions.map((r) =&amp;gt; (
        &amp;lt;button
          class="reactions__btn"
          data-reaction={r.key}
          aria-label={r.label}
          aria-pressed="false"
          type="button"
        &amp;gt;
          &amp;lt;span class="reactions__emoji" aria-hidden="true"&amp;gt;
            {r.emoji}
          &amp;lt;/span&amp;gt;
          &amp;lt;span class="reactions__count" aria-live="polite"&amp;gt;
            —
          &amp;lt;/span&amp;gt;
          &amp;lt;span class="reactions__label"&amp;gt;{r.label}&amp;lt;/span&amp;gt;
        &amp;lt;/button&amp;gt;
      ))
    }
  &amp;lt;/div&amp;gt;
&amp;lt;/div&amp;gt;

&amp;lt;script&amp;gt;
  document.querySelectorAll&amp;lt;HTMLElement&amp;gt;(".reactions").forEach((root) =&amp;gt; {
    if (root.dataset.bound === "true") return;
    root.dataset.bound = "true";

    const postId = root.dataset.postId;
    if (!postId) return;
    const storageKey = `reactions:${postId}`;

    function loadReacted(): string[] {
      try {
        const stored = JSON.parse(localStorage.getItem(storageKey) ?? "[]");
        return Array.isArray(stored)
          ? stored.filter((value): value is string =&amp;gt; typeof value === "string")
          : [];
      } catch {
        return [];
      }
    }

    const reacted = new Set&amp;lt;string&amp;gt;(loadReacted());

    function saveReacted() {
      localStorage.setItem(storageKey, JSON.stringify([...reacted]));
    }

    function updateButton(btn: HTMLButtonElement, count: number) {
      const reaction = btn.dataset.reaction;
      if (!reaction) return;
      const countEl = btn.querySelector&amp;lt;HTMLElement&amp;gt;(".reactions__count")!;
      countEl.textContent = count &amp;gt; 0 ? String(count) : "—";
      const hasReacted = reacted.has(reaction);
      btn.classList.toggle("reactions__btn--active", hasReacted);
      btn.setAttribute("aria-pressed", hasReacted ? "true" : "false");
    }

    // Load current counts from the API
    async function fetchCounts() {
      try {
        const res = await fetch(`/.netlify/functions/reactions?post=${postId}`);
        if (!res.ok) return;
        const data: Record&amp;lt;string, number&amp;gt; = await res.json();
        root
          .querySelectorAll&amp;lt;HTMLButtonElement&amp;gt;(".reactions__btn")
          .forEach((btn) =&amp;gt; {
            updateButton(btn, data[btn.dataset.reaction!] ?? 0);
          });
      } catch {}
    }

    // Post a reaction
    async function postReaction(btn: HTMLButtonElement) {
      const reaction = btn.dataset.reaction!;
      if (reacted.has(reaction)) return;

      reacted.add(reaction);
      saveReacted();
      btn.classList.add("reactions__btn--active");
      btn.setAttribute("aria-pressed", "true");
      btn.classList.remove("reactions__btn--pop");
      void btn.offsetWidth; // force reflow to restart animation
      btn.classList.add("reactions__btn--pop");

      // Optimistic increment
      const countEl = btn.querySelector&amp;lt;HTMLElement&amp;gt;(".reactions__count")!;
      const current = parseInt(countEl.textContent ?? "0", 10) || 0;
      countEl.textContent = String(current + 1);

      try {
        const res = await fetch(
          `/.netlify/functions/reactions?post=${postId}`,
          {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ reaction }),
          },
        );
        if (res.ok) {
          const data: Record&amp;lt;string, number&amp;gt; = await res.json();
          // Sync with server truth
          root
            .querySelectorAll&amp;lt;HTMLButtonElement&amp;gt;(".reactions__btn")
            .forEach((b) =&amp;gt; {
              updateButton(b, data[b.dataset.reaction!] ?? 0);
            });
        }
      } catch {}
    }

    fetchCounts();

    root
      .querySelectorAll&amp;lt;HTMLButtonElement&amp;gt;(".reactions__btn")
      .forEach((btn) =&amp;gt; {
        btn.addEventListener("click", () =&amp;gt; postReaction(btn));
      });
  });
&amp;lt;/script&amp;gt;

&amp;lt;style lang="scss"&amp;gt;
  @keyframes btn-pop {
    0% {
      transform: scale(1);
    }
    40% {
      transform: scale(1.22);
    }
    70% {
      transform: scale(0.94);
    }
    100% {
      transform: scale(1);
    }
  }

  .reactions {
    margin-bottom: 2.5rem;
  }

  .reactions--hero {
    margin: 0;
  }

  .reactions--compact {
    margin: 0;
  }

  .reactions__heading {
    font-family: "Barlow Condensed", sans-serif;
    font-size: 1.25rem;
    font-weight: 800;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--text-primary);
    margin: 0 0 1.25rem;
  }

  .reactions__buttons {
    display: flex;
    flex-wrap: wrap;
    gap: 0.75rem;
  }

  .reactions__btn {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 0.25rem;
    min-width: 5rem;
    padding: 1rem 1.25rem;
    border: 2px solid var(--border-subtle);
    border-radius: var(--radius-compact);
    background: transparent;
    cursor: pointer;
    transition:
      border-color 0.15s ease,
      background-color 0.15s ease,
      transform 0.15s ease;

    @media (max-width: 639px) {
      min-width: 4.25rem;
      padding: 0.875rem 1rem;
    }

    &amp;amp;:hover {
      border-color: var(--accent-primary);
      background-color: rgba(var(--accent-primary-rgb), 0.04);
      transform: translateY(-2px);

      .reactions__emoji {
        transform: scale(1.15);
      }
    }

    &amp;amp;--active {
      border-color: var(--accent-primary);
      background-color: rgba(var(--accent-primary-rgb), 0.08);

      .reactions__count {
        color: var(--accent-primary);
      }
      .reactions__label {
        color: var(--accent-primary);
      }
    }

    &amp;amp;--pop {
      animation: btn-pop 0.35s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
    }
  }

  .reactions__emoji {
    font-size: 1.75rem;
    line-height: 1;
    transition: transform 0.15s ease;

    @media (prefers-reduced-motion: reduce) {
      transition: none;
    }
  }

  .reactions__count {
    font-family: "Barlow Condensed", sans-serif;
    font-size: 1.25rem;
    font-weight: 800;
    line-height: 1;
    color: var(--text-primary);
    transition: color 0.15s ease;
  }

  .reactions__label {
    font-family: "Barlow Condensed", sans-serif;
    font-size: 0.7rem;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.08em;
    color: var(--text-muted);
    transition: color 0.15s ease;
  }

  .reactions--compact .reactions__heading {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
  }

  .reactions--compact .reactions__buttons {
    gap: 0.45rem;
    justify-content: flex-end;
  }

  .reactions--compact .reactions__btn {
    flex-direction: row;
    justify-content: center;
    min-width: auto;
    gap: 0.35rem;
    padding: 0.5rem 0.65rem;
    border-width: 1px;
    border-radius: var(--radius-pill);
    border-color: color-mix(
      in srgb,
      rgba(var(--overlay-rgb), 0.16) 35%,
      var(--border-subtle)
    );
    background: color-mix(
      in srgb,
      var(--surface-elevated) 76%,
      rgba(var(--overlay-rgb), 0.08)
    );
    backdrop-filter: blur(8px);
  }

  .reactions--compact .reactions__emoji {
    font-size: 1rem;
  }

  .reactions--compact .reactions__count {
    min-width: 1ch;
    font-size: 0.95rem;
  }

  .reactions--compact .reactions__label {
    display: none;
  }

  @media (max-width: 1023px) {
    .reactions--compact.reactions--hero .reactions__heading {
      position: static;
      width: auto;
      height: auto;
      padding: 0;
      margin: 0 0 0.75rem;
      overflow: visible;
      clip: auto;
      white-space: normal;
      border: 0;
      font-size: 0.76rem;
      letter-spacing: 0.1em;
      color: color-mix(in srgb, var(--text-primary) 72%, transparent);
    }

    .reactions--compact.reactions--hero .reactions__buttons {
      justify-content: flex-start;
      gap: 0.5rem;
    }
  }

  .reactions--hero .reactions__heading {
    margin-bottom: 0.75rem;
    font-size: 0.76rem;
    letter-spacing: 0.1em;
    color: color-mix(in srgb, var(--text-primary) 72%, transparent);
  }

  .reactions--hero .reactions__buttons {
    gap: 0.5rem;
  }

  .reactions--hero .reactions__btn {
    flex-direction: row;
    justify-content: center;
    min-width: auto;
    gap: 0.45rem;
    padding: 0.55rem 0.8rem;
    border-width: 1px;
    border-radius: var(--radius-pill);
    border-color: color-mix(
      in srgb,
      rgba(var(--overlay-rgb), 0.16) 35%,
      var(--border-subtle)
    );
    background: color-mix(
      in srgb,
      var(--surface-elevated) 76%,
      rgba(var(--overlay-rgb), 0.08)
    );
    backdrop-filter: blur(8px);
  }

  .reactions--hero .reactions__emoji {
    font-size: 1.1rem;
  }

  .reactions--hero .reactions__count {
    min-width: 1ch;
    font-size: 1rem;
  }

  .reactions--hero .reactions__label {
    display: none;
  }

  @media (max-width: 639px) {
    .reactions--hero .reactions__btn {
      padding: 0.55rem 0.75rem;
    }
  }
&amp;lt;/style&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>astro</category>
      <category>netlify</category>
      <category>engineering</category>
      <category>frontend</category>
    </item>
    <item>
      <title>Adding search to a static Astro blog</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Tue, 15 Sep 2026 16:56:53 +0000</pubDate>
      <link>https://dev.to/sourcier/adding-search-to-a-static-astro-blog-4cdm</link>
      <guid>https://dev.to/sourcier/adding-search-to-a-static-astro-blog-4cdm</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/search-pagefind-astro" rel="noopener noreferrer"&gt;Adding search to a static Astro blog&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Series: Part of &lt;a href="https://sourcier.uk/blog/how-this-blog-was-built" rel="noopener noreferrer"&gt;How this blog was built&lt;/a&gt;: documenting every decision that shaped this site.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Static sites have an obvious gap: there is no server to run a query against. Everything lives in pre-built HTML files on a CDN. The conventional workarounds are to ship users off to Google with a &lt;code&gt;site:&lt;/code&gt; filter, drop in Algolia, or accept that search just isn't a feature.&lt;/p&gt;

&lt;p&gt;None of those felt right. Sending users to Google is a dead end. Algolia has a free tier, but it's an external dependency that needs a sync pipeline and will eventually bill you. And a developer blog without search is awkward the moment you have more than a handful of posts.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://pagefind.app" rel="noopener noreferrer"&gt;Pagefind&lt;/a&gt; is the answer for static sites. It crawls the built HTML, generates a WASM-based index at build time, and serves everything as static files alongside your site. No server, no external service, no runtime dependency. The index ships with the site.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture overview
&lt;/h2&gt;

&lt;p&gt;The full setup spans build time, the content repo, and the browser. Here's where the pieces connect:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgICBzdWJncmFwaCBjb250ZW50WyJDb250ZW50IHJlcG8iXQogICAgICAgIENJWyJjb3Zlci53ZWJwIl0KICAgICAgICBUSFsic2x1Zy10aHVtYm5haWwud2VicCJdCiAgICBlbmQKCiAgICBzdWJncmFwaCBidWlsZFsiQnVpbGQgcGlwZWxpbmUiXQogICAgICAgIENPUFlbInRodW1ibmFpbHM6Y29weSJdCiAgICAgICAgQVNUUk9bImFzdHJvIGJ1aWxkIl0KICAgICAgICBQRlsicGFnZWZpbmQgLS1zaXRlIGRpc3QiXQogICAgICAgIE1FVEFbImRhdGEtcGFnZWZpbmQtbWV0YSJdCiAgICBlbmQKCiAgICBzdWJncmFwaCBicm93c2VyWyJCcm93c2VyIl0KICAgICAgICBNT0RBTFsiU2VhcmNoIG1vZGFsIl0KICAgICAgICBBUElbInBhZ2VmaW5kLmpzIFdBU00iXQogICAgICAgIFJFU1VMVFNbIlJlc3VsdHMgbGlzdCJdCiAgICBlbmQKCiAgICBDSSAtLT58IkltYWdlTWFnaWNrInwgVEgKICAgIFRIIC0tPiBDT1BZCiAgICBDT1BZIC0tPiBBU1RSTwogICAgTUVUQSAtLT4gQVNUUk8KICAgIEFTVFJPIC0tPiBQRgogICAgUEYgLS0-fCJpbmRleCBmaWxlcyJ8IE1PREFMCiAgICBNT0RBTCAtLT58InVzZXIgdHlwZXMifCBBUEkKICAgIEFQSSAtLT58InNlYXJjaCJ8IFJFU1VMVFM" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgICBzdWJncmFwaCBjb250ZW50WyJDb250ZW50IHJlcG8iXQogICAgICAgIENJWyJjb3Zlci53ZWJwIl0KICAgICAgICBUSFsic2x1Zy10aHVtYm5haWwud2VicCJdCiAgICBlbmQKCiAgICBzdWJncmFwaCBidWlsZFsiQnVpbGQgcGlwZWxpbmUiXQogICAgICAgIENPUFlbInRodW1ibmFpbHM6Y29weSJdCiAgICAgICAgQVNUUk9bImFzdHJvIGJ1aWxkIl0KICAgICAgICBQRlsicGFnZWZpbmQgLS1zaXRlIGRpc3QiXQogICAgICAgIE1FVEFbImRhdGEtcGFnZWZpbmQtbWV0YSJdCiAgICBlbmQKCiAgICBzdWJncmFwaCBicm93c2VyWyJCcm93c2VyIl0KICAgICAgICBNT0RBTFsiU2VhcmNoIG1vZGFsIl0KICAgICAgICBBUElbInBhZ2VmaW5kLmpzIFdBU00iXQogICAgICAgIFJFU1VMVFNbIlJlc3VsdHMgbGlzdCJdCiAgICBlbmQKCiAgICBDSSAtLT58IkltYWdlTWFnaWNrInwgVEgKICAgIFRIIC0tPiBDT1BZCiAgICBDT1BZIC0tPiBBU1RSTwogICAgTUVUQSAtLT4gQVNUUk8KICAgIEFTVFJPIC0tPiBQRgogICAgUEYgLS0-fCJpbmRleCBmaWxlcyJ8IE1PREFMCiAgICBNT0RBTCAtLT58InVzZXIgdHlwZXMifCBBUEkKICAgIEFQSSAtLT58InNlYXJjaCJ8IFJFU1VMVFM" alt="Mermaid diagram" width="526" height="1044"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Diagram fallback for Dev.to. View the canonical article for the full version: &lt;a href="https://sourcier.uk/blog/search-pagefind-astro" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/search-pagefind-astro&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The modal UI
&lt;/h2&gt;

&lt;p&gt;The search trigger is a single icon button in the navbar. Clicking it opens a full-viewport overlay with an input and a scrollable results list:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0eci8gtbvfrxxej4cyv4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0eci8gtbvfrxxej4cyv4.png" alt="Search modal UI wireframe showing the navbar search icon, backdrop overlay, input strip, and three result rows with thumbnails" width="700" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Diagram fallback for Dev.to. View the canonical article for the original SVG: &lt;a href="https://sourcier.uk/blog/search-pagefind-astro" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/search-pagefind-astro&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The highlighted row shows keyboard focus. &lt;code&gt;Escape&lt;/code&gt; or clicking the backdrop closes it. &lt;code&gt;↑&lt;/code&gt; / &lt;code&gt;↓&lt;/code&gt; move focus through results. &lt;code&gt;Enter&lt;/code&gt; navigates. Click the expand icon on the wireframe to view it fullscreen.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Pagefind works
&lt;/h2&gt;

&lt;p&gt;After &lt;code&gt;astro build&lt;/code&gt; produces the &lt;code&gt;dist/&lt;/code&gt; directory, you run Pagefind against it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx pagefind &lt;span class="nt"&gt;--site&lt;/span&gt; dist
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pagefind walks the HTML, extracts text from elements marked with &lt;code&gt;data-pagefind-body&lt;/code&gt;, and writes a set of compressed index files into &lt;code&gt;dist/pagefind/&lt;/code&gt;. Those files are served as static assets. The search itself runs entirely in the browser via a small WASM module that Pagefind provides.&lt;/p&gt;

&lt;p&gt;Install it as a dev dependency:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm add &lt;span class="nt"&gt;-D&lt;/span&gt; pagefind
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then append the Pagefind step to the Netlify build command in &lt;code&gt;netlify.toml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[build]&lt;/span&gt;
  &lt;span class="py"&gt;command&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"... &amp;amp;&amp;amp; astro build &amp;amp;&amp;amp; npx pagefind --site dist"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Indexing locally for dev
&lt;/h3&gt;

&lt;p&gt;Pagefind runs post-build and writes its index to &lt;code&gt;dist/pagefind/&lt;/code&gt;. The Astro dev server doesn't serve from &lt;code&gt;dist/&lt;/code&gt;, so the index isn't available during &lt;code&gt;pnpm dev&lt;/code&gt;. The solution is a local script that builds the site, runs Pagefind, and copies the result to &lt;code&gt;public/pagefind/&lt;/code&gt;, which the dev server does serve:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"search:index"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pnpm assets:sync &amp;amp;&amp;amp; SHOW_DRAFTS=true astro build &amp;amp;&amp;amp; pagefind --site dist &amp;amp;&amp;amp; rm -rf public/pagefind &amp;amp;&amp;amp; cp -r dist/pagefind public/pagefind"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;rm -rf&lt;/code&gt; before the copy is intentional. Without it, running the script twice causes &lt;code&gt;cp -r&lt;/code&gt; to nest &lt;code&gt;dist/pagefind&lt;/code&gt; inside an existing &lt;code&gt;public/pagefind&lt;/code&gt;, leaving stale index fragments alongside fresh ones. The old fragments don't get replaced: they persist and corrupt results.&lt;/p&gt;

&lt;p&gt;Both &lt;code&gt;public/pagefind/&lt;/code&gt; and &lt;code&gt;public/search-thumbnails/&lt;/code&gt; (more on those later) are gitignored, they're generated artifacts, not source files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Marking content for indexing
&lt;/h2&gt;

&lt;p&gt;By default, Pagefind indexes everything in the &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt;. You can tell it to be more selective with &lt;code&gt;data-pagefind-body&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;article&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"post"&lt;/span&gt; &lt;span class="na"&gt;data-pagefind-body&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="c"&gt;&amp;lt;!-- Only this element and its descendants are indexed --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/article&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once any element on the site has &lt;code&gt;data-pagefind-body&lt;/code&gt;, Pagefind ignores every page that doesn't have it. This is the right default for a blog: you only want post content indexed, not the navigation, footer, and sidebar that appear on every page.&lt;/p&gt;

&lt;p&gt;This goes on &lt;code&gt;MarkdownPostLayout.astro&lt;/code&gt;, which wraps every post.&lt;/p&gt;

&lt;h2&gt;
  
  
  A modal, not a search page
&lt;/h2&gt;

&lt;p&gt;The obvious implementation is a &lt;code&gt;/search&lt;/code&gt; page with a text input that queries Pagefind. It works, but the UX feels dated: you leave your current context, navigate to a new page, and wait for a result.&lt;/p&gt;

&lt;p&gt;The better pattern is a command-palette style modal that opens inline wherever you are. Type a query, see results immediately, navigate with arrow keys, press Enter to go. It feels modern and fast, and it doesn't interrupt the reading flow.&lt;/p&gt;

&lt;p&gt;The header gets a magnifying glass button:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"social-icon"&lt;/span&gt; &lt;span class="na"&gt;aria-label=&lt;/span&gt;&lt;span class="s"&gt;"Open search"&lt;/span&gt; &lt;span class="na"&gt;data-search-open&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="c"&gt;&amp;lt;!-- Font Awesome icon --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clicking it opens a modal overlay with an input and a results list. Keyboard handling covers the full expected surface: &lt;code&gt;Escape&lt;/code&gt; closes, &lt;code&gt;ArrowUp&lt;/code&gt;/&lt;code&gt;ArrowDown&lt;/code&gt; move through results, &lt;code&gt;Enter&lt;/code&gt; follows the active result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pagefind's JS API
&lt;/h2&gt;

&lt;p&gt;Pagefind ships a default UI component, but it has strong opinions about styling and it generates its own DOM. Using the JS API directly gives full control:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pagefind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/pagefind/pagefind.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pagefind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pagefind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each result's &lt;code&gt;data()&lt;/code&gt; call returns the page's URL, an excerpt with matched terms highlighted, and whatever was stored in &lt;code&gt;data-pagefind-meta&lt;/code&gt; attributes.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;import('/pagefind/pagefind.js')&lt;/code&gt; must be a runtime dynamic import. Vite processes static imports at build time, and &lt;code&gt;pagefind.js&lt;/code&gt; doesn't exist until after the build runs. The script in &lt;code&gt;Header.astro&lt;/code&gt; uses &lt;code&gt;is:inline&lt;/code&gt; so Astro doesn't process it through Vite:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;is:inline&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="c1"&gt;// Dynamic import runs in the browser at runtime, not at build time&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pagefind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/pagefind/pagefind.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pagefind is only initialised once. The first search triggers the init, and subsequent queries reuse the already-loaded instance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Metadata: title and cover image
&lt;/h2&gt;

&lt;p&gt;Pagefind reads &lt;code&gt;data-pagefind-meta&lt;/code&gt; attributes to store custom fields alongside each result. The initial attempt used a single attribute on the &lt;code&gt;&amp;lt;article&amp;gt;&lt;/code&gt; element:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;article&lt;/span&gt; &lt;span class="na"&gt;data-pagefind-body&lt;/span&gt; &lt;span class="na"&gt;data-pagefind-meta=&lt;/span&gt;&lt;span class="s"&gt;"title:Choosing the tech stack,image:/_astro/cover.webp"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This produces results where the title field contains &lt;code&gt;"Choosing the tech stack,image:/_astro/cover.webp"&lt;/code&gt;. The entire string after &lt;code&gt;title:&lt;/code&gt; is treated as the title value, including the comma and everything after it.&lt;/p&gt;

&lt;p&gt;The correct approach is a separate element per field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;data-pagefind-meta=&lt;/span&gt;&lt;span class="s"&gt;"title"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"visually-hidden"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Choosing the tech stack&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;data-pagefind-meta=&lt;/span&gt;&lt;span class="s"&gt;"image[src]"&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"/search-thumbnails/choosing-the-tech-stack/choosing-the-tech-stack-thumbnail.webp"&lt;/span&gt;
     &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"visually-hidden"&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="na"&gt;aria-hidden=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;image[src]&lt;/code&gt; syntax tells Pagefind to read the value from the &lt;code&gt;src&lt;/code&gt; attribute rather than the text content of the element.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why visually-hidden and not display:none
&lt;/h3&gt;

&lt;p&gt;The first instinct is to hide these elements with &lt;code&gt;display: none&lt;/code&gt;. It seems clean: they're purely for Pagefind's benefit, not for the user.&lt;/p&gt;

&lt;p&gt;Pagefind's crawler skips elements with &lt;code&gt;display: none&lt;/code&gt;. The meta is never read, and the fields come back &lt;code&gt;undefined&lt;/code&gt; in results.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;.visually-hidden&lt;/code&gt; pattern keeps the element in the layout engine: it's positioned, sized, and rendered, but visually invisible and excluded from the accessibility tree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight scss"&gt;&lt;code&gt;&lt;span class="nc"&gt;.visually-hidden&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;absolute&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;-1px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;overflow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;clip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;white-space&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;nowrap&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pagefind's crawler reads it. Screen readers ignore it via &lt;code&gt;aria-hidden="true"&lt;/code&gt;. Users don't see it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thumbnail problem
&lt;/h2&gt;

&lt;p&gt;Cover images on this blog go through Astro's image optimisation pipeline, which hashes filenames at build time: &lt;code&gt;cover.webp&lt;/code&gt; becomes &lt;code&gt;cover.D7kJPmN_Z3QwX.webp&lt;/code&gt;. That hashed path is what Pagefind indexes.&lt;/p&gt;

&lt;p&gt;In development, the Astro dev server generates images on demand and serves them at the original relative path, so the hashed path from the build doesn't exist. Pagefind reads the right path from the built HTML, stores it in the index, and it works in production, but the images 404 in dev.&lt;/p&gt;

&lt;p&gt;The fix: pre-generate thumbnails as stable, predictably-named files that are served from the same path in both dev and production.&lt;/p&gt;

&lt;h3&gt;
  
  
  Generating thumbnails with ImageMagick
&lt;/h3&gt;

&lt;p&gt;Each post directory in the content repo gets a &lt;code&gt;&amp;lt;slug&amp;gt;-thumbnail.webp&lt;/code&gt;, a 96×96 center-cropped version of the cover image, generated once using ImageMagick:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;magick cover.jpg &lt;span class="nt"&gt;-resize&lt;/span&gt; 96x96^ &lt;span class="nt"&gt;-gravity&lt;/span&gt; Center &lt;span class="nt"&gt;-extent&lt;/span&gt; 96x96 choosing-the-tech-stack-thumbnail.webp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;96x96^&lt;/code&gt; flag resizes to fill the target dimensions (scaling up the smaller dimension), and &lt;code&gt;-gravity Center -extent 96x96&lt;/code&gt; crops to the exact size, keeping the centre of the image.&lt;/p&gt;

&lt;p&gt;These thumbnails live alongside the article: colocated in the content repo, committed once, never regenerated unless the cover changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Copying thumbnails before build and dev
&lt;/h3&gt;

&lt;p&gt;The thumbnails live in &lt;code&gt;collections/posts/&amp;lt;slug&amp;gt;/&amp;lt;slug&amp;gt;-thumbnail.webp&lt;/code&gt;. Astro serves from &lt;code&gt;public/&lt;/code&gt; (in dev) and &lt;code&gt;dist/&lt;/code&gt; (after build), not from &lt;code&gt;collections/&lt;/code&gt;. The &lt;code&gt;thumbnails:copy&lt;/code&gt; package command mirrors them to &lt;code&gt;public/search-thumbnails/&amp;lt;slug&amp;gt;/&amp;lt;slug&amp;gt;-thumbnail.webp&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"thumbnails:copy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"node scripts/sync-public-assets.mjs thumbnails"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both &lt;code&gt;pnpm dev&lt;/code&gt; and &lt;code&gt;pnpm search:index&lt;/code&gt; run &lt;code&gt;pnpm thumbnails:copy&lt;/code&gt; first, and &lt;code&gt;pnpm search:index&lt;/code&gt; also runs &lt;code&gt;pnpm post-images:copy&lt;/code&gt;, so generated assets are always in place before the server starts or the search index is built. The Netlify build command also includes the asset sync step.&lt;/p&gt;

&lt;p&gt;The layout then constructs the stable URL directly, no Astro &lt;code&gt;getImage()&lt;/code&gt; involved:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;thumbSrc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;frontmatter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cover&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;thumbnail&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`/search-thumbnails/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;postId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;postId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-thumbnail.webp`&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/search-thumbnails/choosing-the-tech-stack/choosing-the-tech-stack-thumbnail.webp&lt;/code&gt; is the same URL in dev and production. The image is always there. No hashing, no broken thumbnails.&lt;/p&gt;

&lt;h2&gt;
  
  
  Styles must go in global.scss
&lt;/h2&gt;

&lt;p&gt;Astro's scoped &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; blocks add a hash to every class name at build time, something like &lt;code&gt;.search-panel[data-astro-cid-abc123]&lt;/code&gt;. Elements injected into the DOM at runtime via &lt;code&gt;innerHTML&lt;/code&gt; don't have that hash attribute, so the scoped styles don't apply to them.&lt;/p&gt;

&lt;p&gt;Everything that styles the search modal and results lives in &lt;code&gt;global.scss&lt;/code&gt;, not in a scoped &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; block in &lt;code&gt;Header.astro&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design decisions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  No search page
&lt;/h3&gt;

&lt;p&gt;A dedicated &lt;code&gt;/search&lt;/code&gt; route is the obvious implementation, but it has a friction cost: you leave your current context, wait for a page load, and then navigate back. The modal pattern keeps you where you are. Open, type, go: three steps, no navigation until you've found what you want.&lt;/p&gt;

&lt;p&gt;The command-palette pattern is familiar from editors like VS Code's &lt;code&gt;⌘K&lt;/code&gt; and Spotlight. Users already know how it works.&lt;/p&gt;

&lt;h3&gt;
  
  
  The JS API over the default UI
&lt;/h3&gt;

&lt;p&gt;Pagefind ships a ready-made UI component. It renders its own DOM, injects its own styles, and supports themes. For a blog that already has a design system and a specific interaction pattern in mind, the default UI introduces more constraints than it removes.&lt;/p&gt;

&lt;p&gt;The JS API takes three lines to get search results and returns plain data: URL, excerpt, custom meta. The result DOM is written by the same code that writes the rest of the site. No style conflicts, no theme mismatch, no overriding someone else's HTML structure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dynamic import, not a static import
&lt;/h3&gt;

&lt;p&gt;You can't statically import &lt;code&gt;pagefind.js&lt;/code&gt; because the file doesn't exist at build time. Pagefind generates it after &lt;code&gt;astro build&lt;/code&gt; runs. A static &lt;code&gt;import&lt;/code&gt; would fail at the Vite bundling step. The runtime dynamic import &lt;code&gt;await import('/pagefind/pagefind.js')&lt;/code&gt; sidesteps Vite entirely and loads only when the user first opens the search modal.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;is:inline&lt;/code&gt; attribute on the script tag is what keeps Astro from handing it to Vite.&lt;/p&gt;

&lt;h3&gt;
  
  
  Loading skeleton before results arrive
&lt;/h3&gt;

&lt;p&gt;Pagefind's WASM module takes a moment to load on first use. A blank results area during that gap looks broken. Three skeleton rows, placeholder blocks with a shimmer animation, fill the space immediately, giving the user feedback that something is happening. The skeletons are replaced by real results as soon as the search resolves.&lt;/p&gt;

&lt;h3&gt;
  
  
  Predictable thumbnail paths
&lt;/h3&gt;

&lt;p&gt;Astro's image optimisation pipeline hashes filenames: &lt;code&gt;cover.webp&lt;/code&gt; becomes &lt;code&gt;cover.D7kJPmN.webp&lt;/code&gt;. That hash changes every time the image is reprocessed. Pagefind stores whatever URL it finds at index time, which means indexed paths would become stale whenever the build produces a new hash.&lt;/p&gt;

&lt;p&gt;Pre-generating thumbnails with a stable, slug-based filename (&lt;code&gt;choosing-the-tech-stack-thumbnail.webp&lt;/code&gt;) breaks that dependency. The URL Pagefind indexes is the same URL that will exist in the next build and every build after that.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;visually-hidden&lt;/code&gt;, not &lt;code&gt;display: none&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;This is documented in detail in the metadata section above, but the principle is worth making explicit: anything you hide from the DOM with &lt;code&gt;display: none&lt;/code&gt; is also hidden from Pagefind's crawler. &lt;code&gt;.visually-hidden&lt;/code&gt; is the only pattern that satisfies all three constraints simultaneously: visually absent, accessible-tree absent, and crawler-visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding a new post
&lt;/h2&gt;

&lt;p&gt;When you write a new post with a cover image, generate the thumbnail once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;magick path/to/cover.jpg &lt;span class="nt"&gt;-resize&lt;/span&gt; 96x96^ &lt;span class="nt"&gt;-gravity&lt;/span&gt; Center &lt;span class="nt"&gt;-extent&lt;/span&gt; 96x96 collections/posts/your-post-slug/your-post-slug-thumbnail.webp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then add &lt;code&gt;thumbnail: './your-post-slug-thumbnail.webp'&lt;/code&gt; as a child of &lt;code&gt;cover:&lt;/code&gt; in the post frontmatter. The copy script and index rebuild will pick it up automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Working on something similar?
&lt;/h2&gt;

&lt;p&gt;If you're building a content site or developer blog and would rather not unpick these problems yourself, I'm available for consulting. &lt;a href="https://dev.to/contact"&gt;Get in touch via the contact page&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>astro</category>
      <category>engineering</category>
      <category>frontend</category>
    </item>
    <item>
      <title>Moving blog content to a private repository</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Fri, 04 Sep 2026 11:34:45 +0000</pubDate>
      <link>https://dev.to/sourcier/moving-blog-content-to-a-private-repository-1nei</link>
      <guid>https://dev.to/sourcier/moving-blog-content-to-a-private-repository-1nei</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/private-content-repo" rel="noopener noreferrer"&gt;Moving blog content to a private repository&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Series: Part of &lt;a href="https://sourcier.uk/blog/how-this-blog-was-built" rel="noopener noreferrer"&gt;How this blog was built&lt;/a&gt;, documenting every decision that shaped this site.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This site's source code is public. Anyone can see how the layouts work, how the&lt;br&gt;
comment system is wired up, how the scheduled build fires. I want that to stay&lt;br&gt;
open: it's useful to others and it keeps me honest about code quality.&lt;/p&gt;

&lt;p&gt;What I don't want public is every unpublished draft sitting in the repository&lt;br&gt;
weeks before it goes live. The content, meaning Markdown files, cover images,&lt;br&gt;
and frontmatter, needed to move somewhere private without changing how the&lt;br&gt;
site builds or deploys.&lt;/p&gt;
&lt;h2&gt;
  
  
  The constraint
&lt;/h2&gt;

&lt;p&gt;Astro's content collections expect post files at a specific path. The collection&lt;br&gt;
loader in &lt;code&gt;src/content.config.ts&lt;/code&gt; points to &lt;code&gt;collections/posts/&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;loader&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;**/*.md&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;!README.md&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;base&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./collections/posts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whatever solution I chose, Astro needed to find Markdown files at that path at&lt;br&gt;
build time. No loader changes, no path rewiring, no special plugins.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why not a git submodule
&lt;/h2&gt;

&lt;p&gt;The obvious answer is a git submodule: point &lt;code&gt;collections/posts/&lt;/code&gt; at a private&lt;br&gt;
repository and let git handle the rest.&lt;/p&gt;

&lt;p&gt;It works locally, but Netlify's build pipeline clones&lt;br&gt;
submodules during its "preparing repo" stage, before the build command runs.&lt;br&gt;
For a private submodule, Netlify needs SSH access to the repository. The way&lt;br&gt;
it authenticates is through a deploy key, and GitHub deploy keys are scoped&lt;br&gt;
to a single repository. The same key cannot be added to two repos.&lt;/p&gt;

&lt;p&gt;Since Netlify's deploy key was already registered on the main site repository,&lt;br&gt;
adding it to the content repository returned a "key already in use" error. You&lt;br&gt;
can work around this with machine users or manually managed SSH keys, but it&lt;br&gt;
felt like fighting the tooling rather than working with it.&lt;/p&gt;
&lt;h2&gt;
  
  
  The build-time clone approach
&lt;/h2&gt;

&lt;p&gt;The simpler solution: skip submodules entirely and clone the private repository&lt;br&gt;
as a build step. The content directory is gitignored in the main repository and&lt;br&gt;
fetched fresh on every build.&lt;/p&gt;

&lt;p&gt;The entire change is in &lt;code&gt;netlify.toml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[build]&lt;/span&gt;
  &lt;span class="py"&gt;command&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"git clone --depth 1 https://${GH_PAT}@github.com/sourcier/sourcier.uk-content.git collections/posts &amp;amp;&amp;amp; rm -rf collections/posts/.git &amp;amp;&amp;amp; astro build"&lt;/span&gt;
  &lt;span class="py"&gt;publish&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"dist"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three commands chained together:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Clone&lt;/strong&gt; the private content repository into &lt;code&gt;collections/posts/&lt;/code&gt; using a
GitHub personal access token for authentication. &lt;code&gt;--depth 1&lt;/code&gt; skips history,
since we only need the latest files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remove &lt;code&gt;.git/&lt;/code&gt;&lt;/strong&gt; from the cloned directory. Without this, Netlify's secrets
scanner detects the token in &lt;code&gt;collections/posts/.git/config&lt;/code&gt; and fails the
build. Removing the &lt;code&gt;.git&lt;/code&gt; directory strips the credential before the scan
runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build&lt;/strong&gt; the Astro site as normal. The content is in place and the collection
loader picks it up without any configuration changes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IExSCiAgICBTSVRFWyJQdWJsaWMgc2l0ZSByZXBvXG5zb3VyY2llci51ayJdCiAgICBDT05URU5UWyJQcml2YXRlIGNvbnRlbnQgcmVwb1xuc291cmNpZXIudWstY29udGVudCJdCiAgICBzdWJncmFwaCBidWlsZFsiTmV0bGlmeSBidWlsZCBjb21tYW5kIl0KICAgICAgICBCMVsiZ2l0IGNsb25lIGludG9cbmNvbGxlY3Rpb25zL3Bvc3RzLyJdCiAgICAgICAgQjJbInJtIC1yZiAuZ2l0Il0KICAgICAgICBCM1siYXN0cm8gYnVpbGQiXQogICAgICAgIEIxIC0tPiBCMiAtLT4gQjMKICAgIGVuZAogICAgQ0ROWyJOZXRsaWZ5IENETlxuZGlzdC8iXQogICAgR0FbIkdpdEh1YiBBY3Rpb25zXG5kZXBsb3kueW1sIl0KICAgIFNJVEUgLS0-fHB1c2ggdHJpZ2dlcnN8IGJ1aWxkCiAgICBDT05URU5UIC0tPnxjbG9uZWQgYXQgYnVpbGR8IEIxCiAgICBCMyAtLT4gQ0ROCiAgICBDT05URU5UIC0tPnxwdXNoIHRvIG1haW58IEdBCiAgICBHQSAtLT58UE9TVCBidWlsZCBob29rfCBidWlsZA" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IExSCiAgICBTSVRFWyJQdWJsaWMgc2l0ZSByZXBvXG5zb3VyY2llci51ayJdCiAgICBDT05URU5UWyJQcml2YXRlIGNvbnRlbnQgcmVwb1xuc291cmNpZXIudWstY29udGVudCJdCiAgICBzdWJncmFwaCBidWlsZFsiTmV0bGlmeSBidWlsZCBjb21tYW5kIl0KICAgICAgICBCMVsiZ2l0IGNsb25lIGludG9cbmNvbGxlY3Rpb25zL3Bvc3RzLyJdCiAgICAgICAgQjJbInJtIC1yZiAuZ2l0Il0KICAgICAgICBCM1siYXN0cm8gYnVpbGQiXQogICAgICAgIEIxIC0tPiBCMiAtLT4gQjMKICAgIGVuZAogICAgQ0ROWyJOZXRsaWZ5IENETlxuZGlzdC8iXQogICAgR0FbIkdpdEh1YiBBY3Rpb25zXG5kZXBsb3kueW1sIl0KICAgIFNJVEUgLS0-fHB1c2ggdHJpZ2dlcnN8IGJ1aWxkCiAgICBDT05URU5UIC0tPnxjbG9uZWQgYXQgYnVpbGR8IEIxCiAgICBCMyAtLT4gQ0ROCiAgICBDT05URU5UIC0tPnxwdXNoIHRvIG1haW58IEdBCiAgICBHQSAtLT58UE9TVCBidWlsZCBob29rfCBidWlsZA" alt="Mermaid diagram" width="1484" height="309"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Diagram fallback for Dev.to. View the canonical article for the full version: &lt;a href="https://sourcier.uk/blog/private-content-repo" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/private-content-repo&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Setting up the GitHub token
&lt;/h2&gt;

&lt;p&gt;The token needs minimal permissions. A fine-grained personal access token scoped&lt;br&gt;
to the content repository with read-only Contents access is sufficient:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;a href="https://github.com/settings/tokens?type=beta" rel="noopener noreferrer"&gt;GitHub → Settings → Personal access tokens → Fine-grained tokens&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Create a new token scoped to the content repository only&lt;/li&gt;
&lt;li&gt;Grant &lt;strong&gt;Contents&lt;/strong&gt; → &lt;strong&gt;Read-only&lt;/strong&gt; permission&lt;/li&gt;
&lt;li&gt;Add the token as &lt;code&gt;GH_PAT&lt;/code&gt; in the Netlify dashboard under
&lt;strong&gt;Site configuration → Environment variables&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The token never appears in the repository. It's injected at build time through&lt;br&gt;
the environment variable.&lt;/p&gt;
&lt;h2&gt;
  
  
  The .gitignore entry
&lt;/h2&gt;

&lt;p&gt;Since the content is cloned at build time, the directory is gitignored in the&lt;br&gt;
main repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# blog content (cloned from private repo at build time)
collections/posts/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Local development
&lt;/h2&gt;

&lt;p&gt;For local development, clone the content repository once into &lt;code&gt;collections/posts/&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone git@github.com:sourcier/sourcier.uk-content.git collections/posts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the directory is gitignored, the main repository doesn't track it. You&lt;br&gt;
work inside &lt;code&gt;collections/posts/&lt;/code&gt; as its own git repo: commit content changes&lt;br&gt;
there and push to the content repository's remote. The main site repo never&lt;br&gt;
sees those files.&lt;/p&gt;

&lt;p&gt;Running &lt;code&gt;pnpm dev&lt;/code&gt; picks up the content from disk exactly as before. Nothing&lt;br&gt;
changes about the local workflow except that content commits go to a different&lt;br&gt;
remote.&lt;/p&gt;
&lt;h2&gt;
  
  
  VS Code workspace setup
&lt;/h2&gt;

&lt;p&gt;With two git repositories on disk, a VS Code multi-root workspace makes the&lt;br&gt;
local setup feel intentional rather than awkward. The &lt;code&gt;.code-workspace&lt;/code&gt; file at&lt;br&gt;
the root of the site repository adds &lt;code&gt;collections/posts&lt;/code&gt; as a second folder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"folders"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sourcier.uk (site)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sourcier.uk (content)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"collections/posts"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"settings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"terminal.integrated.cwd"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"${workspaceFolder:sourcier.uk (site)}"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Opening this file gives you both repositories in a single VS Code window. The&lt;br&gt;
Source Control panel shows two separate entries, one for each repo, so staging,&lt;br&gt;
committing, and pushing content changes is completely independent from site&lt;br&gt;
changes. The file explorer shows both roots side by side.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;settings&lt;/code&gt; block is optional, but &lt;code&gt;terminal.integrated.cwd&lt;/code&gt; is worth adding.&lt;br&gt;
Without it, VS Code opens terminals relative to whichever file is currently&lt;br&gt;
active, which means a terminal opened while editing a post drops you into&lt;br&gt;
&lt;code&gt;collections/posts/&lt;/code&gt; instead of the project root where &lt;code&gt;pnpm dev&lt;/code&gt; and other site&lt;br&gt;
commands live.&lt;/p&gt;

&lt;p&gt;Because &lt;code&gt;collections/posts/&lt;/code&gt; is gitignored by the site repo, VS Code won't&lt;br&gt;
accidentally stage content files as part of a site commit. Each repository&lt;br&gt;
manages its own history, and the workspace just surfaces both at once.&lt;/p&gt;
&lt;h2&gt;
  
  
  Triggering deploys from the content repository
&lt;/h2&gt;

&lt;p&gt;Pushing to the content repository doesn't trigger a Netlify deploy: the main&lt;br&gt;
site repository hasn't changed, so Netlify has nothing to react to.&lt;/p&gt;

&lt;p&gt;The fix is a Netlify build hook: a URL you POST to kick off a build regardless&lt;br&gt;
of code changes.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;In the Netlify dashboard go to &lt;strong&gt;Site configuration → Build hooks&lt;/strong&gt; and
create a new hook. Copy the URL.&lt;/li&gt;
&lt;li&gt;In the content repository, add that URL as a secret named &lt;code&gt;NETLIFY_BUILD_HOOK&lt;/code&gt;
under &lt;strong&gt;Settings → Secrets and variables → Actions&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Add a workflow to the content repository:
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy site&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Trigger Netlify build&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;curl -X POST -d '{}' "${{ secrets.NETLIFY_BUILD_HOOK }}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Now every push to &lt;code&gt;main&lt;/code&gt; in the content repository fires a full Netlify build,&lt;br&gt;
which clones the latest content and rebuilds the site.&lt;/p&gt;
&lt;h2&gt;
  
  
  What this gives you
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Drafts stay private.&lt;/strong&gt; Unpublished posts, half-written ideas, and cover images&lt;br&gt;
for future content don't appear in the public repository's commit history.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The site code stays open.&lt;/strong&gt; Layouts, components, serverless functions, and&lt;br&gt;
configuration remain public and visible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The build is identical.&lt;/strong&gt; Astro's content collection loader doesn't know or&lt;br&gt;
care where the files came from. They're at the expected path, and the schema&lt;br&gt;
validates them the same way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No infrastructure changes.&lt;/strong&gt; No new services, no CMS, no API layer. It's&lt;br&gt;
two git repositories and a one-line build command.&lt;/p&gt;
&lt;h2&gt;
  
  
  Gating the preview branch behind a passcode
&lt;/h2&gt;

&lt;p&gt;The build hook workflow targets &lt;code&gt;main&lt;/code&gt;, so the public site auto-deploys on&lt;br&gt;
content changes. But the &lt;code&gt;preview&lt;/code&gt; branch, the one that includes drafts, is&lt;br&gt;
also a public URL. Anyone who finds or guesses &lt;code&gt;preview--sourcieruk.netlify.app&lt;/code&gt;&lt;br&gt;
can read unpublished posts.&lt;/p&gt;

&lt;p&gt;The solution is a Netlify Edge Function that intercepts every request on the&lt;br&gt;
&lt;code&gt;preview&lt;/code&gt; deploy and serves a passcode form until the visitor authenticates.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Context&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@netlify/edge-functions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;COOKIE_NAME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;preview_auth&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;COOKIE_MAX_AGE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 7 days&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;computeToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;passcode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;encoder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;importKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;raw&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;encoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;passcode&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;HMAC&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SHA-256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sign&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;HMAC&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;encoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;preview_authenticated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;btoa&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCharCode&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;previewAuth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;passcode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Netlify&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PREVIEW_PASSCODE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// No passcode configured — pass through (production unaffected)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;passcode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expectedToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;computeToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;passcode&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cookies&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseCookies&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cookie&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;COOKIE_NAME&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;expectedToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;submitted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;code&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;submitted&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;passcode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;redirectTo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sanitizeRedirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;redirect&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;302&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;location&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;redirectTo&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;set-cookie&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;COOKIE_NAME&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;expectedToken&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;; HttpOnly; Secure; SameSite=Strict; Max-Age=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;COOKIE_MAX_AGE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;; Path=/`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;renderForm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;renderForm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth calling out:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cookie stores an HMAC, not the passcode.&lt;/strong&gt; The passcode is used as an&lt;br&gt;
HMAC key to sign the string &lt;code&gt;"preview_authenticated"&lt;/code&gt;. The resulting signature&lt;br&gt;
goes into the cookie. The raw passcode never leaves the server: if the cookie&lt;br&gt;
is stolen it reveals nothing about the code, and it's valid only on this origin.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cookie is &lt;code&gt;HttpOnly&lt;/code&gt;, &lt;code&gt;Secure&lt;/code&gt;, and &lt;code&gt;SameSite=Strict&lt;/code&gt;.&lt;/strong&gt; It can't be read&lt;br&gt;
by JavaScript, only travels over HTTPS, and is never sent cross-site.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Production is unaffected by design.&lt;/strong&gt; The function checks for a&lt;br&gt;
&lt;code&gt;PREVIEW_PASSCODE&lt;/code&gt; environment variable and no-ops immediately if it isn't set.&lt;br&gt;
The variable is scoped to branch deploys only in the Netlify dashboard, so the&lt;br&gt;
production site never even evaluates the auth logic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Changing the passcode invalidates all sessions instantly.&lt;/strong&gt; The HMAC changes&lt;br&gt;
when the key changes, so existing cookies no longer match. No session store, no&lt;br&gt;
token revocation list.&lt;/p&gt;

&lt;p&gt;To wire it up:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;In the Netlify dashboard: &lt;strong&gt;Site configuration → Environment variables → Add variable&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;Key: &lt;code&gt;PREVIEW_PASSCODE&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Value: a short memorable code, or generate one with &lt;code&gt;openssl rand -base64 12&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Scope: Branch deploys only&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Install &lt;code&gt;@netlify/edge-functions&lt;/code&gt; as a devDependency, this gives TypeScript
the types locally. Netlify's edge runtime provides the module automatically
at deploy time, so it only needs to be present for type checking:
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   pnpm add &lt;span class="nt"&gt;-D&lt;/span&gt; @netlify/edge-functions
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;ol&gt;
&lt;li&gt;Netlify auto-discovers edge functions in &lt;code&gt;netlify/edge-functions/&lt;/code&gt;, no extra
&lt;code&gt;netlify.toml&lt;/code&gt; configuration is needed. The path is declared inside the
function itself via &lt;code&gt;export const config: Config = { path: "/*" }&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The full edge function (including the form renderer) is in the&lt;br&gt;
&lt;a href="https://github.com/sourcier/sourcier.uk/blob/main/netlify/edge-functions/preview-auth.ts" rel="noopener noreferrer"&gt;site repository&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Purging content from the existing git history
&lt;/h2&gt;

&lt;p&gt;Moving files to a private repository stops future content from appearing in the&lt;br&gt;
public repo. But every post you committed before the split is still visible in&lt;br&gt;
the history. Anyone can check out an older commit and read all your drafts.&lt;/p&gt;

&lt;p&gt;The tool for this is &lt;a href="https://github.com/newren/git-filter-repo" rel="noopener noreferrer"&gt;&lt;code&gt;git filter-repo&lt;/code&gt;&lt;/a&gt;.&lt;br&gt;
It rewrites history by replaying every commit through a filter, in this case,&lt;br&gt;
"keep everything except &lt;code&gt;collections/posts/&lt;/code&gt;":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;git-filter-repo
git filter-repo &lt;span class="nt"&gt;--path&lt;/span&gt; collections/posts &lt;span class="nt"&gt;--invert-paths&lt;/span&gt; &lt;span class="nt"&gt;--force&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--path&lt;/code&gt; identifies the directory to target. &lt;code&gt;--invert-paths&lt;/code&gt; turns the filter&lt;br&gt;
around: instead of keeping only that path, it removes it. &lt;code&gt;--force&lt;/code&gt; is required&lt;br&gt;
because the repo has a remote, and git-filter-repo refuses to run on repos with&lt;br&gt;
remotes unless you explicitly confirm intent.&lt;/p&gt;

&lt;p&gt;After the rewrite, git-filter-repo removes the &lt;code&gt;origin&lt;/code&gt; remote as a safety&lt;br&gt;
measure. Add it back and force push:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git remote add origin git@github.com:sourcier/sourcier.uk-content.git
git push &lt;span class="nt"&gt;--force&lt;/span&gt; origin main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you have other branches (a &lt;code&gt;preview&lt;/code&gt; branch, for example), recreate them&lt;br&gt;
from the rewritten &lt;code&gt;main&lt;/code&gt; and force push those too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git branch &lt;span class="nt"&gt;-D&lt;/span&gt; preview
git checkout &lt;span class="nt"&gt;-b&lt;/span&gt; preview
git push &lt;span class="nt"&gt;--force&lt;/span&gt; origin preview
git checkout main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The force push replaces every ref on GitHub with the rewritten chain. The old&lt;br&gt;
SHAs, and the content blobs they referenced, are no longer accessible via the&lt;br&gt;
public repository.&lt;/p&gt;

&lt;p&gt;One caveat: GitHub caches some data, such as pull request diffs and web UI&lt;br&gt;
caches, that may retain stale content for a period after the force push. For a&lt;br&gt;
personal repo with no open PRs this expires naturally, but you can contact&lt;br&gt;
GitHub Support to request an immediate cache purge if needed.&lt;/p&gt;
&lt;h2&gt;
  
  
  What to watch for
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;README.md in the content repository.&lt;/strong&gt; Every GitHub repository gets a&lt;br&gt;
&lt;code&gt;README.md&lt;/code&gt; at the root. When the content repository is cloned into&lt;br&gt;
&lt;code&gt;collections/posts/&lt;/code&gt;, that &lt;code&gt;README.md&lt;/code&gt; lands in the loader's base directory.&lt;br&gt;
Astro's glob loader picks it up and tries to validate it against the post&lt;br&gt;
schema, which fails immediately because it has no &lt;code&gt;title&lt;/code&gt;, no &lt;code&gt;pubDate&lt;/code&gt;, none&lt;br&gt;
of the required frontmatter fields.&lt;/p&gt;

&lt;p&gt;The fix is a negation pattern in the glob loader:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;loader&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;**/*.md&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;!README.md&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;base&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./collections/posts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This excludes any file named &lt;code&gt;README.md&lt;/code&gt; regardless of where the build runs&lt;br&gt;
from. Without this, the build works fine locally (where the directory contains&lt;br&gt;
only posts) but fails on Netlify every time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two repositories to manage.&lt;/strong&gt; Content and code live in separate repos. When&lt;br&gt;
a code change depends on a content change (a new frontmatter field, for&lt;br&gt;
example), both repos need to be updated and the deploy needs both changes&lt;br&gt;
present. In practice this hasn't been an issue: schema changes are infrequent&lt;br&gt;
and easy to coordinate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Token expiry.&lt;/strong&gt; Fine-grained tokens have an expiration date. If the token&lt;br&gt;
expires, builds fail silently with a clone authentication error. Set a calendar&lt;br&gt;
reminder or use a long-lived token if your threat model allows it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Branch alignment.&lt;/strong&gt; The build command clones the &lt;code&gt;main&lt;/code&gt; branch of the content&lt;br&gt;
repository. If you work on a content branch, you'll need to adjust the clone&lt;br&gt;
command or merge to &lt;code&gt;main&lt;/code&gt; before deploying.&lt;/p&gt;

&lt;h2&gt;
  
  
  Working on something similar?
&lt;/h2&gt;

&lt;p&gt;If you're building a content pipeline, managing private content alongside public&lt;br&gt;
code, or setting up deploy automation, I'm available for consulting.&lt;br&gt;
&lt;a href="https://dev.to/contact"&gt;Get in touch via the contact page&lt;/a&gt; and tell me what you're working on.&lt;/p&gt;

</description>
      <category>engineering</category>
      <category>netlify</category>
      <category>meta</category>
    </item>
    <item>
      <title>Web analytics on a static Astro blog</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Fri, 04 Sep 2026 11:34:23 +0000</pubDate>
      <link>https://dev.to/sourcier/web-analytics-on-a-static-astro-blog-4gl6</link>
      <guid>https://dev.to/sourcier/web-analytics-on-a-static-astro-blog-4gl6</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/web-analytics-astro" rel="noopener noreferrer"&gt;Web analytics on a static Astro blog&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Series: Part of &lt;a href="https://sourcier.uk/blog/how-this-blog-was-built" rel="noopener noreferrer"&gt;How this blog was built&lt;/a&gt; — documenting every decision that shaped this site.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A blog without analytics is guesswork. You have no idea whether anyone is reading,&lt;br&gt;
which posts land, or where people drop off. For a personal site that's fine for&lt;br&gt;
a while, but eventually you want data.&lt;/p&gt;

&lt;p&gt;The question for a statically-generated blog isn't &lt;em&gt;whether&lt;/em&gt; to add analytics,&lt;br&gt;
it's &lt;em&gt;which&lt;/em&gt; ones, and how to avoid polluting your development logs with noise.&lt;/p&gt;
&lt;h2&gt;
  
  
  Picking a tool
&lt;/h2&gt;

&lt;p&gt;There are several free options that work well with an Astro/Netlify stack:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PostHog&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1M events/month free&lt;/td&gt;
&lt;td&gt;Product analytics + web analytics; generous free tier&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GoatCounter&lt;/td&gt;
&lt;td&gt;Free for public sites&lt;/td&gt;
&lt;td&gt;Lightweight, no cookies, no self-hosting needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GA4&lt;/td&gt;
&lt;td&gt;Fully free&lt;/td&gt;
&lt;td&gt;Full-featured but requires a cookie consent banner&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare Web Analytics&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;Zero JS, but requires your DNS on Cloudflare&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Netlify Analytics&lt;/td&gt;
&lt;td&gt;$9/month&lt;/td&gt;
&lt;td&gt;Server-side, zero JS impact on the page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Umami&lt;/td&gt;
&lt;td&gt;Free (self-hosted)&lt;/td&gt;
&lt;td&gt;Privacy-first, but needs a server to run on&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I went with &lt;strong&gt;PostHog&lt;/strong&gt; for a few reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The free tier covers 1 million events per month.&lt;/li&gt;
&lt;li&gt;It tracks page views, but also gives you session recordings, funnels, and
feature flags if you ever want them.&lt;/li&gt;
&lt;li&gt;The EU cloud (&lt;code&gt;eu.i.posthog.com&lt;/code&gt;) means data stays in Europe, which
simplifies GDPR considerations.&lt;/li&gt;
&lt;li&gt;The setup is a small script, no npm package, no build step changes.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  The implementation
&lt;/h2&gt;

&lt;p&gt;The analytics snippet lives in two places: a conditional &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag in&lt;br&gt;
&lt;code&gt;src/layouts/BaseLayout.astro&lt;/code&gt;, which wraps every page on the site, and a&lt;br&gt;
small vendor bootstrap file in &lt;code&gt;public/scripts/posthog-bootstrap.js&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  Production-only loading
&lt;/h3&gt;

&lt;p&gt;The most important constraint: the snippet should only load in production. In&lt;br&gt;
local development, PostHog would record your own page views, skew the data, and&lt;br&gt;
clutter the network panel with requests to an external service.&lt;/p&gt;

&lt;p&gt;Astro exposes &lt;code&gt;import.meta.env.PROD&lt;/code&gt;, a boolean that is &lt;code&gt;true&lt;/code&gt; during a&lt;br&gt;
production build and &lt;code&gt;false&lt;/code&gt; during &lt;code&gt;astro dev&lt;/code&gt;. Wrapping the script tag in a&lt;br&gt;
conditional expression gates it cleanly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
  import.meta.env.PROD &amp;amp;&amp;amp; (
    &amp;lt;script
      src="/scripts/posthog-bootstrap.js"
      data-posthog-key={import.meta.env.PUBLIC_POSTHOG_KEY}
      data-posthog-host={
        import.meta.env.PUBLIC_POSTHOG_HOST || "https://eu.i.posthog.com"
      }
      defer
    /&amp;gt;
  )
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In development the entire block evaluates to nothing, so no script tag is&lt;br&gt;
emitted and no network requests are made.&lt;/p&gt;
&lt;h3&gt;
  
  
  Why an external file instead of &lt;code&gt;define:vars&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The first version of this wired PostHog's project key straight into an inline&lt;br&gt;
script using Astro's &lt;code&gt;define:vars&lt;/code&gt; directive, which injects server-side&lt;br&gt;
variables into an inline &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; block at build time. That worked, but&lt;br&gt;
PostHog's loader snippet is a wall of minified vendor JavaScript, and Prettier&lt;br&gt;
reformats every &lt;code&gt;.astro&lt;/code&gt; file on save. Having that vendor blob embedded&lt;br&gt;
directly inside the component meant every save reflowed it along with the&lt;br&gt;
rest of the file, turning a one-line stub into unreadable diffs and risking a&lt;br&gt;
subtle edit to code nobody should be hand-editing.&lt;/p&gt;

&lt;p&gt;Moving the snippet into its own &lt;code&gt;.js&lt;/code&gt; file under &lt;code&gt;public/&lt;/code&gt; fixes the&lt;br&gt;
build-time half of the problem: Astro serves anything in &lt;code&gt;public/&lt;/code&gt; as a&lt;br&gt;
static asset without bundling or transforming it, so there's no chance of the&lt;br&gt;
Astro compiler mangling the vendor code. It also confines Prettier's&lt;br&gt;
formatting to a single vendor file that isn't meant to be hand-edited, rather&lt;br&gt;
than fighting with the surrounding &lt;code&gt;.astro&lt;/code&gt; template on every save. Since an&lt;br&gt;
external script has no access to the Astro component's scope, configuration&lt;br&gt;
has to travel through &lt;code&gt;data-*&lt;/code&gt; attributes instead of &lt;code&gt;define:vars&lt;/code&gt;, read back&lt;br&gt;
out with &lt;code&gt;document.currentScript.dataset&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;currentScript&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentScript&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;currentScript&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;HTMLScriptElement&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="nx"&gt;currentScript&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;posthogKey&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;posthogKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;currentScript&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;posthogKey&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;posthogHost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;currentScript&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;posthogHost&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://eu.i.posthog.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="cm"&gt;/* eslint-disable */&lt;/span&gt;
  &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;){&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__SV&lt;/span&gt;&lt;span class="o"&gt;||&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;posthog&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_i&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;init&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;){&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;g&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;){&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(){&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)))}}(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;script&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text/javascript&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;crossOrigin&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;anonymous&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt;&lt;span class="o"&gt;=!&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;api_host&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/static/array.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementsByTagName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;script&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nx"&gt;parentNode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insertBefore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;!==&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[]:&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;posthog&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;people&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;people&lt;/span&gt;&lt;span class="o"&gt;||&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;toString&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;){&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;posthog&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;posthog&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;!==&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="o"&gt;+=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="o"&gt;||&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="o"&gt;+=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; (stub)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;people&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;toString&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(){&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; (stub)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys onSessionId&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;g&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;])},&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__SV&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)}(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;posthog&lt;/span&gt;&lt;span class="o"&gt;||&lt;/span&gt;&lt;span class="p"&gt;[]);&lt;/span&gt;
  &lt;span class="nx"&gt;posthog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;posthogKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;api_host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;posthogHost&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="cm"&gt;/* eslint-enable */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The minified stub is PostHog's own &lt;a href="https://posthog.com/docs/libraries/js" rel="noopener noreferrer"&gt;array.js&lt;/a&gt;&lt;br&gt;
loader, copied verbatim from their documentation. It bootstraps a minimal&lt;br&gt;
stub synchronously, then loads the full library asynchronously from the&lt;br&gt;
PostHog CDN. The &lt;code&gt;eslint-disable&lt;/code&gt; comment matters too: the vendor code&lt;br&gt;
doesn't follow this project's lint rules, and it shouldn't be rewritten to&lt;br&gt;
satisfy them.&lt;/p&gt;

&lt;p&gt;Variables prefixed with &lt;code&gt;PUBLIC_&lt;/code&gt; in Astro are safe to expose this way.&lt;br&gt;
PostHog's project key is designed to be public: it appears in the page&lt;br&gt;
source of every site using PostHog. The only real requirement is that it&lt;br&gt;
never gets hardcoded in source control, which the environment variables&lt;br&gt;
below take care of.&lt;/p&gt;
&lt;h2&gt;
  
  
  Environment variables
&lt;/h2&gt;

&lt;p&gt;Two environment variables are required:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variable&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PUBLIC_POSTHOG_KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Your PostHog project API key (&lt;code&gt;phc_...&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PUBLIC_POSTHOG_HOST&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;https://eu.i.posthog.com&lt;/code&gt; (EU) or &lt;code&gt;https://us.i.posthog.com&lt;/code&gt; (US)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h3&gt;
  
  
  Getting the key
&lt;/h3&gt;

&lt;p&gt;Sign up at &lt;a href="https://posthog.com" rel="noopener noreferrer"&gt;posthog.com&lt;/a&gt;, create a project, and copy the&lt;br&gt;
API key from &lt;strong&gt;Project Settings → Project API Key&lt;/strong&gt;. It starts with &lt;code&gt;phc_&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  Local development
&lt;/h3&gt;

&lt;p&gt;Create a &lt;code&gt;.env&lt;/code&gt; file in the project root (already in &lt;code&gt;.gitignore&lt;/code&gt; via&lt;br&gt;
Astro's default setup):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;PUBLIC_POSTHOG_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;phc_your_key_here
&lt;span class="nv"&gt;PUBLIC_POSTHOG_HOST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://eu.i.posthog.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even with these set locally, the snippet won't fire. The &lt;code&gt;import.meta.env.PROD&lt;/code&gt;&lt;br&gt;
check takes care of that.&lt;/p&gt;
&lt;h3&gt;
  
  
  GitHub Actions
&lt;/h3&gt;

&lt;p&gt;This site doesn't build on Netlify. CI builds the static site in GitHub&lt;br&gt;
Actions, and Netlify just deploys the pre-built &lt;code&gt;dist/&lt;/code&gt; folder with&lt;br&gt;
&lt;code&gt;netlify deploy --no-build&lt;/code&gt;, so the environment variables that get baked into&lt;br&gt;
the HTML have to live as GitHub Actions repository secrets&lt;br&gt;
(&lt;strong&gt;Settings → Secrets and variables → Actions&lt;/strong&gt;), not in the Netlify&lt;br&gt;
dashboard. The build step in &lt;code&gt;.github/workflows/ci.yml&lt;/code&gt; passes them through&lt;br&gt;
explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pnpm build&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;SHOW_DRAFTS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;false"&lt;/span&gt;
    &lt;span class="na"&gt;PUBLIC_STRIPE_PUBLISHABLE_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.PUBLIC_STRIPE_PUBLISHABLE_KEY }}&lt;/span&gt;
    &lt;span class="na"&gt;PUBLIC_POSTHOG_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.PUBLIC_POSTHOG_KEY }}&lt;/span&gt;
    &lt;span class="na"&gt;PUBLIC_POSTHOG_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.PUBLIC_POSTHOG_HOST }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One thing this setup doesn't solve yet: the preview build&lt;br&gt;
(&lt;code&gt;SHOW_DRAFTS: "true"&lt;/code&gt;, deployed to the passcode-protected preview alias)&lt;br&gt;
uses the exact same PostHog secrets as the production build. Every preview&lt;br&gt;
and branch deploy reports into the same PostHog project as production, so&lt;br&gt;
draft traffic and my own review sessions on the preview alias mix in with&lt;br&gt;
real visitor data. Splitting them would mean a second PostHog project and a&lt;br&gt;
second pair of secrets scoped per build job, which isn't worth the overhead&lt;br&gt;
yet for a personal blog.&lt;/p&gt;
&lt;h2&gt;
  
  
  What you get out of the box
&lt;/h2&gt;

&lt;p&gt;Once deployed, PostHog automatically captures:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Page views&lt;/strong&gt;: URL, referrer, and UTM parameters&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sessions&lt;/strong&gt;: grouping page views by visitor session&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Device and browser&lt;/strong&gt;: OS, browser, screen resolution&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Geography&lt;/strong&gt;: country and city, derived from IP, not stored&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The dashboard is ready to use from the first page view with no configuration&lt;br&gt;
beyond the snippet.&lt;/p&gt;
&lt;h2&gt;
  
  
  A note on cookie consent
&lt;/h2&gt;

&lt;p&gt;PostHog uses cookies and local storage by default to identify returning&lt;br&gt;
visitors across sessions. Depending on your jurisdiction and audience, this&lt;br&gt;
may require a cookie consent banner.&lt;/p&gt;

&lt;p&gt;This site doesn't have one yet. &lt;code&gt;posthog.init()&lt;/code&gt; runs with PostHog's&lt;br&gt;
defaults, no &lt;code&gt;persistence&lt;/code&gt; override, which sets a cookie on the first page&lt;br&gt;
view. For a personal blog with a UK/EU audience, the strictest interpretation&lt;br&gt;
of GDPR would require consent before setting analytics cookies, so this is a&lt;br&gt;
compliance gap I'm accepting for now rather than one I've solved.&lt;/p&gt;

&lt;p&gt;A pragmatic middle ground, if I revisit this, is configuring PostHog to use&lt;br&gt;
&lt;code&gt;persistence: 'memory'&lt;/code&gt; to avoid setting any cookies, at the cost of losing&lt;br&gt;
cross-session identity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;posthog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;posthogKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;api_host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;posthogHost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;persistence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;memory&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Page views and event counts would still be accurate, I'd just lose the&lt;br&gt;
ability to track individual user journeys across multiple sessions.&lt;/p&gt;

</description>
      <category>astro</category>
      <category>engineering</category>
      <category>analytics</category>
    </item>
    <item>
      <title>A custom 404 page</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Thu, 27 Aug 2026 15:23:09 +0000</pubDate>
      <link>https://dev.to/sourcier/a-custom-404-page-4deb</link>
      <guid>https://dev.to/sourcier/a-custom-404-page-4deb</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/custom-404-page" rel="noopener noreferrer"&gt;A custom 404 page&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Series: Part of &lt;a href="https://sourcier.uk/blog/how-this-blog-was-built" rel="noopener noreferrer"&gt;How this blog was built&lt;/a&gt; — documenting every decision that shaped this site.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A 404 page is easy to forget. You build the real pages, you wire up the routes, and the 404 sits at the bottom of the list — "I'll do that later." Later arrives when someone pastes a broken link, follows a dead URL from an old tweet, or misremembers your slug. What they see in that moment is not nothing: it is a page, it represents your site, and it should look like it belongs there.&lt;/p&gt;

&lt;p&gt;In Astro, building one is trivial. The interesting part is the design.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq5mgaawyz9f16rgdp0m9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq5mgaawyz9f16rgdp0m9.png" alt="Custom 404 page wireframe showing a hero panel with the outlined 404 number and ghost offset next to the Nothing here heading and two buttons, and a card grid below linking to guides and core pages" width="700" height="640"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Diagram fallback for Dev.to. View the canonical article for the original SVG: &lt;a href="https://sourcier.uk/blog/custom-404-page" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/custom-404-page&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Click the expand icon to view it fullscreen.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Astro handles 404s
&lt;/h2&gt;

&lt;p&gt;Create a file at &lt;code&gt;src/pages/404.astro&lt;/code&gt; and Astro will render it as &lt;code&gt;/404.html&lt;/code&gt; in the static build output. Netlify serves that file automatically for any path that doesn't match a real route. No configuration required — it works out of the box.&lt;/p&gt;

&lt;p&gt;The page has access to everything a normal page does: layouts, components, styles, the full design system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Skipping the standard layout
&lt;/h2&gt;

&lt;p&gt;The first decision was whether to use the &lt;code&gt;PageHero&lt;/code&gt; component that every other page uses. It gives you a kicker, a title, a subtitle, breadcrumbs, and optional cover image — which is fine for real content, but overkill for an error page. A 404 doesn't need breadcrumbs. It doesn't need a cover image. It needs to tell the user what happened and give them somewhere to go.&lt;/p&gt;

&lt;p&gt;Dropping &lt;code&gt;PageHero&lt;/code&gt; frees up the layout, but "somewhere to go" turned out to be more than one link. Rather than a single centred block, the page ended up as two sections: a hero panel that states the problem, and a grid of cards underneath that gives the visitor several concrete next steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hero panel
&lt;/h2&gt;

&lt;p&gt;The hero is a self-contained &lt;code&gt;page-panel&lt;/code&gt; with a two-column grid on tablet and up: the outlined number on the left, the eyebrow, heading, copy, and buttons on the right. Below that breakpoint it stacks into a single column.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight scss"&gt;&lt;code&gt;&lt;span class="nc"&gt;.not-found__panel&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="na"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="mi"&gt;.5rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;align-items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;center&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;radial-gradient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nb"&gt;circle&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="nb"&gt;top&lt;/span&gt; &lt;span class="nb"&gt;right&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
      &lt;span class="nf"&gt;color-mix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;in&lt;/span&gt; &lt;span class="n"&gt;srgb&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="n"&gt;accent-secondary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="m"&gt;14%&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;transparent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="m"&gt;0%&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
      &lt;span class="nb"&gt;transparent&lt;/span&gt; &lt;span class="m"&gt;34%&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="nf"&gt;linear-gradient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="m"&gt;150deg&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
      &lt;span class="nf"&gt;color-mix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;in&lt;/span&gt; &lt;span class="n"&gt;srgb&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="n"&gt;accent-primary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="m"&gt;4%&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="n"&gt;surface-elevated&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="m"&gt;0%&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
      &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="n"&gt;surface-elevated&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="m"&gt;62%&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="n"&gt;surface-elevated&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;min-width&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;768px&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;grid-template-columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;minmax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;9rem&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="mi"&gt;.6fr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;minmax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="mi"&gt;.4fr&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="na"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The gradients are subtle: a hint of the pink accent bleeding in from the left, a hint of the secondary green in the top-right corner, layered over the same &lt;code&gt;--surface-elevated&lt;/code&gt; token every other panel on the site uses, so the page still looks like it belongs here rather than a one-off design experiment.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number
&lt;/h2&gt;

&lt;p&gt;The visual anchor is the "404" itself. The approach here is outlined text: transparent fill, a pink stroke, with a faint offset copy behind it to create a ghost effect.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight scss"&gt;&lt;code&gt;&lt;span class="nc"&gt;.not-found__number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;font-family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"Barlow Condensed"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;sans-serif&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;6rem&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;18vw&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;12rem&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;font-weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;900&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;line-height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="mi"&gt;.9&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;letter-spacing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;-0&lt;/span&gt;&lt;span class="mi"&gt;.03em&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;transparent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="na"&gt;-webkit-text-stroke&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2px&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="n"&gt;accent-primary&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;relative&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nd"&gt;::after&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"404"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;absolute&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="na"&gt;inset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="n"&gt;accent-primary-rgb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="mi"&gt;.07&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="na"&gt;-webkit-text-stroke&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;translate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;6px&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;6px&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nl"&gt;z-index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;-1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-webkit-text-stroke&lt;/code&gt; has broad browser support and the effect is subtle enough that it degrades gracefully if it didn't. The &lt;code&gt;::after&lt;/code&gt; pseudo-element repeats the text, shifts it six pixels, and uses a very low-opacity solid fill — it looks like a shadow but reads as intentional.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;clamp(6rem, 18vw, 12rem)&lt;/code&gt; handles the responsive sizing without a media query: small on mobile, fluid in the middle, capped once the panel's own column width takes over on wider screens.&lt;/p&gt;

&lt;h2&gt;
  
  
  The copy and the primary actions
&lt;/h2&gt;

&lt;p&gt;Inside the right-hand column, there's a small eyebrow above the heading, a two-line explanation, and two calls to action styled with the site's existing Bulma button classes rather than anything bespoke:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;p class="not-found__eyebrow"&amp;gt;Page not found&amp;lt;/p&amp;gt;
&amp;lt;h1 class="not-found__heading"&amp;gt;Nothing here.&amp;lt;/h1&amp;gt;
&amp;lt;p class="not-found__sub"&amp;gt;
  That URL may have moved, been deleted, or never existed. Start
  from the homepage, jump into the latest writing, or pick the guide
  that matches where you are now.
&amp;lt;/p&amp;gt;
&amp;lt;div class="not-found__actions"&amp;gt;
  &amp;lt;a href="/" class="button is-primary is-medium"&amp;gt;Home&amp;lt;/a&amp;gt;
  &amp;lt;a href="/blog" class="button is-light is-medium"&amp;gt;Browse the blog&amp;lt;/a&amp;gt;
&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Nothing here." is short deliberately. "Page not found" is accurate but passive, so it moved to a small uppercase eyebrow instead, and the &lt;code&gt;&amp;lt;h1&amp;gt;&lt;/code&gt; gets to be the blunt version. The &lt;code&gt;is-primary&lt;/code&gt; button (solid pink) covers the obvious escape route; &lt;code&gt;is-light&lt;/code&gt; gives a lower-commitment second option for someone who just wants to keep reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try these instead: guides and core pages
&lt;/h2&gt;

&lt;p&gt;Two buttons cover the obvious cases, but a 404 is also a reasonable place to hand someone a menu. Below the hero panel sits a second section, labelled "Try these instead", with a card grid mixing two sources:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The site's &lt;code&gt;audienceGuides&lt;/code&gt; data — the same array that powers the guide landing pages — mapped straight into cards.&lt;/li&gt;
&lt;li&gt;A short, hard-coded list of core pages: Blog, About, Contact.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{audienceGuides.map((guide) =&amp;gt; (
  &amp;lt;article class="not-found-card not-found-card--guide"&amp;gt;
    &amp;lt;p class="not-found-card__eyebrow"&amp;gt;Guide&amp;lt;/p&amp;gt;
    &amp;lt;h3 class="not-found-card__title"&amp;gt;{guide.title}&amp;lt;/h3&amp;gt;
    &amp;lt;p class="not-found-card__body"&amp;gt;{guide.summary}&amp;lt;/p&amp;gt;
    &amp;lt;a href={guide.href} class="not-found-card__cta"&amp;gt;Open the guide&amp;lt;/a&amp;gt;
  &amp;lt;/article&amp;gt;
))}
{corePages.map((page) =&amp;gt; (
  &amp;lt;article class="not-found-card"&amp;gt;
    &amp;lt;p class="not-found-card__eyebrow"&amp;gt;Page&amp;lt;/p&amp;gt;
    &amp;lt;h3 class="not-found-card__title"&amp;gt;{page.label}&amp;lt;/h3&amp;gt;
    &amp;lt;p class="not-found-card__body"&amp;gt;{page.description}&amp;lt;/p&amp;gt;
    &amp;lt;a href={page.href} class="not-found-card__cta"&amp;gt;Open {page.label}&amp;lt;/a&amp;gt;
  &amp;lt;/article&amp;gt;
))}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reusing &lt;code&gt;audienceGuides&lt;/code&gt; instead of duplicating the guide titles and blurbs means the 404 page can't drift out of sync with the guides landing page: if a guide is renamed or its summary is rewritten, this grid picks it up automatically. The two card types are visually distinguished by a thin top border: guide cards use &lt;code&gt;--accent-secondary&lt;/code&gt;, core page cards use &lt;code&gt;--accent-primary&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Accessibility notes
&lt;/h2&gt;

&lt;p&gt;The number carries &lt;code&gt;aria-hidden="true"&lt;/code&gt;, since it's decoration and the &lt;code&gt;&amp;lt;h1&amp;gt;&lt;/code&gt; already says what happened, so a screen reader doesn't need to announce "404" on top of "Nothing here." Both sections use &lt;code&gt;aria-labelledby&lt;/code&gt; pointing at their own heading &lt;code&gt;id&lt;/code&gt; (&lt;code&gt;not-found-heading&lt;/code&gt;, &lt;code&gt;not-found-paths-heading&lt;/code&gt;) rather than a generic &lt;code&gt;aria-label&lt;/code&gt;, so the accessibility tree gets the same heading text a sighted reader sees, instead of a second, possibly diverging, piece of copy to maintain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Full code listing
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
import BaseLayout from "../layouts/BaseLayout.astro";
import { audienceGuides } from "../data/audienceGuides";

const corePages = [
  {
    href: "/blog",
    label: "Blog",
    description: "\"Start with the latest writing and browse by topic.\","
  },
  {
    href: "/about",
    label: "About",
    description: "\"Find out who I help and the kind of work I do.\","
  },
  {
    href: "/contact",
    label: "Contact",
    description: "\"Get in touch about mentoring, consulting, or a question.\","
  },
];
---

&amp;lt;BaseLayout
  pageTitle="Page not found — Sourcier"
  description="The page you're looking for doesn't exist."
&amp;gt;
  &amp;lt;div class="not-found flow-section flow-section--loose"&amp;gt;
    &amp;lt;div class="container is-max-desktop"&amp;gt;
      &amp;lt;section class="not-found__hero" aria-labelledby="not-found-heading"&amp;gt;
        &amp;lt;div class="page-panel not-found__panel"&amp;gt;
          &amp;lt;div class="not-found__number" aria-hidden="true"&amp;gt;404&amp;lt;/div&amp;gt;
          &amp;lt;div class="not-found__copy"&amp;gt;
            &amp;lt;p class="not-found__eyebrow"&amp;gt;Page not found&amp;lt;/p&amp;gt;
            &amp;lt;h1 class="not-found__heading" id="not-found-heading"&amp;gt;
              Nothing here.
            &amp;lt;/h1&amp;gt;
            &amp;lt;p class="not-found__sub"&amp;gt;
              That URL may have moved, been deleted, or never existed. Start
              from the homepage, jump into the latest writing, or pick the guide
              that matches where you are now.
            &amp;lt;/p&amp;gt;
            &amp;lt;div class="not-found__actions"&amp;gt;
              &amp;lt;a href="/" class="button is-primary is-medium"&amp;gt;Home&amp;lt;/a&amp;gt;
              &amp;lt;a href="/blog" class="button is-light is-medium"&amp;gt;
                Browse the blog
              &amp;lt;/a&amp;gt;
            &amp;lt;/div&amp;gt;
          &amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
      &amp;lt;/section&amp;gt;

      &amp;lt;section
        class="not-found__paths"
        aria-labelledby="not-found-paths-heading"
      &amp;gt;
        &amp;lt;p class="section-label"&amp;gt;Try these instead&amp;lt;/p&amp;gt;
        &amp;lt;h2 class="not-found__paths-heading" id="not-found-paths-heading"&amp;gt;
          Start from a guide or a core page
        &amp;lt;/h2&amp;gt;
        &amp;lt;div class="not-found__grid"&amp;gt;
          {
            audienceGuides.map((guide) =&amp;gt; (
              &amp;lt;article class="not-found-card not-found-card--guide"&amp;gt;
                &amp;lt;p class="not-found-card__eyebrow"&amp;gt;Guide&amp;lt;/p&amp;gt;
                &amp;lt;h3 class="not-found-card__title"&amp;gt;{guide.title}&amp;lt;/h3&amp;gt;
                &amp;lt;p class="not-found-card__body"&amp;gt;{guide.summary}&amp;lt;/p&amp;gt;
                &amp;lt;a href={guide.href} class="not-found-card__cta"&amp;gt;
                  Open the guide
                &amp;lt;/a&amp;gt;
              &amp;lt;/article&amp;gt;
            ))
          }
          {
            corePages.map((page) =&amp;gt; (
              &amp;lt;article class="not-found-card"&amp;gt;
                &amp;lt;p class="not-found-card__eyebrow"&amp;gt;Page&amp;lt;/p&amp;gt;
                &amp;lt;h3 class="not-found-card__title"&amp;gt;{page.label}&amp;lt;/h3&amp;gt;
                &amp;lt;p class="not-found-card__body"&amp;gt;{page.description}&amp;lt;/p&amp;gt;
                &amp;lt;a href={page.href} class="not-found-card__cta"&amp;gt;
                  Open {page.label}
                &amp;lt;/a&amp;gt;
              &amp;lt;/article&amp;gt;
            ))
          }
        &amp;lt;/div&amp;gt;
      &amp;lt;/section&amp;gt;
    &amp;lt;/div&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/BaseLayout&amp;gt;

&amp;lt;style lang="scss"&amp;gt;
  .not-found {
    padding: 0 1.5rem;
  }

  .not-found__panel {
    display: grid;
    gap: 1.5rem;
    align-items: center;
    margin-bottom: 3rem;
    background:
      radial-gradient(
        circle at top right,
        color-mix(in srgb, var(--accent-secondary) 14%, transparent) 0%,
        transparent 34%
      ),
      linear-gradient(
        150deg,
        color-mix(in srgb, var(--accent-primary) 4%, var(--surface-elevated)) 0%,
        var(--surface-elevated) 62%
      ),
      var(--surface-elevated);

    @media (min-width: 768px) {
      grid-template-columns: minmax(9rem, 0.6fr) minmax(0, 1.4fr);
      gap: 2rem;
    }
  }

  .not-found__number {
    font-family: "Barlow Condensed", sans-serif;
    font-size: clamp(6rem, 18vw, 12rem);
    font-weight: 900;
    line-height: 0.9;
    letter-spacing: -0.03em;
    color: transparent;
    -webkit-text-stroke: 2px var(--accent-primary);
    user-select: none;
    position: relative;

    &amp;amp;::after {
      content: "404";
      position: absolute;
      inset: 0;
      color: rgba(var(--accent-primary-rgb), 0.07);
      -webkit-text-stroke: 0;
      transform: translate(6px, 6px);
      z-index: -1;
    }
  }

  .not-found__eyebrow {
    margin: 0 0 0.65rem;
    font-family: "Barlow Condensed", sans-serif;
    font-size: 0.8rem;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.1em;
    color: var(--accent-secondary);
  }

  .not-found__heading {
    margin: 0;
    font-family: "Barlow Condensed", sans-serif;
    font-size: clamp(2rem, 5vw, 3.5rem);
    line-height: 0.95;
    text-transform: uppercase;
    color: var(--text-primary);
  }

  .not-found__sub {
    margin: 0.95rem 0 0;
    max-width: 58ch;
    line-height: 1.75;
    color: var(--text-muted);
  }

  .not-found__actions {
    display: flex;
    flex-wrap: wrap;
    gap: 0.75rem;
    margin-top: 1.4rem;
  }

  .not-found__paths-heading {
    margin: 0 0 1.5rem;
    font-family: "Barlow Condensed", sans-serif;
    font-size: clamp(1.8rem, 4vw, 2.5rem);
    line-height: 1.02;
    text-transform: uppercase;
    color: var(--text-primary);
  }

  .not-found__grid {
    display: grid;
    gap: 1rem;

    @media (min-width: 768px) {
      grid-template-columns: repeat(3, minmax(0, 1fr));
    }
  }

  .not-found-card {
    display: flex;
    flex-direction: column;
    gap: 0.85rem;
    min-height: 100%;
    padding: 1.4rem;
    border: 1px solid var(--border-subtle);
    border-top: 3px solid var(--accent-primary);
    border-radius: var(--radius-panel);
    background:
      linear-gradient(
        160deg,
        color-mix(in srgb, var(--accent-primary) 4%, var(--surface-elevated)) 0%,
        var(--surface-elevated) 62%
      ),
      var(--surface-elevated);
    box-shadow: var(--shadow-panel);
    transition:
      transform 0.18s ease,
      box-shadow 0.18s ease,
      border-color 0.18s ease;

    &amp;amp;:hover {
      transform: translateY(-3px);
      box-shadow: var(--shadow-lift);
    }
  }

  .not-found-card--guide {
    border-top-color: var(--accent-secondary);
    background:
      radial-gradient(
        circle at top right,
        color-mix(in srgb, var(--accent-secondary) 12%, transparent) 0%,
        transparent 34%
      ),
      linear-gradient(
        160deg,
        color-mix(in srgb, var(--accent-primary) 3%, var(--surface-elevated)) 0%,
        var(--surface-elevated) 62%
      ),
      var(--surface-elevated);
  }

  .not-found-card__eyebrow {
    margin: 0;
    font-family: "Barlow Condensed", sans-serif;
    font-size: 0.78rem;
    font-weight: 700;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: var(--text-muted);
  }

  .not-found-card--guide .not-found-card__eyebrow {
    color: var(--accent-secondary);
  }

  .not-found-card__title {
    margin: 0;
    font-family: "Barlow Condensed", sans-serif;
    font-size: 1.15rem;
    line-height: 1.05;
    text-transform: uppercase;
    color: var(--text-primary);
  }

  .not-found-card__body {
    margin: 0;
    line-height: 1.65;
    color: var(--text-muted);
  }

  .not-found-card__cta {
    margin-top: auto;
    display: inline-flex;
    align-items: center;
    padding-block: 0.75rem;
    font-family: "Barlow Condensed", sans-serif;
    font-size: 0.86rem;
    font-weight: 700;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--accent-primary);
    text-decoration: none;
  }

  .not-found-card--guide .not-found-card__cta {
    color: var(--accent-secondary);
  }
&amp;lt;/style&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What Netlify does with it
&lt;/h2&gt;

&lt;p&gt;Netlify serves &lt;code&gt;404.html&lt;/code&gt; for any unmatched route. No &lt;code&gt;netlify.toml&lt;/code&gt; redirect rule needed, no custom headers — it just works. Deploy it and broken links get your page instead of the browser default.&lt;/p&gt;

</description>
      <category>astro</category>
      <category>frontend</category>
      <category>meta</category>
    </item>
    <item>
      <title>Paginating a Blog in Astro</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Tue, 25 Aug 2026 12:09:49 +0000</pubDate>
      <link>https://dev.to/sourcier/paginating-a-blog-in-astro-3cdl</link>
      <guid>https://dev.to/sourcier/paginating-a-blog-in-astro-3cdl</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/pagination-in-astro" rel="noopener noreferrer"&gt;Paginating a Blog in Astro&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Series: Part of &lt;a href="https://sourcier.uk/blog/how-this-blog-was-built" rel="noopener noreferrer"&gt;How this blog was built&lt;/a&gt; — documenting every decision that shaped this site.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Most blog tutorials reach for &lt;code&gt;paginate()&lt;/code&gt; — Astro's built-in helper — and call it done. It works, but the URLs it produces (&lt;code&gt;/blog/2&lt;/code&gt;, &lt;code&gt;/blog/3&lt;/code&gt;) aren't great for SEO. Search engines prefer a clear signal that these are &lt;em&gt;pages of a list&lt;/em&gt;, not individual resources. &lt;code&gt;/blog/page/2&lt;/code&gt; communicates that explicitly.&lt;/p&gt;

&lt;p&gt;This post walks through how I built pagination on this site: clean URLs, no client-side JavaScript, placeholder cards, and a reusable &lt;code&gt;BlogGrid&lt;/code&gt; component.&lt;/p&gt;

&lt;h2&gt;
  
  
  The URL structure
&lt;/h2&gt;

&lt;p&gt;The target structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/blog           → page 1 (canonical)
/blog/page/2    → page 2
/blog/page/3    → page 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Page 1 lives at &lt;code&gt;/blog&lt;/code&gt; with no page number — it's the canonical listing URL and the one that gets linked to from everywhere. Pages 2 and beyond use &lt;code&gt;/blog/page/N&lt;/code&gt; to signal clearly that they are paginated continuations.&lt;/p&gt;

&lt;p&gt;I deliberately avoided &lt;code&gt;/blog/1&lt;/code&gt; for page 1. A URL like &lt;code&gt;/blog/1&lt;/code&gt; implies there's a post with the ID &lt;code&gt;1&lt;/code&gt;, or that the canonical URL is somehow different from &lt;code&gt;/blog&lt;/code&gt;. Neither is true.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not &lt;code&gt;paginate()&lt;/code&gt;?
&lt;/h2&gt;

&lt;p&gt;Astro's &lt;code&gt;paginate()&lt;/code&gt; is great for getting started. You export &lt;code&gt;getStaticPaths&lt;/code&gt; from a &lt;code&gt;[...page].astro&lt;/code&gt; file, pass your content collection and a page size, and Astro generates all the pages. The catch is the URL pattern it produces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/blog           → page 1
/blog/2         → page 2
/blog/3         → page 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That flat structure is fine functionally, but &lt;code&gt;/blog/page/2&lt;/code&gt; is more semantically correct and is the pattern recommended by Google for paginated content. To get it with Astro you need to take back control of routing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two route files instead of one
&lt;/h2&gt;

&lt;p&gt;Rather than fighting &lt;code&gt;paginate()&lt;/code&gt;, I use two separate route files:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;src/pages/blog/index.astro&lt;/code&gt;&lt;/strong&gt; handles page 1. It's a static page — no &lt;code&gt;getStaticPaths&lt;/code&gt; needed — that fetches all posts, slices the first page, and renders the grid.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;src/pages/blog/page/[page].astro&lt;/code&gt;&lt;/strong&gt; handles pages 2 and above. It uses &lt;code&gt;getStaticPaths&lt;/code&gt; to generate one static page per remaining page number.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/pages/blog/page/[page].astro&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getStaticPaths&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PAGE_SIZE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;allPosts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getCollection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;posts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isPublished&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pubDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;valueOf&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pubDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;valueOf&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;totalPages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;allPosts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;PAGE_SIZE&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;totalPages&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;length: totalPages - 1&lt;/code&gt; is key — page 1 is handled by &lt;code&gt;index.astro&lt;/code&gt;, so this file only generates pages 2 through N. Attempting to generate page 1 here would create a conflicting route at &lt;code&gt;/blog/page/1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;isPublished&lt;/code&gt; is the same helper from the &lt;a href="https://dev.to/blog/scheduled-publishing-astro"&gt;scheduled publishing post&lt;/a&gt; — it filters out drafts and future-dated posts outside of dev mode, so archive pages never leak unpublished content.&lt;/p&gt;

&lt;p&gt;Notice that &lt;code&gt;getStaticPaths&lt;/code&gt; only returns &lt;code&gt;params&lt;/code&gt;, not &lt;code&gt;props&lt;/code&gt;. The actual page data — the slice of posts, &lt;code&gt;currentPage&lt;/code&gt;, &lt;code&gt;prevUrl&lt;/code&gt;, &lt;code&gt;nextUrl&lt;/code&gt; — is recomputed in the component script below, using &lt;code&gt;Astro.params.page&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PAGE_SIZE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;currentPage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Astro&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;allPosts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getCollection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;posts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isPublished&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pubDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;valueOf&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pubDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;valueOf&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;totalPages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;allPosts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;PAGE_SIZE&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;allPosts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentPage&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;PAGE_SIZE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;currentPage&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;PAGE_SIZE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prevUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;currentPage&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/blog&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`/blog/page/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;currentPage&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nextUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;currentPage&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;totalPages&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`/blog/page/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;currentPage&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That means the same fetch-sort-slice logic is duplicated three times across &lt;code&gt;index.astro&lt;/code&gt;, &lt;code&gt;getStaticPaths&lt;/code&gt;, and the component script in &lt;code&gt;[page].astro&lt;/code&gt;. It's a small DRY violation, but &lt;code&gt;getStaticPaths&lt;/code&gt; runs in an isolated build-time context before the rest of the file executes, so there's no way to share the computed &lt;code&gt;allPosts&lt;/code&gt; array between it and the component body without a separate module-level helper. For three call sites doing one cheap array operation, the duplication was the simpler trade-off.&lt;/p&gt;

&lt;h2&gt;
  
  
  A shared BlogGrid component
&lt;/h2&gt;

&lt;p&gt;Both route files render the same grid. Rather than duplicating the markup, I extracted it into &lt;code&gt;src/components/BlogGrid.astro&lt;/code&gt;, which accepts these props:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Props&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CollectionEntry&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;posts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;currentPage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;totalPages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;prevUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;nextUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;paginationBase&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;sectionLabel&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;sectionTitle&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;sectionDescription&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It renders the post grid, placeholder cards, and pagination controls. Both &lt;code&gt;index.astro&lt;/code&gt; and &lt;code&gt;[page].astro&lt;/code&gt; import it and pass their data through, along with a section label and heading so the same component reads correctly whether it's showing "Recent articles" on page 1 or "Posts on page 2" further into the archive. &lt;code&gt;paginationBase&lt;/code&gt; is what lets the &lt;a href="https://dev.to/blog/tag-system-astro"&gt;tag system&lt;/a&gt; reuse this exact component for paginated tag pages — it swaps &lt;code&gt;/blog/page/N&lt;/code&gt; for &lt;code&gt;/tags/&amp;lt;tag&amp;gt;/N&lt;/code&gt; without any other change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Placeholder cards for a half-full grid
&lt;/h2&gt;

&lt;p&gt;While the post count is low, a 9-post grid with only 4 real posts on the last page would look half-empty. Placeholder cards fill the remaining slots instead of leaving obvious gaps.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxorv8p5ktyv05lz78wkf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxorv8p5ktyv05lz78wkf.png" alt="Blog grid wireframe showing two full rows of cards and a third row with two real cards and one placeholder card, plus pagination controls below" width="700" height="540"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Diagram fallback for Dev.to. View the canonical article for the original SVG: &lt;a href="https://sourcier.uk/blog/pagination-in-astro" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/pagination-in-astro&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Click the expand icon to view it fullscreen.&lt;/p&gt;

&lt;p&gt;The grid is responsive — two columns on tablet, three on desktop — so a placeholder count that's correct for one breakpoint can be wrong for the other. &lt;code&gt;BlogGrid.astro&lt;/code&gt; computes both independently, then works out how many placeholders are needed at both breakpoints, only one, or neither:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;showGhostCards&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;currentPage&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;totalPages&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TABLET_COLS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DESKTOP_COLS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tabletGhostCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;showGhostCards&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;TABLET_COLS&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;TABLET_COLS&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;TABLET_COLS&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;desktopGhostCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;showGhostCards&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;DESKTOP_COLS&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;DESKTOP_COLS&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;DESKTOP_COLS&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sharedGhostCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tabletGhostCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;desktopGhostCount&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tabletOnlyGhostCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tabletGhostCount&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;sharedGhostCount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;desktopOnlyGhostCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;desktopGhostCount&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;sharedGhostCount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only the last page ever gets placeholders — every other page is a full row at both breakpoints. The three resulting counts each render into a differently-classed cell (&lt;code&gt;--tablet-up&lt;/code&gt;, &lt;code&gt;--tablet-only&lt;/code&gt;, &lt;code&gt;--desktop-only&lt;/code&gt;) so CSS media queries can show or hide the right ones without any JavaScript recalculating the layout on resize.&lt;/p&gt;

&lt;p&gt;I deliberately kept &lt;code&gt;BlogCardPlaceholder.astro&lt;/code&gt; static rather than reaching for an animated loading skeleton. These cards aren't hiding content that's still loading — the grid is fully rendered at build time, there's nothing to wait for. A shimmering skeleton would be actively misleading here, so the placeholder is just a dashed-border card with a quiet "More posts coming soon" label:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;div class="card card__blog card__blog--placeholder" aria-hidden="true"&amp;gt;
  &amp;lt;div class="card__blog--placeholder-inner"&amp;gt;
    &amp;lt;span class="card__blog--placeholder-label"&amp;gt;More posts coming soon&amp;lt;/span&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;aria-hidden="true"&lt;/code&gt; and &lt;code&gt;pointer-events: none&lt;/code&gt; keep it invisible to screen readers and non-interactive, since there's nothing behind it to click or announce.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pagination controls
&lt;/h2&gt;

&lt;p&gt;The navigation sits below the grid and only renders when there's more than one page. Previous and next links use the &lt;code&gt;prevUrl&lt;/code&gt;/&lt;code&gt;nextUrl&lt;/code&gt; props passed in from the route files. Page number buttons are generated from &lt;code&gt;totalPages&lt;/code&gt;, routed through a small &lt;code&gt;pageUrl()&lt;/code&gt; helper so the same markup works for both &lt;code&gt;/blog/page/N&lt;/code&gt; and a tag page's &lt;code&gt;paginationBase&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;pageUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;paginationBase&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;paginationBase&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;paginationBase&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/blog&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`/blog/page/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{Array.from({ length: totalPages }).map((_, i) =&amp;gt; {
  const n = i + 1;
  return (
    &amp;lt;a href={pageUrl(n)} aria-current={n === currentPage ? "page" : undefined}&amp;gt;
      {n}
    &amp;lt;/a&amp;gt;
  );
})}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;n === 1&lt;/code&gt; branch ensures the first page button always links to &lt;code&gt;/blog&lt;/code&gt; (or the tag's base URL), never &lt;code&gt;/blog/page/1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Disabled states on Previous/Next use &lt;code&gt;aria-disabled&lt;/code&gt; and &lt;code&gt;pointer-events: none&lt;/code&gt; rather than swapping &lt;code&gt;&amp;lt;a&amp;gt;&lt;/code&gt; for &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt;, which keeps the DOM structure consistent across all pages.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this produces
&lt;/h2&gt;

&lt;p&gt;At build time, Astro generates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dist/blog/index.html        ← page 1
dist/blog/page/2/index.html ← page 2 (when posts &amp;gt; 9)
dist/blog/page/3/index.html ← page 3 (when posts &amp;gt; 18)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything is static HTML. No client-side JavaScript, no API calls, no hydration — the pagination just works as links between pre-rendered pages.&lt;/p&gt;

&lt;p&gt;As more posts are published, the grid fills naturally. Once 10 posts exist, page 2 appears automatically at the next build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Full code listing
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
import { getCollection } from "astro:content";
import { isPublished } from "../../../utils/drafts";
import BaseLayout from "../../../layouts/BaseLayout.astro";
import PageHero from "../../../components/PageHero.astro";
import MailingListCTA from "../../../components/MailingListCTA.astro";
import BlogGrid from "../../../components/BlogGrid.astro";
import BlogTagCloud from "../../../components/BlogTagCloud.astro";

export async function getStaticPaths() {
  const PAGE_SIZE = 9;
  const allPosts = (await getCollection("posts"))
    .filter(isPublished)
    .sort((a, b) =&amp;gt; b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
  const totalPages = Math.ceil(allPosts.length / PAGE_SIZE);
  return Array.from({ length: totalPages - 1 }, (_, i) =&amp;gt; ({
    params: { page: String(i + 2) },
  }));
}

const PAGE_SIZE = 9;
const currentPage = Number(Astro.params.page);

const allPosts = (await getCollection("posts"))
  .filter(isPublished)
  .sort((a, b) =&amp;gt; b.data.pubDate.valueOf() - a.data.pubDate.valueOf());

const totalPages = Math.ceil(allPosts.length / PAGE_SIZE);
const posts = allPosts.slice(
  (currentPage - 1) * PAGE_SIZE,
  currentPage * PAGE_SIZE,
);
const prevUrl = currentPage === 2 ? "/blog" : `/blog/page/${currentPage - 1}`;
const nextUrl =
  currentPage &amp;lt; totalPages ? `/blog/page/${currentPage + 1}` : null;
---

&amp;lt;BaseLayout pageTitle={`Blog — Page ${currentPage} — Sourcier`}&amp;gt;
  &amp;lt;PageHero kicker="Writing" title="Blog" subtitle="..." /&amp;gt;

  &amp;lt;BlogGrid
    posts={posts}
    currentPage={currentPage}
    totalPages={totalPages}
    prevUrl={prevUrl}
    nextUrl={nextUrl}
    sectionLabel="Archive"
    sectionTitle={`Posts on page ${currentPage}`}
    sectionDescription={`Page ${currentPage} of ${totalPages} from the blog archive.`}
  /&amp;gt;

  &amp;lt;BlogTagCloud /&amp;gt;
  &amp;lt;MailingListCTA /&amp;gt;
&amp;lt;/BaseLayout&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
import type { CollectionEntry } from "astro:content";
import BlogPost from "./BlogPost.astro";
import BlogCardPlaceholder from "./BlogCardPlaceholder.astro";
import readingTime from "reading-time";

interface Props {
  posts: CollectionEntry&amp;lt;"posts"&amp;gt;[];
  currentPage: number;
  totalPages: number;
  prevUrl: string | null;
  nextUrl: string | null;
  paginationBase?: string;
  sectionLabel?: string;
  sectionTitle?: string;
  sectionDescription?: string;
}

const { posts, currentPage, totalPages, prevUrl, nextUrl, paginationBase } =
  Astro.props;

const showGhostCards = currentPage === totalPages;
const TABLET_COLS = 2;
const DESKTOP_COLS = 3;
const tabletGhostCount = showGhostCards
  ? (TABLET_COLS - (posts.length % TABLET_COLS)) % TABLET_COLS
  : 0;
const desktopGhostCount = showGhostCards
  ? (DESKTOP_COLS - (posts.length % DESKTOP_COLS)) % DESKTOP_COLS
  : 0;
const sharedGhostCount = Math.min(tabletGhostCount, desktopGhostCount);
const tabletOnlyGhostCount = tabletGhostCount - sharedGhostCount;
const desktopOnlyGhostCount = desktopGhostCount - sharedGhostCount;

function pageUrl(n: number) {
  if (paginationBase) {
    return n === 1 ? paginationBase : `${paginationBase}/${n}`;
  }
  return n === 1 ? "/blog" : `/blog/page/${n}`;
}
---

&amp;lt;section class="section blog-grid-section flow-section"&amp;gt;
  &amp;lt;div class="container is-max-desktop"&amp;gt;
    {posts.map((post) =&amp;gt; (
      &amp;lt;div class="blog-grid__cell"&amp;gt;
        &amp;lt;BlogPost
          title={post.data.title}
          url={`/blog/${post.id}`}
          cover={post.data.cover}
          pubDate={post.data.pubDate}
          draft={post.data.draft}
          readingTime={readingTime(post.body ?? "").text}
        /&amp;gt;
      &amp;lt;/div&amp;gt;
    ))}
    {Array.from({ length: sharedGhostCount }).map(() =&amp;gt; (
      &amp;lt;div class="blog-grid__cell blog-grid__ghost-cell blog-grid__ghost-cell--tablet-up"&amp;gt;
        &amp;lt;BlogCardPlaceholder /&amp;gt;
      &amp;lt;/div&amp;gt;
    ))}
    {Array.from({ length: tabletOnlyGhostCount }).map(() =&amp;gt; (
      &amp;lt;div class="blog-grid__cell blog-grid__ghost-cell blog-grid__ghost-cell--tablet-only"&amp;gt;
        &amp;lt;BlogCardPlaceholder /&amp;gt;
      &amp;lt;/div&amp;gt;
    ))}
    {Array.from({ length: desktopOnlyGhostCount }).map(() =&amp;gt; (
      &amp;lt;div class="blog-grid__cell blog-grid__ghost-cell blog-grid__ghost-cell--desktop-only"&amp;gt;
        &amp;lt;BlogCardPlaceholder /&amp;gt;
      &amp;lt;/div&amp;gt;
    ))}

    {totalPages &amp;gt; 1 &amp;amp;&amp;amp; (
      &amp;lt;nav class="pagination-nav" aria-label="Blog pagination"&amp;gt;
        &amp;lt;a
          href={prevUrl}
          aria-disabled={!prevUrl}
          tabindex={!prevUrl ? -1 : 0}
        &amp;gt;
          &amp;amp;larr; Previous
        &amp;lt;/a&amp;gt;
        &amp;lt;div class="pagination-nav__pages" role="list"&amp;gt;
          {Array.from({ length: totalPages }).map((_, i) =&amp;gt; {
            const n = i + 1;
            return (
              &amp;lt;a
                href={pageUrl(n)}
                role="listitem"
                aria-current={n === currentPage ? "page" : undefined}
              &amp;gt;
                {n}
              &amp;lt;/a&amp;gt;
            );
          })}
        &amp;lt;/div&amp;gt;
        &amp;lt;a href={nextUrl} aria-disabled={!nextUrl} tabindex={!nextUrl ? -1 : 0}&amp;gt;
          Next &amp;amp;rarr;
        &amp;lt;/a&amp;gt;
      &amp;lt;/nav&amp;gt;
    )}
  &amp;lt;/div&amp;gt;
&amp;lt;/section&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>astro</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Improving code blocks in Astro</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Tue, 09 Jun 2026 10:00:10 +0000</pubDate>
      <link>https://dev.to/sourcier/improving-code-blocks-in-astro-65m</link>
      <guid>https://dev.to/sourcier/improving-code-blocks-in-astro-65m</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/improving-code-blocks-astro" rel="noopener noreferrer"&gt;Improving code blocks in Astro&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Series: Part of &lt;a href="https://sourcier.uk/blog/how-this-blog-was-built" rel="noopener noreferrer"&gt;How this blog was built&lt;/a&gt; — documenting every decision that shaped this site.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Astro ships with built-in syntax highlighting through Shiki, and for the most part it&lt;br&gt;
does the job. But out of the box you get highlighted code and not much else: no copy&lt;br&gt;
button, no language badge, no way to mark specific lines or highlight a changed word,&lt;br&gt;
no framing to distinguish a terminal command from a config file. For a blog that is&lt;br&gt;
primarily about code, those gaps show up constantly. I wanted blocks that added&lt;br&gt;
context at a glance without requiring custom CSS for every new feature.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://expressive-code.com/" rel="noopener noreferrer"&gt;Expressive Code&lt;/a&gt; is an Astro integration that replaces&lt;br&gt;
the default code fence renderer with polished, accessible components — syntax&lt;br&gt;
highlighting, dual themes, a copy button, language labels, editor and terminal frames,&lt;br&gt;
and line/text markers, all driven by code fence attributes. No custom CSS or&lt;br&gt;
JavaScript required.&lt;/p&gt;

&lt;p&gt;The alternative is building it yourself: a custom rehype plugin to transform code&lt;br&gt;
nodes, hand-rolled CSS for every theme variant, client-side JavaScript for the copy&lt;br&gt;
button, and your own logic for diff markers and line highlighting. I looked at that&lt;br&gt;
route and decided the maintenance surface was not worth it. Expressive Code solves the&lt;br&gt;
whole problem in a single integration, the feature set is well ahead of anything I&lt;br&gt;
would build in a reasonable time, and the API maps cleanly to what you already write&lt;br&gt;
in a code fence.&lt;/p&gt;
&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;p&gt;Install the integration and the optional line numbers plugin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm add astro-expressive-code @expressive-code/plugin-line-numbers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This site uses a manual &lt;code&gt;data-theme&lt;/code&gt; toggle rather than &lt;code&gt;prefers-color-scheme&lt;/code&gt;, so&lt;br&gt;
&lt;code&gt;useDarkModeMediaQuery&lt;/code&gt; is disabled and &lt;code&gt;themeCssSelector&lt;/code&gt; maps theme variants to&lt;br&gt;
that attribute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astro/config&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;expressiveCode&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astro-expressive-code&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pluginLineNumbers&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@expressive-code/plugin-line-numbers&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;integrations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nf"&gt;expressiveCode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;themes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;one-light&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;one-dark-pro&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;pluginLineNumbers&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;
      &lt;span class="na"&gt;defaultProps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;showLineNumbers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;wrap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;overridesByLang&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bash,sh,zsh&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;preserveIndent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;styleOverrides&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;codePaddingInline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1.5rem&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;useDarkModeMediaQuery&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;themeCssSelector&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="nx"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dark&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
          &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[data-theme="dark"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
          &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;:root:not([data-theme="dark"])&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;markdown&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;syntaxHighlight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;syntaxHighlight: false&lt;/code&gt; hands all code fence processing over to Expressive Code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Themes
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;themes&lt;/code&gt; takes an array of Shiki theme names — first is the light variant, second is&lt;br&gt;
dark. Expressive Code emits scoped CSS variables for both and activates each via the&lt;br&gt;
selector returned by &lt;code&gt;themeCssSelector&lt;/code&gt;. Any pair from&lt;br&gt;
&lt;a href="https://shiki.style/themes" rel="noopener noreferrer"&gt;the Shiki catalogue&lt;/a&gt; works.&lt;/p&gt;
&lt;h2&gt;
  
  
  Frames
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy132ykbxuqdthnf5yryl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy132ykbxuqdthnf5yryl.png" alt="Code block variants wireframe showing plain code, editor frame with file tab, terminal frame with traffic lights, and line highlights with diff markers" width="700" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Diagram fallback for Dev.to. View the canonical article for the original SVG: &lt;a href="https://sourcier.uk/blog/improving-code-blocks-astro" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/improving-code-blocks-astro&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every code block is wrapped in a frame. The frame type — &lt;strong&gt;editor&lt;/strong&gt; or &lt;strong&gt;terminal&lt;/strong&gt; —&lt;br&gt;
is detected automatically from the language identifier, but can be overridden.&lt;/p&gt;
&lt;h3&gt;
  
  
  Editor frames
&lt;/h3&gt;

&lt;p&gt;There are two ways to set the tab title — a &lt;code&gt;title&lt;/code&gt; attribute on the fence, or a&lt;br&gt;
file name comment in the first four lines of the code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
js title="src/utils/format.js"&lt;br&gt;
export function formatDate(date) {&lt;br&gt;
  return new Intl.DateTimeFormat('en-GB').format(date);&lt;br&gt;
}&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
js&lt;br&gt;
// src/utils/format.js&lt;br&gt;
export function formatDate(date) {&lt;br&gt;
  return new Intl.DateTimeFormat('en-GB').format(date);&lt;br&gt;
}&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;title&lt;/code&gt; attribute — the tab label is set directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;formatDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DateTimeFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en-GB&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;File name comment — extracted as the tab title and removed from the rendered output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/utils/format.js&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;formatDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DateTimeFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en-GB&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Terminal frames
&lt;/h3&gt;

&lt;p&gt;Shell languages (&lt;code&gt;bash&lt;/code&gt;, &lt;code&gt;sh&lt;/code&gt;, &lt;code&gt;zsh&lt;/code&gt;, &lt;code&gt;ps1&lt;/code&gt;, etc.) are automatically rendered as&lt;br&gt;
terminal frames. A title is optional:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm build
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"No title — still a terminal frame"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Overriding frame type
&lt;/h3&gt;

&lt;p&gt;Force a specific type with the &lt;code&gt;frame&lt;/code&gt; attribute. Useful when a shell script should&lt;br&gt;
look like an editor tab, or when you want to strip all chrome from a block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
ps frame="code" title="PowerShell Profile.ps1"&lt;br&gt;
function Watch-Tail { Get-Content -Tail 20 -Wait $args }&lt;br&gt;
New-Alias tail Watch-Tail&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight postscript"&gt;&lt;code&gt;&lt;span class="nf"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Watch-Tail&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;Get-Content&lt;/span&gt; &lt;span class="nf"&gt;-Tail&lt;/span&gt; &lt;span class="mf"&gt;20&lt;/span&gt; &lt;span class="nf"&gt;-Wait&lt;/span&gt; &lt;span class="nf"&gt;$args&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;New-Alias&lt;/span&gt; &lt;span class="nf"&gt;tail&lt;/span&gt; &lt;span class="nf"&gt;Watch-Tail&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
sh frame="none"&lt;br&gt;
echo "No frame at all"&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"No frame at all"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Line numbers
&lt;/h2&gt;

&lt;p&gt;Enabled globally via &lt;code&gt;defaultProps: { showLineNumbers: true }&lt;/code&gt;. Both props can be&lt;br&gt;
overridden per block — turn them off entirely, or start the counter at an arbitrary&lt;br&gt;
number when showing a file excerpt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
js showLineNumbers=false&lt;br&gt;
export function formatDate(date) {&lt;br&gt;
  return new Intl.DateTimeFormat('en-GB').format(date);&lt;br&gt;
}&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
js startLineNumber=42&lt;br&gt;
export function formatDate(date) {&lt;br&gt;
  return new Intl.DateTimeFormat('en-GB').format(date);&lt;br&gt;
}&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;showLineNumbers=false&lt;/code&gt; — line numbers hidden:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;formatDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DateTimeFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en-GB&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;startLineNumber=42&lt;/code&gt; — counter starts at 42, useful for excerpts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;formatDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DateTimeFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en-GB&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Word wrap
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;wrap: true&lt;/code&gt; enables soft wrapping globally. Long lines fold visually to the next&lt;br&gt;
line rather than causing a horizontal scrollbar. &lt;code&gt;preserveIndent&lt;/code&gt; (default: &lt;code&gt;true&lt;/code&gt;)&lt;br&gt;
keeps wrapped lines aligned with their original indentation — useful for code.&lt;br&gt;
Setting it to &lt;code&gt;false&lt;/code&gt; makes wrapped lines start at column 1, which suits terminal&lt;br&gt;
output, so the config uses &lt;code&gt;overridesByLang&lt;/code&gt; to apply that for &lt;code&gt;bash,sh,zsh&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Both can be overridden per block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
js wrap=true&lt;br&gt;
const result = await fetch('&lt;a href="https://api.example.com/v1/users?filter=active&amp;amp;sort=createdAt&amp;amp;order=desc&amp;amp;limit=100&amp;amp;page=2'" rel="noopener noreferrer"&gt;https://api.example.com/v1/users?filter=active&amp;amp;sort=createdAt&amp;amp;order=desc&amp;amp;limit=100&amp;amp;page=2'&lt;/a&gt;);&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
js wrap=false&lt;br&gt;
const result = await fetch('&lt;a href="https://api.example.com/v1/users?filter=active&amp;amp;sort=createdAt&amp;amp;order=desc&amp;amp;limit=100&amp;amp;page=2'" rel="noopener noreferrer"&gt;https://api.example.com/v1/users?filter=active&amp;amp;sort=createdAt&amp;amp;order=desc&amp;amp;limit=100&amp;amp;page=2'&lt;/a&gt;);&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;wrap=true&lt;/code&gt; — long line folds to the next line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.example.com/v1/users?filter=active&amp;amp;sort=createdAt&amp;amp;order=desc&amp;amp;limit=100&amp;amp;page=2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;wrap=false&lt;/code&gt; — long line causes a horizontal scrollbar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.example.com/v1/users?filter=active&amp;amp;sort=createdAt&amp;amp;order=desc&amp;amp;limit=100&amp;amp;page=2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Line markers
&lt;/h2&gt;

&lt;p&gt;Draw attention to specific lines or ranges using &lt;code&gt;mark&lt;/code&gt;, &lt;code&gt;ins&lt;/code&gt;, and &lt;code&gt;del&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;mark={N}&lt;/code&gt; — neutral highlight&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ins={N}&lt;/code&gt; — green "added" highlight with a &lt;code&gt;+&lt;/code&gt; indicator&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;del={N}&lt;/code&gt; — red "removed" highlight with a &lt;code&gt;-&lt;/code&gt; indicator
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
js mark={1} ins={3-5} del={7}&lt;br&gt;
import { defineConfig } from 'astro/config';&lt;/p&gt;

&lt;p&gt;import expressiveCode from 'astro-expressive-code';&lt;br&gt;
import { pluginLineNumbers } from '@expressive-code/plugin-line-numbers';&lt;br&gt;
import emoji from 'remark-emoji';&lt;/p&gt;

&lt;p&gt;import { oldPlugin } from './old-plugin';&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astro/config&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;expressiveCode&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astro-expressive-code&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pluginLineNumbers&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@expressive-code/plugin-line-numbers&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;emoji&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;remark-emoji&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;oldPlugin&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./old-plugin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Combine multiple ranges in one attribute: &lt;code&gt;ins={1-2, 5, 8-10}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Labels can be added to any marked range — wrap the value in &lt;code&gt;{"label:": range}&lt;/code&gt;&lt;br&gt;
and a coloured badge appears at the start of the highlighted block. The label&lt;br&gt;
string must end with a colon:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
js ins={"1":3-5} del={"2":7}&lt;br&gt;
import { defineConfig } from 'astro/config';&lt;/p&gt;

&lt;p&gt;import expressiveCode from 'astro-expressive-code';&lt;br&gt;
import { pluginLineNumbers } from '@expressive-code/plugin-line-numbers';&lt;br&gt;
import emoji from 'remark-emoji';&lt;/p&gt;

&lt;p&gt;import { oldPlugin } from './old-plugin';&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astro/config&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;expressiveCode&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astro-expressive-code&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pluginLineNumbers&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@expressive-code/plugin-line-numbers&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;emoji&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;remark-emoji&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;oldPlugin&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./old-plugin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Using diff syntax
&lt;/h3&gt;

&lt;p&gt;Set the language to &lt;code&gt;diff&lt;/code&gt; and prefix lines with &lt;code&gt;+&lt;/code&gt; or &lt;code&gt;-&lt;/code&gt;. Add &lt;code&gt;lang="..."&lt;/code&gt; to&lt;br&gt;
keep syntax highlighting for the actual language:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
diff lang="js"&lt;br&gt;
  export default defineConfig({&lt;br&gt;
    integrations: [&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;    shikiConfig({ themes: { light: 'one-light', dark: 'one-dark-pro' } }),&lt;/li&gt;
&lt;li&gt;    expressiveCode({ themes: ['one-light', 'one-dark-pro'] }),
],
});
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;  export default defineConfig({
    integrations: [
&lt;span class="gd"&gt;-     shikiConfig({ themes: { light: 'one-light', dark: 'one-dark-pro' } }),
&lt;/span&gt;&lt;span class="gi"&gt;+     expressiveCode({ themes: ['one-light', 'one-dark-pro'] }),
&lt;/span&gt;    ],
  });
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  Text markers
&lt;/h2&gt;

&lt;p&gt;Mark arbitrary text within lines using the same &lt;code&gt;mark&lt;/code&gt;, &lt;code&gt;ins&lt;/code&gt;, or &lt;code&gt;del&lt;/code&gt; attributes&lt;br&gt;
with a quoted string value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
js ins="expressiveCode" del="shikiConfig" mark="themes"&lt;br&gt;
import expressiveCode from 'astro-expressive-code';&lt;/p&gt;

&lt;p&gt;export default defineConfig({&lt;br&gt;
  integrations: [expressiveCode({ themes: ['one-light', 'one-dark-pro'] })],&lt;br&gt;
  markdown: { shikiConfig: { themes: { light: 'one-light' } } },&lt;br&gt;
});&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;expressiveCode&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astro-expressive-code&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;integrations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;expressiveCode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;themes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;one-light&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;one-dark-pro&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;})],&lt;/span&gt;
  &lt;span class="na"&gt;markdown&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;shikiConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;themes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;light&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;one-light&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use a &lt;code&gt;/regex/&lt;/code&gt; for pattern-based matching, or repeat the attribute for multiple&lt;br&gt;
values: &lt;code&gt;ins="foo" ins="bar"&lt;/code&gt;. Capture groups narrow the match to a sub-expression:&lt;br&gt;
&lt;code&gt;/import (expressiveCode)/&lt;/code&gt; marks only the identifier, not the whole import statement.&lt;/p&gt;

&lt;h2&gt;
  
  
  The full picture
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;astro-expressive-code&lt;/code&gt; in place, a single config block handles syntax&lt;br&gt;
highlighting, dual themes, line numbers, word wrap, copy buttons, and language labels.&lt;br&gt;
Editor and terminal frames add context without extra markup. Line and text markers let&lt;br&gt;
you direct the reader's attention precisely — all driven by code fence attributes that&lt;br&gt;
read naturally in the source.&lt;/p&gt;

&lt;p&gt;If you are setting this up on your own Astro site, or have a different approach to&lt;br&gt;
code block styling, &lt;a href="https://dev.to/contact"&gt;I'd like to hear about it&lt;/a&gt;. The rest of the series&lt;br&gt;
covers the table of contents, pagination, search, and more — sign up to the mailing.&lt;br&gt;
list below to get each post the morning it drops.&lt;/p&gt;

</description>
      <category>astro</category>
      <category>engineering</category>
      <category>frontend</category>
    </item>
    <item>
      <title>Running Local AI Models on macOS</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Thu, 04 Jun 2026 10:34:15 +0000</pubDate>
      <link>https://dev.to/sourcier/running-local-ai-models-on-macos-41bk</link>
      <guid>https://dev.to/sourcier/running-local-ai-models-on-macos-41bk</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/local-ai-ollama-setup" rel="noopener noreferrer"&gt;Running Local AI Models on macOS&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I use GitHub Copilot at work and Claude for personal projects. Both switched to usage-based billing this month, dropping the flat subscription model. For anyone using these tools heavily across multiple projects, that shift makes the monthly cost unpredictable. Running models locally removes that variable entirely: no usage bills, no rate limits, and everything stays on your machine.&lt;/p&gt;

&lt;p&gt;The quality gap has closed enough that local models are a realistic daily driver now, not just an experiment.&lt;/p&gt;

&lt;p&gt;This guide covers the first-time setup on a Mac with Apple Silicon. I run this on an M1 MacBook Pro with 16 GB of unified memory. The default settings are tuned for that hardware, but each relevant section also covers what to change if you have more RAM.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it fits together
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgICBBW1ZTIENvZGUgQ29waWxvdF0gLS0-fEhUVFAgQVBJfCBCW09sbGFtYSBzZXJ2ZXJdCiAgICBCIC0tPnxsb2FkcyBtb2RlbHwgQ1tVbmlmaWVkIG1lbW9yeV0KICAgIEMgLS0-fHJlYWRzIGZyb218IERbTW9kZWwgZmlsZXMgb24gZGlza10" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgICBBW1ZTIENvZGUgQ29waWxvdF0gLS0-fEhUVFAgQVBJfCBCW09sbGFtYSBzZXJ2ZXJdCiAgICBCIC0tPnxsb2FkcyBtb2RlbHwgQ1tVbmlmaWVkIG1lbW9yeV0KICAgIEMgLS0-fHJlYWRzIGZyb218IERbTW9kZWwgZmlsZXMgb24gZGlza10" alt="Mermaid diagram" width="208" height="454"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Diagram fallback for Dev.to. View the canonical article for the full version: &lt;a href="https://sourcier.uk/blog/local-ai-ollama-setup" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/local-ai-ollama-setup&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;macOS with Apple Silicon (M series)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://brew.sh" rel="noopener noreferrer"&gt;Homebrew&lt;/a&gt; installed&lt;/li&gt;
&lt;li&gt;A few GB of free disk space per model (most 7–8B models need 4–5 GB each)&lt;/li&gt;
&lt;li&gt;VS Code, for the integration sections at the end; a GitHub Copilot subscription is needed to use cloud models, but the local Ollama integration works without one&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Install Ollama
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt; is the runtime that downloads, manages, and serves local models. Install it via Homebrew:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--cask&lt;/span&gt; ollama
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Alternatively, download the installer directly from &lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;ollama.com&lt;/a&gt;. Once launched, Ollama places an icon in the menu bar and starts the API server at &lt;code&gt;http://localhost:11434&lt;/code&gt;. Confirm it is running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl http://localhost:11434
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response should be &lt;code&gt;Ollama is running&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Memory and performance settings
&lt;/h2&gt;

&lt;p&gt;Running a language model is fundamentally a memory operation, not a compute one. A model's weights are the billions of numerical parameters that encode its behaviour, and they must be loaded entirely into RAM before a single token can be generated. A 7B model in Q4_K_M quantisation takes around 4–5 GB; an 8B model is similar. If those weights do not fit and the system starts paging to disk, inference slows to a near halt regardless of how fast your CPU is.&lt;/p&gt;

&lt;p&gt;On Apple Silicon this matters more than on a typical machine: the CPU, Metal GPU, and every running application share a single pool of unified memory. VS Code, a dev server, a browser, and Ollama are all drawing from the same 16 GB.&lt;/p&gt;

&lt;p&gt;Ollama's defaults are generous with memory, which compounds these pressures. Without tuning, the runtime may load multiple models simultaneously, allocate a context window far larger than needed, and leave your other tools fighting for RAM.&lt;/p&gt;

&lt;p&gt;Add these variables to &lt;code&gt;~/.zshrc&lt;/code&gt; or &lt;code&gt;~/.zprofile&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Limit concurrency — one model at a time on 16 GB&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OLLAMA_MAX_LOADED_MODELS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OLLAMA_NUM_PARALLEL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1

&lt;span class="c"&gt;# Keep the model warm between requests — avoids cold-start latency in VS Code&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OLLAMA_KEEP_ALIVE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;30m

&lt;span class="c"&gt;# Default context window&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OLLAMA_CONTEXT_LENGTH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4096

&lt;span class="c"&gt;# Apple Silicon optimisations — the highest-impact pair for 16 GB&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OLLAMA_FLASH_ATTENTION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OLLAMA_KV_CACHE_TYPE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;q8_0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then apply them without restarting your shell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;source&lt;/span&gt; ~/.zshrc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OLLAMA_MAX_LOADED_MODELS=1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Prevents multiple models competing for the same 16 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OLLAMA_NUM_PARALLEL=1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Explicit default; prevents accidental concurrent loads&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OLLAMA_FLASH_ATTENTION=1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reduces peak activation memory on M1 Metal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OLLAMA_KV_CACHE_TYPE=q8_0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Halves KV cache RAM compared to the default &lt;code&gt;f16&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OLLAMA_KEEP_ALIVE=30m&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Model stays loaded between requests, no cold-start delay&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;OLLAMA_FLASH_ATTENTION&lt;/code&gt; and &lt;code&gt;OLLAMA_KV_CACHE_TYPE=q8_0&lt;/code&gt; together free around 1–2 GB of effective headroom. That is enough to run 8B parameter models comfortably on 16 GB when they would otherwise be marginal.&lt;/p&gt;

&lt;h3&gt;
  
  
  Adjusting for more RAM
&lt;/h3&gt;

&lt;p&gt;The settings above are conservative, tuned for 16 GB. On machines with more unified memory you can relax the concurrency limits and drop the KV cache compression:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;16 GB (M1/M2)&lt;/th&gt;
&lt;th&gt;32 GB (M2 Pro/M3 Pro)&lt;/th&gt;
&lt;th&gt;64 GB+ (M3 Max/Ultra)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OLLAMA_MAX_LOADED_MODELS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;3&lt;/code&gt; or more&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OLLAMA_NUM_PARALLEL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;4&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OLLAMA_KV_CACHE_TYPE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;q8_0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;q8_0&lt;/code&gt; or omit&lt;/td&gt;
&lt;td&gt;Omit: use default &lt;code&gt;f16&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;OLLAMA_FLASH_ATTENTION=1&lt;/code&gt; is still worth keeping on any Apple Silicon machine: it reduces peak activation memory regardless of total RAM.&lt;/p&gt;

&lt;h3&gt;
  
  
  Staying fully local
&lt;/h3&gt;

&lt;p&gt;Ollama does not send your prompts anywhere by default. If you are working with sensitive data and want a hard guarantee, add this flag too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Optional — disables remote inference and web search entirely&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OLLAMA_NO_CLOUD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Choosing a model
&lt;/h2&gt;

&lt;p&gt;Every model has a name and a size tag. The number in the tag reflects how many billion parameters it contains, which determines both output quality and how much RAM it needs to load. Use the table below to pick the right fit for your hardware and use case.&lt;/p&gt;

&lt;h3&gt;
  
  
  Model reference
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;th&gt;Vision&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gemma3:4b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~2.5 GB&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Fast chat, vision, light tasks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;qwen3:8b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~4.5 GB&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Best all-rounder, strong reasoning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;qwen2.5vl:7b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~4.5 GB&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Vision and text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;qwen2.5vl:3b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~2 GB&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Lightweight vision&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;qwen2.5-coder:7b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~4.3 GB&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Code generation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mistral-nemo&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~7 GB&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Long documents, 32K context&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gemma3:12b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~8 GB&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Higher quality, viable with flash attention&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nomic-embed-text&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~0.3 GB&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Embeddings and RAG pipelines&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On &lt;strong&gt;16 GB&lt;/strong&gt;, avoid 13B models and larger. They will page to swap and feel sluggish under any real workload. On &lt;strong&gt;32 GB&lt;/strong&gt; you can run 13B and 14B models comfortably, and &lt;code&gt;gemma3:12b&lt;/code&gt; and &lt;code&gt;qwen3:14b&lt;/code&gt; become reliable daily drivers. On &lt;strong&gt;64 GB or more&lt;/strong&gt;, 27B and 32B models are viable. Check &lt;a href="https://ollama.com/library" rel="noopener noreferrer"&gt;ollama.com/library&lt;/a&gt; for the full catalogue.&lt;/p&gt;

&lt;p&gt;Prefer &lt;strong&gt;Q4_K_M quantised&lt;/strong&gt; variants when available. They offer the best speed-to-quality tradeoff regardless of hardware tier.&lt;/p&gt;

&lt;p&gt;When you want to attach an image to a conversation, switch to a vision model like &lt;code&gt;qwen2.5vl:7b&lt;/code&gt; or &lt;code&gt;gemma3:4b&lt;/code&gt;. Text-only models reject image input.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pulling and running a model
&lt;/h3&gt;

&lt;p&gt;Use &lt;code&gt;ollama pull&lt;/code&gt; to download a model and &lt;code&gt;ollama run&lt;/code&gt; to test it interactively:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama pull qwen3:8b
ollama run qwen3:8b
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first pull downloads several gigabytes, so run this on a decent connection. After that, the model lives on disk at &lt;code&gt;~/.ollama/models/&lt;/code&gt; and launches instantly.&lt;/p&gt;

&lt;p&gt;A good starting set for most daily-use scenarios:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama pull qwen3:8b           &lt;span class="c"&gt;# daily driver — best all-rounder&lt;/span&gt;
ollama pull qwen2.5-coder:7b   &lt;span class="c"&gt;# coding tasks&lt;/span&gt;
ollama pull qwen2.5vl:7b       &lt;span class="c"&gt;# vision and text&lt;/span&gt;
ollama pull gemma3:4b          &lt;span class="c"&gt;# lightweight vision alternative&lt;/span&gt;
ollama pull nomic-embed-text   &lt;span class="c"&gt;# embeddings and RAG&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  VS Code Copilot integration
&lt;/h2&gt;

&lt;p&gt;VS Code Copilot can use a local Ollama server as a model provider. The setup is straightforward, but there is one catch: Copilot reads each model's maximum reported context size and may allocate the full window upfront.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Reported max context&lt;/th&gt;
&lt;th&gt;KV cache cost at max&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;qwen3:8b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;41K tokens&lt;/td&gt;
&lt;td&gt;~4 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;qwen2.5vl:7b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;128K tokens&lt;/td&gt;
&lt;td&gt;~16 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;qwen2.5-coder:7b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;33K tokens&lt;/td&gt;
&lt;td&gt;~3 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;OLLAMA_CONTEXT_LENGTH=4096&lt;/code&gt; sets a global default, but Copilot does not always respect it in API requests. The reliable fix is a &lt;strong&gt;Modelfile&lt;/strong&gt;: a small config file that bakes a capped context window into a named model variant.&lt;/p&gt;

&lt;h3&gt;
  
  
  Create capped model variants
&lt;/h3&gt;

&lt;p&gt;A Modelfile is a plain text file that tells Ollama how to build a named variant from an existing base. The two fields that matter here are &lt;code&gt;FROM&lt;/code&gt; (the base model to derive from) and &lt;code&gt;PARAMETER num_ctx&lt;/code&gt; (the context window to enforce):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FROM qwen3:8b
PARAMETER num_ctx 4096
PARAMETER temperature 0.7
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;temperature&lt;/code&gt; controls how much variation the model introduces when generating a response. &lt;code&gt;0.7&lt;/code&gt; is a reasonable general-purpose default: creative enough to avoid repetitive output, focused enough to stay on topic. The coder variant uses &lt;code&gt;0.2&lt;/code&gt; because code generation benefits from deterministic output. There is usually one right answer, not several equally valid variations.&lt;/p&gt;

&lt;p&gt;Running &lt;code&gt;ollama create &amp;lt;name&amp;gt; -f &amp;lt;Modelfile&amp;gt;&lt;/code&gt; registers that file as a new named model. No additional data is downloaded: Ollama references the base model already on disk with the specified parameters baked in.&lt;/p&gt;

&lt;p&gt;The following creates all four variants in one pass:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/ollama-models

&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'FROM qwen3:8b\nPARAMETER num_ctx 4096\nPARAMETER temperature 0.7\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; ~/ollama-models/Modelfile.qwen3-fast

&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'FROM qwen2.5-coder:7b\nPARAMETER num_ctx 4096\nPARAMETER temperature 0.2\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; ~/ollama-models/Modelfile.coder-fast

&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'FROM qwen2.5vl:7b\nPARAMETER num_ctx 4096\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; ~/ollama-models/Modelfile.vision-fast

&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'FROM gemma3:4b\nPARAMETER num_ctx 4096\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; ~/ollama-models/Modelfile.gemma-fast

ollama create qwen3-fast  &lt;span class="nt"&gt;-f&lt;/span&gt; ~/ollama-models/Modelfile.qwen3-fast
ollama create coder-fast  &lt;span class="nt"&gt;-f&lt;/span&gt; ~/ollama-models/Modelfile.coder-fast
ollama create vision-fast &lt;span class="nt"&gt;-f&lt;/span&gt; ~/ollama-models/Modelfile.vision-fast
ollama create gemma-fast  &lt;span class="nt"&gt;-f&lt;/span&gt; ~/ollama-models/Modelfile.gemma-fast
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Connect to Ollama and select a model
&lt;/h3&gt;

&lt;p&gt;To wire VS Code Copilot to a local Ollama server, add this to your VS Code &lt;code&gt;settings.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"github.copilot.chat.ollama.endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://localhost:11434"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also do this through the UI: open Copilot Chat (Cmd+Shift+I on macOS), click the model picker dropdown at the top of the chat panel, and choose "Manage Models". VS Code discovers all models running on &lt;code&gt;localhost:11434&lt;/code&gt; automatically once Ollama is running.&lt;/p&gt;

&lt;p&gt;Once connected, the capped variants appear in the picker alongside any cloud models. Switch to &lt;code&gt;qwen3-fast&lt;/code&gt;, &lt;code&gt;coder-fast&lt;/code&gt;, &lt;code&gt;vision-fast&lt;/code&gt;, or &lt;code&gt;gemma-fast&lt;/code&gt; depending on the task. After starting a conversation, confirm the model loaded:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama ps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Copilot CLI
&lt;/h3&gt;

&lt;p&gt;GitHub Copilot has a standalone CLI for the terminal. Install it via Homebrew:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;copilot-cli
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once installed, run &lt;code&gt;copilot&lt;/code&gt; from any project directory. On first launch it asks you to trust the folder and log in to GitHub. You type prompts directly in the terminal and Copilot can read, modify, and run files in the current directory. It supports plan mode (Shift+Tab to toggle), custom agents, and MCP servers.&lt;/p&gt;

&lt;p&gt;You can point it at Ollama to use local models rather than GitHub's cloud. The quickest way is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama launch copilot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This opens a model selector populated from Ollama's library. To specify a model directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama launch copilot &lt;span class="nt"&gt;--model&lt;/span&gt; qwen3:8b
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For manual wiring, set the Ollama endpoint via environment variables before running &lt;code&gt;copilot&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;COPILOT_PROVIDER_BASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://localhost:11434/v1
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;COPILOT_PROVIDER_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;COPILOT_PROVIDER_WIRE_API&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;responses
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;COPILOT_MODEL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;qwen3:8b
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One caveat: Copilot CLI works best with a generous context window. The Ollama docs recommend at least 64K tokens, so the 4K capped variants created above are too small for it. Use the base models directly and raise &lt;code&gt;OLLAMA_CONTEXT_LENGTH&lt;/code&gt; to &lt;code&gt;32768&lt;/code&gt; or higher when running Copilot CLI sessions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;This covers the full stack: Ollama installed and tuned, a model set selected for different use cases, VS Code Copilot wired to local variants, and the standalone Copilot CLI pointed at Ollama. On 16 GB the memory settings and capped context variants make local inference genuinely practical for everyday coding and chat work, not just a curiosity.&lt;/p&gt;

&lt;p&gt;For tasks that fit in a 4K context window a local model handles them without touching any external service. For longer context, heavier reasoning, or the times a cloud model simply performs better, the paid providers are still there. The difference is that reaching for them is now a deliberate choice rather than the default.&lt;/p&gt;

&lt;p&gt;Keeping up with new model releases is a single &lt;code&gt;ollama pull&lt;/code&gt; command. Ollama fetches only changed layers, so updates stay fast even at multi-GB model sizes.&lt;/p&gt;

&lt;p&gt;I'm also working on a dedicated machine for local AI inference: custom hardware that removes the unified memory constraint entirely. I'll write that up once it's running. If you're building something similar or have a setup you're happy with, drop a comment below or subscribe via the form at the end of this page to catch that post when it lands.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>ollama</category>
      <category>macos</category>
      <category>tooling</category>
    </item>
    <item>
      <title>GitHub Copilot for Engineers: Getting Better Results</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Tue, 02 Jun 2026 09:50:19 +0000</pubDate>
      <link>https://dev.to/sourcier/github-copilot-for-engineers-getting-better-results-41l2</link>
      <guid>https://dev.to/sourcier/github-copilot-for-engineers-getting-better-results-41l2</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/github-copilot-for-engineers" rel="noopener noreferrer"&gt;GitHub Copilot for Engineers: Getting Better Results&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;GitHub Copilot moved to usage-based billing in June 2026, dropping the flat subscription model that made monthly costs predictable. For teams using it heavily across multiple projects, that shift puts a premium on being deliberate: reaching for the right model, keeping prompts focused, and building a configuration that produces good results without a lot of back-and-forth iteration.&lt;/p&gt;

&lt;p&gt;Many of us install the extension, start with the defaults, and only tune settings later. The defaults are a reasonable starting point, but they are not a full configuration. A small investment in setup changes how much you get out of every request on an ordinary working day, and that matters more now that each request has a cost attached.&lt;/p&gt;

&lt;p&gt;This guide covers the full path: getting the tooling in place, choosing models with cost in mind, layering global and project-level rules, and building out instructions, agents, and skills that make Copilot predictable across different kinds of work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture overview
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVtQcm9tcHRzIGluIFZTIENvZGUgb3IgQ0xJXSAtLT4gQltJbnN0cnVjdGlvbnMgbGF5ZXJdCiAgQiAtLT4gQ1tBZ2VudCBzZWxlY3Rpb24gYW5kIG1vZGVsXQogIEMgLS0-IERbU2tpbGxzIHdvcmtmbG93XQogIEQgLS0-IEVbVG9vbHM6IENMSSBmaXJzdCwgTUNQIHdoZW4gbmVlZGVkXQogIEUgLS0-IEZbUmV2aWV3IGFuZCB2YWxpZGF0ZSBvdXRwdXRd" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVtQcm9tcHRzIGluIFZTIENvZGUgb3IgQ0xJXSAtLT4gQltJbnN0cnVjdGlvbnMgbGF5ZXJdCiAgQiAtLT4gQ1tBZ2VudCBzZWxlY3Rpb24gYW5kIG1vZGVsXQogIEMgLS0-IERbU2tpbGxzIHdvcmtmbG93XQogIEQgLS0-IEVbVG9vbHM6IENMSSBmaXJzdCwgTUNQIHdoZW4gbmVlZGVkXQogIEUgLS0-IEZbUmV2aWV3IGFuZCB2YWxpZGF0ZSBvdXRwdXRd" alt="Mermaid diagram" width="276" height="614"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Diagram fallback for Dev.to. View the canonical article for the full version: &lt;a href="https://sourcier.uk/blog/github-copilot-for-engineers" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/github-copilot-for-engineers&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Before you start
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Subscription and VS Code extension
&lt;/h3&gt;

&lt;p&gt;You need an active GitHub Copilot subscription. Plans are available at individual, business, and enterprise tiers at &lt;a href="https://github.com/features/copilot" rel="noopener noreferrer"&gt;github.com/features/copilot&lt;/a&gt;. Once active, all tools use your GitHub account credentials.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://marketplace.visualstudio.com/items?itemName=GitHub.copilot" rel="noopener noreferrer"&gt;GitHub Copilot extension for VS Code&lt;/a&gt; is the primary day-to-day interface. Install it from the Extensions panel or via the CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;code &lt;span class="nt"&gt;--install-extension&lt;/span&gt; GitHub.copilot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The extension provides inline completions as you type, Copilot Chat in the sidebar, inline chat on any selection via &lt;code&gt;Cmd+I&lt;/code&gt; / &lt;code&gt;Ctrl+I&lt;/code&gt;, agent mode for multi-step tasks, and multi-file edits with a single review step.&lt;/p&gt;

&lt;p&gt;Defaults keep improving, so avoid cargo-culting old setting lists. Focus on non-default tweaks that improve signal quality and control usage:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Effect&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;github.copilot.nextEditSuggestions.enabled&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;true&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Surfaces likely next edits proactively during implementation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;github.copilot.chat.codesearch.enabled&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;true&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Improves answers on larger repos by pulling semantic code context&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Copilot CLI
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://docs.github.com/en/copilot/concepts/agents/copilot-cli/about-copilot-cli" rel="noopener noreferrer"&gt;GitHub Copilot CLI&lt;/a&gt; is a standalone AI agent for the terminal.&lt;/p&gt;

&lt;p&gt;Install via Homebrew:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;copilot-cli
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or via pnpm, if you prefer Node.js tooling:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm add &lt;span class="nt"&gt;-g&lt;/span&gt; @github/copilot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On first launch, authenticate with your GitHub account by following the &lt;code&gt;/login&lt;/code&gt; prompt. The CLI has two modes: an interactive session where you have a back-and-forth conversation while Copilot reads and modifies files in the current directory, and a programmatic mode where you pass a single prompt with &lt;code&gt;-p&lt;/code&gt; and the CLI executes and exits.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Start an interactive session&lt;/span&gt;
copilot

&lt;span class="c"&gt;# One-shot task with explicit tool approval&lt;/span&gt;
copilot &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"Show me this week's commits and summarise them"&lt;/span&gt; &lt;span class="nt"&gt;--allow-tool&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'shell(git)'&lt;/span&gt;

&lt;span class="c"&gt;# Open a PR with the changes&lt;/span&gt;
copilot &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"Refactor the auth module to use async/await and open a PR"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  awesome-copilot
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/github/awesome-copilot" rel="noopener noreferrer"&gt;github.com/github/awesome-copilot&lt;/a&gt; is the official community-curated repository of Copilot instructions, agents, skills, and prompts. Before writing any configuration from scratch, check here first. It is far faster to adapt a battle-tested instruction file than to start from a blank page. The catalogue covers common engineering workflows: security review, frontend, documentation, and more.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choose models by task
&lt;/h2&gt;

&lt;p&gt;Model choice should match task shape. GitHub Copilot subscriptions give you access to a range of models. Here is how to map them to real work:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Recommended model&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Quick edits and inline completions&lt;/td&gt;
&lt;td&gt;GPT-5.4&lt;/td&gt;
&lt;td&gt;Strong quality at lower effort per prompt, reducing rework&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;General feature work and refactoring&lt;/td&gt;
&lt;td&gt;GPT-5.4 or Claude Sonnet 4.5&lt;/td&gt;
&lt;td&gt;Strong reasoning with a good speed and cost balance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Complex architecture and deep analysis&lt;/td&gt;
&lt;td&gt;Claude Sonnet 4.5&lt;/td&gt;
&lt;td&gt;Extended reasoning and long context without premium pricing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Security review&lt;/td&gt;
&lt;td&gt;Claude Sonnet 4.5&lt;/td&gt;
&lt;td&gt;Strong policy alignment and risk detection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Documentation and standards&lt;/td&gt;
&lt;td&gt;GPT-5.4 or Claude Sonnet 4.5&lt;/td&gt;
&lt;td&gt;Consistent structure, clear prose&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent and multi-step repo operations&lt;/td&gt;
&lt;td&gt;Claude Sonnet 4.5 or GPT-5.4&lt;/td&gt;
&lt;td&gt;Reliable tool-calling across many steps&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A practical pattern:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Start with your default model.&lt;/li&gt;
&lt;li&gt;If the output is shallow, switch to a stronger reasoning model.&lt;/li&gt;
&lt;li&gt;If the output is too slow for a simple task, switch back to a faster model.&lt;/li&gt;
&lt;li&gt;Keep one model per agent role for consistency: engineering, security, UX, docs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Agents (covered below) let you pin a model in their frontmatter, so the same task always runs with the same capability profile.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer global and project settings
&lt;/h2&gt;

&lt;p&gt;The most reliable setup is layered: global defaults for how you work everywhere, project-level rules for what is unique to a specific repo.&lt;/p&gt;

&lt;h3&gt;
  
  
  Global settings
&lt;/h3&gt;

&lt;p&gt;Global Copilot configuration lives under your home directory:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;~/.copilot/instructions/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Always-on rules applied across all repos&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;~/.copilot/agents/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reusable agent definitions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;~/.copilot/skills/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reusable skill playbooks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;~/.copilot/mcp-config.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Global MCP server connections&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Managing these in a dotfiles repo and syncing them to &lt;code&gt;$HOME&lt;/code&gt; on each machine gives you a consistent baseline without reconfiguring per project. Good candidates for global rules include workflow behaviour and tool preferences, secure coding defaults, code-commenting standards, and framework-specific instructions for React, TypeScript, and similar.&lt;/p&gt;

&lt;p&gt;If you want a concrete reference, this is my setup: &lt;a href="https://github.com/sourcier/dotfiles" rel="noopener noreferrer"&gt;github.com/sourcier/dotfiles&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Project-level settings
&lt;/h3&gt;

&lt;p&gt;For project-specific behaviour, add a &lt;code&gt;.github/copilot-instructions.md&lt;/code&gt; to the repo root, or place &lt;code&gt;.instructions.md&lt;/code&gt; files in &lt;code&gt;.github/instructions/&lt;/code&gt;. VS Code picks these up automatically when you open the project.&lt;/p&gt;

&lt;p&gt;Use project-level rules for naming conventions unique to this repo, folder architecture and module boundaries, test and QA requirements, and build and deployment constraints. Use global rules for communication style, security baseline, preferred CLIs, package manager choices, and repeatable personal workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Instructions, agents, and skills
&lt;/h2&gt;

&lt;p&gt;These three tools serve different purposes. Treating them as interchangeable leads to a setup that is noisy and hard to maintain.&lt;/p&gt;

&lt;h3&gt;
  
  
  Instructions: always-on policy
&lt;/h3&gt;

&lt;p&gt;Instructions are Markdown files with YAML frontmatter that Copilot reads automatically whenever the &lt;code&gt;applyTo&lt;/code&gt; glob matches the file you are working in. Use them for stable guardrails and standards that should apply silently in the background.&lt;/p&gt;

&lt;p&gt;Two patterns work well: broad instructions with &lt;code&gt;applyTo: '*'&lt;/code&gt; for universal rules, and targeted instructions with file-type globs for framework-specific rules.&lt;/p&gt;

&lt;p&gt;Minimal template:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;applyTo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;**/*.ts'&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;TypeScript&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;service-layer&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;standards'&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="gh"&gt;# Service Standards&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Use strict typing
&lt;span class="p"&gt;-&lt;/span&gt; Return typed errors
&lt;span class="p"&gt;-&lt;/span&gt; Validate external input at boundaries
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Agents: task-specific operators
&lt;/h3&gt;

&lt;p&gt;Agents are &lt;code&gt;.agent.md&lt;/code&gt; files that define a reusable persona with a pinned model, a tool budget, and a system prompt. Use them when the same class of work needs consistent behaviour every time.&lt;/p&gt;

&lt;p&gt;An agent definition includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;name&lt;/code&gt;: shown in the VS Code agent picker&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;description&lt;/code&gt;: used for routing; Copilot reads this to decide which agent fits a request&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;model&lt;/code&gt;: pinned model for this workflow&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tools&lt;/code&gt;: explicit list of tools the agent is allowed to use&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Good starting agents: a Software Engineer for general implementation and refactoring, a Security Reviewer for OWASP-focused code review, an Expert Frontend Engineer for React and TypeScript work, and a Tech Writer for documentation and READMEs.&lt;/p&gt;

&lt;p&gt;Create a new agent when a task requires a distinct review lens, needs a stable model and tool profile, or when you want consistent output style for that workflow.&lt;/p&gt;

&lt;h3&gt;
  
  
  Skills: reusable playbooks
&lt;/h3&gt;

&lt;p&gt;Skills are &lt;code&gt;SKILL.md&lt;/code&gt; files that contain detailed step-by-step procedures for a specific domain. The agent reads the skill file at invocation time rather than keeping it in context permanently, which means skills can be as long and detailed as needed without bloating every conversation.&lt;/p&gt;

&lt;p&gt;Good candidates: a premium frontend UI craftsmanship checklist, a workflow for creating &lt;code&gt;AGENTS.md&lt;/code&gt; files for a new repo, or a Playwright website exploration procedure.&lt;/p&gt;

&lt;p&gt;The rule of thumb:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Instructions&lt;/strong&gt; = default behaviour&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agents&lt;/strong&gt; = who does the work&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skills&lt;/strong&gt; = how specialised work gets executed&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  MCP servers
&lt;/h2&gt;

&lt;p&gt;MCP (Model Context Protocol) extends Copilot with external capabilities: browsers, issue trackers, cloud providers, databases, and more. Servers are configured in &lt;code&gt;~/.copilot/mcp-config.json&lt;/code&gt; for global access, or &lt;code&gt;.vscode/mcp.json&lt;/code&gt; for project scope.&lt;/p&gt;

&lt;p&gt;Three common patterns:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Local command server&lt;/strong&gt;: runs a pre-installed binary on your machine (preferred)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remote HTTP server&lt;/strong&gt;: connects to an external service over HTTPS&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;npx/uvx on-demand&lt;/strong&gt;: package runner launches the server each time; avoid for servers you use regularly as the cold boot adds latency on every invocation&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Installing common servers
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Playwright MCP&lt;/strong&gt;: browser automation and visual QA:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;playwright-mcp
&lt;span class="c"&gt;# or via pnpm&lt;/span&gt;
pnpm add &lt;span class="nt"&gt;-g&lt;/span&gt; @playwright/mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Azure MCP Server&lt;/strong&gt;: Azure resource inspection and management:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew tap azure/azure-cli
brew &lt;span class="nb"&gt;install &lt;/span&gt;azmcp
&lt;span class="c"&gt;# Authenticate before first use&lt;/span&gt;
az login
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Example config
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"playwright"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"playwright-mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"--headless"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"atlassian/atlassian-mcp-server"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://mcp.atlassian.com/v1/mcp/"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Azure MCP Server"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"azmcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"server"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"start"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Prefer a globally installed binary via Homebrew or pnpm for any server you use regularly. Reserve &lt;code&gt;npx&lt;/code&gt; for one-off evaluation of a new server before committing to a permanent install.&lt;/p&gt;

&lt;h3&gt;
  
  
  When to reach for MCP
&lt;/h3&gt;

&lt;p&gt;Follow this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Native CLI first: &lt;code&gt;git&lt;/code&gt;, &lt;code&gt;gh&lt;/code&gt;, &lt;code&gt;pnpm&lt;/code&gt;, &lt;code&gt;docker&lt;/code&gt;, cloud CLIs.&lt;/li&gt;
&lt;li&gt;Use MCP when no good CLI path exists or when MCP adds capability the CLI cannot.&lt;/li&gt;
&lt;li&gt;Keep the MCP list minimal and intentional.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Security checklist
&lt;/h3&gt;

&lt;p&gt;Before adding any MCP server:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Never hardcode tokens in config files. Use environment variables or a secret store.&lt;/li&gt;
&lt;li&gt;Prefer least-privilege credentials.&lt;/li&gt;
&lt;li&gt;Use trusted hosts only for remote HTTP servers.&lt;/li&gt;
&lt;li&gt;Disable or remove servers you are not actively using.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Rolling it out
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Individual setup
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Install the VS Code extension and Copilot CLI.&lt;/li&gt;
&lt;li&gt;Create &lt;code&gt;~/.copilot/instructions/&lt;/code&gt; and add one global instruction file. Workflow preferences and a security baseline are the highest-value starting points.&lt;/li&gt;
&lt;li&gt;Browse &lt;a href="https://github.com/github/awesome-copilot" rel="noopener noreferrer"&gt;awesome-copilot&lt;/a&gt; and copy two or three instruction files relevant to your stack.&lt;/li&gt;
&lt;li&gt;Add a &lt;code&gt;.github/copilot-instructions.md&lt;/code&gt; to your primary repo with project-specific conventions.&lt;/li&gt;
&lt;li&gt;Try agent mode for a non-trivial task to get a feel for how it behaves.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Team rollout
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Define a shared baseline in a dotfiles or inner-source repo covering instructions, agents, skills, and MCP config.&lt;/li&gt;
&lt;li&gt;Add two to four high-value project instructions per repo.&lt;/li&gt;
&lt;li&gt;Create three core agents: engineering, security, and documentation review.&lt;/li&gt;
&lt;li&gt;Add skills only for repeated, specialised workflows.&lt;/li&gt;
&lt;li&gt;Review output quality every two weeks and refine rules and prompts based on what you observe.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Keeping it improving
&lt;/h2&gt;

&lt;p&gt;Results degrade when instructions conflict, when prompts are vague, or when agents are used for everything regardless of fit. A simple quality loop prevents that drift:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Be explicit in prompts: goal, constraints, relevant files, and done criteria.&lt;/li&gt;
&lt;li&gt;Keep instructions concise and non-conflicting.&lt;/li&gt;
&lt;li&gt;Scope rules with &lt;code&gt;applyTo&lt;/code&gt; so they trigger only where needed.&lt;/li&gt;
&lt;li&gt;Use specialised agents for repeated high-value workflows.&lt;/li&gt;
&lt;li&gt;Validate with tests and lint, and feed failures back into instructions.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Quick audit checklist
&lt;/h3&gt;

&lt;p&gt;When the setup is in place, use this to evaluate it quickly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Model selection is intentional by task type.&lt;/li&gt;
&lt;li&gt;Global rules exist for workflow and security.&lt;/li&gt;
&lt;li&gt;Project rules exist for repo-specific conventions.&lt;/li&gt;
&lt;li&gt;Agent catalogue maps to real team workflows.&lt;/li&gt;
&lt;li&gt;Skills exist only for deep, repeated procedures.&lt;/li&gt;
&lt;li&gt;MCP servers are minimal, secure, and actively used.&lt;/li&gt;
&lt;li&gt;Prompts include explicit success criteria.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/github/awesome-copilot" rel="noopener noreferrer"&gt;awesome-copilot&lt;/a&gt;: community instructions, agents, skills, and prompts&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.github.com/en/copilot" rel="noopener noreferrer"&gt;GitHub Copilot documentation&lt;/a&gt;: official reference for all features&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.github.com/en/copilot/concepts/agents/copilot-cli/about-copilot-cli" rel="noopener noreferrer"&gt;Copilot CLI documentation&lt;/a&gt;: about the CLI, modes, and security considerations&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://marketplace.visualstudio.com/items?itemName=GitHub.copilot" rel="noopener noreferrer"&gt;VS Code Copilot extension&lt;/a&gt;: extension page with changelog and settings reference&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrap up
&lt;/h2&gt;

&lt;p&gt;The model table in this guide covers the cloud providers available through a Copilot subscription. If you want to cut cloud costs further or keep sensitive work entirely on your machine, running models locally is worth exploring. My next post covers exactly that: installing and tuning Ollama on Apple Silicon, choosing models by use case, and wiring local inference into VS Code Copilot and the CLI. It goes live June 4.&lt;/p&gt;

&lt;p&gt;If this guide helped, use it as a practical checklist this week: pick one primary model, tighten your instruction layers, and remove one source of prompt churn from your workflow. Then share what changed for you in the comments, or subscribe via the form at the end of this page to get the local AI follow-up as soon as it publishes.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>githubcopilot</category>
      <category>tooling</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Adding comments to a static Astro blog with Netlify Forms</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Wed, 27 May 2026 11:48:06 +0000</pubDate>
      <link>https://dev.to/sourcier/adding-comments-to-a-static-astro-blog-with-netlify-forms-b7d</link>
      <guid>https://dev.to/sourcier/adding-comments-to-a-static-astro-blog-with-netlify-forms-b7d</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/comments-netlify-forms-astro" rel="noopener noreferrer"&gt;Adding comments to a static Astro blog with Netlify Forms&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Series: Part of &lt;a href="https://sourcier.uk/blog/how-this-blog-was-built" rel="noopener noreferrer"&gt;How this blog was built&lt;/a&gt; — documenting every decision that shaped this site.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Comments on a static site are one of those problems that sounds simple until you&lt;br&gt;
actually sit down to solve it. You've got a few options.&lt;/p&gt;

&lt;p&gt;You can reach for a third-party widget: Disqus, Commento, or Giscus. They all work,&lt;br&gt;
and Giscus in particular is clever if your readers are likely to have GitHub&lt;br&gt;
accounts. But they all introduce an external dependency you don't control, and&lt;br&gt;
most of them inject JavaScript you didn't write.&lt;/p&gt;

&lt;p&gt;You can build a full backend: a database, an API, authentication for moderation.&lt;br&gt;
That's a lot of infrastructure for what is, on a personal blog, a fairly low-volume&lt;br&gt;
use case.&lt;/p&gt;

&lt;p&gt;Or you can use what you already have. If you're hosting on Netlify, you've already&lt;br&gt;
got &lt;a href="https://docs.netlify.com/forms/setup/" rel="noopener noreferrer"&gt;Netlify Forms&lt;/a&gt; and serverless&lt;br&gt;
Functions available. The approach I settled on uses both, inspired by&lt;br&gt;
&lt;a href="https://github.com/philhawksworth/jamstack-comments-engine" rel="noopener noreferrer"&gt;Phil Hawksworth's jamstack-comments-engine&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  The approach
&lt;/h2&gt;

&lt;p&gt;The system runs in four steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A visitor submits the comment form. Netlify intercepts the POST and stores it
in its Forms queue. No backend code needed.&lt;/li&gt;
&lt;li&gt;A webhook triggers &lt;code&gt;comment-handler&lt;/code&gt;, which sends an email with HMAC-signed
approve and delete links.&lt;/li&gt;
&lt;li&gt;Clicking &lt;strong&gt;Approve&lt;/strong&gt; calls &lt;code&gt;approve-comment&lt;/code&gt;, which re-posts the comment data
to a second form (&lt;code&gt;approved-comments&lt;/code&gt;) and removes it from the queue.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get-comments&lt;/code&gt; reads only from &lt;code&gt;approved-comments&lt;/code&gt;, so only reviewed content
ever reaches readers.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fc2VxdWVuY2VEaWFncmFtCiAgICBhY3RvciBWaXNpdG9yCiAgICBwYXJ0aWNpcGFudCBFZGdlIGFzIE5ldGxpZnkgRWRnZQogICAgcGFydGljaXBhbnQgSGFuZGxlciBhcyBjb21tZW50LWhhbmRsZXIKICAgIHBhcnRpY2lwYW50IEluYm94IGFzIE15IEluYm94CiAgICBwYXJ0aWNpcGFudCBBcHByb3ZlIGFzIGFwcHJvdmUtY29tbWVudAogICAgcGFydGljaXBhbnQgR2V0Q29tbWVudHMgYXMgZ2V0LWNvbW1lbnRzCgogICAgVmlzaXRvci0-PkVkZ2U6IFBPU1QgY29tbWVudAogICAgRWRnZS0-PkVkZ2U6IFN0b3JlIGluIGJsb2ctY29tbWVudHMgcXVldWUKICAgIEVkZ2UtPj5IYW5kbGVyOiBXZWJob29rIHRyaWdnZXIKICAgIEhhbmRsZXItPj5JbmJveDogRW1haWwgd2l0aCBBcHByb3ZlIC8gRGVsZXRlIGxpbmtzCiAgICBJbmJveC0-PkFwcHJvdmU6IENsaWNrIEFwcHJvdmUKICAgIEFwcHJvdmUtPj5FZGdlOiBQT1NUIHRvIGFwcHJvdmVkLWNvbW1lbnRzCiAgICBBcHByb3ZlLT4-RWRnZTogREVMRVRFIGZyb20gcXVldWUKICAgIFZpc2l0b3ItPj5HZXRDb21tZW50czogR0VUIC9nZXQtY29tbWVudHMKICAgIEdldENvbW1lbnRzLT4-RWRnZTogRmV0Y2ggYXBwcm92ZWQtY29tbWVudHMgQVBJCiAgICBHZXRDb21tZW50cy0-PlZpc2l0b3I6IFJldHVybiBhcHByb3ZlZCBjb21tZW50cw" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fc2VxdWVuY2VEaWFncmFtCiAgICBhY3RvciBWaXNpdG9yCiAgICBwYXJ0aWNpcGFudCBFZGdlIGFzIE5ldGxpZnkgRWRnZQogICAgcGFydGljaXBhbnQgSGFuZGxlciBhcyBjb21tZW50LWhhbmRsZXIKICAgIHBhcnRpY2lwYW50IEluYm94IGFzIE15IEluYm94CiAgICBwYXJ0aWNpcGFudCBBcHByb3ZlIGFzIGFwcHJvdmUtY29tbWVudAogICAgcGFydGljaXBhbnQgR2V0Q29tbWVudHMgYXMgZ2V0LWNvbW1lbnRzCgogICAgVmlzaXRvci0-PkVkZ2U6IFBPU1QgY29tbWVudAogICAgRWRnZS0-PkVkZ2U6IFN0b3JlIGluIGJsb2ctY29tbWVudHMgcXVldWUKICAgIEVkZ2UtPj5IYW5kbGVyOiBXZWJob29rIHRyaWdnZXIKICAgIEhhbmRsZXItPj5JbmJveDogRW1haWwgd2l0aCBBcHByb3ZlIC8gRGVsZXRlIGxpbmtzCiAgICBJbmJveC0-PkFwcHJvdmU6IENsaWNrIEFwcHJvdmUKICAgIEFwcHJvdmUtPj5FZGdlOiBQT1NUIHRvIGFwcHJvdmVkLWNvbW1lbnRzCiAgICBBcHByb3ZlLT4-RWRnZTogREVMRVRFIGZyb20gcXVldWUKICAgIFZpc2l0b3ItPj5HZXRDb21tZW50czogR0VUIC9nZXQtY29tbWVudHMKICAgIEdldENvbW1lbnRzLT4-RWRnZTogRmV0Y2ggYXBwcm92ZWQtY29tbWVudHMgQVBJCiAgICBHZXRDb21tZW50cy0-PlZpc2l0b3I6IFJldHVybiBhcHByb3ZlZCBjb21tZW50cw" alt="Mermaid diagram" width="1339" height="659"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Diagram fallback for Dev.to. View the canonical article for the full version: &lt;a href="https://sourcier.uk/blog/comments-netlify-forms-astro" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/comments-netlify-forms-astro&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There's no database to provision, no moderation dashboard to watch, and no&lt;br&gt;
third-party script on the page. Comments don't go live until I explicitly approve&lt;br&gt;
them from my inbox.&lt;/p&gt;
&lt;h2&gt;
  
  
  The form
&lt;/h2&gt;

&lt;p&gt;Netlify detects forms at build time by scanning the static HTML for &lt;code&gt;data-netlify="true"&lt;/code&gt;.&lt;br&gt;
Because this is an Astro site, the form is a server-rendered &lt;code&gt;.astro&lt;/code&gt; component, which&lt;br&gt;
means it appears in the built HTML and Netlify registers it automatically on first deploy.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt;
  &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"blog-comments"&lt;/span&gt;
  &lt;span class="na"&gt;method=&lt;/span&gt;&lt;span class="s"&gt;"POST"&lt;/span&gt;
  &lt;span class="na"&gt;data-netlify=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;
  &lt;span class="na"&gt;netlify-honeypot=&lt;/span&gt;&lt;span class="s"&gt;"bot-field"&lt;/span&gt;
&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"hidden"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"form-name"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"blog-comments"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"hidden"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"postSlug"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;{postId}&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="c"&gt;&amp;lt;!-- honeypot --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"bot-field"&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"display:none"&lt;/span&gt; &lt;span class="na"&gt;tabindex=&lt;/span&gt;&lt;span class="s"&gt;"-1"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="c"&gt;&amp;lt;!-- fields: name, email (optional), comment --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/form&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth noting here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;form-name&lt;/code&gt; hidden field is required when submitting via &lt;code&gt;fetch&lt;/code&gt; rather than a
native form POST. Netlify uses it to route the payload to the right form bucket.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;postSlug&lt;/code&gt; stores the post identifier. When reading comments back, this is what ties
each submission to its post.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;netlify-honeypot="bot-field"&lt;/code&gt; attribute tells Netlify to silently drop any
submission that fills in the &lt;code&gt;bot-field&lt;/code&gt; input. Real users don't see it; bots
typically fill every field.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The form submits via &lt;code&gt;fetch&lt;/code&gt; with &lt;code&gt;Content-Type: application/x-www-form-urlencoded&lt;/code&gt;&lt;br&gt;
to the current page URL; Netlify intercepts those requests before they hit the origin.&lt;/p&gt;
&lt;h2&gt;
  
  
  The functions
&lt;/h2&gt;

&lt;p&gt;There are three Netlify Functions in total.&lt;/p&gt;
&lt;h3&gt;
  
  
  get-comments
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;netlify/functions/get-comments.js&lt;/code&gt; takes a &lt;code&gt;?slug=&lt;/code&gt; query param and fetches&lt;br&gt;
submissions from the &lt;code&gt;approved-comments&lt;/code&gt; form via the Netlify API, filtered by slug.&lt;/p&gt;

&lt;p&gt;Email addresses are hashed server-side before the response leaves the function; the&lt;br&gt;
raw address is never sent to the browser:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;gravatarHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;md5&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;comments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;submissions&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;postSlug&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;comment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;comment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// Use the original submission date, not the approval date&lt;/span&gt;
    &lt;span class="na"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;originalDate&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;emailHash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;gravatarHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MD5 is the hash format Gravatar's API requires; hashing also means the raw email&lt;br&gt;
address never leaves the server.&lt;/p&gt;

&lt;p&gt;Using &lt;code&gt;originalDate&lt;/code&gt; rather than &lt;code&gt;created_at&lt;/code&gt; matters here: &lt;code&gt;created_at&lt;/code&gt; on an&lt;br&gt;
approved submission reflects the moment it was approved, not when the visitor&lt;br&gt;
wrote it. The approval function stamps the original queue date into &lt;code&gt;originalDate&lt;/code&gt;&lt;br&gt;
when it copies the submission across.&lt;/p&gt;

&lt;p&gt;The access token lives in an environment variable; it never touches the browser.&lt;br&gt;
The function returns an empty array if the variables aren't set, so the site&lt;br&gt;
degrades gracefully in local dev.&lt;/p&gt;
&lt;h3&gt;
  
  
  comment-handler
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;netlify/functions/comment-handler.js&lt;/code&gt; is triggered by a Netlify outgoing webhook&lt;br&gt;
whenever a new submission hits the &lt;code&gt;blog-comments&lt;/code&gt; queue. It sends an HTML email&lt;br&gt;
via &lt;a href="https://resend.com" rel="noopener noreferrer"&gt;Resend&lt;/a&gt; (the same delivery layer used for &lt;a href="https://dev.to/blog/new-post-notifications-resend"&gt;new post notifications&lt;/a&gt;) containing the comment text and two&lt;br&gt;
HMAC-SHA256-signed action links:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;submissionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;submissionId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;approveToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;approve&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;deleteToken&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;delete&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;approveUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;siteUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/.netlify/functions/approve-comment`&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="s2"&gt;`?action=approve&amp;amp;id=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;token=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;approveToken&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each token encodes both the submission ID and the intended action, so an approve&lt;br&gt;
token can't be replayed as a delete, and tokens for one submission don't work on&lt;br&gt;
another.&lt;/p&gt;
&lt;h3&gt;
  
  
  approve-comment
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;netlify/functions/approve-comment.js&lt;/code&gt; handles the link clicks. It:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Verifies the HMAC token with &lt;code&gt;crypto.timingSafeEqual&lt;/code&gt; to prevent timing attacks&lt;/li&gt;
&lt;li&gt;For &lt;strong&gt;approve&lt;/strong&gt;: fetches the submission from the Netlify API, re-posts it to
&lt;code&gt;approved-comments&lt;/code&gt; with an &lt;code&gt;originalDate&lt;/code&gt; field, then deletes the pending entry&lt;/li&gt;
&lt;li&gt;For &lt;strong&gt;delete&lt;/strong&gt;: deletes the pending submission directly&lt;/li&gt;
&lt;li&gt;Returns a minimal HTML confirmation page either way
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;verifyToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;submissionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;submissionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The approve step posts to the site's own URL; Netlify's edge intercepts it and&lt;br&gt;
stores it in the &lt;code&gt;approved-comments&lt;/code&gt; bucket, exactly as it does for visitor&lt;br&gt;
submissions. No direct Netlify API write is needed.&lt;/p&gt;
&lt;h2&gt;
  
  
  Rendering comments
&lt;/h2&gt;

&lt;p&gt;Client-side JavaScript calls &lt;code&gt;/.netlify/functions/get-comments?slug={postId}&lt;/code&gt; on page&lt;br&gt;
load and renders whatever comes back.&lt;/p&gt;

&lt;p&gt;One discipline worth keeping here: never use &lt;code&gt;innerHTML&lt;/code&gt; with raw user data. Because&lt;br&gt;
the comment cards are built as an HTML template string, &lt;code&gt;innerHTML&lt;/code&gt; is unavoidable for&lt;br&gt;
inserting the full card structure, but all user-supplied values are passed through&lt;br&gt;
&lt;code&gt;escapeHtml&lt;/code&gt; before they touch the template:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;escapeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;amp;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;amp;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/"/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;quot;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/'/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;#39;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No raw user content ever reaches the HTML parser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gravatar avatars
&lt;/h2&gt;

&lt;p&gt;Each commenter gets an avatar. If they provided an email address, the &lt;code&gt;emailHash&lt;/code&gt; from&lt;br&gt;
the function is used to fetch their Gravatar. If they didn't, or if no Gravatar is&lt;br&gt;
registered, a pink circle with their initial is shown instead.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;d=404&lt;/code&gt; parameter tells Gravatar to return a 404 rather than a default image.&lt;br&gt;
&lt;code&gt;onerror&lt;/code&gt; hides the &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; and the initial shows through. &lt;code&gt;onload&lt;/code&gt; hides the initial&lt;br&gt;
when a real Gravatar loads successfully:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;avatarInner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;emailHash&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;img src="https://www.gravatar.com/avatar/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;emailHash&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;?s=72&amp;amp;d=404"
         onload="this.nextElementSibling.style.display='none'"
         onerror="this.style.display='none'" /&amp;gt;
     &amp;lt;span class="comment__avatar-initial"&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;initial&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/span&amp;gt;`&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;span class="comment__avatar-initial"&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;initial&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/span&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt; is always in the DOM behind the image, so the fallback requires no extra&lt;br&gt;
logic.&lt;/p&gt;
&lt;h2&gt;
  
  
  Styling dynamically injected content in Astro
&lt;/h2&gt;

&lt;p&gt;This tripped me up. Astro's scoped CSS works by adding a unique attribute&lt;br&gt;
(e.g. &lt;code&gt;data-astro-cid-xxx&lt;/code&gt;) to every element it renders, and then qualifying all the&lt;br&gt;
CSS selectors with that attribute. That means the styles only match elements that were&lt;br&gt;
rendered at build time.&lt;/p&gt;

&lt;p&gt;Comment cards are injected via &lt;code&gt;innerHTML&lt;/code&gt; at runtime; they never get the scoping&lt;br&gt;
attribute. The fix is to wrap those selectors in &lt;code&gt;:global()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight scss"&gt;&lt;code&gt;&lt;span class="cm"&gt;/* scoped — applies to server-rendered elements */&lt;/span&gt;
&lt;span class="nc"&gt;.comments__heading&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cm"&gt;/* global — applies to runtime-injected elements */&lt;/span&gt;
&lt;span class="nd"&gt;:global&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;.comment&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nd"&gt;:global&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;.comment__avatar&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything that's server-rendered stays scoped. Only the comment card classes need to&lt;br&gt;
escape scoping.&lt;/p&gt;
&lt;h2&gt;
  
  
  Registering the approved-comments form
&lt;/h2&gt;

&lt;p&gt;Netlify discovers forms by scanning built HTML at deploy time. The &lt;code&gt;blog-comments&lt;/code&gt;&lt;br&gt;
form lives in the &lt;code&gt;Comments.astro&lt;/code&gt; component, so it's found automatically. The&lt;br&gt;
&lt;code&gt;approved-comments&lt;/code&gt; form is never rendered on a page; it only receives programmatic&lt;br&gt;
POSTs from &lt;code&gt;approve-comment&lt;/code&gt;. Without an explicit registration it would never be&lt;br&gt;
created in the Netlify dashboard.&lt;/p&gt;

&lt;p&gt;The fix is a hidden placeholder form in &lt;code&gt;Comments.astro&lt;/code&gt;, alongside the visible&lt;br&gt;
&lt;code&gt;blog-comments&lt;/code&gt; form that visitors submit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"approved-comments"&lt;/span&gt; &lt;span class="na"&gt;data-netlify=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt; &lt;span class="na"&gt;hidden&lt;/span&gt; &lt;span class="na"&gt;aria-hidden=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"hidden"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"postSlug"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"hidden"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"hidden"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"hidden"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"comment"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"hidden"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"originalDate"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/form&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Netlify only needs to find a form in one built page to register it. Since&lt;br&gt;
&lt;code&gt;Comments.astro&lt;/code&gt; is rendered on every blog post, the form is present in every post&lt;br&gt;
page's HTML and will be picked up on the first deploy. The &lt;code&gt;hidden&lt;/code&gt; attribute keeps&lt;br&gt;
it invisible; &lt;code&gt;aria-hidden="true"&lt;/code&gt; removes it from the accessibility tree.&lt;/p&gt;
&lt;h2&gt;
  
  
  Setting it up on Netlify
&lt;/h2&gt;

&lt;p&gt;After the first deploy:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Get a personal access token&lt;/strong&gt;: Netlify → User settings → Applications →
Personal access tokens&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set up a Resend account&lt;/strong&gt; and verify a sender domain (their free tier covers
3,000 emails per month, more than enough). If you already followed the
&lt;a href="https://dev.to/blog/mailing-list-astro"&gt;mailing list post&lt;/a&gt;, your Resend account and sender
domain are already configured.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add these environment variables&lt;/strong&gt; in Netlify → Site configuration →
Environment variables:

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;NETLIFY_PAT&lt;/code&gt;: personal access token from step 1. Avoid the name &lt;code&gt;NETLIFY_ACCESS_TOKEN&lt;/code&gt;; Netlify auto-overwrites it at runtime with a limited machine token&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;APPROVAL_SECRET&lt;/code&gt;: a random secret for HMAC signing
(&lt;code&gt;openssl rand -hex 32&lt;/code&gt; works well)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SITE_URL&lt;/code&gt;: the public URL, e.g. &lt;code&gt;https://sourcier.uk&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;RESEND_API_KEY&lt;/code&gt;: Resend API key&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;NOTIFY_FROM_EMAIL&lt;/code&gt;: verified Resend sender address&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;NOTIFY_EMAIL&lt;/code&gt;: where to receive approval emails&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add a webhook&lt;/strong&gt;: Netlify → Forms → &lt;code&gt;blog-comments&lt;/code&gt; → Form notifications →
Add notification → Outgoing webhook →
URL: &lt;code&gt;https://your-site/.netlify/functions/comment-handler&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Submit a test comment&lt;/strong&gt; to create the first &lt;code&gt;approved-comments&lt;/code&gt; entry, then
copy its Form ID from the Netlify Forms dashboard URL&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add the final variable&lt;/strong&gt;:

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;APPROVED_COMMENTS_FORM_ID&lt;/code&gt;: form ID from step 5&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Trigger a redeploy&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;After that, every new comment fires a notification email. Approve or delete it&lt;br&gt;
by clicking the link. No dashboard visit required.&lt;/p&gt;
&lt;h2&gt;
  
  
  Refreshing the list after submission
&lt;/h2&gt;

&lt;p&gt;After a successful POST, &lt;code&gt;loadComments()&lt;/code&gt; is called a second time so the list&lt;br&gt;
reflects whatever the server currently holds. Because Netlify Forms requires manual&lt;br&gt;
approval before submissions appear via the API, the newly posted comment won't show&lt;br&gt;
up immediately, but any comments approved in the meantime will, and the list stays&lt;br&gt;
in sync rather than going stale.&lt;/p&gt;

&lt;p&gt;To make the refresh feel intentional rather than jarring, &lt;code&gt;renderComments&lt;/code&gt; accepts&lt;br&gt;
an &lt;code&gt;animate&lt;/code&gt; flag. When set, the list container fades out, swaps its HTML, then&lt;br&gt;
fades back in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;renderComments&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;comments&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;animate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildCommentsHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;comments&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;animate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;listEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;listEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;is-fading&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;listEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;animationend&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;listEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;listEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;is-fading&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;listEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;is-entering&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;listEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;animationend&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;listEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;is-entering&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;once&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;once&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The initial page-load call passes no flag, so the first render is instant with no&lt;br&gt;
flash. The post-submission refresh passes &lt;code&gt;animate = true&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The two CSS keyframes are defined in the component's scoped styles:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight scss"&gt;&lt;code&gt;&lt;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="nt"&gt;comments-fade-out&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nt"&gt;from&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nt"&gt;to&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;-6px&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="nt"&gt;comments-fade-in&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nt"&gt;from&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;8px&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nt"&gt;to&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;:global&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;#comments-list&lt;/span&gt;&lt;span class="nc"&gt;.is-fading&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;comments-fade-out&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="mi"&gt;.2s&lt;/span&gt; &lt;span class="n"&gt;ease&lt;/span&gt; &lt;span class="n"&gt;forwards&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nd"&gt;:global&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;#comments-list&lt;/span&gt;&lt;span class="nc"&gt;.is-entering&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;comments-fade-in&lt;/span&gt;  &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="mi"&gt;.25s&lt;/span&gt; &lt;span class="n"&gt;ease&lt;/span&gt; &lt;span class="n"&gt;forwards&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The selectors need &lt;code&gt;:global()&lt;/code&gt; for the same reason comment card styles do; the list&lt;br&gt;
element is in the server-rendered HTML but the classes are toggled at runtime by&lt;br&gt;
JavaScript, so Astro's scoped-CSS attribute won't be present on the selector when the&lt;br&gt;
animation fires.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;The main remaining limitation is that comments don't appear immediately after&lt;br&gt;
submission; the visitor sees a "submitted for review" message and has to come&lt;br&gt;
back later to see it live. The list refreshes after submission, but an unapproved&lt;br&gt;
comment can't show up in that refresh.&lt;/p&gt;

&lt;p&gt;The cleanest fix would be to optimistically insert the pending comment into the&lt;br&gt;
DOM immediately, marked visually as "awaiting approval", and then confirm or&lt;br&gt;
remove it on the next real fetch. That adds state management I haven't needed&lt;br&gt;
yet; volume is low enough that the current UX is fine for now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;The full implementation spans four files: &lt;code&gt;Comments.astro&lt;/code&gt; and the three serverless&lt;br&gt;
functions. Netlify Forms handles the queue and webhook delivery, Resend sends the&lt;br&gt;
notification email, and the HMAC signing keeps approve and delete actions&lt;br&gt;
tamper-proof. Nothing goes live until I've clicked a link from my inbox, with no&lt;br&gt;
database to provision and no third-party script on the page.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;Comments.astro&lt;/code&gt; component and all three functions are in the&lt;br&gt;
&lt;a href="https://github.com/sourcier/sourcier.uk" rel="noopener noreferrer"&gt;sourcier.uk repository&lt;/a&gt; if you want to&lt;br&gt;
use them as a starting point.&lt;/p&gt;

</description>
      <category>astro</category>
      <category>netlify</category>
      <category>engineering</category>
      <category>frontend</category>
    </item>
  </channel>
</rss>
