<?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>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>
    <item>
      <title>Adding a mailing list to a static Astro blog with Resend</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Thu, 21 May 2026 09:48:48 +0000</pubDate>
      <link>https://dev.to/sourcier/adding-a-mailing-list-to-a-static-astro-blog-with-resend-16lb</link>
      <guid>https://dev.to/sourcier/adding-a-mailing-list-to-a-static-astro-blog-with-resend-16lb</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/mailing-list-astro" rel="noopener noreferrer"&gt;Adding a mailing list to a static Astro blog with Resend&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;Adding a mailing list to a static site is one of those features that looks like&lt;br&gt;
it needs a whole backend — a database of subscribers, a queue, an unsubscribe&lt;br&gt;
flow. In practice, if you're already on Netlify and already using&lt;br&gt;
&lt;a href="https://resend.com" rel="noopener noreferrer"&gt;Resend&lt;/a&gt; for transactional email, you can bolt on a working&lt;br&gt;
subscription form in an afternoon.&lt;/p&gt;

&lt;p&gt;Here's exactly how I did it on this site.&lt;/p&gt;
&lt;h2&gt;
  
  
  What we're building
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;MailingListCTA&lt;/code&gt; Astro component that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Renders an email input and a subscribe button&lt;/li&gt;
&lt;li&gt;Submits via &lt;code&gt;fetch&lt;/code&gt; to a Netlify Function&lt;/li&gt;
&lt;li&gt;Shows inline success or error feedback without a page reload&lt;/li&gt;
&lt;li&gt;Includes a honeypot field to block bot submissions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Netlify Function:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Validates the email server-side&lt;/li&gt;
&lt;li&gt;Silently discards bot submissions (honeypot check)&lt;/li&gt;
&lt;li&gt;Calls the Resend Segments API to add the contact&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%2Fc2VxdWVuY2VEaWFncmFtCiAgICBhY3RvciBVc2VyCiAgICBwYXJ0aWNpcGFudCBGb3JtIGFzIE1haWxpbmdMaXN0Q1RBCiAgICBwYXJ0aWNpcGFudCBGbiBhcyBzdWJzY3JpYmUgZnVuY3Rpb24KICAgIHBhcnRpY2lwYW50IFJlc2VuZCBhcyBSZXNlbmQgQVBJCiAgICBVc2VyLT4-Rm9ybTogRW50ZXIgZW1haWwsIGNsaWNrIFN1YnNjcmliZQogICAgRm9ybS0-PkZuOiBQT1NUIHtlbWFpbCwgd2Vic2l0ZX0KICAgIGFsdCBIb25leXBvdCBmaWVsZCBmaWxsZWQKICAgICAgICBGbi0tPj5Gb3JtOiAyMDAgT0sgKHNpbGVudGx5IGRpc2NhcmQpCiAgICBlbHNlIEludmFsaWQgZW1haWwgZm9ybWF0CiAgICAgICAgRm4tLT4-Rm9ybTogNDAwIHtlcnJvcn0KICAgICAgICBGb3JtLT4-VXNlcjogSW5saW5lIGVycm9yIG1lc3NhZ2UKICAgIGVsc2UgVmFsaWQgZW1haWwKICAgICAgICBGbi0-PlJlc2VuZDogUE9TVCAvY29udGFjdHMgd2l0aCBzZWdtZW50IElECiAgICAgICAgUmVzZW5kLS0-PkZuOiAyMDEgQ3JlYXRlZAogICAgICAgIEZuLT4-UmVzZW5kOiBQT1NUIC9lbWFpbHMgKHdlbGNvbWUgZW1haWwpCiAgICAgICAgRm4tLT4-Rm9ybTogMjAwIHtzdWNjZXNzOiB0cnVlfQogICAgICAgIEZvcm0tPj5Vc2VyOiAiWW91J3JlIHN1YnNjcmliZWQhIgogICAgZW5k" 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%2Fc2VxdWVuY2VEaWFncmFtCiAgICBhY3RvciBVc2VyCiAgICBwYXJ0aWNpcGFudCBGb3JtIGFzIE1haWxpbmdMaXN0Q1RBCiAgICBwYXJ0aWNpcGFudCBGbiBhcyBzdWJzY3JpYmUgZnVuY3Rpb24KICAgIHBhcnRpY2lwYW50IFJlc2VuZCBhcyBSZXNlbmQgQVBJCiAgICBVc2VyLT4-Rm9ybTogRW50ZXIgZW1haWwsIGNsaWNrIFN1YnNjcmliZQogICAgRm9ybS0-PkZuOiBQT1NUIHtlbWFpbCwgd2Vic2l0ZX0KICAgIGFsdCBIb25leXBvdCBmaWVsZCBmaWxsZWQKICAgICAgICBGbi0tPj5Gb3JtOiAyMDAgT0sgKHNpbGVudGx5IGRpc2NhcmQpCiAgICBlbHNlIEludmFsaWQgZW1haWwgZm9ybWF0CiAgICAgICAgRm4tLT4-Rm9ybTogNDAwIHtlcnJvcn0KICAgICAgICBGb3JtLT4-VXNlcjogSW5saW5lIGVycm9yIG1lc3NhZ2UKICAgIGVsc2UgVmFsaWQgZW1haWwKICAgICAgICBGbi0-PlJlc2VuZDogUE9TVCAvY29udGFjdHMgd2l0aCBzZWdtZW50IElECiAgICAgICAgUmVzZW5kLS0-PkZuOiAyMDEgQ3JlYXRlZAogICAgICAgIEZuLT4-UmVzZW5kOiBQT1NUIC9lbWFpbHMgKHdlbGNvbWUgZW1haWwpCiAgICAgICAgRm4tLT4-Rm9ybTogMjAwIHtzdWNjZXNzOiB0cnVlfQogICAgICAgIEZvcm0tPj5Vc2VyOiAiWW91J3JlIHN1YnNjcmliZWQhIgogICAgZW5k" alt="Mermaid diagram" width="1013" height="764"&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/mailing-list-astro" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/mailing-list-astro&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  Setting up Resend Segments
&lt;/h2&gt;

&lt;p&gt;Resend recently migrated from Audiences to&lt;br&gt;
&lt;a href="https://resend.com/docs/api-reference/segments/create-segment" rel="noopener noreferrer"&gt;Segments&lt;/a&gt; — Audiences&lt;br&gt;
still work but are deprecated and will be removed. The concept is the same: a&lt;br&gt;
named list of contacts you can send broadcasts to.&lt;/p&gt;

&lt;p&gt;Create a segment in the Resend dashboard. Once created, copy the segment ID —&lt;br&gt;
you'll need it as an environment variable.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Netlify Function
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;netlify/functions/subscribe.ts&lt;/code&gt;. The function receives a &lt;code&gt;POST&lt;/code&gt; with&lt;br&gt;
&lt;code&gt;{ email, website }&lt;/code&gt; in the body. The &lt;code&gt;website&lt;/code&gt; field is the honeypot.&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ALLOWED_ORIGIN&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;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="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isValidEmail&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[^\s&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;[^\s&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;\.[^\s&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="nf"&gt;test&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;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="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;corsHeaders&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;ALLOWED_ORIGIN&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;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="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;corsHeaders&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="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="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;corsHeaders&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;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;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;apiKey&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;RESEND_API_KEY&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;segmentId&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;RESEND_SEGMENT_ID&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;apiKey&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;segmentId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;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;subscribe: RESEND_API_KEY or RESEND_SEGMENT_ID is not set&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="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;500&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;corsHeaders&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;Server configuration error&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;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;email&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="nl"&gt;website&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;corsHeaders&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 request body&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;email&lt;/span&gt; &lt;span class="o"&gt;=&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;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&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;email&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;trim&lt;/span&gt;&lt;span class="p"&gt;()&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;honeypot&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;website&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;honeypot&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;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="nx"&gt;corsHeaders&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;success&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="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;email&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isValidEmail&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="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;corsHeaders&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;A valid email address is required&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;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;`https://api.resend.com/contacts`&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="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;apiKey&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="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="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;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;unsubscribed&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;segments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;segmentId&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="o"&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;errorBody&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;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`subscribe: Resend API error &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;status&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;errorBody&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="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;502&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;corsHeaders&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;Could not subscribe. Please try again later.&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="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;corsHeaders&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="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No Resend SDK&lt;/strong&gt; — calling the REST API directly with &lt;code&gt;fetch&lt;/code&gt; keeps the
function dependency-free and fast to cold-start.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CORS headers&lt;/strong&gt; — the function sets &lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt; to
&lt;code&gt;SITE_URL&lt;/code&gt; from environment, with an &lt;code&gt;OPTIONS&lt;/code&gt; preflight handler.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Honeypot is silently accepted&lt;/strong&gt; — returning &lt;code&gt;200&lt;/code&gt; when the honeypot is
filled means bots get no signal that they were caught. Returning &lt;code&gt;400&lt;/code&gt; would
tell them to try again without the field.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Welcome email
&lt;/h3&gt;

&lt;p&gt;After the contact is successfully added, the function sends a welcome email&lt;br&gt;
using &lt;code&gt;POST /emails&lt;/code&gt;. The send is done with &lt;code&gt;.catch()&lt;/code&gt; so a failure doesn't&lt;br&gt;
break the subscription response.&lt;/p&gt;

