<?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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3877016%2Fe616827e-3f77-4299-a5fe-2503dc341bce.jpeg</url>
      <title>DEV Community: Roger Rajaratnam</title>
      <link>https://dev.to/sourcier</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sourcier"/>
    <language>en</language>
    <item>
      <title>Adding 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>
    <item>
      <title>Page history and credits on a static blog</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Wed, 13 May 2026 19:13:56 +0000</pubDate>
      <link>https://dev.to/sourcier/page-history-and-credits-on-a-static-blog-1a6m</link>
      <guid>https://dev.to/sourcier/page-history-and-credits-on-a-static-blog-1a6m</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/page-history-credits" rel="noopener noreferrer"&gt;Page history and credits on a static 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;Most blog posts operate on an implicit contract: once published, they don't change.&lt;br&gt;
Or if they do change, the change is invisible. This is fine for minor edits, but&lt;br&gt;
when you correct something meaningful — a wrong date, a misattributed quote, broken&lt;br&gt;
code — readers who've already seen the post have no way of knowing.&lt;/p&gt;

&lt;p&gt;This blog has two optional features that address this: a page history log and a&lt;br&gt;
credits section. Both are schema-validated fields in the content collection and&lt;br&gt;
rendered at the bottom of post pages.&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%2FZmxvd2NoYXJ0IFRECiAgQVtNYXJrZG93biBmcm9udG1hdHRlclxuY29sbGVjdGlvbnMvcG9zdHMvPHNsdWc-L2luZGV4Lm1kXSAtLT4gQltDb250ZW50IHNjaGVtYVxuc3JjL2NvbnRlbnQuY29uZmlnLnRzXQogIEIgLS0-IENbImdldENvbGxlY3Rpb24oJ3Bvc3RzJykiXQogIEMgLS0-IERbTWFya2Rvd25Qb3N0TGF5b3V0LmFzdHJvXQogIEQgLS0-IEVbUGFnZUhpc3RvcnkuYXN0cm9cbnJlbmRlcnMgaGlzdG9yeSBlbnRyaWVzXQogIEQgLS0-IEZbUGFnZUNyZWRpdHMuYXN0cm9cbnJlbmRlcnMgY3JlZGl0IGVudHJpZXNdCiAgRSAtLT4gR1tSZW5kZXJlZCBwb3N0IHBhZ2VdCiAgRiAtLT4gRw" 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%2FZmxvd2NoYXJ0IFRECiAgQVtNYXJrZG93biBmcm9udG1hdHRlclxuY29sbGVjdGlvbnMvcG9zdHMvPHNsdWc-L2luZGV4Lm1kXSAtLT4gQltDb250ZW50IHNjaGVtYVxuc3JjL2NvbnRlbnQuY29uZmlnLnRzXQogIEIgLS0-IENbImdldENvbGxlY3Rpb24oJ3Bvc3RzJykiXQogIEMgLS0-IERbTWFya2Rvd25Qb3N0TGF5b3V0LmFzdHJvXQogIEQgLS0-IEVbUGFnZUhpc3RvcnkuYXN0cm9cbnJlbmRlcnMgaGlzdG9yeSBlbnRyaWVzXQogIEQgLS0-IEZbUGFnZUNyZWRpdHMuYXN0cm9cbnJlbmRlcnMgY3JlZGl0IGVudHJpZXNdCiAgRSAtLT4gR1tSZW5kZXJlZCBwb3N0IHBhZ2VdCiAgRiAtLT4gRw" alt="Mermaid diagram" width="586" height="662"&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/page-history-credits" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/page-history-credits&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  UI mockup
&lt;/h2&gt;

&lt;p&gt;The wireframe below shows the presentation intent for both metadata surfaces: a&lt;br&gt;
timeline-style history block and compact, pill-style credits.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxbm8xjtvl23s7m7hajv5.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%2Fxbm8xjtvl23s7m7hajv5.png" alt="Wireframe mockup showing the page history timeline and credits chip list rendered below a blog post, including annotation callouts for semantic tags and styling intent" width="700" height="520"&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/page-history-credits" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/page-history-credits&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  Page history
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;history&lt;/code&gt; field in a post's frontmatter is an optional array of revision entries:&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;history&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;datetime&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2026-03-26T00:00:00&lt;/span&gt;
    &lt;span class="na"&gt;note&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Initial publish.&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;datetime&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2026-03-27T12:00:00&lt;/span&gt;
    &lt;span class="na"&gt;note&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;-&lt;/span&gt;
      &lt;span class="s"&gt;Corrected the HMAC algorithm description — it&amp;amp;#39;s SHA-256, not SHA-1.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each entry has a &lt;code&gt;datetime&lt;/code&gt; (coerced to a &lt;code&gt;Date&lt;/code&gt; by Zod) and a &lt;code&gt;note&lt;/code&gt; string.&lt;br&gt;
Notes support inline HTML, so links to related pages and emphasis are possible.&lt;/p&gt;

&lt;p&gt;The Zod schema definition:&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;history&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;coerce&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="na"&gt;note&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&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;optional&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;PageHistory.astro&lt;/code&gt; renders the entries as an &lt;code&gt;&amp;lt;ol&amp;gt;&lt;/code&gt; — a chronological list where&lt;br&gt;
each &lt;code&gt;&amp;lt;li&amp;gt;&lt;/code&gt; pairs a &lt;code&gt;&amp;lt;time&amp;gt;&lt;/code&gt; element with a &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt; for the note:&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="page-history"&amp;gt;
  &amp;lt;p class="page-history__heading"&amp;gt;Page history&amp;lt;/p&amp;gt;
  &amp;lt;ol class="page-history__log"&amp;gt;
    {entries.map((entry) =&amp;gt; (
      &amp;lt;li class="page-history__entry"&amp;gt;
        &amp;lt;time
          class="page-history__time"
          datetime={entry.datetime.toISOString()}
        &amp;gt;
          {formatDatetime(entry.datetime)}
        &amp;lt;/time&amp;gt;
        &amp;lt;span class="page-history__note" set:html={entry.note} /&amp;gt;
      &amp;lt;/li&amp;gt;
    ))}
  &amp;lt;/ol&amp;gt;
&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;&amp;lt;time&amp;gt;&lt;/code&gt; element carries the machine-readable ISO 8601 datetime in its&lt;br&gt;
&lt;code&gt;datetime&lt;/code&gt; attribute. The human-readable text is formatted with &lt;code&gt;toLocaleDateString&lt;/code&gt;&lt;br&gt;
using the &lt;code&gt;en-GB&lt;/code&gt; locale.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;set:html&lt;/code&gt; is used for the note rather than &lt;code&gt;{entry.note}&lt;/code&gt; because notes can&lt;br&gt;
contain inline HTML. This is an intentional tradeoff — the content is&lt;br&gt;
author-controlled in a static repository, not user-submitted, so the XSS risk&lt;br&gt;
is the same as any other HTML in the site.&lt;/p&gt;

&lt;p&gt;Visually, the history block is rendered at reduced opacity (0.65) and with&lt;br&gt;
a left border — it's clearly secondary information, present for transparency&lt;br&gt;
rather than as a primary content element.&lt;/p&gt;
&lt;h2&gt;
  
  
  Credits
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;credits&lt;/code&gt; field follows the same pattern — an optional array, validated by Zod,&lt;br&gt;
with label, text, and an optional URL:&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;credits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Cover image&lt;/span&gt;
    &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Kelly Sikkema on Unsplash&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://unsplash.com/@kellysikkema&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Diagram library&lt;/span&gt;
    &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Mermaid&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://mermaid.js.org/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;credits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;optional&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;optional&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;PageCredits.astro&lt;/code&gt; renders them as a &lt;code&gt;&amp;lt;ul&amp;gt;&lt;/code&gt;. Each &lt;code&gt;&amp;lt;li&amp;gt;&lt;/code&gt; pairs the label with&lt;br&gt;
either an anchor or a plain &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt; depending on whether a URL is present. URLs&lt;br&gt;
use &lt;code&gt;target="_blank"&lt;/code&gt; with &lt;code&gt;rel="noopener noreferrer"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Attribution is a first-class concern here, not an afterthought. Every Unsplash cover&lt;br&gt;
image has its photographer credited. Libraries and tools that made a feature possible&lt;br&gt;
are listed. When a post is directly inspired by another person's work, that's&lt;br&gt;
acknowledged explicitly. This isn't just good etiquette — it's consistent with how&lt;br&gt;
I'd want my own work credited.&lt;/p&gt;

&lt;p&gt;Both components share the same visual treatment: muted, compact, below the main&lt;br&gt;
content and the share widget. They're there for the reader who cares about the&lt;br&gt;
detail, invisible to the reader who doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why both belong in the schema
&lt;/h2&gt;

&lt;p&gt;It would be easy to treat history and credits as presentational concerns — markdown&lt;br&gt;
at the bottom of a post, maintained by hand. Putting them in the schema instead&lt;br&gt;
means they're validated on every build, available to any component or page that&lt;br&gt;
needs them, and impossible to malform silently. The discipline of typing them enforces&lt;br&gt;
consistency: every credit has a label, every history entry has a datetime.&lt;/p&gt;

&lt;p&gt;Neither field is required. A post with no meaningful revision history doesn't need&lt;br&gt;
a history block. A post with no external sources doesn't need credits. The optionality&lt;br&gt;
is intentional — adding boilerplate entries just to fill a section would dilute the&lt;br&gt;
signal these features are meant to carry.&lt;/p&gt;

</description>
      <category>astro</category>
      <category>engineering</category>
      <category>meta</category>
    </item>
    <item>
      <title>Building a share widget with the Clipboard API</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Tue, 05 May 2026 10:59:07 +0000</pubDate>
      <link>https://dev.to/sourcier/building-a-share-widget-with-the-clipboard-api-4hco</link>
      <guid>https://dev.to/sourcier/building-a-share-widget-with-the-clipboard-api-4hco</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/share-post-clipboard" rel="noopener noreferrer"&gt;Building a share widget with the Clipboard API&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;Sharing a post is one of those interactions that looks trivial to implement, yet has&lt;br&gt;
a few subtle corners once you get into it, particularly around the copy-to-clipboard&lt;br&gt;
flow and URL construction for each channel.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SharePost.astro&lt;/code&gt; is a small component that covers four share targets: LinkedIn,&lt;br&gt;
Reddit, email, and a copy-link button. It has no external dependencies, no JavaScript&lt;br&gt;
frameworks, and no tracking.&lt;/p&gt;
&lt;h2&gt;
  
  
  The component props
&lt;/h2&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;title&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;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;vertical&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;variant&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;default&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;hero&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;sidebar&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;menu&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;&lt;code&gt;url&lt;/code&gt; is the fully qualified canonical URL of the post, passed in from&lt;br&gt;
&lt;code&gt;PageHero.astro&lt;/code&gt; as &lt;code&gt;Astro.url.href&lt;/code&gt;. &lt;code&gt;vertical&lt;/code&gt; flips the layout to a&lt;br&gt;
column stack for sidebar placement. &lt;code&gt;variant&lt;/code&gt; applies a modifier class that&lt;br&gt;
controls spacing and sizing for the different contexts the widget appears in.&lt;/p&gt;
&lt;h2&gt;
  
  
  LinkedIn share URL
&lt;/h2&gt;

&lt;p&gt;LinkedIn's share endpoint accepts a &lt;code&gt;url&lt;/code&gt; parameter:&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;linkedinUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="s2"&gt;`https://www.linkedin.com/sharing/share-offsite/?url=&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;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;encodeURIComponent&lt;/code&gt; is essential here. A raw URL with query parameters would&lt;br&gt;
break the LinkedIn endpoint's own query string parsing. The component doesn't&lt;br&gt;
pass the title separately: LinkedIn scrapes the OG tags from the shared URL and&lt;br&gt;
uses those for the preview. As long as &lt;code&gt;og:title&lt;/code&gt; and &lt;code&gt;og:description&lt;/code&gt; are set&lt;br&gt;
correctly (they are: see the &lt;a href="https://dev.to/blog/opengraph-seo-astro"&gt;OpenGraph post&lt;/a&gt;), the&lt;br&gt;
preview will be accurate.&lt;/p&gt;

&lt;p&gt;The link uses &lt;code&gt;target="_blank"&lt;/code&gt; with &lt;code&gt;rel="noopener noreferrer"&lt;/code&gt;. &lt;code&gt;noopener&lt;/code&gt;&lt;br&gt;
prevents the opened tab from accessing &lt;code&gt;window.opener&lt;/code&gt; (a known XSS vector).&lt;br&gt;
&lt;code&gt;noreferrer&lt;/code&gt; prevents the referrer header from being sent, which also implies&lt;br&gt;
&lt;code&gt;noopener&lt;/code&gt;, but including both is explicit and safe.&lt;/p&gt;
&lt;h2&gt;
  
  
  Reddit share URL
&lt;/h2&gt;

&lt;p&gt;Reddit's submission endpoint accepts both &lt;code&gt;url&lt;/code&gt; and &lt;code&gt;title&lt;/code&gt; parameters:&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;redditUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="s2"&gt;`https://www.reddit.com/submit?url=&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;url&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;title=&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;title&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;Unlike LinkedIn, Reddit doesn't scrape OG tags to pre-fill the submission title,&lt;br&gt;
so the title is passed explicitly. The submission form still lets the user edit&lt;br&gt;
both fields before posting.&lt;/p&gt;
&lt;h2&gt;
  
  
  Email share URL
&lt;/h2&gt;

&lt;p&gt;Email sharing uses a &lt;code&gt;mailto:&lt;/code&gt; URI with pre-populated subject and body:&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;emailBody&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="s2"&gt;`I thought you might find this interesting:\n\n"&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;"\n\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;emailUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="s2"&gt;`mailto:?subject=&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;title&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;body=&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;emailBody&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;Both the subject and body are &lt;code&gt;encodeURIComponent&lt;/code&gt;-encoded. Without encoding,&lt;br&gt;
special characters in the title (ampersands, quotes, question marks) would&lt;br&gt;
corrupt the &lt;code&gt;mailto:&lt;/code&gt; URI. The pre-populated body includes a brief framing line,&lt;br&gt;
the title in quotes, and the URL on its own line. This reads naturally when the&lt;br&gt;
recipient receives it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Copy link with the Clipboard API
&lt;/h2&gt;

&lt;p&gt;The copy button uses the asynchronous Clipboard API, which requires a secure&lt;br&gt;
context (HTTPS or localhost):&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="nb"&gt;document&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[data-copy-link-btn]&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;forEach&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bound&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="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bound&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="nx"&gt;btn&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;click&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="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;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&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;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&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="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clipboard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeText&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="k"&gt;catch&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="nf"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Copy this link&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="p"&gt;}&lt;/span&gt;

      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;label&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="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.copy-link-label&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;label&lt;/span&gt;&lt;span class="p"&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="nf"&gt;setAttribute&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-label&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;Link copied&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="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;title&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;Link copied&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;label&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;Copied!&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nf"&gt;setTimeout&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;label&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;Copy link&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="nf"&gt;setAttribute&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-label&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;Copy link&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="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;title&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;Copy link&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="mi"&gt;2000&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 URL to copy is stored in &lt;code&gt;data-url&lt;/code&gt; on the button element, set at render time&lt;br&gt;
from the &lt;code&gt;url&lt;/code&gt; prop. Falling back to &lt;code&gt;window.location.href&lt;/code&gt; is a sensible&lt;br&gt;
defensive default.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;data-bound&lt;/code&gt; guard prevents double-binding when the component is rendered&lt;br&gt;
twice on the same page (once in the page hero, once in the sidebar). Without it,&lt;br&gt;
each click would fire two listeners.&lt;/p&gt;

&lt;p&gt;After writing to the clipboard, the label text switches to "Copied!" for two&lt;br&gt;
seconds. This is a deliberate choice over a checkmark icon or a toast notification:&lt;br&gt;
a minimal in-place feedback mechanism that needs no additional UI state or animation&lt;br&gt;
complexity. The two-second timeout is long enough to be noticeable but short enough&lt;br&gt;
to reset before a user might click again.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;aria-label&lt;/code&gt; and &lt;code&gt;title&lt;/code&gt; attributes are updated in sync with the label text so&lt;br&gt;
assistive technology announces the correct state.&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;navigator.clipboard.writeText&lt;/code&gt; rejects (insecure context, permission denied),&lt;br&gt;
the catch block falls back to &lt;code&gt;window.prompt&lt;/code&gt;, which pre-fills the URL so the user&lt;br&gt;
can copy it manually. &lt;code&gt;document.execCommand('copy')&lt;/code&gt; is not used as a fallback&lt;br&gt;
because it is deprecated and inconsistently supported across modern browsers.&lt;/p&gt;
&lt;h2&gt;
  
  
  Placement in the post layout
