Cassidy Williams

Software Engineer in Chicago

Cassidy's face

Building a static newsletter archive


I’ve been writing my newsletter for 9.5 years now. This thing has had the same format since 2017 and has really been my consistent “thing” through marriage, moving cross-country, a pandemic, having kids, changing companies, and so on.

It’s definitely a labor of love. I’ve lost quite a bit of sleep on it over the years, but it’s important to me. It’s led to such cool connections between humans around the internet (Ten and David met this week after realizing they were both visiting Japan while answering the practice coding question!! How cool is that??), people getting jobs, authors getting amplified from their articles… I just really love this little space.

My newsletter stack

Anyway! I use Buttondown for hosting and sending the emails that I draft from Obsidian, and have been just linking to the Buttondown archive for previous issues.

My subscription page… has basically stayed the same since 2017. It was a plain ol’ HTML page for most of that time, until I ported my (open source!) to Astro in 2022. And even then, when porting it over, I just copied and pasted the HTML for the most part.

I’ve been procrastinating on rewriting the subscription page, because the main thing I’ve wanted to do if I rewrote it was to link to a static archive that I hosted myself.

Since each issue is written in markdown and rendered in HTML, I thought, in theory, that porting over all of my issues to my website would be easy. And then I tried it a few times, and failed every single time. It was just complex enough to do (even with the wonderful Buttondown API and some ideas I had around GitHub Actions) that I kept thinking, “eh, if it ain’t broke, don’t fix it,” and would give up when I would hit snags.

AI tools and my website

One of the things that might be kind of obvious to any reader here at this point would be like “Cassidy, why wouldn’t you use AI for this?”

  1. When I first tried this a few times, AI was not the thing it was today.
  2. Like I say in my site’s AI Manifesto, I really like not using AI to build my own website, it feels nice to hand-craft it.

I dragged my feet (and still kinda do) on ever letting any tool touch my website. I like that it’s mine and I know every line of code in it.

I acknowledge though that… at this point in time, AI would just make things faster.

And it did.

Planning and prompting

I don’t know what finally gave me the kick in the pants to do it, but I decided to write out plan that I felt good about to start porting all of my newsletter issues over, and put this in the GItHub Copilot app:

I want to use the Buttondown CLI (or API) to pull my newsletter archive. I want to generate one page per issue.

  • URL: cassidoo.co/newsletter/2026-09-02 (date is the stable key, issue number can live on the page)
  • Visible link title: Issue #400: “If you see someone without a smile…”
  • HTML <title>: rendezvous with cassidoo - Issue #400
  • Meta description: Weekly web-dev links, an interview question, cool finds, and a joke. Issue #400 of cassidoo’s newsletter.
  • Note: we would be trimming the emoji out of the subject line.
  • Let’s use an issues/ folder at the top level of the repo for each issue.
  • I want the date to always be the Sunday of the send. Sometimes I send it at like 9pm Sunday, sometimes it’s 1am Monday, and I want it to consistently be listed as the Sunday. BUT, that being said, the date for Monday should work too, so if someone put in /2026-08-31 or /2026-09-01, that would redirect to the same issue.
  • I want /latest to point to the latest issue.

This is generally the frontmatter format I’d like to work with:

  buttondownId: em_…
  buttondownSlug: u1f490-if-you-see-someone-without-a-smile-today
  sourceUrl: https://buttondown.com/cassidoo/archive/…/ # from API's absolute_url
  issue: 400
  date: "2026-08-31"
  subject: '"If you see someone without a smile today, give ''em yours." - Dolly Parton'
  emoji: 💐

Beyond this, I would like to create a “Sync newsletter issues” GitHub Action that pulls the latest issue and adds that file to the repo. I’ve added my Buttondown API key to my repo’s environment, so you can assume that exists. It should also update /latest.

And… this worked pretty dang well! Outside of adding the issue files themselves to my repo, it ended up with a relatively small amount of code changes. The GitHub Action is one that I was honestly really happy to not have to write myself:

name: Sync newsletter issues

on:
  schedule:
    # Mondays at 13:00 UTC (7/8am Central), after the Sunday-night send.
    - cron: "0 13 * * 1"
  workflow_dispatch:

permissions:
  contents: write

concurrency:
  group: sync-newsletter
  cancel-in-progress: false

jobs:
  sync:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - run: npm ci

      - run: npm run newsletter:sync
        env:
          BUTTONDOWN_API_KEY: ${{ secrets.BUTTONDOWN_API_KEY }}

      - name: Commit and push new issues
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "..."
          git add issues
          if git diff --cached --quiet; then
            echo "No new issues."
            exit 0
          fi
          git commit -m "Sync newsletter issues from Buttondown"
          git push

      - name: Set canonical URLs for published issues
        run: npm run newsletter:canonical -- --apply
        env:
          BUTTONDOWN_API_KEY: ${{ secrets.BUTTONDOWN_API_KEY }}

Beyond this, there were some scripts that were really just “meta” scripts like fetching sent emails:

async function* fetchSentEmails() {
	let url = `${API_BASE}/emails?ordering=-publish_date&status=sent&status=imported`;
	while (url) {
		const page = await apiGet(url);
		for (const email of page.results) {
			if (!["sent", "imported"].includes(email.status)) continue;
			if (email.email_type === "private" || email.email_type === "premium")
				continue;
			yield email;
		}
		url = page.next;
	}
}

(and then formatting the returned output for my requested Astro format)

and a script for my Netlify _redirects that lets me still have my normal hand-written redirects (like for my 404 page), but also redirects for normalizing the dates in my URLs, like so:

/newsletter/latest      /newsletter/2026-10-11  302
/newsletter/2017-04-01  /newsletter/2017-04-02  301
/newsletter/2017-04-03  /newsletter/2017-04-02  301
# ...

(note: a 301 redirect is permanent, which I want, and 302 is for a temporary one, which is perfect for my latest issue link)

Once I had the logistical scripts running and working, I was able to do my fun side of the website like actually indeed rewriting and rearranging the subscription page, and formatting the CSS of the whole archive. I used almost all of my same styles for my blog, but slightly tweaked everything for the different metadata of each issue, and really enjoyed that trial-and-error process.

Timing-wise, it took me probably a month and a half (keep in mind I’m also parenting two children under the age of 4 and working full time, ha, so I was not consistently working on this) to get everything to where I wanted it from first prompt to merged code.

Now what?

Now… it’s live! You can see again the subscription page and the whole archive, all the way back to 2017!

It’s not a huge, life-changing thing, but I’m just so glad to finally have these pages here on my own platform. Buttondown is awesome, don’t get me wrong, but now that I own the pages, I have some more ideas for how I want users to be able to surf through issues, filter sections, and also see the video versions I started recording this year!

It’s a process, and I’m really, really happy to get this part of it done.

All that being said, I gotta say it: please smash that subscribe button and share with your friends! See you in your inbox.


View posts by tag

#personal #musings #advice #recommendation #technical #learning #work #meta #events #project