&lt;p&gt;Rather than embedding HTML directly in the function, the welcome email is stored&lt;br&gt;
as a &lt;a href="https://resend.com/docs/dashboard/templates/introduction" rel="noopener noreferrer"&gt;Resend template&lt;/a&gt;.&lt;br&gt;
This means you can edit the email copy in the Resend dashboard without touching&lt;br&gt;
or redeploying the function.&lt;/p&gt;

&lt;p&gt;When &lt;code&gt;RESEND_WELCOME_TEMPLATE_ID&lt;/code&gt; is set, the function sends via the template.&lt;br&gt;
Otherwise it falls back to inline HTML, so the function keeps working before&lt;br&gt;
you've set up the template.&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;emailPayload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;welcomeTemplateId&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Sourcier &amp;lt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;fromEmail&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&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="na"&gt;template&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;welcomeTemplateId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;variables&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;BLOG_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;siteUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/blog`&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="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Sourcier &amp;lt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;fromEmail&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&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="na"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;You're subscribed to Sourcier&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;max-width:560px;margin:0 auto;padding:2rem 1.5rem;color:#0f0f0f"&amp;gt;
  &amp;lt;p style="font-size:1.5rem;font-weight:800;text-transform:uppercase;letter-spacing:0.02em;margin:0 0 1rem"&amp;gt;Welcome to Sourcier&amp;lt;/p&amp;gt;
  &amp;lt;p style="margin:0 0 1rem;line-height:1.6"&amp;gt;Thanks for signing up. You'll get an email whenever I publish something new — engineering deep-dives, lessons from the field, and the occasional opinion.&amp;lt;/p&amp;gt;
  &amp;lt;p style="margin:0 0 1.5rem;line-height:1.6"&amp;gt;In the meantime, browse the &amp;lt;a href="&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;/blog" style="color:#e8006a"&amp;gt;blog&amp;lt;/a&amp;gt; to see what's already there.&amp;lt;/p&amp;gt;
  &amp;lt;p style="margin:0;color:#6b6b6b;font-size:0.875rem"&amp;gt;You can unsubscribe at any time by replying to this email.&amp;lt;/p&amp;gt;
&amp;lt;/div&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;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="s2"&gt;https://api.resend.com/emails&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;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="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;apiKey&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="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="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;emailPayload&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;err&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;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&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;subscribe: welcome email failed:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that &lt;code&gt;template&lt;/code&gt; and &lt;code&gt;html&lt;/code&gt; are mutually exclusive — Resend returns a&lt;br&gt;
validation error if you include both. The template must also be &lt;strong&gt;published&lt;/strong&gt; in&lt;br&gt;
the Resend dashboard before it can be used; draft templates won't send.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;fromEmail&lt;/code&gt; guard means the function still works in local dev without&lt;br&gt;
&lt;code&gt;NOTIFY_FROM_EMAIL&lt;/code&gt; set — it simply skips the welcome email.&lt;/p&gt;
&lt;h3&gt;
  
  
  Creating the template with a script
&lt;/h3&gt;

&lt;p&gt;Rather than manually creating the template in the Resend dashboard, the repo&lt;br&gt;
includes a setup script at &lt;code&gt;scripts/create-welcome-template.js&lt;/code&gt;. Run it once&lt;br&gt;
after cloning:&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;RESEND_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;re_xxx node scripts/create-welcome-template.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Checks whether a template with the alias &lt;code&gt;sourcier-welcome&lt;/code&gt; already exists&lt;/li&gt;
&lt;li&gt;If it does — updates it with &lt;code&gt;PATCH /templates/:id&lt;/code&gt; and re-publishes&lt;/li&gt;
&lt;li&gt;If it doesn't — creates it with &lt;code&gt;POST /templates&lt;/code&gt; and publishes&lt;/li&gt;
&lt;li&gt;Prints the template ID to copy into your Netlify env vars&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The alias acts as a stable lookup key, so running the script again on future&lt;br&gt;
edits updates the template in-place rather than creating duplicates. After&lt;br&gt;
publishing via the script, email sends using the template will immediately use&lt;br&gt;
the updated version.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Astro component
&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%2F5xwll61ylprcrblif6wf.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%2F5xwll61ylprcrblif6wf.png" alt="Mailing list subscribe form wireframe showing four states side by side: default with email input and Subscribe button, loading with spinner, success with checkmark, and error with inline message" width="700" height="460"&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/mailing-list-astro" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/mailing-list-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 &lt;code&gt;MailingListCTA&lt;/code&gt; component is a dark card that sits at content width on any&lt;br&gt;
page. The submit logic lives in a shared &lt;code&gt;subscribeForm.ts&lt;/code&gt; utility so both the&lt;br&gt;
full-width card and the sidebar component use the same behaviour without&lt;br&gt;
duplicating code.&lt;/p&gt;
&lt;h3&gt;
  
  
  Honeypot field
&lt;/h3&gt;

&lt;p&gt;The honeypot is a text input that is visually hidden using CSS — positioned&lt;br&gt;
off-screen, not just &lt;code&gt;display: none&lt;/code&gt;, because some bots skip fields hidden that&lt;br&gt;
way.&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;p&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"mailing-cta__honeypot"&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;label&lt;/span&gt; &lt;span class="na"&gt;for=&lt;/span&gt;&lt;span class="s"&gt;"mailing-cta-website"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Leave this blank&lt;span class="nt"&gt;&amp;lt;/label&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"mailing-cta-website"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"website"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"text"&lt;/span&gt; &lt;span class="na"&gt;tabindex=&lt;/span&gt;&lt;span class="s"&gt;"-1"&lt;/span&gt; &lt;span class="na"&gt;autocomplete=&lt;/span&gt;&lt;span class="s"&gt;"off"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/p&amp;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 scss"&gt;&lt;code&gt;&lt;span class="nc"&gt;.mailing-cta__honeypot&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;left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;-9999px&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;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;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;pointer-events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&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;tabindex="-1"&lt;/code&gt; ensures keyboard users and screen readers can't reach it.&lt;br&gt;
&lt;code&gt;aria-hidden="true"&lt;/code&gt; on the wrapper removes it from the accessibility tree&lt;br&gt;
entirely.&lt;/p&gt;
&lt;h3&gt;
  
  
  Shared form utility
&lt;/h3&gt;

&lt;p&gt;The submit handler lives in &lt;code&gt;src/utils/subscribeForm.ts&lt;/code&gt;. Both &lt;code&gt;MailingListCTA&lt;/code&gt;&lt;br&gt;
and &lt;code&gt;MailingListCTASidebar&lt;/code&gt; call &lt;code&gt;bindSubscribeForm()&lt;/code&gt; with a config object that&lt;br&gt;
maps DOM IDs to CSS class names and copy:&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;SubscribeFormConfig&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;formId&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;emailId&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;feedbackClass&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;feedbackSuccessClass&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;feedbackErrorClass&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;successLabel&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;defaultButtonLabel&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;source&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;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;bindSubscribeForm&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;SubscribeFormConfig&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&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;form&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="nf"&gt;getElementById&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;formId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;HTMLFormElement&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;feedback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;form&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;[aria-live]&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="kc"&gt;null&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;input&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="nf"&gt;getElementById&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;emailId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;HTMLInputElement&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;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;form&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;feedback&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;input&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="nx"&gt;form&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;submit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&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;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&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="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;email&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;btn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;form&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;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;button[type=submit]&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;btn&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="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;disabled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;btn&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;Subscribing…&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;feedback&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hidden&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;feedback&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;className&lt;/span&gt; &lt;span class="o"&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;feedbackClass&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="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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/.netlify/functions/subscribe&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;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;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;website&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;elements&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;namedItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;website&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="nx"&gt;HTMLInputElement&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="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;source&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;source&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="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="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="nx"&gt;feedback&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="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;successLabel&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;feedback&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="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;feedbackSuccessClass&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reset&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;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;You're in&lt;/span&gt;&lt;span class="dl"&gt;"&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;disabled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;feedback&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Something went wrong. Please try again.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;feedback&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="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;feedbackErrorClass&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;disabled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;btn&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="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;defaultButtonLabel&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;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;feedback&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;Something went wrong. Please try again.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;feedback&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="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;feedbackErrorClass&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;disabled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;btn&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="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;defaultButtonLabel&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;feedback&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hidden&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="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;
&lt;strong&gt;&lt;code&gt;feedback.className&lt;/code&gt; is reset&lt;/strong&gt; on each submission so a previous success or
error class doesn't carry over if the user submits again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;btn.textContent = "You're in"&lt;/code&gt;&lt;/strong&gt; on success locks the button with a
confirmation label so the user knows the action was recorded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;source&lt;/code&gt;&lt;/strong&gt; is an optional field passed through to the function body, giving
a hook for tracking which page the subscriber came from.&lt;/li&gt;
&lt;li&gt;The feedback element has &lt;code&gt;aria-live="polite"&lt;/code&gt; so screen readers announce the
outcome. It starts &lt;code&gt;hidden&lt;/code&gt; so it takes up no space until there's something to show.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Environment variables
&lt;/h2&gt;