&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%2Fntavmnag4eoy29zbfovu.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%2Fntavmnag4eoy29zbfovu.png" alt="Share widget wireframe showing default state with LinkedIn, Reddit, email, and copy link buttons alongside the active copied state with in-place text feedback" width="700" height="360"&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/share-post-clipboard" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/share-post-clipboard&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The share widget appears twice on a post page. The two placements serve different&lt;br&gt;
reading stages: the hero slot catches readers the moment they arrive, before they&lt;br&gt;
have committed to the article; the sidebar slot catches them while they are reading&lt;br&gt;
or after they finish, without requiring them to scroll back to the top.&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;PageHero.astro&lt;/code&gt;, the widget sits horizontally below the post metadata, visible&lt;br&gt;
immediately without scrolling. In &lt;code&gt;MarkdownPostLayout.astro&lt;/code&gt;, a vertical variant&lt;br&gt;
appears in the sidebar, staying in view as the reader moves through the content:&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;SharePost
  url={Astro.url.href}
  title={frontmatter.title}
  vertical={true}
/&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;vertical&lt;/code&gt; prop simply toggles a CSS modifier class that changes &lt;code&gt;flex-direction&lt;/code&gt;&lt;br&gt;
from &lt;code&gt;row&lt;/code&gt; to &lt;code&gt;column&lt;/code&gt; and adjusts alignment. No logic changes, just layout.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it together
&lt;/h2&gt;

&lt;p&gt;Four share targets, no dependencies, and fewer than 120 lines including the styles.&lt;br&gt;
The share URLs follow the same pattern: encode the inputs, assemble the query string,&lt;br&gt;
let the platform handle the rest. The clipboard interaction is the only part that&lt;br&gt;
requires JavaScript, and even that is a single async handler with a &lt;code&gt;window.prompt&lt;/code&gt;&lt;br&gt;
fallback for the rare case where the API is unavailable.&lt;/p&gt;

&lt;p&gt;The subtlest part of the implementation is the &lt;code&gt;data-bound&lt;/code&gt; guard. The widget&lt;br&gt;
appears twice on every post page and the Astro script block runs once per page&lt;br&gt;
load, so without the guard each button would accumulate duplicate listeners on&lt;br&gt;
every render. It is a one-liner that is easy to miss and quietly breaks the UX&lt;br&gt;
if you do.&lt;/p&gt;

&lt;p&gt;Next up: page history and credits, covering transparent revision logs and why&lt;br&gt;
attribution deserves to be a first-class concern rather than an afterthought.&lt;/p&gt;

</description>
      <category>astro</category>
      <category>engineering</category>
      <category>frontend</category>
    </item>
    <item>
      <title>Breadcrumb navigation with Schema.org markup</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Fri, 01 May 2026 13:21:58 +0000</pubDate>
      <link>https://dev.to/sourcier/breadcrumb-navigation-with-schemaorg-markup-5gc8</link>
      <guid>https://dev.to/sourcier/breadcrumb-navigation-with-schemaorg-markup-5gc8</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/breadcrumb-schema-astro" rel="noopener noreferrer"&gt;Breadcrumb navigation with Schema.org markup&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;Breadcrumbs are one of those components that look simple but have several layers&lt;br&gt;
of correctness: the visual trail, the accessible markup, and the structured data&lt;br&gt;
for search engines. All three matter and only one of them is visible to users.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Breadcrumb.astro&lt;/code&gt; handles all three, and it does so with a fallback auto-generation&lt;br&gt;
system that derives the crumb list from the URL path when no explicit list is provided.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0hpc6j2gnr64gd5paws0.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%2F0hpc6j2gnr64gd5paws0.png" alt="Breadcrumb component in two contexts — inverted on the dark post hero and default on a light surface — with annotations for aria-current, BreadcrumbList schema markup, and the SERP breadcrumb trail it produces" width="700" height="390"&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/breadcrumb-schema-astro" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/breadcrumb-schema-astro&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  The component interface
&lt;/h2&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;Crumb&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;label&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;href&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="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;crumbs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Crumb&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;inverted&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;noContainer&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;crumbs&lt;/code&gt; is an array of label/href pairs. The final crumb in the list should have&lt;br&gt;
no &lt;code&gt;href&lt;/code&gt; — it represents the current page and shouldn't be a link. The &lt;code&gt;inverted&lt;/code&gt;&lt;br&gt;
prop flips the colour scheme for placement on dark backgrounds (like the post hero).&lt;br&gt;
The &lt;code&gt;noContainer&lt;/code&gt; prop skips the &lt;code&gt;.container&lt;/code&gt; wrapper, used when the breadcrumb&lt;br&gt;
sits inside a layout that already handles max-width constraints.&lt;/p&gt;

&lt;p&gt;The component prepends a "Home" crumb automatically so callers don't have to include&lt;br&gt;
it in every usage:&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;allCrumbs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Home&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;crumbs&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Auto-generation from URL path
&lt;/h2&gt;

&lt;p&gt;In &lt;code&gt;PageHero.astro&lt;/code&gt;, if no explicit &lt;code&gt;crumbs&lt;/code&gt; prop is passed, the component falls&lt;br&gt;
back to deriving them from the URL:&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;autoCrumbs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Crumb&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;segments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;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="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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;meaningful&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;segments&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="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="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;page&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\d&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;s&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;meaningful&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;seg&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;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;seg&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; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b\w&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="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;meaningful&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The filter strips the literal &lt;code&gt;"page"&lt;/code&gt; segment and any purely numeric segments —&lt;br&gt;
so &lt;code&gt;/blog/page/2&lt;/code&gt; produces crumbs for "Blog" but not "Page" or "2". This is the&lt;br&gt;
right behaviour: pagination is a structural detail, not a meaningful content level.&lt;/p&gt;

&lt;p&gt;Labels are formatted by replacing hyphens with spaces and capitalising each word.&lt;br&gt;
&lt;code&gt;"why-astro"&lt;/code&gt; becomes &lt;code&gt;"Why Astro"&lt;/code&gt;. It's not perfect for every slug — a post&lt;br&gt;
titled "Using MDX" with slug &lt;code&gt;using-mdx&lt;/code&gt; would render as "Using Mdx" — but it's&lt;br&gt;
good enough for this site's naming conventions.&lt;/p&gt;
&lt;h2&gt;
  
  
  Schema.org BreadcrumbList
&lt;/h2&gt;

&lt;p&gt;Search engines use &lt;a href="https://schema.org/BreadcrumbList" rel="noopener noreferrer"&gt;BreadcrumbList structured data&lt;/a&gt;&lt;br&gt;
to display a breadcrumb trail in search results. The markup sits inline in the&lt;br&gt;
rendered HTML using &lt;code&gt;itemscope&lt;/code&gt; and &lt;code&gt;itemprop&lt;/code&gt; attributes:&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;ol
  class="breadcrumb__list"
  itemscope
  itemtype="https://schema.org/BreadcrumbList"
&amp;gt;
  {items.map(({ label, href, isLast, position }) =&amp;gt; (
    &amp;lt;li
      class="breadcrumb__item"
      itemprop="itemListElement"
      itemscope
      itemtype="https://schema.org/ListItem"
    &amp;gt;
      {!isLast &amp;amp;&amp;amp; href ? (
        &amp;lt;a href={href} class="breadcrumb__link" itemprop="item"&amp;gt;
          &amp;lt;span itemprop="name"&amp;gt;{label}&amp;lt;/span&amp;gt;
        &amp;lt;/a&amp;gt;
      ) : (
        &amp;lt;span
          class="breadcrumb__current"
          aria-current="page"
          itemprop="name"
        &amp;gt;
          {label}
        &amp;lt;/span&amp;gt;
      )}
      &amp;lt;meta itemprop="position" content={String(position)} /&amp;gt;
    &amp;lt;/li&amp;gt;
  ))}