&lt;p&gt;Add these in the Netlify dashboard under &lt;strong&gt;Site configuration → Environment&lt;br&gt;
variables&lt;/strong&gt;:&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;RESEND_API_KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Your Resend API key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RESEND_SEGMENT_ID&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The segment ID from the Resend dashboard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NOTIFY_FROM_EMAIL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Verified sender address, e.g. &lt;code&gt;hello@sourcier.uk&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SITE_URL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Your public site URL, e.g. &lt;code&gt;https://sourcier.uk&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RESEND_WELCOME_TEMPLATE_ID&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Template ID printed by &lt;code&gt;scripts/create-welcome-template.js&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RESEND_TOPIC_ID&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Optional — scopes broadcasts to a specific topic&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;RESEND_API_KEY&lt;/code&gt; is likely already set if you're using Resend for other&lt;br&gt;
notifications on the same site. &lt;code&gt;RESEND_WELCOME_TEMPLATE_ID&lt;/code&gt; and &lt;code&gt;RESEND_TOPIC_ID&lt;/code&gt;&lt;br&gt;
are optional — the function falls back to inline HTML if the template ID is absent.&lt;/p&gt;
&lt;h2&gt;
  
  
  Adding the component to pages
&lt;/h2&gt;

&lt;p&gt;Import and drop the component wherever you want the CTA to appear:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
import MailingListCTA from "../components/MailingListCTA.astro";
---

&amp;lt;!-- rest of page --&amp;gt;
&amp;lt;MailingListCTA /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I added it to blog posts, guide pages, tag pages, and the standalone pages —&lt;br&gt;
home, blog index, about, and contact.&lt;/p&gt;
&lt;h2&gt;
  
  
  Sidebar variant
&lt;/h2&gt;

&lt;p&gt;Blog post pages have a sticky sidebar that shows the table of contents. A&lt;br&gt;
full-width card below the article felt like too much repetition, so I also built&lt;br&gt;
a compact &lt;code&gt;MailingListCTASidebar&lt;/code&gt; component that sits below the ToC and shares&lt;br&gt;
the same Netlify Function.&lt;/p&gt;

&lt;p&gt;The sidebar variant is a self-contained dark card with the same form logic,&lt;br&gt;
but uses &lt;code&gt;display: block; width: 100%&lt;/code&gt; for the input and button rather than a&lt;br&gt;
side-by-side layout.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
import MailingListCTASidebar from "../components/MailingListCTASidebar.astro";
---

&amp;lt;aside class="post__sidebar"&amp;gt;
  &amp;lt;nav class="toc"&amp;gt;&amp;lt;!-- ... --&amp;gt;&amp;lt;/nav&amp;gt;
  &amp;lt;MailingListCTASidebar /&amp;gt;
&amp;lt;/aside&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Dark mode theming
&lt;/h2&gt;

&lt;p&gt;The card background is hardcoded to &lt;code&gt;#0f0f0f&lt;/code&gt; rather than&lt;br&gt;
&lt;code&gt;var(--color-ink)&lt;/code&gt;. This is intentional — &lt;code&gt;--color-ink&lt;/code&gt; flips to &lt;code&gt;#f0f0f0&lt;/code&gt; in&lt;br&gt;
dark mode (it's the text colour token), so using it for a background produces a&lt;br&gt;
near-white card. The footer on this site has the same issue and uses the same&lt;br&gt;
fix.&lt;/p&gt;

&lt;p&gt;To make the card visible in dark mode where the page background is &lt;code&gt;#111111&lt;/code&gt;, I&lt;br&gt;
added a pink top border and a subtle edge border:&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;.mailing-cta__card&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;#0f0f0f&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;border-radius&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="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&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;border-top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3px&lt;/span&gt; &lt;span class="nb"&gt;solid&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;color-pink&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;border-left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="nf"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&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;.06&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;border-right&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="nf"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&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;.06&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;border-bottom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="nf"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&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;.06&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 pink top border serves as the primary visual anchor in both modes. In light&lt;br&gt;
mode the contrast between in the dark card and white page does the work; in dark&lt;br&gt;
mode the subtle borders define the card edges.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Resend handles for you
&lt;/h2&gt;

&lt;p&gt;Once a contact is in your audience, Resend takes care of the rest:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Duplicate contacts&lt;/strong&gt; — adding the same email again updates the existing
record rather than creating a duplicate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unsubscribe management&lt;/strong&gt; — you can send broadcasts with unsubscribe links
built in, and Resend updates the contact's &lt;code&gt;unsubscribed&lt;/code&gt; flag automatically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Broadcasts&lt;/strong&gt; — send to the full audience from the Resend dashboard or via the
&lt;code&gt;POST /broadcasts&lt;/code&gt; API.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The free tier covers 3,000 emails per month and 100 contacts in audiences, which&lt;br&gt;
is plenty for a personal blog getting started.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;The full implementation is around 200 lines across three files: &lt;code&gt;subscribe.ts&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;subscribeForm.ts&lt;/code&gt;, and the two Astro components. Resend handles deduplication,&lt;br&gt;
unsubscribe management, and broadcast delivery, keeping the site code lean.&lt;/p&gt;

&lt;p&gt;If you're already using Resend for comment notifications, the only new piece is&lt;br&gt;
&lt;code&gt;subscribe.ts&lt;/code&gt;. The welcome email script is a one-off setup, and the components&lt;br&gt;
drop in wherever a CTA makes sense.&lt;/p&gt;

</description>
      <category>astro</category>
      <category>netlify</category>
      <category>engineering</category>
    </item>
    <item>
      <title>Scheduled publishing in Astro on Netlify</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Thu, 21 May 2026 09:48:39 +0000</pubDate>
      <link>https://dev.to/sourcier/scheduled-publishing-in-astro-on-netlify-52oc</link>
      <guid>https://dev.to/sourcier/scheduled-publishing-in-astro-on-netlify-52oc</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/scheduled-publishing-astro" rel="noopener noreferrer"&gt;Scheduled publishing in Astro on Netlify&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 elegant deployment story right up until you need to publish&lt;br&gt;
something on a specific date. A CMS solves this with a "schedule" button. A&lt;br&gt;
database-backed blog solves this with a query clause. A static site rebuilds once&lt;br&gt;
at deploy time — after that, nothing changes until the next deploy.&lt;/p&gt;

&lt;p&gt;For most personal blogs that's fine. Mine has posts queued weeks ahead with&lt;br&gt;
deliberate publish dates, so letting it drift wasn't an option.&lt;/p&gt;

&lt;p&gt;The solution has three parts: a helper that knows whether a post is visible &lt;em&gt;right&lt;br&gt;
now&lt;/em&gt;, a scheduled function that triggers a daily rebuild, and a cron expression&lt;br&gt;
chosen so the build always fires before 9am UK time. None of it requires a CMS or&lt;br&gt;
a database.&lt;/p&gt;
&lt;h2&gt;
  
  
  The problem with &lt;code&gt;draft: false&lt;/code&gt; alone
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;draft&lt;/code&gt; field already keeps in-progress posts off the live site. But &lt;code&gt;draft&lt;/code&gt;&lt;br&gt;
is a binary flag set at write time — you have to remember to flip it, and the post&lt;br&gt;
goes live on the next deploy, not at a predictable time.&lt;/p&gt;

&lt;p&gt;What's needed is a second condition: the post's &lt;code&gt;pubDate&lt;/code&gt; must be in the past&lt;br&gt;
before it appears. The build already has access to the current time, so this is a&lt;br&gt;
straightforward filter.&lt;/p&gt;
&lt;h2&gt;
  
  
  &lt;code&gt;isPublished()&lt;/code&gt; — one filter to rule them all
&lt;/h2&gt;

&lt;p&gt;Every page and component that calls &lt;code&gt;getCollection("posts")&lt;/code&gt; needs to apply the&lt;br&gt;
same logic. The cleanest way to enforce this is a shared helper in&lt;br&gt;
&lt;code&gt;src/utils/drafts.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Drafts are hidden by default. `pnpm dev` enables them locally via SHOW_DRAFTS=true.&lt;/span&gt;
&lt;span class="c1"&gt;// Also enabled in production when SHOW_DRAFTS=true (used by the preview branch deploy).&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;showDrafts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="o"&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;meta&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;SHOW_DRAFTS&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;PublicationData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;pubDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&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;type&lt;/span&gt; &lt;span class="nx"&gt;PublicationStatus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;draft&lt;/span&gt;&lt;span class="dl"&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;scheduled&lt;/span&gt;&lt;span class="dl"&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;published&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;getPublicationData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PublicationData&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;PublicationData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;PublicationData&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;data&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;input&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;input&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;function&lt;/span&gt; &lt;span class="nf"&gt;getPublicationStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PublicationData&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;PublicationData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;PublicationStatus&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="nf"&gt;getPublicationData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&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;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;draft&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;draft&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;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pubDate&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="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;scheduled&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;published&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;function&lt;/span&gt; &lt;span class="nf"&gt;isPubliclyPublished&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="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;pubDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}):&lt;/span&gt; &lt;span class="nx"&gt;boolean&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;getPublicationStatus&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="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;published&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;// Returns true for posts that should be visible at build/request time.&lt;/span&gt;
&lt;span class="c1"&gt;// Hides drafts (unless showDrafts) and posts whose pubDate is in the future.&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;isPublished&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="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;pubDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}):&lt;/span&gt; &lt;span class="nx"&gt;boolean&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;showDrafts&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;true&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;isPubliclyPublished&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;isPublished&lt;/code&gt; replaces every inline draft check across the codebase. Before&lt;br&gt;
this, each call site had a slightly different spelling of the same test — and&lt;br&gt;
none of them checked the date:&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;// Before — only checked draft, missed pubDate entirely&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;post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// After — consistent and date-aware&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The call sites appear in pages, paginated routes, tag pages, and sidebar&lt;br&gt;
components — nine files in total. Replacing them all at once means there is no&lt;br&gt;
path through the build where a future-dated post can slip through.&lt;/p&gt;

&lt;p&gt;Two functions in &lt;code&gt;drafts.ts&lt;/code&gt; are worth keeping straight:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;isPublished&lt;/code&gt;&lt;/strong&gt; — use this for rendering post lists. When &lt;code&gt;SHOW_DRAFTS=true&lt;/code&gt;
(set by default when you run &lt;code&gt;pnpm dev&lt;/code&gt;), it passes through drafts and scheduled
posts so you can preview queued content locally. On production builds it hides
both.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;isPubliclyPublished&lt;/code&gt;&lt;/strong&gt; — use this anywhere that must reflect strict public
state regardless of preview mode: RSS feeds, post counts, sitemaps. It always
behaves as if &lt;code&gt;SHOW_DRAFTS&lt;/code&gt; is off.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Setting &lt;code&gt;pubDate&lt;/code&gt; values
&lt;/h2&gt;

&lt;p&gt;For the filter to work predictably, &lt;code&gt;pubDate&lt;/code&gt; values need to be straightforward&lt;br&gt;
UTC timestamps with no offset:&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="na"&gt;pubDate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2026-04-13T00:00:00&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A date like &lt;code&gt;2026-04-13T09:00:00+01:00&lt;/code&gt; evaluates to &lt;code&gt;08:00 UTC&lt;/code&gt;. If the build&lt;br&gt;
fires at &lt;code&gt;07:45 UTC&lt;/code&gt;, the post will not appear until the following day's build —&lt;br&gt;
one day late and silently wrong. Midnight UTC removes this class of error entirely.&lt;/p&gt;

&lt;p&gt;If two posts share the same date and you care about their sort order, a short&lt;br&gt;
offset keeps them before the build window and in the intended sequence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Appears first in descending sort (higher timestamp)&lt;/span&gt;
&lt;span class="na"&gt;pubDate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2026-03-30T00:10:00&lt;/span&gt;

&lt;span class="c1"&gt;# Appears second&lt;/span&gt;
&lt;span class="na"&gt;pubDate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2026-03-30T00:00:00&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The scheduled Netlify function
&lt;/h2&gt;

&lt;p&gt;Astro builds the site once at deploy time. To have it pick up newly-eligible posts&lt;br&gt;
each day, we need to trigger a fresh deploy on a schedule.&lt;/p&gt;

&lt;p&gt;Netlify supports this natively: a function declared with a &lt;code&gt;schedule&lt;/code&gt; in&lt;br&gt;
&lt;code&gt;netlify.toml&lt;/code&gt; runs as a cron job. Our function's only job is to call the Netlify&lt;br&gt;
build hook 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="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;handler&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;hookId&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;BUILD_HOOK_ID&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;hookId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;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;BUILD_HOOK_ID is not set — skipping scheduled build.&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="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;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`https://api.netlify.com/build_hooks/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hookId&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;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="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;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="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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Scheduled build triggered successfully.&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;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Failed to trigger build: &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;status&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;statusText&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No npm packages needed — the function uses the standard &lt;code&gt;fetch&lt;/code&gt; and an environment&lt;br&gt;
variable for the hook ID.&lt;/p&gt;
&lt;h2&gt;
  
  
  netlify.toml configuration
&lt;/h2&gt;

&lt;p&gt;The schedule is declared alongside the function configuration 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;[functions]&lt;/span&gt;
  &lt;span class="py"&gt;directory&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"netlify/functions"&lt;/span&gt;
  &lt;span class="py"&gt;node_bundler&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"esbuild"&lt;/span&gt;

&lt;span class="c"&gt;# Rebuild daily so future-dated posts go live automatically.&lt;/span&gt;
&lt;span class="c"&gt;# Requires BUILD_HOOK_ID env var — see netlify/functions/scheduled-build.mjs.&lt;/span&gt;
&lt;span class="nn"&gt;[functions."scheduled-build"]&lt;/span&gt;
  &lt;span class="py"&gt;schedule&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"45 7 * * *"&lt;/span&gt; &lt;span class="c"&gt;# always before 09:00 UK time: 07:45 GMT in winter, 08:45 BST in summer&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Netlify's cron syntax is always UTC. &lt;code&gt;45 7 * * *&lt;/code&gt; fires at 07:45 UTC — before&lt;br&gt;
09:00 in both BST (UTC+1) and GMT (UTC+0). If you'd rather guarantee 08:45 BST&lt;br&gt;
and accept 07:45 GMT in winter, the expression is the same — there is no&lt;br&gt;
timezone-aware option in cron, so you pick the UTC value that satisfies your&lt;br&gt;
worst case.&lt;/p&gt;
&lt;h2&gt;
  
  
  Dashboard setup
&lt;/h2&gt;

&lt;p&gt;One step in the Netlify dashboard is required:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Site configuration → Build &amp;amp; deploy → Build hooks&lt;/strong&gt; — create a hook named
"Scheduled publish". Netlify generates a URL ending in a unique ID.&lt;/li&gt;
&lt;li&gt;Copy just the ID from the URL (the path segment after &lt;code&gt;/build_hooks/&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Site configuration → Environment variables&lt;/strong&gt; — add a new variable:

&lt;ul&gt;
&lt;li&gt;Key: &lt;code&gt;BUILD_HOOK_ID&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Value: the ID you copied&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;BUILD_HOOK_ID&lt;/code&gt; to your local &lt;code&gt;.env.example&lt;/code&gt; (without a value) so it's
documented for anyone cloning the repository.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The function reads this variable and constructs the full URL itself, so the secret&lt;br&gt;
is never hardcoded in the repository.&lt;/p&gt;
&lt;h2&gt;
  
  
  Verifying the setup
&lt;/h2&gt;

&lt;p&gt;Before waiting for the next scheduled run, confirm everything is wired up&lt;br&gt;
correctly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trigger a build manually.&lt;/strong&gt; POST to the hook URL directly from your terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.netlify.com/build_hooks/YOUR_HOOK_ID"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace &lt;code&gt;YOUR_HOOK_ID&lt;/code&gt; with the ID you copied. Netlify responds with &lt;code&gt;{}&lt;/code&gt; and a&lt;br&gt;
200 — check the Deploys tab in the dashboard to confirm a build starts within a&lt;br&gt;
few seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check function logs after a scheduled run.&lt;/strong&gt; Once the cron fires, Netlify logs&lt;br&gt;
the function's output under &lt;strong&gt;Functions&lt;/strong&gt; in the dashboard. Select&lt;br&gt;
&lt;code&gt;scheduled-build&lt;/code&gt; and look for &lt;code&gt;Scheduled build triggered successfully.&lt;/code&gt; in the&lt;br&gt;
invocation log. If &lt;code&gt;BUILD_HOOK_ID&lt;/code&gt; is missing or misconfigured, the error message&lt;br&gt;
from the early return will appear there instead.&lt;/p&gt;
&lt;h2&gt;
  
  
  How it fits together
&lt;/h2&gt;

&lt;p&gt;A post ready to publish looks like this:&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="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;My&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;next&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;post"&lt;/span&gt;
&lt;span class="na"&gt;pubDate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2026-04-20T00:00:00&lt;/span&gt;
&lt;span class="na"&gt;draft&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Push to &lt;code&gt;main&lt;/code&gt;. Netlify deploys immediately — because &lt;code&gt;pubDate&lt;/code&gt; is in the future,&lt;br&gt;
&lt;code&gt;isPublished&lt;/code&gt; returns &lt;code&gt;false&lt;/code&gt; and the post is excluded from every page. On the&lt;br&gt;
morning of April 20th, the &lt;code&gt;scheduled-build&lt;/code&gt; function fires at 07:45 UTC, triggers&lt;br&gt;
a new deploy, and &lt;code&gt;isPublished&lt;/code&gt; returns &lt;code&gt;true&lt;/code&gt;. The post goes live without any&lt;br&gt;
manual intervention.&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%2FZmxvd2NoYXJ0IExSCiAgICBDUk9OWyJDcm9uIHRyaWdnZXJcbjA3OjQ1IFVUQyBkYWlseSJdIC0tPiBGTlsic2NoZWR1bGVkLWJ1aWxkXG5OZXRsaWZ5IGZ1bmN0aW9uIl0KICAgIEZOIC0tPiBIT09LWyJQT1NUIGJ1aWxkIGhvb2tcbk5ldGxpZnkgQVBJIl0KICAgIEhPT0sgLS0-IEJVSUxEWyJOZXRsaWZ5IHJlYnVpbGRcbmFzdHJvIGJ1aWxkIl0KICAgIEJVSUxEIC0tPiBGSUxURVJbImlzUHVibGlzaGVkKClcbmRyYWZ0OiBmYWxzZSBBTkQgcHViRGF0ZSA8PSBub3ciXQogICAgRklMVEVSIC0tPnx0cnVlfCBMSVZFWyJQb3N0IGFwcGVhcnNcbm9uIENETiJdCiAgICBGSUxURVIgLS0-fGZhbHNlfCBXQUlUWyJQb3N0IGhpZGRlblxudW50aWwgbmV4dCBidWlsZCJd" 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%2FZmxvd2NoYXJ0IExSCiAgICBDUk9OWyJDcm9uIHRyaWdnZXJcbjA3OjQ1IFVUQyBkYWlseSJdIC0tPiBGTlsic2NoZWR1bGVkLWJ1aWxkXG5OZXRsaWZ5IGZ1bmN0aW9uIl0KICAgIEZOIC0tPiBIT09LWyJQT1NUIGJ1aWxkIGhvb2tcbk5ldGxpZnkgQVBJIl0KICAgIEhPT0sgLS0-IEJVSUxEWyJOZXRsaWZ5IHJlYnVpbGRcbmFzdHJvIGJ1aWxkIl0KICAgIEJVSUxEIC0tPiBGSUxURVJbImlzUHVibGlzaGVkKClcbmRyYWZ0OiBmYWxzZSBBTkQgcHViRGF0ZSA8PSBub3ciXQogICAgRklMVEVSIC0tPnx0cnVlfCBMSVZFWyJQb3N0IGFwcGVhcnNcbm9uIENETiJdCiAgICBGSUxURVIgLS0-fGZhbHNlfCBXQUlUWyJQb3N0IGhpZGRlblxudW50aWwgbmV4dCBidWlsZCJd" alt="Mermaid diagram" width="1844" height="198"&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/scheduled-publishing-astro" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/scheduled-publishing-astro&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Where &lt;code&gt;draft&lt;/code&gt; still fits in
&lt;/h2&gt;

&lt;p&gt;With scheduled publishing in place, &lt;code&gt;draft&lt;/code&gt; and &lt;code&gt;pubDate&lt;/code&gt; serve two distinct roles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;draft: true&lt;/code&gt;&lt;/strong&gt; means the post isn't ready — you're still writing it, it might&lt;br&gt;
be half-finished, and you don't want it visible even in a deploy preview. It hides&lt;br&gt;
the post indefinitely regardless of its date. Running &lt;code&gt;pnpm dev&lt;/code&gt; reveals it locally&lt;br&gt;
(&lt;code&gt;SHOW_DRAFTS=true&lt;/code&gt; is set by default in the dev script). Nothing goes live until&lt;br&gt;
you explicitly flip the flag.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;draft: false&lt;/code&gt; with a future &lt;code&gt;pubDate&lt;/code&gt;&lt;/strong&gt; means the post is complete and queued.&lt;br&gt;
You're done writing, you're happy with it, and you want it to go live on a specific&lt;br&gt;
date without any further action from you.&lt;/p&gt;

&lt;p&gt;The practical workflow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Start writing → &lt;code&gt;draft: true&lt;/code&gt;, no &lt;code&gt;pubDate&lt;/code&gt; needed yet&lt;/li&gt;
&lt;li&gt;Finish writing → &lt;code&gt;draft: false&lt;/code&gt;, set a future &lt;code&gt;pubDate&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Push → the post sits invisibly in the repository until its date arrives&lt;/li&gt;
&lt;li&gt;Morning of the publish date → the scheduled build picks it up automatically&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The only thing to be careful about: if you push &lt;code&gt;draft: false&lt;/code&gt; with a &lt;em&gt;past&lt;/em&gt;&lt;br&gt;
&lt;code&gt;pubDate&lt;/code&gt;, the post goes live immediately on that deploy rather than waiting for&lt;br&gt;
the next scheduled build. Past dates are treated as "already due", not scheduled.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't do
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Minute-precision timing.&lt;/strong&gt; Builds take a minute or two, so "publish on April&lt;br&gt;
20th" means "publish sometime between 07:45 and ~08:00 UTC on April 20th". For a&lt;br&gt;
personal blog that's entirely fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build deduplication.&lt;/strong&gt; If you push a code change on the same morning, Netlify&lt;br&gt;
may queue two builds back to back. Both would produce the correct result — the&lt;br&gt;
second one is just redundant. You could add a check in the function to skip the&lt;br&gt;
trigger if a recent deploy already exists, but it's rarely worth the complexity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unpublishing.&lt;/strong&gt; Moving a post's &lt;code&gt;pubDate&lt;/code&gt; forward while keeping &lt;code&gt;draft: false&lt;/code&gt;&lt;br&gt;
will not remove it from the live site because Netlify serves the last successful&lt;br&gt;
build until a new one is deployed. Drafts (&lt;code&gt;draft: true&lt;/code&gt;) are the right tool for&lt;br&gt;
keeping content off the site.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;With the three pieces in place, scheduled publishing runs without any manual&lt;br&gt;
intervention. &lt;code&gt;drafts.ts&lt;/code&gt; gives you &lt;code&gt;isPublished&lt;/code&gt; for filtering post lists and&lt;br&gt;
&lt;code&gt;isPubliclyPublished&lt;/code&gt; for feeds and counts. The scheduled function fires daily&lt;br&gt;
at the time you configured and triggers a fresh build. The build hook in the&lt;br&gt;
Netlify dashboard is the only setup step that lives outside the repository.&lt;/p&gt;

&lt;p&gt;The authoring workflow reduces to: write the post, set &lt;code&gt;draft: false&lt;/code&gt; with a&lt;br&gt;
future &lt;code&gt;pubDate&lt;/code&gt;, push, and walk away. The scheduled build on publish day takes&lt;br&gt;
care of the rest.&lt;/p&gt;

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

&lt;p&gt;If you're building a content pipeline, a scheduled job, or anything that needs&lt;br&gt;
reliable deploy automation — I'm available for consulting. &lt;a href="https://dev.to/contact"&gt;Get in touch via the&lt;br&gt;
contact page&lt;/a&gt; and tell me what you're working on.&lt;/p&gt;

</description>
      <category>astro</category>
      <category>netlify</category>
      <category>engineering</category>
    </item>
    <item>
      <title>Sending new post notifications with Resend</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Fri, 15 May 2026 13:48:00 +0000</pubDate>
      <link>https://dev.to/sourcier/sending-new-post-notifications-with-resend-5ghp</link>
      <guid>https://dev.to/sourcier/sending-new-post-notifications-with-resend-5ghp</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/new-post-notifications-resend" rel="noopener noreferrer"&gt;Sending new post notifications with Resend&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;When a new post goes live, I want subscribers to know about it. The mailing list&lt;br&gt;
runs through &lt;a href="https://resend.com" rel="noopener noreferrer"&gt;Resend&lt;/a&gt; — subscribers are stored in a Resend&lt;br&gt;
Segment, and broadcasting to them means calling the Resend Broadcasts API. The&lt;br&gt;
question was: how do I trigger that broadcast as part of the publish flow, without&lt;br&gt;
adding complexity to the build pipeline?&lt;/p&gt;

&lt;p&gt;The answer is a standalone Node.js script — &lt;code&gt;scripts/notify-new-post.js&lt;/code&gt; — that&lt;br&gt;
runs manually after publishing. It reads frontmatter directly, builds an HTML email,&lt;br&gt;
previews it in the terminal, and asks for confirmation before sending.&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%2FZmxvd2NoYXJ0IFRECiAgICBBWyJub2RlIHNjcmlwdHMvbm90aWZ5LW5ldy1wb3N0LmpzIl0gLS0-IEJbIlNjYW4gcG9zdCBkaXJlY3Rvcmllc1xucGFyc2UgZnJvbnRtYXR0ZXIiXQogICAgQiAtLT4gQ1siRmlsdGVyOiBkcmFmdCA9PSBmYWxzZVxucHViRGF0ZSBpbiBwYXN0IHdlZWsiXQogICAgQyAtLT4gRFsiUHJvbXB0OiBzZWxlY3QgcG9zdCJdCiAgICBEIC0tPiBFWyJCdWlsZCBIVE1MICsgcGxhaW4gdGV4dFxuaW5saW5lIENTUyBzdHlsZXMiXQogICAgRSAtLT4gRlsiUHJpbnQgcHJldmlldyB0byB0ZXJtaW5hbCJdCiAgICBGIC0tPiBHeyJDb25maXJtIHNlbmQ_IHkvTiJ9CiAgICBHIC0tPnx5fCBIWyJQT1NUIC9icm9hZGNhc3RzXG5DcmVhdGUgYnJvYWRjYXN0Il0KICAgIEcgLS0-fE58IElbIkFib3J0ZWQg4oCUIG5vIGVtYWlsIHNlbnQiXQogICAgSCAtLT4gSlsiUE9TVCAvYnJvYWRjYXN0cy97aWR9L3NlbmRcbkRpc3BhdGNoIHRvIHNlZ21lbnQiXQogICAgSiAtLT4gS1siRW1haWwgZGVsaXZlcmVkXG50byBhbGwgc2VnbWVudCBzdWJzY3JpYmVycyJd" 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%2FZmxvd2NoYXJ0IFRECiAgICBBWyJub2RlIHNjcmlwdHMvbm90aWZ5LW5ldy1wb3N0LmpzIl0gLS0-IEJbIlNjYW4gcG9zdCBkaXJlY3Rvcmllc1xucGFyc2UgZnJvbnRtYXR0ZXIiXQogICAgQiAtLT4gQ1siRmlsdGVyOiBkcmFmdCA9PSBmYWxzZVxucHViRGF0ZSBpbiBwYXN0IHdlZWsiXQogICAgQyAtLT4gRFsiUHJvbXB0OiBzZWxlY3QgcG9zdCJdCiAgICBEIC0tPiBFWyJCdWlsZCBIVE1MICsgcGxhaW4gdGV4dFxuaW5saW5lIENTUyBzdHlsZXMiXQogICAgRSAtLT4gRlsiUHJpbnQgcHJldmlldyB0byB0ZXJtaW5hbCJdCiAgICBGIC0tPiBHeyJDb25maXJtIHNlbmQ_IHkvTiJ9CiAgICBHIC0tPnx5fCBIWyJQT1NUIC9icm9hZGNhc3RzXG5DcmVhdGUgYnJvYWRjYXN0Il0KICAgIEcgLS0-fE58IElbIkFib3J0ZWQg4oCUIG5vIGVtYWlsIHNlbnQiXQogICAgSCAtLT4gSlsiUE9TVCAvYnJvYWRjYXN0cy97aWR9L3NlbmRcbkRpc3BhdGNoIHRvIHNlZ21lbnQiXQogICAgSiAtLT4gS1siRW1haWwgZGVsaXZlcmVkXG50byBhbGwgc2VnbWVudCBzdWJzY3JpYmVycyJd" alt="Mermaid diagram" width="577" height="1402"&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/new-post-notifications-resend" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/new-post-notifications-resend&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  Why a script rather than a build hook
&lt;/h2&gt;

&lt;p&gt;There are a few reasons.&lt;/p&gt;

&lt;p&gt;A build hook would run on every deploy, including deploys for unrelated changes like&lt;br&gt;
CSS fixes or draft work. Broadcasts should only happen for new public posts —&lt;br&gt;
triggering them from the build process would require additional logic to detect&lt;br&gt;
whether anything post-worthy had actually changed, which gets complicated quickly.&lt;/p&gt;

&lt;p&gt;Running the script manually is intentional friction. It forces a moment of review&lt;br&gt;
before an email goes out to every subscriber. That's the right default.&lt;/p&gt;
&lt;h2&gt;
  
  
  Dependencies
&lt;/h2&gt;

&lt;p&gt;The script uses one external dependency: &lt;a href="https://github.com/SBoudrias/Inquirer.js" rel="noopener noreferrer"&gt;&lt;code&gt;@inquirer/prompts&lt;/code&gt;&lt;/a&gt;&lt;br&gt;
for interactive select and confirmation prompts. Everything else is Node.js built-ins —&lt;br&gt;
&lt;code&gt;readFileSync&lt;/code&gt;, &lt;code&gt;readdirSync&lt;/code&gt;, path utilities. No Astro, no Zod, no content collections.&lt;/p&gt;
&lt;h2&gt;
  
  
  Reading frontmatter without a build
&lt;/h2&gt;

&lt;p&gt;The script includes a minimal frontmatter parser rather than pulling in a YAML library:&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;parseFrontmatter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^---&lt;/span&gt;&lt;span class="se"&gt;\r?\n([\s\S]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;?)\r?\n&lt;/span&gt;&lt;span class="sr"&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;match&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;yaml&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;match&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="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="p"&gt;{};&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;yaml&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="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\r?\n&lt;/span&gt;&lt;span class="sr"&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;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;(\w&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;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;"'&amp;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;]?\s&lt;/span&gt;&lt;span class="sr"&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;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="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;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Handle YAML block scalar for description (&amp;gt;- or &amp;gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;descBlock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;yaml&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^description:&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&amp;gt;-&lt;/span&gt;&lt;span class="se"&gt;?\r?\n((?:[&lt;/span&gt;&lt;span class="sr"&gt; &lt;/span&gt;&lt;span class="se"&gt;\t]&lt;/span&gt;&lt;span class="sr"&gt;+.+&lt;/span&gt;&lt;span class="se"&gt;\r?\n?)&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/m&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;descBlock&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;descBlock&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\r?\n&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;l&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;l&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="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;join&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="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This only handles simple &lt;code&gt;key: value&lt;/code&gt; lines and the &lt;code&gt;&amp;gt;-&lt;/code&gt; block scalar for&lt;br&gt;
&lt;code&gt;description&lt;/code&gt;. It's intentionally minimal — the script doesn't need to parse&lt;br&gt;
the full YAML AST.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;listPostIds&lt;/code&gt; function finds posts published in the past week that aren't drafts.&lt;br&gt;
Each directory read is wrapped in a try/catch so unreadable or malformed posts are&lt;br&gt;
silently skipped rather than crashing the script:&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;listPostIds&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;postsDir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;collections&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;posts&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;oneWeekAgo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;7&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;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;1000&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;readdirSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;postsDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;withFileTypes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;d&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;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isDirectory&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;d&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;try&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;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;postsDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;d&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;index.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;utf8&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;fm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseFrontmatter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pubDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fm&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="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;fm&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;getTime&lt;/span&gt;&lt;span class="p"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isDraft&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;draft&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;true&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;d&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="nx"&gt;pubDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isDraft&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="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="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;p&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;p&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&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;isDraft&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;pubDate&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;oneWeekAgo&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;pubDate&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;pubDate&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;p&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;p&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Environment variables
&lt;/h2&gt;

&lt;p&gt;The script reads secrets from a &lt;code&gt;.env&lt;/code&gt; file in the project root (using Node 20.12's&lt;br&gt;
&lt;code&gt;process.loadEnvFile&lt;/code&gt;) or from shell environment variables — shell variables take&lt;br&gt;
precedence:&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;envFile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.env&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="nf"&gt;existsSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;envFile&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="k"&gt;typeof&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;loadEnvFile&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;function&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;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loadEnvFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;envFile&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;Required variables:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;RESEND_API_KEY&lt;/code&gt; — Resend API key with broadcast send permissions&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;RESEND_SEGMENT_ID&lt;/code&gt; — the Segment ID to broadcast to&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SITE_URL&lt;/code&gt; — base URL used to construct post links (defaults to &lt;code&gt;https://sourcier.uk&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Optional variables:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;NOTIFY_FROM_EMAIL&lt;/code&gt; — the &lt;code&gt;From:&lt;/code&gt; address in the broadcast (defaults to &lt;code&gt;Roger @ Sourcier &amp;lt;hello@sourcier.uk&amp;gt;&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;RESEND_TOPIC_ID&lt;/code&gt; — if set, attaches a topic to the broadcast for unsubscribe granularity&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The email content
&lt;/h2&gt;

&lt;p&gt;The broadcast is sent with both an HTML body and a plain-text fallback. Email clients&lt;br&gt;
that can't render HTML receive the plain-text version; everything else gets the styled one.&lt;/p&gt;

&lt;p&gt;The HTML is built as an inline-styled string. Email clients don't support external&lt;br&gt;
stylesheets or CSS custom properties — everything needs to be inline and use safe font stacks:&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;buildHtml&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="s2"&gt;`
&amp;lt;div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;max-width:560px;margin:0 auto;padding:2rem 1.5rem;color:#0f0f0f"&amp;gt;
  &amp;lt;p style="margin:0 0 1.5rem;line-height:1.6"&amp;gt;Hi — I just published something new on Sourcier.&amp;lt;/p&amp;gt;
  &amp;lt;p style="font-size:1.5rem;font-weight:800;letter-spacing:-0.01em;margin:0 0 1rem;line-height:1.2"&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/p&amp;gt;
  &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;excerpt&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;p style="margin:0 0 1.5rem;line-height:1.6;color:#444"&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;excerpt&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/p&amp;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="s2"&gt;
  &amp;lt;a href="&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" style="display:inline-block;background:#e8006a;color:#fff;text-decoration:none;padding:0.65rem 1.5rem;font-weight:700;font-size:0.875rem;letter-spacing:0.04em;text-transform:uppercase"&amp;gt;Read the post →&amp;lt;/a&amp;gt;
  &amp;lt;p style="margin:1.5rem 0 0;line-height:1.6;color:#444"&amp;gt;If it sparks any thoughts, I'd love to hear them — there's a comments section at the bottom of the post.&amp;lt;/p&amp;gt;
  &amp;lt;p style="margin:1rem 0 0;line-height:1.6"&amp;gt;— Roger&amp;lt;/p&amp;gt;
  &amp;lt;hr style="margin:2rem 0;border:none;border-top:1px solid #e5e5e5"&amp;gt;
  &amp;lt;p style="margin:0;color:#999;font-size:0.8125rem;line-height:1.5"&amp;gt;
    You're receiving this because you subscribed at sourcier.uk.&amp;lt;br&amp;gt;
    &amp;lt;a href="{{{RESEND_UNSUBSCRIBE_URL}}}" style="color:#999"&amp;gt;Unsubscribe&amp;lt;/a&amp;gt;
  &amp;lt;/p&amp;gt;
&amp;lt;/div&amp;gt;`&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;{{{RESEND_UNSUBSCRIBE_URL}}}&lt;/code&gt; placeholder is Resend's broadcast template&lt;br&gt;
variable — it's replaced at send time with a personalised unsubscribe link for each&lt;br&gt;
recipient. It's required by anti-spam regulations (CAN-SPAM, GDPR).&lt;/p&gt;
&lt;h2&gt;
  
  
  Confirmation before sending
&lt;/h2&gt;

&lt;p&gt;The script uses &lt;code&gt;@inquirer/prompts&lt;/code&gt; for both the post selection and the send confirmation.&lt;br&gt;
After printing the preview, it asks:&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;shouldSend&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;confirm&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Send this to all subscribers?&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;default&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="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;shouldSend&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Aborted — nothing was sent.&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="nf"&gt;exit&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The default is &lt;code&gt;false&lt;/code&gt;, so pressing Enter without typing &lt;code&gt;y&lt;/code&gt; aborts safely.&lt;/p&gt;

&lt;p&gt;If the post has &lt;code&gt;draft: true&lt;/code&gt; in its frontmatter, the script surfaces a warning&lt;br&gt;
and asks a second confirmation before continuing. It doesn't block sending outright —&lt;br&gt;
there are legitimate reasons to test-send a draft — but it makes the state explicit.&lt;/p&gt;
&lt;h2&gt;
  
  
  The two-step API call
&lt;/h2&gt;

&lt;p&gt;Sending a broadcast is two separate calls to the Resend API. The first creates the&lt;br&gt;
broadcast and returns an ID:&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;createRes&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;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;RESEND_API&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/broadcasts`&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="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;apiKey&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="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="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FROM&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SUBJECT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;segment_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;segmentId&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;createRes&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second dispatches it to the segment:&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;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;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;RESEND_API&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/broadcasts/&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;/send`&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="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;apiKey&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;p&gt;Separating creation from dispatch is useful — it means the broadcast exists in the&lt;br&gt;
Resend dashboard before it's sent, so you can inspect or cancel it if something looks wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running the script
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node scripts/notify-new-post.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script lists posts published in the past week, prompts to select one, shows a&lt;br&gt;
preview, and waits for confirmation. Pass &lt;code&gt;--debug&lt;/code&gt; to log the full API request&lt;br&gt;
payload and response status to the terminal without sending anything.&lt;/p&gt;

&lt;p&gt;Total runtime is a few seconds.&lt;/p&gt;




&lt;p&gt;The approach here is deliberately low-tech: no build integration, no CI step, no&lt;br&gt;
webhook. A standalone script with a confirmation prompt is the right level of&lt;br&gt;
automation for something that goes out to every subscriber. The friction is the&lt;br&gt;
feature.&lt;/p&gt;

&lt;p&gt;The full script is in the&lt;br&gt;
&lt;a href="https://github.com/sourcier/sourcier.uk/blob/main/scripts/notify-new-post.js" rel="noopener noreferrer"&gt;sourcier.uk repository&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>astro</category>
      <category>resend</category>
      <category>engineering</category>
    </item>
    <item>
      <title>Deploying an Astro blog to Netlify</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Thu, 14 May 2026 09:03:24 +0000</pubDate>
      <link>https://dev.to/sourcier/deploying-an-astro-blog-to-netlify-1190</link>
      <guid>https://dev.to/sourcier/deploying-an-astro-blog-to-netlify-1190</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/deploying-astro-netlify" rel="noopener noreferrer"&gt;Deploying an Astro blog to Netlify&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 blog runs entirely on Netlify's free tier. Static HTML goes out over the CDN,&lt;br&gt;
serverless functions handle comments and the mailing list, and an edge function gates&lt;br&gt;
deploy previews — all without any infrastructure to manage.&lt;/p&gt;

&lt;p&gt;This post covers the configuration details: what goes in &lt;code&gt;netlify.toml&lt;/code&gt;, how&lt;br&gt;
functions are set up, which environment variables are required, and how the deploy&lt;br&gt;
preview workflow integrates with the draft post system.&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%2FZmxvd2NoYXJ0IFRECiAgICBHSVRbIkdpdCBwdXNoIHRvIG1haW4iXSAtLT4gQlVJTERbIk5ldGxpZnlcbmFzdHJvIGJ1aWxkIl0KICAgIEJVSUxEIC0tPiBDRE5bIlN0YXRpYyBhc3NldHNcbk5ldGxpZnkgQ0ROIl0KICAgIEJVSUxEIC0tPiBGRElSWyJuZXRsaWZ5L2Z1bmN0aW9ucy8iXQogICAgRkRJUiAtLT4gRjFbImNvbW1lbnQtaGFuZGxlciJdCiAgICBGRElSIC0tPiBGMlsiYXBwcm92ZS1jb21tZW50Il0KICAgIEZESVIgLS0-IEYzWyJnZXQtY29tbWVudHMiXQogICAgRkRJUiAtLT4gRjRbInN1YnNjcmliZSJdCiAgICBCVUlMRCAtLT4gRUZESVJbIm5ldGxpZnkvZWRnZS1mdW5jdGlvbnMvIl0KICAgIEVGRElSIC0tPiBFRjFbInByZXZpZXctYXV0aFxuZ2F0ZXMgYWxsIHJvdXRlcyJdCiAgICBFTlZCWyJCdWlsZC10aW1lIGVudiB2YXJzXG5QVUJMSUNfKiArIFNIT1dfRFJBRlRTIl0gLS4tPnxiYWtlZCBpbnRvIEhUTUx8IEJVSUxECiAgICBFTlZSWyJSdW50aW1lIGVudiB2YXJzXG5zZWNyZXRzICsgdG9rZW5zIl0gLS4tPnxpbmplY3RlZCBhdCByZXF1ZXN0IHRpbWV8IEZESVIKICAgIEVOVlIgLS4tPnxQUkVWSUVXX1BBU1NDT0RFfCBFRjEKICAgIEdJVDJbIkJyYW5jaCBwdXNoIC8gUFIiXSAtLT4gUFJFVklFV1siRGVwbG95IHByZXZpZXdcbmh0dHBzOi8vZGVwbG95LXByZXZpZXctTi0tc2l0ZS5uZXRsaWZ5LmFwcCJdCiAgICBFRjEgLS4tPnxwYXNzY29kZSBnYXRlfCBQUkVWSUVX" 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%2FZmxvd2NoYXJ0IFRECiAgICBHSVRbIkdpdCBwdXNoIHRvIG1haW4iXSAtLT4gQlVJTERbIk5ldGxpZnlcbmFzdHJvIGJ1aWxkIl0KICAgIEJVSUxEIC0tPiBDRE5bIlN0YXRpYyBhc3NldHNcbk5ldGxpZnkgQ0ROIl0KICAgIEJVSUxEIC0tPiBGRElSWyJuZXRsaWZ5L2Z1bmN0aW9ucy8iXQogICAgRkRJUiAtLT4gRjFbImNvbW1lbnQtaGFuZGxlciJdCiAgICBGRElSIC0tPiBGMlsiYXBwcm92ZS1jb21tZW50Il0KICAgIEZESVIgLS0-IEYzWyJnZXQtY29tbWVudHMiXQogICAgRkRJUiAtLT4gRjRbInN1YnNjcmliZSJdCiAgICBCVUlMRCAtLT4gRUZESVJbIm5ldGxpZnkvZWRnZS1mdW5jdGlvbnMvIl0KICAgIEVGRElSIC0tPiBFRjFbInByZXZpZXctYXV0aFxuZ2F0ZXMgYWxsIHJvdXRlcyJdCiAgICBFTlZCWyJCdWlsZC10aW1lIGVudiB2YXJzXG5QVUJMSUNfKiArIFNIT1dfRFJBRlRTIl0gLS4tPnxiYWtlZCBpbnRvIEhUTUx8IEJVSUxECiAgICBFTlZSWyJSdW50aW1lIGVudiB2YXJzXG5zZWNyZXRzICsgdG9rZW5zIl0gLS4tPnxpbmplY3RlZCBhdCByZXF1ZXN0IHRpbWV8IEZESVIKICAgIEVOVlIgLS4tPnxQUkVWSUVXX1BBU1NDT0RFfCBFRjEKICAgIEdJVDJbIkJyYW5jaCBwdXNoIC8gUFIiXSAtLT4gUFJFVklFV1siRGVwbG95IHByZXZpZXdcbmh0dHBzOi8vZGVwbG95LXByZXZpZXctTi0tc2l0ZS5uZXRsaWZ5LmFwcCJdCiAgICBFRjEgLS4tPnxwYXNzY29kZSBnYXRlfCBQUkVWSUVX" alt="Mermaid diagram" width="1393" height="702"&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/deploying-astro-netlify" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/deploying-astro-netlify&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  netlify.toml
&lt;/h2&gt;

&lt;p&gt;Everything Netlify needs to know about building and running the site is in&lt;br&gt;
&lt;code&gt;netlify.toml&lt;/code&gt; at the project root:&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;[dev]&lt;/span&gt;
  &lt;span class="py"&gt;framework&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"astro"&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;"astro dev"&lt;/span&gt;
  &lt;span class="py"&gt;targetPort&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4321&lt;/span&gt;
  &lt;span class="py"&gt;autoLaunch&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&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;"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;span class="nn"&gt;[functions]&lt;/span&gt;
  &lt;span class="py"&gt;directory&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"netlify/functions"&lt;/span&gt;
  &lt;span class="py"&gt;node_bundler&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"esbuild"&lt;/span&gt;

&lt;span class="nn"&gt;[[headers]]&lt;/span&gt;
  &lt;span class="py"&gt;for&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/*"&lt;/span&gt;
  &lt;span class="nn"&gt;[headers.values]&lt;/span&gt;
    &lt;span class="py"&gt;Cache-Control&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"public, max-age=0, must-revalidate"&lt;/span&gt;

&lt;span class="nn"&gt;[[headers]]&lt;/span&gt;
  &lt;span class="py"&gt;for&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/_astro/*"&lt;/span&gt;
  &lt;span class="nn"&gt;[headers.values]&lt;/span&gt;
    &lt;span class="py"&gt;Cache-Control&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"public, max-age=31536000, immutable"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;[dev]&lt;/code&gt; section configures Netlify Dev — &lt;code&gt;netlify dev&lt;/code&gt; in the terminal starts&lt;br&gt;
both the Astro dev server and the function runtime together, so you can test&lt;br&gt;
serverless functions against the local site. &lt;code&gt;autoLaunch = false&lt;/code&gt; prevents Netlify&lt;br&gt;
Dev from opening a browser tab automatically.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;[build]&lt;/code&gt; points to the Astro build command and the output directory. Astro outputs&lt;br&gt;
to &lt;code&gt;dist/&lt;/code&gt; by default.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;[functions]&lt;/code&gt; tells Netlify where to find the serverless functions and which&lt;br&gt;
bundler to use. &lt;code&gt;esbuild&lt;/code&gt; is significantly faster than webpack for bundling Node.js&lt;br&gt;
functions and handles ES module imports correctly.&lt;/p&gt;
&lt;h3&gt;
  
  
  Cache headers
&lt;/h3&gt;

&lt;p&gt;The two &lt;code&gt;[[headers]]&lt;/code&gt; blocks implement a split caching strategy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/*&lt;/code&gt; — HTML pages get &lt;code&gt;max-age=0, must-revalidate&lt;/code&gt;. The browser caches the response but revalidates on every request. When a new deploy lands, Netlify invalidates the CDN edge cache, so clients pick up the new version immediately.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/_astro/*&lt;/code&gt; — Astro outputs hashed filenames for all JS and CSS bundles (e.g. &lt;code&gt;_astro/index.B1fJkLmN.js&lt;/code&gt;). Because the hash changes whenever the content changes, these assets can be cached indefinitely with &lt;code&gt;max-age=31536000, immutable&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without the second rule, browsers would re-fetch unchanged bundles on every page load. Without the first, stale HTML pages could reference bundle URLs that no longer exist.&lt;/p&gt;
&lt;h2&gt;
  
  
  The functions directory
&lt;/h2&gt;

&lt;p&gt;Netlify Functions are TypeScript files in &lt;code&gt;netlify/functions/&lt;/code&gt;. Each file is a&lt;br&gt;
separate function, accessible at &lt;code&gt;/.netlify/functions/{filename}&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;netlify/
  functions/
    approve-comment.ts   → /.netlify/functions/approve-comment
    comment-handler.ts   → /.netlify/functions/comment-handler
    get-comments.ts      → /.netlify/functions/get-comments
    subscribe.ts         → /.netlify/functions/subscribe
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Functions are not bundled with the site — Netlify deploys them separately. The&lt;br&gt;
&lt;code&gt;node_bundler = "esbuild"&lt;/code&gt; setting handles tree-shaking and resolves &lt;code&gt;import&lt;/code&gt;&lt;br&gt;
statements so each function file can use npm packages.&lt;/p&gt;
&lt;h2&gt;
  
  
  Edge functions
&lt;/h2&gt;

&lt;p&gt;Edge functions run at Netlify's CDN edge — before the response is served — rather&lt;br&gt;
than as on-demand Lambda invocations. They live in &lt;code&gt;netlify/edge-functions/&lt;/code&gt; and&lt;br&gt;
are configured through the exported &lt;code&gt;config&lt;/code&gt; object in each file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="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;&lt;code&gt;preview-auth.ts&lt;/code&gt; runs on every request. It reads the &lt;code&gt;PREVIEW_PASSCODE&lt;/code&gt;&lt;br&gt;
environment variable. When no passcode is configured (production), the function&lt;br&gt;
calls &lt;code&gt;context.next()&lt;/code&gt; immediately and is a transparent pass-through. When a&lt;br&gt;
passcode is set (preview deploys), it gates the entire site behind a passcode form&lt;br&gt;
and sets an &lt;code&gt;HttpOnly; Secure; SameSite=Strict&lt;/code&gt; session cookie on success.&lt;/p&gt;

&lt;p&gt;This is how draft posts are safely visible on deploy previews without being&lt;br&gt;
publicly accessible. The &lt;code&gt;SHOW_DRAFTS=true&lt;/code&gt; build variable makes the Astro build&lt;br&gt;
include draft posts; the edge function ensures only someone with the passcode can&lt;br&gt;
reach them.&lt;/p&gt;
&lt;h2&gt;
  
  
  Environment variables
&lt;/h2&gt;

&lt;p&gt;None of the secrets are stored in &lt;code&gt;netlify.toml&lt;/code&gt;. Environment variables split into&lt;br&gt;
two groups depending on when they are consumed.&lt;/p&gt;
&lt;h3&gt;
  
  
  Build-time variables
&lt;/h3&gt;

&lt;p&gt;These are read by &lt;code&gt;astro build&lt;/code&gt; and baked into the generated HTML. Any variable&lt;br&gt;
referenced via &lt;code&gt;import.meta.env&lt;/code&gt; falls into this category and must be present when&lt;br&gt;
the build runs:&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;What it is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SHOW_DRAFTS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Set to &lt;code&gt;"true"&lt;/code&gt; on preview branch deploys to include draft and scheduled posts&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h3&gt;
  
  
  Runtime variables
&lt;/h3&gt;

&lt;p&gt;These are read by serverless and edge functions at request time and are never&lt;br&gt;
embedded in the built HTML. Keep them in the Netlify dashboard only&lt;br&gt;
(Site configuration → Environment variables):&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;Used by&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PREVIEW_PASSCODE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;preview-auth.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Passcode protecting deploy previews — leave unset in production&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For local development, copy these into a &lt;code&gt;.env&lt;/code&gt; file in the project root. The&lt;br&gt;
functions read them via &lt;code&gt;process.env&lt;/code&gt;. Never commit &lt;code&gt;.env&lt;/code&gt; — add it to &lt;code&gt;.gitignore&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;PREVIEW_PASSCODE&lt;/code&gt; note:&lt;/strong&gt; Leave this unset in the production site context. When&lt;br&gt;
unset, &lt;code&gt;preview-auth.ts&lt;/code&gt; is a transparent pass-through and adds no overhead.&lt;br&gt;
Generate a strong value with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 32
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Deploy previews and draft posts
&lt;/h2&gt;

&lt;p&gt;Netlify automatically generates a deploy preview URL for every pull request and&lt;br&gt;
branch push. The URL takes the form &lt;code&gt;https://deploy-preview-{n}--{site-name}.netlify.app&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Draft posts are hidden by default. The &lt;code&gt;isPublished()&lt;/code&gt; helper in&lt;br&gt;
&lt;code&gt;src/utils/drafts.ts&lt;/code&gt; reads the &lt;code&gt;SHOW_DRAFTS&lt;/code&gt; build-time variable:&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;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;showDrafts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="o"&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;meta&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;SHOW_DRAFTS&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isPublished&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="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;pubDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}):&lt;/span&gt; &lt;span class="nx"&gt;boolean&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;showDrafts&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;true&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;isPubliclyPublished&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Setting &lt;code&gt;SHOW_DRAFTS=true&lt;/code&gt; on the preview branch context in the Netlify dashboard&lt;br&gt;
makes the build include draft and scheduled posts. The &lt;code&gt;preview-auth&lt;/code&gt; edge function&lt;br&gt;
then gates that deploy behind a passcode, so the preview URL is not publicly accessible.&lt;/p&gt;

&lt;p&gt;This is more reliable than temporarily setting &lt;code&gt;draft: false&lt;/code&gt; in frontmatter and&lt;br&gt;
remembering to reset it before merging. There is no risk of accidentally publishing&lt;br&gt;
a post that was only meant to be previewed.&lt;/p&gt;

&lt;h2&gt;
  
  
  One-time Netlify dashboard setup for comments
&lt;/h2&gt;

&lt;p&gt;The comments webhook isn't in &lt;code&gt;netlify.toml&lt;/code&gt; — it's a one-time setup in the&lt;br&gt;
Netlify dashboard:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;strong&gt;Forms&lt;/strong&gt; → &lt;code&gt;blog-comments&lt;/code&gt; → &lt;strong&gt;Form notifications&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Add notification → &lt;strong&gt;Outgoing webhook&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;URL: &lt;code&gt;https://your-site.netlify.app/.netlify/functions/comment-handler&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This wires up the webhook that triggers the moderation email whenever a new comment&lt;br&gt;
arrives. It only needs to be configured once per site, which is why it's not in the&lt;br&gt;
TOML file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;netlify.toml&lt;/code&gt; in place, the deployment configuration is declarative and version-controlled alongside the site code. The split caching strategy — aggressive immutable caching for hashed assets, revalidate-always for HTML — keeps the site fast without ever serving stale pages after a deploy.&lt;/p&gt;

&lt;p&gt;The functions and edge-functions directories draw a clear line between work that happens at request time on the server and at the CDN edge. &lt;code&gt;preview-auth&lt;/code&gt; in particular is what makes safe draft previewing possible — &lt;code&gt;SHOW_DRAFTS&lt;/code&gt; controls what gets built, and the passcode gate controls who can see it.&lt;/p&gt;

&lt;p&gt;All the secrets stay in the Netlify dashboard, nothing sensitive is in the repository, and a fresh deploy of the whole setup is reproducible from the TOML file and the environment variable list above.&lt;/p&gt;

</description>
      <category>astro</category>
      <category>netlify</category>
      <category>engineering</category>
    </item>
  </channel>
</rss>