&amp;lt;/ol&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;&amp;lt;meta itemprop="position"&amp;gt;&lt;/code&gt; tag carries the 1-based index for each crumb.&lt;br&gt;
Google uses this to understand the hierarchy — it won't infer the order from&lt;br&gt;
DOM order alone when using microdata.&lt;/p&gt;
&lt;h2&gt;
  
  
  Accessibility
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;&amp;lt;nav&amp;gt;&lt;/code&gt; element has an &lt;code&gt;aria-label="Breadcrumb"&lt;/code&gt; attribute to distinguish it&lt;br&gt;
from other navigation landmarks on the page (the main navbar also uses &lt;code&gt;&amp;lt;nav&amp;gt;&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;nav&lt;/span&gt; &lt;span class="na"&gt;aria-label=&lt;/span&gt;&lt;span class="s"&gt;"Breadcrumb"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"breadcrumb-nav"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The current page crumb uses &lt;code&gt;aria-current="page"&lt;/code&gt; and is rendered as &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt;&lt;br&gt;
rather than &lt;code&gt;&amp;lt;a&amp;gt;&lt;/code&gt; — it's the page the user is already on, so making it a link&lt;br&gt;
would be misleading. Screen readers announce &lt;code&gt;aria-current="page"&lt;/code&gt; explicitly,&lt;br&gt;
giving users the context they need.&lt;/p&gt;

&lt;p&gt;The complete component and its integration in &lt;code&gt;PageHero.astro&lt;/code&gt; 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;.&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;---
interface Crumb {
  label: string;
  href?: string;
}

interface Props {
  crumbs: Crumb[];
  inverted?: boolean;
  noContainer?: boolean;
}

const { crumbs, inverted = false, noContainer = false } = Astro.props;
const allCrumbs = [{ label: "Home", href: "/" }, ...crumbs];

const items = allCrumbs.map((crumb, index) =&amp;gt; ({
  ...crumb,
  isLast: index === allCrumbs.length - 1,
  position: index + 1,
}));
---

&amp;lt;nav
  aria-label="Breadcrumb"
  class:list={["breadcrumb-nav", { "breadcrumb-nav--inverted": inverted }]}
&amp;gt;
  {
    noContainer ? (
      &amp;lt;ol
        class="breadcrumb__list"
        itemscope
        itemtype="https://schema.org/BreadcrumbList"
      &amp;gt;
        {items.map(({ label, href, isLast, position }) =&amp;gt; (
          &amp;lt;li
            class="breadcrumb__item"
            itemprop="itemListElement"
            itemscope
            itemtype="https://schema.org/ListItem"
          &amp;gt;
            {!isLast &amp;amp;&amp;amp; href ? (
              &amp;lt;a href={href} class="breadcrumb__link" itemprop="item"&amp;gt;
                &amp;lt;span itemprop="name"&amp;gt;{label}&amp;lt;/span&amp;gt;
              &amp;lt;/a&amp;gt;
            ) : (
              &amp;lt;span
                class="breadcrumb__current"
                aria-current="page"
                itemprop="name"
              &amp;gt;
                {label}
              &amp;lt;/span&amp;gt;
            )}
            &amp;lt;meta itemprop="position" content={String(position)} /&amp;gt;
          &amp;lt;/li&amp;gt;
        ))}
      &amp;lt;/ol&amp;gt;
    ) : (
      &amp;lt;div class="container is-max-desktop"&amp;gt;
        &amp;lt;ol
          class="breadcrumb__list"
          itemscope
          itemtype="https://schema.org/BreadcrumbList"
        &amp;gt;
          {items.map(({ label, href, isLast, position }) =&amp;gt; (
            &amp;lt;li
              class="breadcrumb__item"
              itemprop="itemListElement"
              itemscope
              itemtype="https://schema.org/ListItem"
            &amp;gt;
              {!isLast &amp;amp;&amp;amp; href ? (
                &amp;lt;a href={href} class="breadcrumb__link" itemprop="item"&amp;gt;
                  &amp;lt;span itemprop="name"&amp;gt;{label}&amp;lt;/span&amp;gt;
                &amp;lt;/a&amp;gt;
              ) : (
                &amp;lt;span
                  class="breadcrumb__current"
                  aria-current="page"
                  itemprop="name"
                &amp;gt;
                  {label}
                &amp;lt;/span&amp;gt;
              )}
              &amp;lt;meta itemprop="position" content={String(position)} /&amp;gt;
            &amp;lt;/li&amp;gt;
          ))}
        &amp;lt;/ol&amp;gt;
      &amp;lt;/div&amp;gt;
    )
  }
&amp;lt;/nav&amp;gt;

&amp;lt;style lang="scss"&amp;gt;
  .breadcrumb-nav {
    padding: 0.6rem 1.5rem;
    background-color: var(--surface-elevated);
    border-bottom: 1px solid var(--border-subtle);

    &amp;amp;--inverted {
      padding: 0;
      background-color: transparent;
      border-bottom: none;
      margin-bottom: 1.25rem;

      .breadcrumb__link {
        color: var(--text-on-strong-alpha-45);

        &amp;amp;:hover,
        &amp;amp;:focus-visible {
          color: var(--accent-primary);
        }
      }

      .breadcrumb__current {
        color: var(--text-on-strong-alpha-75);
      }

      .breadcrumb__item:not(:last-child)::after {
        color: var(--text-on-strong-alpha-25);
      }
    }
  }

  .breadcrumb__list {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0;
    list-style: none;
    margin: 0;
    padding: 0;

    font-family: "Barlow Condensed", sans-serif;
    font-size: 0.8rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.06em;
  }

  .breadcrumb__item {
    display: flex;
    align-items: center;

    &amp;amp;:not(:last-child)::after {
      content: "›";
      margin: 0 0.4em;
      color: var(--text-muted);
      font-weight: 400;
    }
  }

  .breadcrumb__link {
    color: var(--text-muted);
    text-decoration: none;
    transition: color 0.15s ease;

    &amp;amp;:hover,
    &amp;amp;:focus-visible {
      color: var(--accent-primary);
    }
  }

  .breadcrumb__current {
    color: var(--text-primary);
  }
&amp;lt;/style&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>astro</category>
      <category>engineering</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Adding an RSS feed to an Astro blog</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Tue, 28 Apr 2026 14:58:32 +0000</pubDate>
      <link>https://dev.to/sourcier/adding-an-rss-feed-to-an-astro-blog-505d</link>
      <guid>https://dev.to/sourcier/adding-an-rss-feed-to-an-astro-blog-505d</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/rss-feed-astro" rel="noopener noreferrer"&gt;Adding an RSS feed to an Astro blog&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;RSS is the oldest and most reliable way to follow a blog. No algorithm, no&lt;br&gt;
platform dependency, no notification settings. A reader checks the feed URL,&lt;br&gt;
sees new items, shows them. That simplicity is exactly why it's worth supporting.&lt;/p&gt;

&lt;p&gt;Adding an RSS feed to an Astro site is straightforward with the &lt;code&gt;@astrojs/rss&lt;/code&gt;&lt;br&gt;
package. There are a few things to get right: draft filtering, absolute URLs,&lt;br&gt;
and a self-referencing link that validators expect.&lt;/p&gt;
&lt;h2&gt;
  
  
  Installing the package
&lt;/h2&gt;

&lt;p&gt;Astro doesn't ship with RSS support out of the box, but the official integration&lt;br&gt;
adds everything needed:&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 @astrojs/rss
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The feed endpoint
&lt;/h2&gt;

&lt;p&gt;The feed lives at &lt;code&gt;src/pages/rss.xml.js&lt;/code&gt;. Astro treats any &lt;code&gt;.js&lt;/code&gt; file in&lt;br&gt;
&lt;code&gt;src/pages/&lt;/code&gt; as a route, and a named &lt;code&gt;GET&lt;/code&gt; export marks it as an endpoint that&lt;br&gt;
generates output at build time.&lt;/p&gt;

&lt;p&gt;A few parts of this are easy to get wrong.&lt;/p&gt;
&lt;h3&gt;
  
  
  Draft filtering
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;draft&lt;/code&gt; flag alone isn't enough. A post with &lt;code&gt;draft: false&lt;/code&gt; and a future&lt;br&gt;
&lt;code&gt;pubDate&lt;/code&gt; is &lt;strong&gt;scheduled&lt;/strong&gt;, not live. Filtering on &lt;code&gt;!post.data.draft&lt;/code&gt; would leak&lt;br&gt;
it into the feed before it's published.&lt;/p&gt;

&lt;p&gt;This blog distinguishes three publication states:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;th&gt;Condition&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;draft&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;draft: true&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;scheduled&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;draft: false&lt;/code&gt;, future &lt;code&gt;pubDate&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;published&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;draft: false&lt;/code&gt;, past or current &lt;code&gt;pubDate&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;isPubliclyPublished&lt;/code&gt; utility returns &lt;code&gt;true&lt;/code&gt; only for the &lt;code&gt;published&lt;/code&gt; state:&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;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="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="o"&gt;&amp;amp;&amp;amp;&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;pubDate&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you haven't extracted this into a utility, the inline equivalent is:&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;now&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="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="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;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="o"&gt;&amp;amp;&amp;amp;&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;pubDate&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;now&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="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not use &lt;code&gt;import.meta.env.DEV&lt;/code&gt; to conditionally include drafts. The RSS feed&lt;br&gt;
should never expose unpublished content, regardless of the build environment.&lt;/p&gt;
&lt;h3&gt;
  
  
  Sorting
&lt;/h3&gt;

&lt;p&gt;Posts are sorted by &lt;code&gt;pubDate&lt;/code&gt; descending so the most recent item appears first.&lt;br&gt;
Most RSS readers display items in the order they appear in the feed XML, so the&lt;br&gt;
sort order matters.&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="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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Post links
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;link&lt;/code&gt; value uses &lt;code&gt;post.id&lt;/code&gt;, which in Astro's Content Layer API is the&lt;br&gt;
folder name of each post. For a post at &lt;code&gt;collections/posts/rss-feed-astro/index.md&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;post.id&lt;/code&gt; is &lt;code&gt;rss-feed-astro&lt;/code&gt;. Prefixing it with &lt;code&gt;/blog/&lt;/code&gt; produces the correct&lt;br&gt;
page URL, with no slug manipulation needed.&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;link&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="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Absolute URLs
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;site&lt;/code&gt; property on the &lt;code&gt;rss()&lt;/code&gt; call is &lt;code&gt;context.site&lt;/code&gt;, the value set in&lt;br&gt;
&lt;code&gt;astro.config.mjs&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="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;site&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://sourcier.uk&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;@astrojs/rss&lt;/code&gt; package uses this to resolve relative &lt;code&gt;link&lt;/code&gt; values into&lt;br&gt;
absolute URLs. Without &lt;code&gt;site&lt;/code&gt; configured, feed items would have relative URLs&lt;br&gt;
that most RSS readers can't navigate.&lt;/p&gt;
&lt;h3&gt;
  
  
  atom:link self-reference
&lt;/h3&gt;

&lt;p&gt;RSS validators and some readers expect an &lt;code&gt;&amp;lt;atom:link rel="self"&amp;gt;&lt;/code&gt; element in&lt;br&gt;
the channel, pointing back to the feed's own URL. The &lt;code&gt;atom&lt;/code&gt; namespace must also&lt;br&gt;
be declared on the root &lt;code&gt;&amp;lt;rss&amp;gt;&lt;/code&gt; element, which the &lt;code&gt;@astrojs/rss&lt;/code&gt; package handles&lt;br&gt;
via the &lt;code&gt;xmlns&lt;/code&gt; option:&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;xmlns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;atom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;http://www.w3.org/2005/Atom&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;customData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="s2"&gt;`&amp;lt;language&amp;gt;en-gb&amp;lt;/language&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s2"&gt;`&amp;lt;atom:link href="&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;site&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;rss.xml" rel="self" type="application/rss+xml"/&amp;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;join&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without this, the W3C validator flags a "missing atom:link" warning and some&lt;br&gt;
readers cannot determine the canonical feed URL.&lt;/p&gt;
&lt;h2&gt;
  
  
  Validating the feed
&lt;/h2&gt;

&lt;p&gt;Before deploying, it's worth running the built feed through the&lt;br&gt;
&lt;a href="https://validator.w3.org/feed/" rel="noopener noreferrer"&gt;W3C Feed Validation Service&lt;/a&gt; or&lt;br&gt;
&lt;a href="https://www.rssboard.org/rss-validator/" rel="noopener noreferrer"&gt;RSS Board's validator&lt;/a&gt;. Common&lt;br&gt;
mistakes like missing &lt;code&gt;pubDate&lt;/code&gt;, non-absolute &lt;code&gt;link&lt;/code&gt; values, and invalid XML&lt;br&gt;
characters in post content all surface here before they cause problems in readers.&lt;/p&gt;

&lt;p&gt;Build the site locally and check &lt;code&gt;dist/rss.xml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm build &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; open dist/rss.xml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The raw XML should be readable in the browser. If the browser shows a parse&lt;br&gt;
error, something in the feed is malformed.&lt;/p&gt;
&lt;h2&gt;
  
  
  Adding the feed autodiscovery link
&lt;/h2&gt;

&lt;p&gt;RSS readers look for a &lt;code&gt;&amp;lt;link rel="alternate"&amp;gt;&lt;/code&gt; tag in the page &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; to&lt;br&gt;
discover the feed URL automatically. Add it to &lt;code&gt;BaseLayout.astro&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt;
  &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt;
  &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"application/rss+xml"&lt;/span&gt;
  &lt;span class="na"&gt;title=&lt;/span&gt;&lt;span class="s"&gt;"Sourcier RSS Feed"&lt;/span&gt;
  &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/rss.xml"&lt;/span&gt;
&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With this in place, browsers and readers that support RSS autodiscovery will&lt;br&gt;
surface the feed when a user visits any page on the site.&lt;/p&gt;

&lt;p&gt;The complete feed endpoint and autodiscovery link 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;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Full code listing
&lt;/h2&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;rss&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;@astrojs/rss&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;getCollection&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;astro:content&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;isPubliclyPublished&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;../utils/drafts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;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;posts&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;isPubliclyPublished&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="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;rss&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Sourcier — Blog&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Practical software engineering writing for people transitioning into tech, engineers growing in confidence, and teams improving engineering practice.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;site&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;site&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;xmlns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;atom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;http://www.w3.org/2005/Atom&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;items&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="nf"&gt;map&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="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;pubDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;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="na"&gt;link&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="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})),&lt;/span&gt;
    &lt;span class="na"&gt;customData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="s2"&gt;`&amp;lt;language&amp;gt;en-gb&amp;lt;/language&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;`&amp;lt;atom:link href="&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;site&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;rss.xml" rel="self" type="application/rss+xml"/&amp;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;join&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>astro</category>
      <category>engineering</category>
      <category>webdev</category>
    </item>
    <item>
      <title>OpenGraph, Twitter Cards, and article metadata in Astro</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Thu, 23 Apr 2026 17:24:24 +0000</pubDate>
      <link>https://dev.to/sourcier/opengraph-twitter-cards-and-article-metadata-in-astro-28ob</link>
      <guid>https://dev.to/sourcier/opengraph-twitter-cards-and-article-metadata-in-astro-28ob</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/opengraph-seo-astro" rel="noopener noreferrer"&gt;OpenGraph, Twitter Cards, and article metadata 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;When someone shares a link to a blog post, the card that appears in Slack, LinkedIn,&lt;br&gt;
or iMessage is determined by OpenGraph tags in the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;. Get them wrong and the&lt;br&gt;
shared link is an unformatted URL. Get them right and it shows the post title,&lt;br&gt;
description, and a properly sized cover image.&lt;/p&gt;

&lt;p&gt;This is table stakes for any public-facing blog, but the implementation details&lt;br&gt;
matter — specifically, how to handle different content types (articles vs. pages),&lt;br&gt;
where to put canonical URLs, and how to avoid the common mistake of sharing a&lt;br&gt;
relative image path that produces a broken card.&lt;/p&gt;
&lt;h2&gt;
  
  
  Centralising metadata in BaseLayout
&lt;/h2&gt;

&lt;p&gt;All meta tags are defined once in &lt;code&gt;BaseLayout.astro&lt;/code&gt;. Every page passes what it&lt;br&gt;
needs as props; the layout handles the markup. This avoids duplication and ensures&lt;br&gt;
no page accidentally skips essential tags:&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;pageTitle&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;description&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;ogImage&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;ogType&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="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;article&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;canonicalUrl&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;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="nl"&gt;author&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;tags&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;pageTitle&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Practical software engineering guidance from Roger Rajaratnam for people breaking into tech, engineers growing in confidence, and teams improving engineering practice.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;ogImage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/og-image.png&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;ogType&lt;/span&gt; &lt;span class="o"&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="nx"&gt;canonicalUrl&lt;/span&gt; &lt;span class="o"&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;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&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;author&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&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;props&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 description covers general pages. The default &lt;code&gt;ogType&lt;/code&gt; is &lt;code&gt;"website"&lt;/code&gt;.&lt;br&gt;
Both are overridden for posts. The default &lt;code&gt;canonicalUrl&lt;/code&gt; is &lt;code&gt;Astro.url.href&lt;/code&gt; —&lt;br&gt;
the fully qualified URL including the site's &lt;code&gt;site&lt;/code&gt; value from &lt;code&gt;astro.config.mjs&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  The OpenGraph block
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- OpenGraph --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:type"&lt;/span&gt;        &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;{ogType}&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:site_name"&lt;/span&gt;   &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;{siteName}&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:title"&lt;/span&gt;       &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;{pageTitle}&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:description"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;{description}&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:url"&lt;/span&gt;         &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;{canonicalUrl}&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image"&lt;/span&gt;       &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;{new&lt;/span&gt; &lt;span class="na"&gt;URL&lt;/span&gt;&lt;span class="err"&gt;(&lt;/span&gt;&lt;span class="na"&gt;ogImage&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt; &lt;span class="na"&gt;Astro.site&lt;/span&gt; &lt;span class="err"&gt;??&lt;/span&gt; &lt;span class="na"&gt;Astro.url.origin&lt;/span&gt;&lt;span class="err"&gt;).&lt;/span&gt;&lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="err"&gt;}&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image:width"&lt;/span&gt;  &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"1200"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image:height"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"630"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:locale"&lt;/span&gt;      &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"en_GB"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The &lt;code&gt;og:image&lt;/code&gt; value deserves attention. A relative path like &lt;code&gt;/og-image.png&lt;/code&gt;&lt;br&gt;
won't work in OG tags — social crawlers need an absolute URL. Constructing it&lt;br&gt;
with &lt;code&gt;new URL(ogImage, Astro.site)&lt;/code&gt; handles both cases: if &lt;code&gt;ogImage&lt;/code&gt; is already&lt;br&gt;
absolute it passes through unchanged; if it's a root-relative path it's resolved&lt;br&gt;
against the site's configured base URL.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;siteName&lt;/code&gt; is a plain constant — &lt;code&gt;const siteName = "Sourcier"&lt;/code&gt; — defined at the&lt;br&gt;
top of &lt;code&gt;BaseLayout.astro&lt;/code&gt;. &lt;code&gt;og:locale&lt;/code&gt; uses the IETF language tag for British&lt;br&gt;
English, which matches the site's target audience.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;1200×630&lt;/code&gt; is the recommended OG image size that renders well across Facebook,&lt;br&gt;
LinkedIn, and Slack.&lt;/p&gt;
&lt;h2&gt;
  
  
  Article-specific tags
&lt;/h2&gt;

&lt;p&gt;When &lt;code&gt;ogType&lt;/code&gt; is &lt;code&gt;"article"&lt;/code&gt;, the Open Graph protocol defines additional properties&lt;br&gt;
for structured article metadata. These are conditionally rendered:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{pubDate &amp;amp;&amp;amp; (
  &amp;lt;meta
    property="article:published_time"
    content={pubDate.toISOString()}
  /&amp;gt;
)}
{author &amp;amp;&amp;amp; &amp;lt;meta property="article:author" content={author} /&amp;gt;}
{tags &amp;amp;&amp;amp; tags.map((tag) =&amp;gt; (
  &amp;lt;meta property="article:tag" content={tag} /&amp;gt;
))}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;article:published_time&lt;/code&gt; uses the ISO 8601 format with timezone — &lt;code&gt;Date.toISOString()&lt;/code&gt;&lt;br&gt;
provides this. &lt;code&gt;article:tag&lt;/code&gt; can appear multiple times, once per tag. Some scrapers&lt;br&gt;
and indexers use these to understand content type and category.&lt;/p&gt;
&lt;h2&gt;
  
  
  Twitter Cards
&lt;/h2&gt;

&lt;p&gt;X (formerly Twitter) has its own metadata system that runs parallel to OpenGraph. The &lt;code&gt;summary_large_image&lt;/code&gt;&lt;br&gt;
card type displays the image at full width above the title and description:&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;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"twitter:card"&lt;/span&gt;        &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"summary_large_image"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"twitter:title"&lt;/span&gt;       &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;{pageTitle}&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"twitter:description"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;{description}&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"twitter:image"&lt;/span&gt;       &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;{new&lt;/span&gt; &lt;span class="na"&gt;URL&lt;/span&gt;&lt;span class="err"&gt;(&lt;/span&gt;&lt;span class="na"&gt;ogImage&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt; &lt;span class="na"&gt;Astro.site&lt;/span&gt; &lt;span class="err"&gt;??&lt;/span&gt; &lt;span class="na"&gt;Astro.url.origin&lt;/span&gt;&lt;span class="err"&gt;).&lt;/span&gt;&lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="err"&gt;}&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The property names keep the &lt;code&gt;twitter:&lt;/code&gt; prefix — these are a stable protocol standard and won't change regardless of the platform rebrand. X falls back to OpenGraph values for some properties, but it's more reliable to specify them explicitly. The image URL construction is the same as for OG.&lt;/p&gt;

&lt;h2&gt;
  
  
  Canonical URLs
&lt;/h2&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;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"canonical"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;{canonicalUrl}&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The canonical tag tells search engines which URL is the authoritative version of a&lt;br&gt;
page, which matters if content appears at multiple URLs or is syndicated elsewhere.&lt;br&gt;
&lt;code&gt;canonicalUrl&lt;/code&gt; defaults to &lt;code&gt;Astro.url.href&lt;/code&gt; so it's correct without any manual&lt;br&gt;
input, but can be overridden for pages that need a different canonical (for example,&lt;br&gt;
a paginated page that canonicalises to page 1).&lt;/p&gt;
&lt;h2&gt;
  
  
  Passing metadata from post pages
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;MarkdownPostLayout.astro&lt;/code&gt; extracts the relevant frontmatter fields and passes&lt;br&gt;
them to &lt;code&gt;BaseLayout&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const ogImage = frontmatter.cover?.image?.src ?? undefined;

&amp;lt;BaseLayout
  pageTitle={`${frontmatter.title} — Sourcier`}
  description={frontmatter.description}
  ogImage={ogImage}
  ogType="article"
  pubDate={frontmatter.pubDate}
  author={frontmatter.author}
  tags={frontmatter.tags}
&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;ogImage&lt;/code&gt; falls back to &lt;code&gt;undefined&lt;/code&gt; if no cover is present, which means&lt;br&gt;
&lt;code&gt;BaseLayout&lt;/code&gt; will use the default &lt;code&gt;/og-image.png&lt;/code&gt; for posts without cover images.&lt;/p&gt;

&lt;p&gt;You can browse the rest of the site code in the&lt;br&gt;
&lt;a href="https://github.com/sourcier/web-sourcier.uk" rel="noopener noreferrer"&gt;web-sourcier.uk repository&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>astro</category>
      <category>engineering</category>
      <category>frontend</category>
    </item>
    <item>
      <title>Playwright E2E testing AI skills: JavaScript London talk</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Wed, 22 Apr 2026 09:11:56 +0000</pubDate>
      <link>https://dev.to/sourcier/playwright-e2e-testing-ai-skills-javascript-london-talk-1cgl</link>
      <guid>https://dev.to/sourcier/playwright-e2e-testing-ai-skills-javascript-london-talk-1cgl</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/playwright-e2e-testing-talk" rel="noopener noreferrer"&gt;Playwright E2E testing AI skills: JavaScript London talk&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This post is the companion article to my JavaScript London talk,&lt;br&gt;
&lt;a href="https://www.javascriptlondon.com" rel="noopener noreferrer"&gt;Playwright E2E testing AI skills&lt;/a&gt;, hosted in&lt;br&gt;
collaboration with NewDay.&lt;/p&gt;

&lt;p&gt;The meetup is on Wednesday 29 April 2026, from 6:00 PM to 9:00 PM BST, at&lt;br&gt;
NewDay's offices on 7 Handyside Street in King's Cross, London. The evening&lt;br&gt;
also includes talks from &lt;a href="https://www.linkedin.com/in/davidwhitney/" rel="noopener noreferrer"&gt;David Whitney&lt;/a&gt;&lt;br&gt;
and &lt;a href="https://www.linkedin.com/in/elhamkh/" rel="noopener noreferrer"&gt;Elham Khani&lt;/a&gt;, and you can still&lt;br&gt;
&lt;a href="https://www.javascriptlondon.com" rel="noopener noreferrer"&gt;register on the event page&lt;/a&gt; if you plan to&lt;br&gt;
come along.&lt;/p&gt;

&lt;p&gt;If you want the slides alongside the article, you can&lt;br&gt;
&lt;a href="///talks/playwright-e2e-testing-talk.pptx"&gt;download the slide deck (.pptx)&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The short version of the talk is this: Playwright is already a strong default&lt;br&gt;
for browser automation. The AI part becomes useful when it helps you explore a&lt;br&gt;
real site, identify the journeys that matter, and turn that exploration into&lt;br&gt;
better tests.&lt;/p&gt;

&lt;p&gt;The shortest useful explanation of E2E testing is still the same: unit tests&lt;br&gt;
tell you whether the parts work. End-to-end tests tell you whether the product&lt;br&gt;
works.&lt;/p&gt;

&lt;p&gt;If a button is hidden behind a cookie banner, a redirect breaks after login, or&lt;br&gt;
the browser submits the wrong payload, your unit tests can still be green. Your&lt;br&gt;
users will still hit the bug.&lt;/p&gt;

&lt;p&gt;That is the gap Playwright helps close.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why this talk is framed around AI skills
&lt;/h2&gt;

&lt;p&gt;I am not interested in using AI to hide how tests work.&lt;/p&gt;

&lt;p&gt;I am interested in using it to shorten the boring parts around test creation:&lt;br&gt;
exploration, note-taking, finding likely locators, spotting missing assertions,&lt;br&gt;
and drafting candidate cases for review.&lt;/p&gt;

&lt;p&gt;That is a much better fit for AI than asking it to spray out a huge test suite&lt;br&gt;
and hoping it guessed the right behaviours.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;use AI to explore and propose&lt;/li&gt;
&lt;li&gt;use Playwright to automate and verify&lt;/li&gt;
&lt;li&gt;use humans to decide what is worth keeping&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That keeps the browser tests honest.&lt;/p&gt;
&lt;h2&gt;
  
  
  Where E2E fits in the testing pyramid
&lt;/h2&gt;

&lt;p&gt;You do not want to test everything end to end. That is the fastest route to a&lt;br&gt;
slow, noisy, expensive test suite.&lt;/p&gt;

&lt;p&gt;You want a small number of high-value E2E tests that protect the user journeys&lt;br&gt;
that matter most.&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%2FZmxvd2NoYXJ0IFRCCiAgVW5pdFsiTWFueSB1bml0IHRlc3RzPGJyLz5GYXN0IGZlZWRiYWNrIG9uIHNtYWxsIHBpZWNlczxici8-TG93ZXN0IGNvc3QgcGVyIHRlc3QiXQogIEludGVncmF0aW9uWyJTb21lIGludGVncmF0aW9uIHRlc3RzPGJyLz5Cb3VuZGFyaWVzIGFuZCBjb250cmFjdHM8YnIvPkNvbXBvc2VkIGJlaGF2aW91ciJdCiAgRTJFWyJGZXcgRTJFIHRlc3RzPGJyLz5Dcml0aWNhbCB1c2VyIGpvdXJuZXlzPGJyLz5IaWdoZXN0IGNvbmZpZGVuY2UgcGVyIHRlc3QiXQoKICBVbml0IC0tPiBJbnRlZ3JhdGlvbiAtLT4gRTJFCgogIGNsYXNzRGVmIHVuaXQgZmlsbDojZjZlY2UxLHN0cm9rZTojZDhjNWFlLGNvbG9yOiMwZjBmMGY7CiAgY2xhc3NEZWYgaW50ZWdyYXRpb24gZmlsbDojZmRlYWYyLHN0cm9rZTojZTgwMDZhLGNvbG9yOiMwZjBmMGY7CiAgY2xhc3NEZWYgZTJlIGZpbGw6I2Y3YzdkYSxzdHJva2U6I2IxMDA0Yixjb2xvcjojMGYwZjBmLHN0cm9rZS13aWR0aDoycHg7CgogIGNsYXNzIFVuaXQgdW5pdDsKICBjbGFzcyBJbnRlZ3JhdGlvbiBpbnRlZ3JhdGlvbjsKICBjbGFzcyBFMkUgZTJlOw" 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%2FZmxvd2NoYXJ0IFRCCiAgVW5pdFsiTWFueSB1bml0IHRlc3RzPGJyLz5GYXN0IGZlZWRiYWNrIG9uIHNtYWxsIHBpZWNlczxici8-TG93ZXN0IGNvc3QgcGVyIHRlc3QiXQogIEludGVncmF0aW9uWyJTb21lIGludGVncmF0aW9uIHRlc3RzPGJyLz5Cb3VuZGFyaWVzIGFuZCBjb250cmFjdHM8YnIvPkNvbXBvc2VkIGJlaGF2aW91ciJdCiAgRTJFWyJGZXcgRTJFIHRlc3RzPGJyLz5Dcml0aWNhbCB1c2VyIGpvdXJuZXlzPGJyLz5IaWdoZXN0IGNvbmZpZGVuY2UgcGVyIHRlc3QiXQoKICBVbml0IC0tPiBJbnRlZ3JhdGlvbiAtLT4gRTJFCgogIGNsYXNzRGVmIHVuaXQgZmlsbDojZjZlY2UxLHN0cm9rZTojZDhjNWFlLGNvbG9yOiMwZjBmMGY7CiAgY2xhc3NEZWYgaW50ZWdyYXRpb24gZmlsbDojZmRlYWYyLHN0cm9rZTojZTgwMDZhLGNvbG9yOiMwZjBmMGY7CiAgY2xhc3NEZWYgZTJlIGZpbGw6I2Y3YzdkYSxzdHJva2U6I2IxMDA0Yixjb2xvcjojMGYwZjBmLHN0cm9rZS13aWR0aDoycHg7CgogIGNsYXNzIFVuaXQgdW5pdDsKICBjbGFzcyBJbnRlZ3JhdGlvbiBpbnRlZ3JhdGlvbjsKICBjbGFzcyBFMkUgZTJlOw" alt="Mermaid diagram" width="276" height="446"&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/playwright-e2e-testing-talk" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/playwright-e2e-testing-talk&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The rule I like is simple: use E2E tests for risk, not for coverage.&lt;/p&gt;

&lt;p&gt;If a broken flow would hurt users, revenue, or trust, it deserves E2E coverage.&lt;br&gt;
If it is easy to prove with a unit or integration test, it probably does not.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why Playwright is still the right foundation
&lt;/h2&gt;

&lt;p&gt;There are other good browser automation tools. Playwright is the one I would&lt;br&gt;
start with today for most web teams because it removes a lot of usual friction.&lt;/p&gt;

&lt;p&gt;It gives you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Chromium, Firefox, and WebKit support out of the box&lt;/li&gt;
&lt;li&gt;a clean TypeScript-first API&lt;/li&gt;
&lt;li&gt;automatic waiting for elements to become actionable&lt;/li&gt;
&lt;li&gt;browser contexts for isolated tests&lt;/li&gt;
&lt;li&gt;a built-in trace viewer for debugging failures in CI&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That matters because AI suggestions are only useful if the underlying tool is&lt;br&gt;
deterministic enough to turn them into repeatable tests.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the playwright-explore-website skill is
&lt;/h2&gt;

&lt;p&gt;In my setup, I use a &lt;code&gt;playwright-explore-website&lt;/code&gt; GitHub Copilot skill backed by&lt;br&gt;
the Playwright MCP server.&lt;/p&gt;

&lt;p&gt;It is a small instruction file that tells Copilot to explore a real site with&lt;br&gt;
Playwright, interact with a handful of important flows, document the relevant&lt;br&gt;
UI elements and expected outcomes, and then propose test cases based on what it&lt;br&gt;
found.&lt;/p&gt;

&lt;p&gt;Its job is not to replace a Playwright test file. Its job is to make the step&lt;br&gt;
before test writing more grounded in the real browser.&lt;/p&gt;

&lt;p&gt;I wrote up the full setup, the original awesome-copilot example I started&lt;br&gt;
from, and the local enhancements in&lt;br&gt;
&lt;a href="https://dev.to/blog/playwright-explore-website"&gt;The playwright-explore-website Copilot skill&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;That write-up stays focused on the skill itself. This post is about how I would&lt;br&gt;
use it in a broader Playwright testing workflow.&lt;/p&gt;

&lt;p&gt;That makes it a good fit when you are working with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an unfamiliar product area&lt;/li&gt;
&lt;li&gt;a staging site you need to smoke test quickly&lt;/li&gt;
&lt;li&gt;a bug report that is missing exact reproduction steps&lt;/li&gt;
&lt;li&gt;a flow where you want candidate locators and assertions before coding the test&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A prompt like this is already specific enough to be useful:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Use the playwright-explore-website skill on https://staging.example.com.
Explore sign-in, password reset, and checkout.
For each flow, document the user steps, the likely stable locators,
the expected outcome, and a draft Playwright test case.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The value is not the raw prompt. The value is the output: a clearer map of the&lt;br&gt;
journey you are about to automate.&lt;/p&gt;
&lt;h2&gt;
  
  
  How I would use it for E2E testing
&lt;/h2&gt;

&lt;p&gt;My preferred workflow is:&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%2FZmxvd2NoYXJ0IExSCiAgICBGbG93W1BpY2sgYSByaXNreSB1c2VyIGpvdXJuZXldIC0tPiBFeHBsb3JlW0V4cGxvcmUgaXQgd2l0aCB0aGUgc2tpbGxdCiAgICBFeHBsb3JlIC0tPiBEcmFmdFtEcmFmdCBjYW5kaWRhdGUgYXNzZXJ0aW9ucyBhbmQgbG9jYXRvcnNdCiAgICBEcmFmdCAtLT4gVGVzdFtXcml0ZSBhIGNsZWFuIFBsYXl3cmlnaHQgdGVzdF0KICAgIFRlc3QgLS0-IENJW1J1biBpbiBDSSB3aXRoIHRyYWNlc10KICAgIENJIC0tPiBSZXZpZXdbS2VlcCwgcmVmaW5lLCBvciBkZWxldGUgYmFzZWQgb24gdmFsdWVd" 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%2FZmxvd2NoYXJ0IExSCiAgICBGbG93W1BpY2sgYSByaXNreSB1c2VyIGpvdXJuZXldIC0tPiBFeHBsb3JlW0V4cGxvcmUgaXQgd2l0aCB0aGUgc2tpbGxdCiAgICBFeHBsb3JlIC0tPiBEcmFmdFtEcmFmdCBjYW5kaWRhdGUgYXNzZXJ0aW9ucyBhbmQgbG9jYXRvcnNdCiAgICBEcmFmdCAtLT4gVGVzdFtXcml0ZSBhIGNsZWFuIFBsYXl3cmlnaHQgdGVzdF0KICAgIFRlc3QgLS0-IENJW1J1biBpbiBDSSB3aXRoIHRyYWNlc10KICAgIENJIC0tPiBSZXZpZXdbS2VlcCwgcmVmaW5lLCBvciBkZWxldGUgYmFzZWQgb24gdmFsdWVd" alt="Mermaid diagram" width="1704" height="94"&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/playwright-e2e-testing-talk" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/playwright-e2e-testing-talk&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is where the AI piece earns its keep.&lt;/p&gt;

&lt;p&gt;Instead of starting from a blank file, you start with a tested path through the&lt;br&gt;
browser, a list of likely selectors, and a set of outcomes worth asserting. You&lt;br&gt;
still need to clean that up into a proper test, but the exploratory work is&lt;br&gt;
faster.&lt;/p&gt;

&lt;p&gt;A good flow looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pick one critical journey.&lt;/li&gt;
&lt;li&gt;Use the skill to explore it and note what the user actually sees.&lt;/li&gt;
&lt;li&gt;Turn the best candidate path into a small Playwright test.&lt;/li&gt;
&lt;li&gt;Replace weak selectors with semantic locators or &lt;code&gt;data-testid&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Run it in CI with traces and fix the first flaky edge before adding more.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is enough to prove the approach without bloating the suite.&lt;/p&gt;
&lt;h2&gt;
  
  
  What E2E tests are good at
&lt;/h2&gt;

&lt;p&gt;Playwright is excellent at checking the flows where the browser is part of the&lt;br&gt;
problem.&lt;/p&gt;

&lt;p&gt;Good targets for E2E tests:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;sign in, sign out, and session refresh flows&lt;/li&gt;
&lt;li&gt;checkout, booking, or other business-critical user journeys&lt;/li&gt;
&lt;li&gt;form submission paths that depend on real navigation or API responses&lt;/li&gt;
&lt;li&gt;cross-browser regressions&lt;/li&gt;
&lt;li&gt;UI issues that only show up once the page is fully assembled&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Poor targets for E2E tests:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;pure business logic&lt;/li&gt;
&lt;li&gt;small validation rules&lt;/li&gt;
&lt;li&gt;isolated component states&lt;/li&gt;
&lt;li&gt;anything a fast unit test can already prove clearly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That distinction still matters even when AI is involved. The point is not to&lt;br&gt;
replace the rest of the suite. The point is to protect the seams.&lt;/p&gt;
&lt;h2&gt;
  
  
  Codegen and exploration are different tools
&lt;/h2&gt;

&lt;p&gt;One of the easiest ways to get moving with Playwright is still to record a flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm create playwright@latest
pnpm playwright codegen https://your-app.example
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Codegen is useful for capturing raw actions quickly.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;playwright-explore-website&lt;/code&gt; skill does a different job. It helps you&lt;br&gt;
understand the flow, identify meaningful assertions, and sketch candidate tests&lt;br&gt;
before you commit to code.&lt;/p&gt;

&lt;p&gt;That distinction matters. Codegen gives you interaction history. Exploration&lt;br&gt;
gives you testing intent.&lt;/p&gt;

&lt;p&gt;You will usually want both:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;use the skill when you need to map the journey&lt;/li&gt;
&lt;li&gt;use codegen when you need a quick action scaffold&lt;/li&gt;
&lt;li&gt;rewrite the result so the test reads like a real scenario&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Keeping tests reliable
&lt;/h2&gt;

&lt;p&gt;Flaky tests are worse than missing tests.&lt;/p&gt;

&lt;p&gt;Once a team stops trusting the suite, the suite stops being useful.&lt;/p&gt;
&lt;h3&gt;
  
  
  Let Playwright wait for you
&lt;/h3&gt;

&lt;p&gt;Playwright automatically waits for elements to be attached, visible, stable, and&lt;br&gt;
ready for interaction before acting on them.&lt;/p&gt;

&lt;p&gt;That is one of the main reasons its tests feel less fragile than older browser&lt;br&gt;
automation stacks.&lt;/p&gt;
&lt;h3&gt;
  
  
  Use selectors that survive refactoring
&lt;/h3&gt;

&lt;p&gt;Prefer the most human-facing locator you can.&lt;/p&gt;

&lt;p&gt;Good order of preference:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;code&gt;getByRole&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;getByLabel&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;getByText&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;data-testid&lt;/code&gt; when you need a stable testing contract&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What you want to avoid is binding tests to styling details like &lt;code&gt;.btn-primary&lt;/code&gt;&lt;br&gt;
or deep CSS paths that change every time the UI gets cleaned up.&lt;/p&gt;
&lt;h3&gt;
  
  
  Treat &lt;code&gt;waitForTimeout&lt;/code&gt; as a smell
&lt;/h3&gt;

&lt;p&gt;If you ever reach for this:&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;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;assume the test still is not right.&lt;/p&gt;

&lt;p&gt;It may pass on your machine and fail in CI. It may also slow the suite down&lt;br&gt;
while still being unreliable.&lt;/p&gt;

&lt;p&gt;Better choices are explicit signals:&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;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;**/confirmation&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/api&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;orders/&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;expect&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="nf"&gt;getByText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Order confirmed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBeVisible&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Make the await match the business signal
&lt;/h3&gt;

&lt;p&gt;This is the distinction that trips people up.&lt;/p&gt;

&lt;p&gt;Playwright already auto-waits for an element to become actionable before it&lt;br&gt;
clicks, fills, or types. Explicit awaits are for the thing that happens after&lt;br&gt;
the action.&lt;/p&gt;

&lt;p&gt;That means &lt;code&gt;await page.getByRole("button", { name: "Place order" }).click()&lt;/code&gt;&lt;br&gt;
can prove the button was clickable. It does not, on its own, prove the order&lt;br&gt;
was created, the redirect finished, or the confirmation UI appeared.&lt;/p&gt;

&lt;p&gt;The right explicit wait depends on the signal that tells you the step is really&lt;br&gt;
done:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;wait for a URL change when the flow navigates&lt;/li&gt;
&lt;li&gt;wait for a response when the backend side effect matters&lt;/li&gt;
&lt;li&gt;wait for a loading state to disappear when the page stays put&lt;/li&gt;
&lt;li&gt;wait for the final visible UI state when that is what the user would notice&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When the click starts the transition, tie the action and the wait together:&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;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;**/confirmation&lt;/span&gt;&lt;span class="dl"&gt;"&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="nf"&gt;getByRole&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&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;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;Place order&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;click&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;expect&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="nf"&gt;getByText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Order confirmed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBeVisible&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the page does not navigate, but the server-side effect is the important&lt;br&gt;
part, wait for that response first and then assert the UI:&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;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForResponse&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="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/orders&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&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&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;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;Place order&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;click&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;expect&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="nf"&gt;getByText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Order confirmed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBeVisible&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That style is more honest about what the test depends on. Instead of hoping a&lt;br&gt;
pause is long enough, you name the signal that proves the journey completed.&lt;/p&gt;
&lt;h2&gt;
  
  
  Page objects are a scaling tool
&lt;/h2&gt;

&lt;p&gt;I do not start with page objects on day one.&lt;/p&gt;

&lt;p&gt;If a suite has one or two tests, a couple of small helper functions are often&lt;br&gt;
enough. Page objects start paying off when multiple tests share the same screen,&lt;br&gt;
the same setup, or the same selectors.&lt;/p&gt;

&lt;p&gt;The job of a page object is narrow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;keep selectors in one place&lt;/li&gt;
&lt;li&gt;expose repeated user actions in product language&lt;/li&gt;
&lt;li&gt;reduce copy-paste when the UI changes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A good page object hides selector plumbing. It should not hide the whole test.&lt;br&gt;
The scenario, the assertions, and the reason the flow matters should usually&lt;br&gt;
stay visible in the test file.&lt;/p&gt;

&lt;p&gt;This is the sort of thing I mean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Page&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;@playwright/test&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;class&lt;/span&gt; &lt;span class="nc"&gt;LoginPage&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

  &lt;span class="nf"&gt;emailField&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;this&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="nf"&gt;getByLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Email&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;passwordField&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;this&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="nf"&gt;getByLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Password&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;async&lt;/span&gt; &lt;span class="nf"&gt;signIn&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;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;emailField&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;fill&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="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;passwordField&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByTestId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;login-submit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;click&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 test that uses it can stay focused on the actual journey:&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;await&lt;/span&gt; &lt;span class="nx"&gt;loginPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;signIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;alice@example.com&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;correct horse battery staple&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&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="nf"&gt;toHaveURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/dashboard/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trade-off is worth it when the same login flow appears in a few tests.&lt;br&gt;
It is not worth it when every page object becomes a giant wrapper around every&lt;br&gt;
DOM node on the screen.&lt;/p&gt;

&lt;p&gt;My rule of thumb is simple: if two or three tests repeat the same selectors and&lt;br&gt;
actions, extract a small page object. Keep it focused on repeated flows, not on&lt;br&gt;
building a mini framework.&lt;/p&gt;
&lt;h2&gt;
  
  
  Other useful jobs for the same skill
&lt;/h2&gt;

&lt;p&gt;The interesting part of &lt;code&gt;playwright-explore-website&lt;/code&gt; is that it is not limited&lt;br&gt;
to authoring E2E tests.&lt;/p&gt;

&lt;p&gt;It is also useful for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;exploratory QA on a staging or preview deployment&lt;/li&gt;
&lt;li&gt;reproducing vague browser bugs from support tickets&lt;/li&gt;
&lt;li&gt;documenting core flows and their expected outcomes&lt;/li&gt;
&lt;li&gt;checking console errors and visible breakage after a deploy&lt;/li&gt;
&lt;li&gt;validating navigation, forms, and content on a marketing site before release&lt;/li&gt;
&lt;li&gt;identifying which user journeys are worth automating next&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I would not use it as a substitute for proper accessibility reviews,&lt;br&gt;
performance testing, or security testing. It is a browser exploration tool, not&lt;br&gt;
a complete quality strategy.&lt;/p&gt;
&lt;h2&gt;
  
  
  Running Playwright in CI and debugging failures
&lt;/h2&gt;

&lt;p&gt;Running the suite in CI is the obvious part.&lt;/p&gt;

&lt;p&gt;The more interesting part is what happens after a failure.&lt;/p&gt;

&lt;p&gt;Playwright's trace viewer is one of the best reasons to use it. When a test&lt;br&gt;
fails, you can capture a trace and inspect:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;every action&lt;/li&gt;
&lt;li&gt;a timeline of the test&lt;/li&gt;
&lt;li&gt;screenshots at each step&lt;/li&gt;
&lt;li&gt;console output&lt;/li&gt;
&lt;li&gt;network requests&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The workflow is straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm playwright &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;--trace&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;on-first-retry
pnpm playwright show-trace trace.zip
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That turns CI failures from guesswork into evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  When not to write an E2E test
&lt;/h2&gt;

&lt;p&gt;If your E2E suite becomes the default answer to every testing question, it will&lt;br&gt;
become slow, brittle, and expensive to maintain.&lt;/p&gt;

&lt;p&gt;Good reasons not to write an E2E test:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the behaviour is already covered clearly in a unit test&lt;/li&gt;
&lt;li&gt;the test would take a long time to set up for very little risk reduction&lt;/li&gt;
&lt;li&gt;the UI state is local and easy to verify at component level&lt;/li&gt;
&lt;li&gt;the failure would not matter much in production&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a test takes a long time to run but almost never catches a meaningful bug,&lt;br&gt;
it is probably not earning its place in the suite. Remove it, or replace it&lt;br&gt;
with a cheaper test that gives clearer feedback.&lt;/p&gt;

&lt;h2&gt;
  
  
  A pragmatic place to start
&lt;/h2&gt;

&lt;p&gt;If you are introducing Playwright, or the AI-assisted workflow around it, this&lt;br&gt;
is a sensible first week plan.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pick one critical user journey.&lt;/li&gt;
&lt;li&gt;Explore it with &lt;code&gt;playwright-explore-website&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Write one clean Playwright test from that exploration.&lt;/li&gt;
&lt;li&gt;Use semantic locators first, then &lt;code&gt;data-testid&lt;/code&gt; where needed.&lt;/li&gt;
&lt;li&gt;Run that test in CI with traces enabled.&lt;/li&gt;
&lt;li&gt;Fix flakiness before adding more coverage.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is enough to learn the tool, prove the value, and build trust in the&lt;br&gt;
approach.&lt;/p&gt;

&lt;p&gt;Once that first path is stable, add the next one.&lt;/p&gt;

&lt;p&gt;Not everything needs an end-to-end test. The paths that matter do.&lt;/p&gt;

&lt;p&gt;If you want the official docs after this overview, start here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://playwright.dev/" rel="noopener noreferrer"&gt;Playwright documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.javascriptlondon.com" rel="noopener noreferrer"&gt;JavaScript London event page&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>engineering</category>
      <category>frontend</category>
      <category>tooling</category>
    </item>
    <item>
      <title>The playwright-explore-website Copilot skill</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Wed, 22 Apr 2026 09:11:43 +0000</pubDate>
      <link>https://dev.to/sourcier/the-playwright-explore-website-copilot-skill-n42</link>
      <guid>https://dev.to/sourcier/the-playwright-explore-website-copilot-skill-n42</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/playwright-explore-website" rel="noopener noreferrer"&gt;The playwright-explore-website Copilot skill&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is the standalone write-up behind the workflow I referenced in&lt;br&gt;
&lt;a href="https://dev.to/blog/playwright-e2e-testing-talk"&gt;my JavaScript London talk companion post&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The short version is that &lt;code&gt;playwright-explore-website&lt;/code&gt; is a small GitHub&lt;br&gt;
Copilot skill that tells Copilot to use the Playwright MCP server to open a&lt;br&gt;
site, explore a few important user flows, document what it found, and suggest&lt;br&gt;
candidate test cases.&lt;/p&gt;

&lt;p&gt;In the latest version, it also treats Playwright as the source of truth for&lt;br&gt;
rendered UI checks. If the task involves a visible change or regression, the&lt;br&gt;
agent should inspect the current UI first, make the change, verify the updated&lt;br&gt;
state afterwards, and clean up temporary screenshots unless the user asked to&lt;br&gt;
keep them.&lt;/p&gt;

&lt;p&gt;I did not invent the starting point from scratch. The initial version was&lt;br&gt;
based on the original&lt;br&gt;
&lt;a href="https://github.com/github/awesome-copilot/blob/main/skills/playwright-explore-website/SKILL.md" rel="noopener noreferrer"&gt;&lt;code&gt;playwright-explore-website&lt;/code&gt; example in awesome-copilot&lt;/a&gt;.&lt;br&gt;
What I changed was the setup guidance and the execution rules, so it behaves&lt;br&gt;
better in real browser work, especially for regression checks and visual QA,&lt;br&gt;
instead of acting like a generic prompt stub.&lt;/p&gt;

&lt;p&gt;This post stays focused on the skill itself: where it came from, how I set it&lt;br&gt;
up locally, and the guardrails I added. The talk companion post covers the&lt;br&gt;
broader Playwright testing workflow around it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IExSCiAgICBQcm9tcHRbVXNlciBhc2tzIHRvIGV4cGxvcmUgYSB3ZWJzaXRlXSAtLT4gU2tpbGxbcGxheXdyaWdodC1leHBsb3JlLXdlYnNpdGUgc2tpbGxdCiAgICBTa2lsbCAtLT4gTUNQW1BsYXl3cmlnaHQgTUNQIHNlcnZlcl0KICAgIE1DUCAtLT4gQnJvd3NlcltSZWFsIGJyb3dzZXIgc2Vzc2lvbl0KICAgIEJyb3dzZXIgLS0-IEZpbmRpbmdzW0Zsb3dzIMK3IGxvY2F0b3JzIMK3IGV4cGVjdGVkIG91dGNvbWVzXQogICAgRmluZGluZ3MgLS0-IENhc2VzW0NhbmRpZGF0ZSB0ZXN0IGNhc2VzXQ" 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%2FZmxvd2NoYXJ0IExSCiAgICBQcm9tcHRbVXNlciBhc2tzIHRvIGV4cGxvcmUgYSB3ZWJzaXRlXSAtLT4gU2tpbGxbcGxheXdyaWdodC1leHBsb3JlLXdlYnNpdGUgc2tpbGxdCiAgICBTa2lsbCAtLT4gTUNQW1BsYXl3cmlnaHQgTUNQIHNlcnZlcl0KICAgIE1DUCAtLT4gQnJvd3NlcltSZWFsIGJyb3dzZXIgc2Vzc2lvbl0KICAgIEJyb3dzZXIgLS0-IEZpbmRpbmdzW0Zsb3dzIMK3IGxvY2F0b3JzIMK3IGV4cGVjdGVkIG91dGNvbWVzXQogICAgRmluZGluZ3MgLS0-IENhc2VzW0NhbmRpZGF0ZSB0ZXN0IGNhc2VzXQ" alt="Mermaid diagram" width="1691" height="94"&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/playwright-explore-website" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/playwright-explore-website&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  What the skill is for
&lt;/h2&gt;

&lt;p&gt;This skill sits in the gap between "open the browser and poke around" and&lt;br&gt;
"write the final Playwright test file".&lt;/p&gt;

&lt;p&gt;It is useful when you need to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;understand an unfamiliar product area quickly&lt;/li&gt;
&lt;li&gt;smoke test a staging or preview deployment&lt;/li&gt;
&lt;li&gt;reproduce a bug report that is missing exact steps&lt;/li&gt;
&lt;li&gt;verify rendered UI before and after a visible change&lt;/li&gt;
&lt;li&gt;identify candidate locators before writing a Playwright test&lt;/li&gt;
&lt;li&gt;turn exploratory browsing into a short list of candidate scenarios&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is why I like it. It makes exploration explicit.&lt;/p&gt;
&lt;h2&gt;
  
  
  The original awesome-copilot example
&lt;/h2&gt;

&lt;p&gt;The original version in awesome-copilot is deliberately small.&lt;/p&gt;

&lt;p&gt;At a high level, it tells Copilot to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;navigate to the provided URL with Playwright MCP&lt;/li&gt;
&lt;li&gt;identify and interact with 3 to 5 core user flows&lt;/li&gt;
&lt;li&gt;document the interactions, locators, and expected outcomes&lt;/li&gt;
&lt;li&gt;close the browser context afterwards&lt;/li&gt;
&lt;li&gt;summarise the findings and propose test cases&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is already a good baseline because it forces exploration before test&lt;br&gt;
generation. The model has to look at the real site first instead of guessing&lt;br&gt;
what the UI probably looks like.&lt;/p&gt;
&lt;h2&gt;
  
  
  My local setup
&lt;/h2&gt;

&lt;p&gt;I keep this as a personal skill rather than a repo-specific one.&lt;/p&gt;

&lt;p&gt;That means the file lives at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;~/.copilot/skills/playwright-explore-website/SKILL.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I prefer that because the same workflow is useful across multiple projects.&lt;br&gt;
Once the skill is there, Copilot can discover it in any repo where browser&lt;br&gt;
exploration makes sense.&lt;/p&gt;

&lt;p&gt;The frontmatter stays intentionally simple:&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;playwright-explore-website&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;Website&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;exploration&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;testing&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;using&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Playwright&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;MCP'&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is not the title. It is that the description contains the&lt;br&gt;
right trigger words, so Copilot can load it when the task is about website&lt;br&gt;
exploration, Playwright, or browser-based testing.&lt;/p&gt;
&lt;h2&gt;
  
  
  Playwright MCP setup
&lt;/h2&gt;

&lt;p&gt;One of the biggest gaps in the original example is that it assumes the&lt;br&gt;
Playwright MCP tools are already available.&lt;/p&gt;

&lt;p&gt;That is fine once your machine is configured. It is less helpful the first time&lt;br&gt;
you try to use the skill and the tools are missing.&lt;/p&gt;

&lt;p&gt;I added an explicit setup section so the skill can bootstrap the missing piece&lt;br&gt;
instead of failing vaguely.&lt;/p&gt;

&lt;p&gt;The CLI path is the shortest route:&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;--add-mcp&lt;/span&gt; &lt;span class="s1"&gt;'{"name":"playwright","command":"pnpm","args":["dlx","@playwright/mcp@latest"]}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you prefer to wire it up in &lt;code&gt;settings.json&lt;/code&gt;, the equivalent config is:&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;"mcp"&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;"servers"&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;"pnpm"&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;"dlx"&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@latest"&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;After that, reload VS Code or the Copilot extension and accept the prompt to&lt;br&gt;
start the MCP server.&lt;/p&gt;

&lt;p&gt;This matters because a good skill should not only describe the happy path. It&lt;br&gt;
should also help the agent recover when a prerequisite is missing.&lt;/p&gt;
&lt;h2&gt;
  
  
  The enhancements I added
&lt;/h2&gt;

&lt;p&gt;I kept the core purpose the same, but I tightened how the skill runs.&lt;/p&gt;
&lt;h3&gt;
  
  
  1. Self-bootstrapping setup instructions
&lt;/h3&gt;

&lt;p&gt;The added &lt;code&gt;## MCP Server Setup&lt;/code&gt; section gives the agent a concrete fallback when&lt;br&gt;
the Playwright tools are unavailable.&lt;/p&gt;

&lt;p&gt;That turns a dead end into a fixable setup problem.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. A serial exploration rule
&lt;/h3&gt;

&lt;p&gt;I added a rule for multi-page and multi-breakpoint work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;If you need to compare multiple pages or breakpoints, inspect them serially or
in separate tabs. Do not queue parallel navigations and screenshots against the
same Playwright page context.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a practical guardrail. Browser exploration becomes noisy very quickly if&lt;br&gt;
you mix multiple navigations and screenshots in one live context.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. Rendered UI review
&lt;/h3&gt;

&lt;p&gt;The newer version also makes the rendered browser state the thing to trust.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;Use Playwright to review the rendered UI directly. For implementation or
regression checks, inspect the current state first, then verify the updated
state after changes instead of relying on code inspection alone.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the rule that changed the skill most in practice. CSS, JSX, or Astro&lt;br&gt;
templates are not the final UI. The browser is. If the job is about a visible&lt;br&gt;
change, the skill now pushes Copilot to validate the actual rendered result&lt;br&gt;
before it signs off.&lt;/p&gt;
&lt;h3&gt;
  
  
  4. Visual audit prompts
&lt;/h3&gt;

&lt;p&gt;I also added an explicit visual review prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;For visual audits, explicitly note supporting-label readability,
hero-to-first-section spacing, footer divider spacing, and
last-section-to-footer separation when relevant.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That came from real UI review work. I wanted the skill to be useful not just&lt;br&gt;
for functional exploration, but also for browser-based design checks.&lt;/p&gt;
&lt;h3&gt;
  
  
  5. Screenshot cleanup
&lt;/h3&gt;

&lt;p&gt;I also added a cleanup rule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;Delete any temporary screenshots you created during the session unless the
user explicitly asked to keep them.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This sounds small, but it matters. Exploration and regression review can leave&lt;br&gt;
behind a pile of disposable screenshots very quickly. If the skill creates&lt;br&gt;
artifacts to reason about the UI, it should also leave the workspace tidy when&lt;br&gt;
those artifacts are no longer needed.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Stronger output requirements
&lt;/h3&gt;

&lt;p&gt;The local version is stricter about what the exploration should produce.&lt;/p&gt;

&lt;p&gt;It does not stop at "I clicked around and it looked fine". It asks for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the user interactions that were performed&lt;/li&gt;
&lt;li&gt;the relevant UI elements and likely locators&lt;/li&gt;
&lt;li&gt;the expected outcomes for each flow&lt;/li&gt;
&lt;li&gt;a concise summary of findings&lt;/li&gt;
&lt;li&gt;proposed test cases based on the exploration&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That output maps much more cleanly to a future Playwright test file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this fits
&lt;/h2&gt;

&lt;p&gt;This post is intentionally about the skill itself.&lt;/p&gt;

&lt;p&gt;If you want the wider workflow around it, including how I use it to explore a&lt;br&gt;
risky journey, compare it with codegen, and turn the findings into a real test,&lt;br&gt;
that is in &lt;a href="https://dev.to/blog/playwright-e2e-testing-talk"&gt;Playwright E2E testing AI skills: JavaScript London talk&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;That boundary is deliberate. The skill helps you explore a real browser session&lt;br&gt;
and capture candidate flows, locators, and outcomes. The Playwright test is&lt;br&gt;
still the final artifact.&lt;/p&gt;

</description>
      <category>engineering</category>
      <category>tooling</category>
      <category>automation</category>
    </item>
    <item>
      <title>Building a tag system in Astro</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Tue, 21 Apr 2026 11:02:24 +0000</pubDate>
      <link>https://dev.to/sourcier/building-a-tag-system-in-astro-24d5</link>
      <guid>https://dev.to/sourcier/building-a-tag-system-in-astro-24d5</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/tag-system-astro" rel="noopener noreferrer"&gt;Building a tag system 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;Tags do more than connect related posts. They create a second navigation system.&lt;br&gt;
On this site the same tag data powers the blog archive cloud, the dedicated&lt;br&gt;
&lt;code&gt;/tags&lt;/code&gt; overview, paginated tag archive pages, and a sidebar browser on&lt;br&gt;
individual post pages.&lt;/p&gt;

&lt;p&gt;The key constraint is consistency. If each surface counts tags differently,&lt;br&gt;
builds URLs differently, or decides for itself which posts are visible, the&lt;br&gt;
whole system drifts. The implementation here stays simple by sharing two rules&lt;br&gt;
everywhere: one slug helper and one publication filter.&lt;/p&gt;

&lt;p&gt;A later update added per-tag descriptions, a featured post highlight, related&lt;br&gt;
topics, and a stats bar, all covered in the second half of this post.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6oezvxdcc4vvp3uqyfje.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%2F6oezvxdcc4vvp3uqyfje.png" alt="Tag system wireframe showing three views: the blog archive weighted cloud with tier-1/2/3 pill sizes, the /tags page with concentric ring layout, and the post sidebar tag list" width="700" height="520"&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/tag-system-astro" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/tag-system-astro&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  Slug normalisation
&lt;/h2&gt;

&lt;p&gt;Every tag is displayed as-is, but routed via a URL-safe slug. A small utility in&lt;br&gt;
&lt;code&gt;src/utils/tags.ts&lt;/code&gt; handles that conversion:&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;function&lt;/span&gt; &lt;span class="nf"&gt;tagSlug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;tag&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="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;\s&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;-&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;/&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;a-z0-9-&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That turns &lt;code&gt;"web development"&lt;/code&gt; into &lt;code&gt;"web-development"&lt;/code&gt;, &lt;code&gt;"Node.js"&lt;/code&gt; into&lt;br&gt;
&lt;code&gt;"nodejs"&lt;/code&gt;, and &lt;code&gt;"C#"&lt;/code&gt; into &lt;code&gt;"c"&lt;/code&gt;. It is intentionally small. A more generic&lt;br&gt;
utility might transliterate Unicode or preserve special cases, but the tag&lt;br&gt;
vocabulary on this site is controlled enough that a simple transform is easier&lt;br&gt;
to reason about.&lt;/p&gt;

&lt;p&gt;The important detail is reuse. The cloud component, sidebar, tags index, and tag&lt;br&gt;
archive routes all import the same helper, so every link resolves to the same&lt;br&gt;
path format.&lt;/p&gt;
&lt;h2&gt;
  
  
  Publication-aware tag counts
&lt;/h2&gt;

&lt;p&gt;Counting tags is trivial. Counting the right tags is the subtle part.&lt;/p&gt;

&lt;p&gt;This site does not let each tag surface invent its own visibility rules. It&lt;br&gt;
filters posts through the shared publication helper and only then derives tag&lt;br&gt;
counts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;isPublished&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;../utils/drafts&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;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="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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tagCounts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="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;post&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;allPosts&lt;/span&gt;&lt;span class="p"&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;tag&lt;/span&gt; &lt;span class="k"&gt;of&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;tags&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;tagCounts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tagCounts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That matters because &lt;code&gt;isPublished()&lt;/code&gt; hides both drafts and future-dated posts&lt;br&gt;
unless &lt;code&gt;SHOW_DRAFTS=true&lt;/code&gt;. The weighted cloud, ring layout, tag archives, and&lt;br&gt;
sidebar all stay in sync with the rest of the site because they start from the&lt;br&gt;
same filtered set.&lt;/p&gt;

&lt;p&gt;Right now the counting logic is repeated across several files. That is still a&lt;br&gt;
good trade-off: the logic is tiny and the behaviour stays obvious. If the rules&lt;br&gt;
become more complex later, extracting a shared &lt;code&gt;getTagCounts()&lt;/code&gt; helper would be&lt;br&gt;
the next clean-up step.&lt;/p&gt;
&lt;h2&gt;
  
  
  The weighted cloud on the blog archive
&lt;/h2&gt;

&lt;p&gt;The weighted cloud lives on the blog archive: &lt;code&gt;/blog&lt;/code&gt; and the paginated&lt;br&gt;
&lt;code&gt;/blog/page/[page]&lt;/code&gt; pages both render the same component.&lt;br&gt;
&lt;/p&gt;

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

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

&lt;/div&gt;



&lt;p&gt;Inside &lt;code&gt;BlogTagCloud.astro&lt;/code&gt;, counts are converted into three visual tiers:&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;tier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&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="kr"&gt;number&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;maxCount&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;minCount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&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;ratio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;minCount&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;maxCount&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;minCount&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;ratio&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.66&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;3&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;ratio&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.33&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those tiers map to modifier classes such as&lt;br&gt;
&lt;code&gt;tag-cloud__pill--tier-1&lt;/code&gt;, &lt;code&gt;tag-cloud__pill--tier-2&lt;/code&gt;, and&lt;br&gt;
&lt;code&gt;tag-cloud__pill--tier-3&lt;/code&gt;. A stepped system is deliberate here. Continuous&lt;br&gt;
scaling works well on the more expressive &lt;code&gt;/tags&lt;/code&gt; page, but in a compact cloud&lt;br&gt;
of pill links it creates visual noise faster than it adds meaning.&lt;/p&gt;

&lt;p&gt;Each pill also includes a small count badge, so someone hovering or scanning the&lt;br&gt;
cloud sees both the qualitative weight and the exact number. An "All topics"&lt;br&gt;
link pushes people from the compact archive cloud into the more detailed &lt;code&gt;/tags&lt;/code&gt;&lt;br&gt;
overview.&lt;/p&gt;
&lt;h2&gt;
  
  
  The concentric ring layout on &lt;code&gt;/tags&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;/tags&lt;/code&gt; page is the more exploratory view. It combines two separate signals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Font size scales continuously with tag frequency.&lt;/li&gt;
&lt;li&gt;Tags are placed on concentric rings, with the most common topic in the centre
and less common topics pushed outward.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The scaling and ring layout are both calculated at build time in the page&lt;br&gt;
frontmatter:&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;MIN_REM&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.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;MAX_REM&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&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;scale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&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="kr"&gt;number&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;maxCount&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;minCount&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;MIN_REM&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;MAX_REM&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;MIN_REM&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;minCount&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;maxCount&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;minCount&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;MAX_REM&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;MIN_REM&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;ringConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;radiusPct&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="na"&gt;max&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="na"&gt;startDeg&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;span class="na"&gt;radiusPct&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;startDeg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;radiusPct&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;35&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;startDeg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;radiusPct&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;48&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;startDeg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After sorting tags by count descending, the page fills the rings from the inside&lt;br&gt;
out and converts each ring slot into percentage coordinates:&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;angleDeg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;radiusPct&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="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;startDeg&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;360&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;items&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rad&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;angleDeg&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="nx"&gt;PI&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;180&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;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;radiusPct&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="mi"&gt;50&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;radiusPct&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;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rad&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;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;radiusPct&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="mi"&gt;50&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;radiusPct&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;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rad&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That produces an SVG-like layout without SVG. Each tag is still a normal anchor,&lt;br&gt;
just absolutely positioned inside a relative container.&lt;/p&gt;

&lt;p&gt;The accessibility model matters here too. The circular cloud is decorative and&lt;br&gt;
marked &lt;code&gt;aria-hidden&lt;/code&gt;, while a plain list remains in the accessibility tree and&lt;br&gt;
is also the visual fallback on small screens. The fallback list is shuffled with&lt;br&gt;
a seeded function so it does not read as a rigid alphabetical index, but it&lt;br&gt;
stays deterministic across builds.&lt;/p&gt;
&lt;h2&gt;
  
  
  Tag archive pages
&lt;/h2&gt;

&lt;p&gt;Each topic gets a clean first-page URL at &lt;code&gt;/tags/[tag]&lt;/code&gt; and subsequent archive&lt;br&gt;
pages at &lt;code&gt;/tags/[tag]/2&lt;/code&gt;, &lt;code&gt;/tags/[tag]/3&lt;/code&gt;, and so on.&lt;/p&gt;

&lt;p&gt;Rather than using Astro's &lt;code&gt;paginate()&lt;/code&gt; helper, the current implementation keeps&lt;br&gt;
two route files:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;src/pages/tags/[tag]/index.astro&lt;/code&gt; handles page 1.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;src/pages/tags/[tag]/[page].astro&lt;/code&gt; emits pages 2 and above.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The second file builds its paths manually:&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="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="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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tagMap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;allPosts&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;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;post&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;allPosts&lt;/span&gt;&lt;span class="p"&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;tag&lt;/span&gt; &lt;span class="k"&gt;of&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;tags&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;slug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tagSlug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tag&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;tagMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;tagMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
      &lt;span class="nx"&gt;tagMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&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="nf"&gt;push&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="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;paths&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;slug&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="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;tagMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&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;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;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;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;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;posts&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;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;tagSlug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;pageNum&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="nx"&gt;pageNum&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="nx"&gt;pageNum&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;paths&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="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;tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="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;pageNum&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;props&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;label&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="nx"&gt;paths&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;Routes use slugs, but page heroes should still display the human-readable tag&lt;br&gt;
text. Looking up the original label from the first matching post preserves&lt;br&gt;
spaces and casing without a separate lookup table.&lt;/p&gt;

&lt;p&gt;Both route files sort posts newest first and share the same &lt;code&gt;PAGE_SIZE = 9&lt;/code&gt;, so&lt;br&gt;
pagination behaves consistently across every topic.&lt;/p&gt;
&lt;h2&gt;
  
  
  The sidebar browser
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;TagsSidebar.astro&lt;/code&gt; is the compact version of the same system. It keeps the same&lt;br&gt;
counting and sorting pattern, but renders a simple vertical list with count&lt;br&gt;
badges rather than a cloud:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const tags = [...tagCounts.entries()]
  .sort(([, a], [, b]) =&amp;gt; b - a)
  .map(([tag, count]) =&amp;gt; ({ tag, count, slug: tagSlug(tag) }));
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sidebar only matters because the post layout mounts it alongside the table&lt;br&gt;
of contents and share controls:&lt;br&gt;
&lt;/p&gt;

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

&amp;lt;aside class="post__sidebar" aria-label="Post sidebar"&amp;gt;
  {/* other sidebar blocks */}
  &amp;lt;TagsSidebar /&amp;gt;
&amp;lt;/aside&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That keeps topic browsing available even when someone lands deep on an&lt;br&gt;
individual article. The UI copy uses "topics" in the reader-facing labels, but&lt;br&gt;
the routes stay under &lt;code&gt;/tags&lt;/code&gt;, which keeps the underlying implementation short&lt;br&gt;
and stable.&lt;/p&gt;
&lt;h2&gt;
  
  
  Pulling the system together
&lt;/h2&gt;

&lt;p&gt;The interesting part of this tag system is not any single surface. It is the&lt;br&gt;
fact that every surface starts from the same visible post set and uses the same&lt;br&gt;
slug transform. Once those two rules are stable, the same dataset can show up as&lt;br&gt;
weighted pills, a ring cloud, paginated archives, or a sidebar list without the&lt;br&gt;
site disagreeing with itself.&lt;/p&gt;

&lt;span&gt;Added 23 April 2026&lt;/span&gt;
&lt;h2&gt;
  
  
  Improving the tag pages
&lt;/h2&gt;

&lt;p&gt;A comment from &lt;a href="https://dev.to/fyodorio"&gt;Fyodor&lt;/a&gt; after this post was first published put it directly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;a href="https://dev.to/fyodorio/comment/375bb"&gt;Now try to add description to each tag so that a tag page wouldn't look that generically bland, huh?&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Fair point. I went ahead and added four improvements to address exactly that.&lt;/p&gt;
&lt;h3&gt;
  
  
  Tag descriptions
&lt;/h3&gt;

&lt;p&gt;A &lt;code&gt;tagDescriptions&lt;/code&gt; map in &lt;code&gt;src/utils/tags.ts&lt;/code&gt; keys a short description to&lt;br&gt;
each tag slug:&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;tagDescriptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;astro&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Everything about building with Astro: content collections, islands architecture, SSG, and the surrounding ecosystem.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;engineering&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Software engineering practice — architecture, trade-offs, and the thinking behind building things well.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full map covering all current tags is in the code listings at the end of this post.&lt;/p&gt;

&lt;p&gt;The tag archive page reads the current slug and falls back gracefully if no&lt;br&gt;
description exists:&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;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tagDescriptions&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That value then drives the &lt;code&gt;&amp;lt;meta&amp;gt;&lt;/code&gt; description, the hero subtitle, and the&lt;br&gt;
panel copy. One source, three uses. New tags that lack an entry get the old&lt;br&gt;
generic fallback, so nothing breaks.&lt;/p&gt;
&lt;h3&gt;
  
  
  Featured post highlight
&lt;/h3&gt;

&lt;p&gt;The most recent post was buried in the grid with no visual prominence. The fix&lt;br&gt;
pulls it out entirely and renders it as a full-width two-column card above&lt;br&gt;
the grid:&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;featuredPost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;allTagPosts&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;remainingPosts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;allTagPosts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;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;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;remainingPosts&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;remainingPosts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;PAGE_SIZE&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The featured card shows the cover image on the left at desktop size with the&lt;br&gt;
title, description, and a read CTA on the right. &lt;code&gt;totalPages&lt;/code&gt; is derived from&lt;br&gt;
&lt;code&gt;remainingPosts&lt;/code&gt;, not &lt;code&gt;allTagPosts&lt;/code&gt;, so pagination stays honest.&lt;/p&gt;
&lt;h3&gt;
  
  
  Related topics row
&lt;/h3&gt;

&lt;p&gt;Between the featured card and the grid, a compact pill row links to up to five&lt;br&gt;
related tags, specifically ones that most frequently co-occur with the current one:&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;relatedTagCounts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="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;post&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;allTagPosts&lt;/span&gt;&lt;span class="p"&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;t&lt;/span&gt; &lt;span class="k"&gt;of&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;tags&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;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tagSlug&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;relatedTagCounts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;relatedTagCounts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="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;relatedTags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;relatedTagCounts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&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="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;a&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;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(([&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;allTagPosts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flatMap&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;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;tagSlug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;slug&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 runs entirely at build time, with no JavaScript in the browser.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stats bar
&lt;/h3&gt;

&lt;p&gt;Inside the topic overview panel, a &lt;code&gt;&amp;lt;dl&amp;gt;&lt;/code&gt; shows three facts: post count,&lt;br&gt;
average reading time per post, and total reading time across the whole archive.&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;totalWords&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;allTagPosts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;acc&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;acc&lt;/span&gt; &lt;span class="o"&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;body&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;readingTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&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;words&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="mi"&gt;0&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;totalReadMinutes&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;totalWords&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;200&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;avgReadMinutes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;allTagPosts&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;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="p"&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;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;totalReadMinutes&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;allTagPosts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&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;200 wpm&lt;/code&gt; divisor is deliberately conservative: it errs on the side of&lt;br&gt;
not under-promising. The stat gives a quick sense of how deep the archive goes&lt;br&gt;
before committing to reading any of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Beyond the foundations
&lt;/h2&gt;

&lt;p&gt;All four improvements build on the same two rules without touching them. The slug helper&lt;br&gt;
and publication filter stay unchanged. What changes is only what each tag page&lt;br&gt;
does with the data once it has it.&lt;/p&gt;

&lt;p&gt;That is a useful sign. When enhancements layer on top without revisiting the&lt;br&gt;
core logic, the foundation was right.&lt;/p&gt;

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

&lt;p&gt;If you want to inspect the finished implementation end to end, expand the&lt;br&gt;
listings below. These are the complete files that make up the tag system&lt;br&gt;
itself. The shared publication helper lives in &lt;code&gt;src/utils/drafts.ts&lt;/code&gt; and is&lt;br&gt;
reused across the rest of the site too.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;src/utils/tags.ts&lt;/code&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;function&lt;/span&gt; &lt;span class="nf"&gt;tagSlug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;tag&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="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;\s&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;-&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;/&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;a-z0-9-&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tagDescriptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;analytics&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Tracking, measuring, and making sense of how people use the web — from basic page views to real user metrics.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;astro&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Everything about building with Astro: content collections, islands architecture, SSG, and the surrounding ecosystem.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;automation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Scripts, workflows, and tooling that take the repetitive work off your plate.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;blogging&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;The craft and mechanics of running a blog — structure, workflow, tooling, and keeping the writing habit going.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;devto&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Cross-posting to DEV.to, syndication strategies, and making the most of the platform.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;dotfiles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Shell configs, editor settings, and the art of making a new machine feel like home.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;engineering&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Software engineering practice — architecture, trade-offs, and the thinking behind building things well.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;frontend&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;HTML, CSS, JavaScript, and everything that shapes what users actually see and interact with.&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;learning-in-public&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;Writing and sharing while you're still figuring things out — the messy, honest, valuable kind.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;meta&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 about the blog itself: how it was built, why decisions were made, and what's changed.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;netlify&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Deploying, hosting, and extending sites with Netlify — functions, forms, edge, and scheduled builds.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;tooling&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Developer tools that sharpen the workflow: linters, formatters, bundlers, and the rest.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;tutorial&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Step-by-step guides with working code, aimed at getting you from zero to done.&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;web-development&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;The broader practice of building for the web — spanning frontend, backend, and everything in between.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;writing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Reflections on writing itself: finding a voice, staying consistent, and communicating clearly.&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;&lt;code&gt;src/components/BlogTagCloud.astro&lt;/code&gt;&lt;/p&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 { tagSlug } from "../utils/tags";

const allPosts = (await getCollection("posts")).filter(isPublished);

const tagCounts = new Map();
for (const post of allPosts) {
  for (const tag of post.data.tags) {
    tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
  }
}

const counts = [...tagCounts.values()];
const minCount = Math.min(...counts);
const maxCount = Math.max(...counts);

function tier(count: number): number {
  if (maxCount === minCount) return 2;
  const ratio = (count - minCount) / (maxCount - minCount);
  if (ratio &amp;amp;gt;= 0.66) return 3;
  if (ratio &amp;amp;gt;= 0.33) return 2;
  return 1;
}

const tags = [...tagCounts.entries()]
  .sort(([, a], [, b]) =&amp;amp;gt; b - a)
  .map(([tag, count]) =&amp;amp;gt; ({
    tag,
    count,
    slug: tagSlug(tag),
    tier: tier(count),
  }));
---






          &amp;lt;p&amp;gt;Topics&amp;lt;/p&amp;gt;
          &amp;lt;h2 id="tag-cloud-heading"&amp;gt;
            Browse by topic
          &amp;lt;/h2&amp;gt;
          &amp;lt;p&amp;gt;
            Jump straight into the subjects you care about without paging
            through the full archive first.
          &amp;lt;/p&amp;gt;

        &amp;lt;a href="/tags"&amp;gt;
          All topics →
        &amp;lt;/a&amp;gt;

      &amp;lt;ul&amp;gt;
        {
          tags.map(({ tag, count, slug, tier }) =&amp;amp;gt; (
            &amp;lt;li&amp;gt;
              &amp;lt;a href="{`/tags/${slug}`}"&amp;gt;
                {tag}
                &amp;lt;span&amp;gt;
                  {count}
                &amp;lt;/span&amp;gt;
              &amp;lt;/a&amp;gt;
            &amp;lt;/li&amp;gt;
          ))
        }
      &amp;lt;/ul&amp;gt;





  .tag-cloud {
    padding: 0 1.5rem;
  }

  .tag-cloud__panel {
    position: relative;
    overflow: hidden;
    padding: clamp(1.75rem, 3.5vw, 2.35rem);
    background:
      radial-gradient(
        circle at top right,
        color-mix(in srgb, var(--accent-secondary) 14%, transparent) 0%,
        transparent 34%
      ),
      radial-gradient(
        circle at left bottom,
        var(--accent-primary-alpha-08) 0%,
        transparent 30%
      ),
      linear-gradient(
        145deg,
        color-mix(
          in srgb,
          var(--surface-page) 88%,
          var(--accent-secondary) 12%
        ),
        var(--surface-elevated)
      );
  }

  .tag-cloud__header {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    margin-bottom: 1.75rem;
    gap: 1rem;

    @media (max-width: 639px) {
      flex-direction: column;
      align-items: flex-start;
    }
  }

  .tag-cloud__intro {
    min-width: 0;
  }

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

  .tag-cloud__description {
    margin: 0.5rem 0 0;
    max-width: 48ch;
    line-height: 1.65;
    color: var(--text-muted);
  }

  .tag-cloud__all-link {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-height: 2.85rem;
    padding: 0.75rem 1.05rem;
    border: 1px solid
      color-mix(in srgb, var(--accent-secondary) 20%, transparent);
    border-radius: var(--radius-pill);
    background: color-mix(
      in srgb,
      var(--accent-secondary) 9%,
      var(--surface-elevated)
    );
    font-family: "Barlow Condensed", sans-serif;
    font-size: 0.75rem;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.08em;
    color: var(--text-muted);
    text-decoration: none;
    transition:
      color 0.15s ease,
      border-color 0.15s ease,
      transform 0.15s ease;

    &amp;amp;amp;:hover {
      color: var(--accent-secondary);
      border-color: var(--accent-secondary-alpha-28);
      transform: translateY(-1px);
    }

    @media (min-width: 640px) {
      align-self: center;
    }
  }

  .tag-cloud__list {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-wrap: wrap;
    gap: 0.75rem;
    align-items: center;
  }

  .tag-cloud__pill {
    display: inline-flex;
    align-items: center;
    gap: 0.4rem;
    text-decoration: none;
    font-family: "Barlow Condensed", sans-serif;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    border: 1.5px solid var(--border-subtle);
    border-radius: var(--radius-pill);
    color: var(--text-muted);
    background-color: color-mix(
      in srgb,
      var(--surface-elevated) 78%,
      transparent
    );
    box-shadow: inset 0 1px 0 var(--text-on-strong-alpha-45);
    transition:
      color 0.15s ease,
      border-color 0.15s ease,
      background-color 0.15s ease,
      transform 0.15s ease;

    &amp;amp;amp;:hover {
      color: var(--accent-secondary);
      border-color: var(--accent-secondary-alpha-26);
      background-color: color-mix(
        in srgb,
        var(--accent-secondary) 9%,
        var(--surface-elevated)
      );
      transform: translateY(-1px);
    }

    &amp;amp;amp;--tier-1 {
      font-size: 0.74rem;
      padding: 0.3rem 0.6rem;
    }

    &amp;amp;amp;--tier-2 {
      font-size: 0.9rem;
      padding: 0.35rem 0.72rem;
    }

    &amp;amp;amp;--tier-3 {
      font-size: 1.04rem;
      padding: 0.42rem 0.82rem;
      border-width: 2px;
      color: var(--text-primary);
      border-color: var(--accent-secondary-alpha-18);
    }
  }

  .tag-cloud__count {
    font-size: 0.65em;
    font-weight: 700;
    color: var(--accent-secondary);
    background-color: color-mix(
      in srgb,
      var(--accent-secondary) 12%,
      transparent
    );
    padding: 0.05rem 0.35rem;
    border-radius: var(--radius-pill);
    transition:
      background-color 0.15s ease,
      color 0.15s ease;

    .tag-cloud__pill:hover &amp;amp;amp; {
      background-color: var(--accent-secondary-alpha-16);
      color: var(--accent-secondary);
    }
  }

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

&lt;/div&gt;
&lt;p&gt;&lt;code&gt;src/components/TagsSidebar.astro&lt;/code&gt;&lt;/p&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 { tagSlug } from "../utils/tags";

const allPosts = (await getCollection("posts")).filter(isPublished);

const tagCounts = new Map();
for (const post of allPosts) {
  for (const tag of post.data.tags) {
    tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
  }
}

const tags = [...tagCounts.entries()]
  .sort(([, a], [, b]) =&amp;amp;gt; b - a)
  .map(([tag, count]) =&amp;amp;gt; ({ tag, count, slug: tagSlug(tag) }));
---


  &amp;lt;p id="tags-sidebar-heading"&amp;gt;Browse topics&amp;lt;/p&amp;gt;
  &amp;lt;ul&amp;gt;
    {
      tags.map(({ tag, count, slug }) =&amp;amp;gt; (
        &amp;lt;li&amp;gt;
          &amp;lt;a href="{`/tags/${slug}`}"&amp;gt;
            &amp;lt;span&amp;gt;{tag}&amp;lt;/span&amp;gt;
            &amp;lt;span&amp;gt;
              {count}
            &amp;lt;/span&amp;gt;
          &amp;lt;/a&amp;gt;
        &amp;lt;/li&amp;gt;
      ))
    }
  &amp;lt;/ul&amp;gt;
  &amp;lt;a href="/tags"&amp;gt;All tags →&amp;lt;/a&amp;gt;



  .tags-sidebar {
    margin-top: 2rem;
    margin-bottom: 2rem;
  }

  .tags-sidebar__heading {
    font-family: "Barlow Condensed", sans-serif;
    font-size: 0.75rem;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.1em;
    color: var(--accent-primary);
    padding-top: 0.75rem;
    border-top: 2px solid var(--accent-primary);
    margin-bottom: 0.75rem;
  }

  .tags-sidebar__list {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0;
  }

  .tags-sidebar__tag {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem;
    padding: 0.45rem 0;
    border-bottom: 1px solid var(--border-subtle);
    text-decoration: none;
    color: var(--text-muted);
    transition:
      color 0.15s ease,
      padding-left 0.15s ease;

    &amp;amp;amp;:hover {
      color: var(--accent-primary);
      padding-left: 0.35rem;
    }
  }

  .tags-sidebar__name {
    font-family: "Barlow Condensed", sans-serif;
    font-size: 0.875rem;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.06em;
  }

  .tags-sidebar__count {
    font-family: "Barlow Condensed", sans-serif;
    font-size: 0.7rem;
    font-weight: 700;
    color: var(--text-muted);
    background-color: var(--border-subtle);
    border-radius: var(--radius-tight);
    padding: 0.1rem 0.4rem;
    min-width: 1.4rem;
    text-align: center;
    transition:
      background-color 0.15s ease,
      color 0.15s ease;

    .tags-sidebar__tag:hover &amp;amp;amp; {
      background-color: rgba(var(--accent-primary-rgb), 0.12);
      color: var(--accent-primary);
    }
  }

  .tags-sidebar__all {
    display: block;
    margin-top: 1rem;
    font-family: "Barlow Condensed", sans-serif;
    font-size: 0.75rem;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.1em;
    color: var(--text-muted);
    text-decoration: none;
    transition: color 0.15s ease;

    &amp;amp;amp;:hover {
      color: var(--accent-primary);
    }
  }

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

&lt;/div&gt;
&lt;p&gt;&lt;code&gt;src/pages/tags/index.astro&lt;/code&gt;&lt;/p&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 { tagSlug } from "../../utils/tags";

const allPosts = (await getCollection("posts")).filter(isPublished);

const tagCounts = new Map();
for (const post of allPosts) {
  for (const tag of post.data.tags) {
    tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
  }
}

const counts = [...tagCounts.values()];
const minCount = Math.min(...counts);
const maxCount = Math.max(...counts);

const MIN_REM = 1.1;
const MAX_REM = 4;

function scale(count: number): number {
  if (maxCount === minCount) return (MIN_REM + MAX_REM) / 2;
  return (
    MIN_REM + ((count - minCount) / (maxCount - minCount)) * (MAX_REM - MIN_REM)
  );
}

const sorted = [...tagCounts.entries()]
  .sort(([, a], [, b]) =&amp;amp;gt; b - a)
  .map(([tag, count]) =&amp;amp;gt; ({
    tag,
    count,
    slug: tagSlug(tag),
    fontSize: scale(count),
  }));

const ringConfig = [
  { radiusPct: 0, max: 1, startDeg: 0 },
  { radiusPct: 18, max: 4, startDeg: -90 },
  { radiusPct: 35, max: 7, startDeg: -50 },
  { radiusPct: 48, max: 12, startDeg: 10 },
];

type TagPos = {
  tag: string;
  count: number;
  slug: string;
  fontSize: number;
  x: number;
  y: number;
  ringIdx: number;
};

const positioned: TagPos[] = [];
let si = 0;

for (let ringIdx = 0; ringIdx &amp;amp;lt; ringConfig.length; ringIdx++) {
  if (si &amp;amp;gt;= sorted.length) break;
  const { radiusPct, max, startDeg } = ringConfig[ringIdx];
  const items = sorted.slice(si, si + max);
  si += items.length;
  items.forEach((item, i) =&amp;amp;gt; {
    const angleDeg = radiusPct === 0 ? 0 : startDeg + (360 * i) / items.length;
    const rad = (angleDeg * Math.PI) / 180;
    const x = radiusPct === 0 ? 50 : 50 + radiusPct * Math.cos(rad);
    const y = radiusPct === 0 ? 50 : 50 + radiusPct * Math.sin(rad);
    positioned.push({ ...item, x, y, ringIdx });
  });
}

function seededShuffle(arr: T[]): T[] {
  const out = [...arr];
  let seed = 42;
  const rand = () =&amp;amp;gt; {
    seed = (seed * 1664525 + 1013904223) &amp;amp;amp; 0xffffffff;
    return (seed &amp;amp;gt;&amp;amp;gt;&amp;amp;gt; 0) / 0xffffffff;
  };
  for (let i = out.length - 1; i &amp;amp;gt; 0; i--) {
    const j = Math.floor(rand() * (i + 1));
    [out[i], out[j]] = [out[j], out[i]];
  }
  return out;
}

const shuffled = seededShuffle(sorted);
const totalTags = sorted.length;
const totalPosts = allPosts.length;

const ringStyle = (ringIdx: number) =&amp;amp;gt; {
  if (ringIdx === 0) return "color: var(--accent-primary); opacity: 1";
  if (ringIdx === 1) return "opacity: 1";
  if (ringIdx === 2) return "opacity: 0.85";
  return "opacity: 0.7";
};
---








          &amp;lt;p&amp;gt;Topic map&amp;lt;/p&amp;gt;
          &amp;lt;h2 id="tag-summary-heading"&amp;gt;
            Explore the writing by theme
          &amp;lt;/h2&amp;gt;
          &amp;lt;p&amp;gt;
            Jump straight into the ideas that show up most often across the
            blog, or skim the full map below if you want to browse more broadly.
          &amp;lt;/p&amp;gt;

        &amp;lt;ul&amp;gt;
          &amp;lt;li&amp;gt;
            &amp;lt;span&amp;gt;Published&amp;lt;/span&amp;gt;
            &amp;lt;span&amp;gt;{totalPosts}&amp;lt;/span&amp;gt;
          &amp;lt;/li&amp;gt;
          &amp;lt;li&amp;gt;
            &amp;lt;span&amp;gt;Topics&amp;lt;/span&amp;gt;
            &amp;lt;span&amp;gt;{totalTags}&amp;lt;/span&amp;gt;
          &amp;lt;/li&amp;gt;
        &amp;lt;/ul&amp;gt;








          &amp;lt;p&amp;gt;
            Each topic links to a collection of related posts. Larger tags mean
            I write about that area more often, so the subjects I return to most
            sit closer to the centre.
          &amp;lt;/p&amp;gt;

            &amp;lt;span&amp;gt;fewer posts&amp;lt;/span&amp;gt;
            &amp;lt;span&amp;gt;→&amp;lt;/span&amp;gt;
            &amp;lt;span&amp;gt;more posts&amp;lt;/span&amp;gt;





            {
              positioned.map(({ tag, slug, fontSize, x, y, ringIdx }) =&amp;amp;gt; (
                &amp;lt;a href="{`/tags/${slug}`}"&amp;gt;
                  {tag}
                &amp;lt;/a&amp;gt;
              ))
            }



        &amp;lt;ul&amp;gt;
          {
            shuffled.map(({ tag, count, slug, fontSize }) =&amp;amp;gt; (
              &amp;lt;li&amp;gt;
                &amp;lt;a href="{`/tags/${slug}`}"&amp;gt;
                  {tag}
                &amp;lt;/a&amp;gt;
              &amp;lt;/li&amp;gt;
            ))
          }
        &amp;lt;/ul&amp;gt;








  .tag-summary {
    padding: 0 1.5rem;
  }

  .tag-summary__panel {
    display: grid;
    gap: 1.25rem;
    border-top-color: var(--accent-secondary);
    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-secondary) 8%, var(--surface-elevated))
          0%,
        var(--surface-elevated) 62%
      ),
      var(--surface-elevated);

    @media (min-width: 768px) {
      grid-template-columns: minmax(0, 1.5fr) auto;
      gap: 2rem;
      align-items: center;
    }
  }

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

  .tag-summary__title {
    margin: 0;
    font-family: "Barlow Condensed", sans-serif;
    font-size: clamp(2rem, 4vw, 2.8rem);
    line-height: 0.98;
    text-transform: uppercase;
    color: var(--text-primary);
  }

  .tag-summary__text {
    margin: 0.85rem 0 0;
    max-width: 58ch;
    line-height: 1.7;
    color: var(--text-muted);
  }

  .tag-summary__stats {
    list-style: none;
    margin: 0;
    padding: 0;
    display: grid;
    gap: 0.75rem;
  }

  .tag-summary__stat {
    display: grid;
    gap: 0.35rem;
    min-width: 9rem;
    padding: 1rem 1.1rem;
    border: 1px solid var(--accent-secondary-alpha-16);
    border-radius: var(--radius-soft);
    background: color-mix(
      in srgb,
      var(--accent-secondary) 8%,
      var(--surface-elevated)
    );
  }

  .tag-summary__label {
    font-size: 0.72rem;
    font-weight: 700;
    letter-spacing: 0.12em;
    text-transform: uppercase;
    color: var(--text-muted);
  }

  .tag-summary__value {
    font-family: "Barlow Condensed", sans-serif;
    font-size: clamp(1.55rem, 4vw, 2.1rem);
    font-weight: 800;
    line-height: 1;
    text-transform: uppercase;
    color: var(--text-primary);
  }

  .cloud-section {
    padding-top: 0;
    padding-bottom: 0;
  }

  .cloud-surface {
    overflow: hidden;
    padding: clamp(1.5rem, 3vw, 2rem);
    background:
      radial-gradient(
        circle at top right,
        color-mix(in srgb, var(--accent-secondary) 10%, transparent) 0%,
        transparent 34%
      ),
      linear-gradient(
        150deg,
        color-mix(in srgb, var(--accent-primary) 3%, var(--surface-elevated)) 0%,
        var(--surface-elevated) 66%
      ),
      var(--surface-elevated);
  }

  .cloud-intro {
    display: flex;
    flex-direction: column;
    gap: 1.5rem;
    margin-bottom: 2.5rem;
    padding-bottom: 2rem;
    border-bottom: 1px solid var(--border-subtle);

    @media (min-width: 768px) {
      flex-direction: row;
      align-items: flex-start;
      justify-content: space-between;
      gap: 4rem;
    }
  }

  .cloud-intro__text {
    margin: 0;
    font-size: 1rem;
    line-height: 1.75;
    color: var(--text-muted);
    max-width: 52ch;
  }

  .cloud-legend {
    flex-shrink: 0;
    display: flex;
    align-items: center;
    gap: 1rem;
  }

  .cloud-legend__arrow {
    color: var(--accent-secondary);
    font-size: 1.25rem;
  }

  .cloud-legend__example {
    font-family: "Barlow Condensed", sans-serif;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: var(--text-primary);
  }

  .cloud-legend__example--sm {
    font-size: 0.875rem;
    opacity: 0.45;
  }

  .cloud-legend__example--lg {
    font-size: 2rem;
    color: var(--accent-secondary);
  }

  .cloud-circle-wrap {
    display: none;
    justify-content: center;
    margin: 0.5rem 0 2rem;

    @media (min-width: 640px) {
      display: flex;
    }
  }

  .cloud-circle {
    position: relative;
    width: min(88vw, 580px);
    aspect-ratio: 1;

    &amp;amp;amp;::before {
      content: "";
      position: absolute;
      inset: 0;
      border-radius: 50%;
      border: 1px solid var(--border-subtle);
      pointer-events: none;
    }

    &amp;amp;amp;::after {
      content: "";
      position: absolute;
      inset: 22%;
      border-radius: 50%;
      border: 1px dashed var(--accent-secondary-alpha-18);
      pointer-events: none;
    }
  }

  .cloud-circle__tag {
    position: absolute;
    transform: translate(-50%, -50%) scale(1);
    font-family: "Barlow Condensed", sans-serif;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    text-decoration: none;
    color: var(--text-primary);
    white-space: nowrap;
    transition:
      color 0.2s ease,
      opacity 0.2s ease,
      transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);

    &amp;amp;amp;:hover {
      color: var(--accent-secondary) !important;
      opacity: 1 !important;
      transform: translate(-50%, -50%) scale(1.18);
      z-index: 1;
    }
  }

  .cloud-circle:has(.cloud-circle__tag:hover) .cloud-circle__tag:not(:hover) {
    opacity: 0.15 !important;
    transform: translate(-50%, -50%) scale(0.95);
  }

  .cloud-list {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    align-items: center;
    gap: 0.75rem 1.5rem;
    list-style: none;
    margin: 1rem 0;
    padding: 0;

    @media (min-width: 640px) {
      position: absolute;
      width: 1px;
      height: 1px;
      overflow: hidden;
      clip: rect(0 0 0 0);
      white-space: nowrap;
    }
  }

  .cloud-list__tag {
    font-family: "Barlow Condensed", sans-serif;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--text-primary);
    text-decoration: none;
    opacity: 0.75;
    white-space: nowrap;
    transition:
      color 0.15s ease,
      opacity 0.15s ease;

    &amp;amp;amp;:hover {
      color: var(--accent-secondary);
      opacity: 1;
    }
  }

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

&lt;/div&gt;
&lt;p&gt;&lt;code&gt;src/pages/tags/[tag]/index.astro&lt;/code&gt;&lt;/p&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 BlogGrid from "../../../components/BlogGrid.astro";
import MailingListCTA from "../../../components/MailingListCTA.astro";
import { tagSlug } from "../../../utils/tags";

export async function getStaticPaths() {
  const allPosts = (await getCollection("posts")).filter(isPublished);
  const tagMap = new Map();
  for (const post of allPosts) {
    for (const tag of post.data.tags) {
      const slug = tagSlug(tag);
      if (!tagMap.has(slug)) tagMap.set(slug, []);
      tagMap.get(slug)!.push(post);
    }
  }
  return [...tagMap.entries()].map(([slug, posts]) =&amp;amp;gt; {
    const label = posts[0].data.tags.find((t) =&amp;amp;gt; tagSlug(t) === slug) ?? slug;
    return { params: { tag: slug }, props: { label } };
  });
}

const PAGE_SIZE = 9;
const { tag } = Astro.params;
const { label } = Astro.props;

const allTagPosts = (await getCollection("posts"))
  .filter(
    (post) =&amp;amp;gt;
      isPublished(post) &amp;amp;amp;&amp;amp;amp; post.data.tags.some((t) =&amp;amp;gt; tagSlug(t) === tag),
  )
  .sort((a, b) =&amp;amp;gt; b.data.pubDate.valueOf() - a.data.pubDate.valueOf());

const totalPages = Math.ceil(allTagPosts.length / PAGE_SIZE);
const posts = allTagPosts.slice(0, PAGE_SIZE);
const nextUrl = totalPages &amp;amp;gt; 1 ? `/tags/${tag}/2` : null;
const postCountLabel =
  allTagPosts.length === 1 ? "1 post" : `${allTagPosts.length} posts`;
---








          &amp;lt;p&amp;gt;Topic archive&amp;lt;/p&amp;gt;
          &amp;lt;h2 id="topic-overview-heading"&amp;gt;
            {postCountLabel} about {label}
          &amp;lt;/h2&amp;gt;
          &amp;lt;p&amp;gt;
            This archive keeps every article filed under this topic in one
            place, newest first, so you can stay with one theme without jumping
            around the rest of the site.
          &amp;lt;/p&amp;gt;


          &amp;lt;a href="/tags"&amp;gt;All topics&amp;lt;/a&amp;gt;
          &amp;lt;a href="/blog"&amp;gt;
            Latest writing
          &amp;lt;/a&amp;gt;





   1 ? `, across ${totalPages} pages.` : "."}`}
  /&amp;amp;gt;





  .topic-overview {
    padding: 0 1.5rem;
  }

  .topic-overview__panel {
    display: grid;
    gap: 1.25rem;
    border-top-color: var(--accent-secondary);
    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-secondary) 8%, var(--surface-elevated))
          0%,
        var(--surface-elevated) 62%
      ),
      var(--surface-elevated);

    @media (min-width: 768px) {
      grid-template-columns: minmax(0, 1.45fr) auto;
      gap: 2rem;
      align-items: center;
    }
  }

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

  .topic-overview__title {
    margin: 0;
    font-family: "Barlow Condensed", sans-serif;
    font-size: clamp(1.9rem, 4vw, 2.7rem);
    line-height: 0.98;
    text-transform: uppercase;
    color: var(--text-primary);
  }

  .topic-overview__text {
    margin: 0.85rem 0 0;
    max-width: 58ch;
    line-height: 1.7;
    color: var(--text-muted);
  }

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

  .topic-overview__link {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-height: 2.85rem;
    padding: 0.7rem 1rem;
    border: 1px solid var(--accent-secondary-alpha-18);
    border-radius: var(--radius-pill);
    background: color-mix(
      in srgb,
      var(--accent-secondary) 8%,
      var(--surface-elevated)
    );
    font-family: "Barlow Condensed", sans-serif;
    font-size: 0.85rem;
    font-weight: 700;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--accent-secondary);
    text-decoration: none;
    transition:
      border-color 0.15s ease,
      transform 0.15s ease;

    &amp;amp;amp;:hover {
      border-color: var(--accent-secondary-alpha-32);
      transform: translateY(-1px);
    }
  }

  .topic-overview__link--secondary {
    border-color: var(--accent-primary-alpha-22);
    background: color-mix(
      in srgb,
      var(--accent-primary) 8%,
      var(--surface-elevated)
    );
    color: var(--accent-primary);
  }

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

&lt;/div&gt;
&lt;p&gt;&lt;code&gt;src/pages/tags/[tag]/[page].astro&lt;/code&gt;&lt;/p&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 BlogGrid from "../../../components/BlogGrid.astro";
import MailingListCTA from "../../../components/MailingListCTA.astro";
import { tagSlug } from "../../../utils/tags";

export async function getStaticPaths() {
  const PAGE_SIZE = 9;
  const allPosts = (await getCollection("posts")).filter(isPublished);
  const tagMap = new Map();
  for (const post of allPosts) {
    for (const tag of post.data.tags) {
      const slug = tagSlug(tag);
      if (!tagMap.has(slug)) tagMap.set(slug, []);
      tagMap.get(slug)!.push(post);
    }
  }
  const paths: object[] = [];
  for (const [slug, posts] of tagMap.entries()) {
    const totalPages = Math.ceil(posts.length / PAGE_SIZE);
    const label = posts[0].data.tags.find((t) =&amp;amp;gt; tagSlug(t) === slug) ?? slug;
    for (let pageNum = 2; pageNum &amp;amp;lt;= totalPages; pageNum++) {
      paths.push({
        params: { tag: slug, page: String(pageNum) },
        props: { label },
      });
    }
  }
  return paths;
}

const PAGE_SIZE = 9;
const { tag, page } = Astro.params;
const { label } = Astro.props;
const currentPage = Number(page);

const allTagPosts = (await getCollection("posts"))
  .filter(
    (post) =&amp;amp;gt;
      isPublished(post) &amp;amp;amp;&amp;amp;amp; post.data.tags.some((t) =&amp;amp;gt; tagSlug(t) === tag),
  )
  .sort((a, b) =&amp;amp;gt; b.data.pubDate.valueOf() - a.data.pubDate.valueOf());

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








          &amp;lt;p&amp;gt;Topic archive&amp;lt;/p&amp;gt;
          &amp;lt;h2 id="topic-overview-heading"&amp;gt;
            Page {currentPage} of {totalPages}
          &amp;lt;/h2&amp;gt;
          &amp;lt;p&amp;gt;
            {postCountLabel} filed under {label}. This page keeps you inside the
            same topic while you move through older entries in the archive.
          &amp;lt;/p&amp;gt;


          &amp;lt;a href="{`/tags/${tag}`}"&amp;gt;
            Topic overview
          &amp;lt;/a&amp;gt;
          &amp;lt;a href="/blog"&amp;gt;
            Latest writing
          &amp;lt;/a&amp;gt;











  .topic-overview {
    padding: 0 1.5rem;
  }

  .topic-overview__panel {
    display: grid;
    gap: 1.25rem;
    border-top-color: var(--accent-secondary);
    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-secondary) 8%, var(--surface-elevated))
          0%,
        var(--surface-elevated) 62%
      ),
      var(--surface-elevated);

    @media (min-width: 768px) {
      grid-template-columns: minmax(0, 1.45fr) auto;
      gap: 2rem;
      align-items: center;
    }
  }

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

  .topic-overview__title {
    margin: 0;
    font-family: "Barlow Condensed", sans-serif;
    font-size: clamp(1.9rem, 4vw, 2.7rem);
    line-height: 0.98;
    text-transform: uppercase;
    color: var(--text-primary);
  }

  .topic-overview__text {
    margin: 0.85rem 0 0;
    max-width: 58ch;
    line-height: 1.7;
    color: var(--text-muted);
  }

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

  .topic-overview__link {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-height: 2.85rem;
    padding: 0.7rem 1rem;
    border: 1px solid var(--accent-secondary-alpha-18);
    border-radius: var(--radius-pill);
    background: color-mix(
      in srgb,
      var(--accent-secondary) 8%,
      var(--surface-elevated)
    );
    font-family: "Barlow Condensed", sans-serif;
    font-size: 0.85rem;
    font-weight: 700;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--accent-secondary);
    text-decoration: none;
    transition:
      border-color 0.15s ease,
      transform 0.15s ease;

    &amp;amp;amp;:hover {
      border-color: var(--accent-secondary-alpha-32);
      transform: translateY(-1px);
    }
  }

  .topic-overview__link--secondary {
    border-color: var(--accent-primary-alpha-22);
    background: color-mix(
      in srgb,
      var(--accent-primary) 8%,
      var(--surface-elevated)
    );
    color: var(--accent-primary);
  }

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

&lt;/div&gt;
&lt;p&gt;You can browse the rest of the site code in the&lt;br&gt;
&lt;a href="https://github.com/sourcier/web-sourcier.uk" rel="noopener noreferrer"&gt;web-sourcier.uk repository&lt;/a&gt;.&lt;/p&gt;

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