My blog is older than most of the tools I use to run it. The oldest posts started life on LiveJournal sometime in the early 2000s, got dragged through a WordPress import at some point in the mid-2000s, spent the better part of a decade as a Hugo site, and, about a week ago, moved into an Astro 5 site that reproduces every one of those old URLs byte for byte. That's 2,630 posts spanning 2001 to 2020, frozen exactly where they landed.
At the same time, :
mattstratton.com is the personal site. It carries the 2,630-post legacy archive and a newer, general-purpose/writing/section for anything I want to write that isn't archival.
speaking.mattstratton.com is the talk archive: every deck, every video, every event, self-hosted.
mattstratton-dev-to isn't a website at all. It's a small tool that keeps dev.to drafts and this git repo in sync, and it's the thing that lets a post published on dev.to also show up natively on setstrailingSlash: 'always'and builds in directory format specifically to reproduce the old Hugo URLs exactly, which in turn had reproduced the WordPress URLs before that. Old inbound links, whether from a 2009 blog roll or a conference site linking a talk recap from 2014, still resolve. Nobody has to fix a dead link because I decided to rewrite my site in a different framework.
The
postscollection is the frozen 2001 to 2020 archive, bulk-converted from Hugo. It doesn't grow. It just needs to keep existing. Everything new goes intowriting, and that one's got more going on than I expected when I started writing this post.
The field guide: my own posts plus the good stuff I wrote elsewhere
/writing/used to be scoped much more narrowly. The working plan before this project had it locked to Postgres-focused, authority-building content, an idea inherited from a content strategy document written for a completely different audience than the one reading my personal blog. The pushback that killed that scoping was simple: I contain multitudes, and a personal site that can only hold one topic isn't really personal anymore.
What's actually interesting about
/writing/now, though, is that it isn't only my own posts. It's a merge of two genuinely different things:
- Native entries in
src/content/writing/, defined by the schema in :
CODEexport interface FieldGuideLink {
title: string;
description: string;
part: "mechanics" | "limits" | "traps" | "decision";
url: string;
}
There are 14 of those right now, all pointing at tigerdata.com, each slotted into one of the same four
partbuckets native posts can opt into. is an Astro content-layer loader that calls one endpoint,GET /emails?ordering=-publish_dateagainst Buttondown's API, follows pagination until it runs out of pages, and hands the result to Astro as thenewslettercollection. Every issue page, the archive index, and the RSS feed are then just static output from a normalgetCollection()call, same as any local markdown file.
Two details worth calling out for anyone tempted to do the same thing:
- If
BUTTONDOWN_API_KEYisn't set, the loader logs a warning and returns an empty collection instead of failing the build. That's not an accident: it means anyone can clone this repo and build it locally without needing my API key. - Buttondown stores each issue's body as Markdown, so the loader runs it through
marked.parse()before it ever reaches a template. The site never touches raw HTML from the API.
CODEconst key = import.meta.env.BUTTONDOWN_API_KEY ?? process.env.BUTTONDOWN_API_KEY;
if (!key) {
logger.warn("BUTTONDOWN_API_KEY not set: newsletter archive will build empty.");
return;
}
speaking.mattstratton.com: getting my slides back from a CDN I don't own
The old speaking site lived on Notist, and Notist had two problems that eventually became one problem: video embeds had already started quietly breaking, and every slide deck was served off Notist's CDN. The day that account lapses, the images and PDFs go with it. A core part of a speaker's professional identity, every talk I've ever given, was living in someone else's database with a thin export option.
So the new site's whole design goal is asset ownership and durability. Plain content in my own repo, on my own domain, that keeps working with no third-party dependency that can be shut off or go quietly stale. Not a redesign. A portability project.
The interesting piece is the slide pipeline, because it runs entirely locally, before a commit ever happens, and never on the deploy host:
CODEoriginals/{id}.pdf --optimize(gs, 300dpi)--> public/slides/{id}.pdf --rasterize--> public/slides/{id}/{n}.webp
(gitignored, full-res) (committed, served, downloadable) (committed, served, viewer images)
The reason it has to run locally: static hosts don't give you root, and you need root (or at least a real package manager) to run
pdftoppmandgs. Trying to shell out to Ghostscript from inside a Netlify build is a fight you will lose. Doing the rasterization on my own machine and committing the output keeps the actual deploy build a boring, fast, host-agnosticastro build.
Video embeds got the same "own it, don't proxy it" treatment. Instead of reproducing Notist's approach (a proxy that, again, had already started breaking), each talk stores a plain provider and video ID in frontmatter, and the page renders a lightweight embed facade that only loads the real player on click.
The current numbers: 106 talks from 2012 to 2026, 93 events, 36 videos, 3,684 slide images. All of it committed, none of it one CDN outage away from disappearing.
Making the talks searchable
Video is the one asset class the speaking site doesn't fully own. It can host the reference, but not the file itself. What it can own is the transcript, and that turned into a genuinely useful feature: full-text search across every talk I've ever given.
Raw transcripts come from YouTube's auto-generated captions, pulled by Claude Code skill handles the cleanup pass, with a strict ruleset: fix punctuation, capitalization, and proper nouns, remove disfluencies, but never reword, paraphrase, or reorder anything actually said. When in doubt, the instruction is to leave the text alone rather than guess.
Search itself runs client-side against two separate JSON payloads, built at deploy time:
is the heavy one, concatenating each talk's transcript and slide text into a single searchable blob, fetched lazily only once you actually type a query:
CODEconst parts = [readTranscript(talk.id), readSlideText(talk.id)].filter(Boolean);
if (!parts.length) continue;
records.push({ url: talkUrl(talk), text: parts.join(" ").replace(/\s+/g, " ") });
, but the short version:
It's tempting to think of this as bidirectional sync, keep dev.to and git in lockstep, push changes both ways. Don't do that. It produces an endless phantom-diff loop where each side keeps thinking it's the one with the real version. Instead, a post is dev.to-owned while it's being drafted, and git ignores it entirely. Once a human merges an import PR, it's git-owned forever, marked by the presence of an
idfield in its frontmatter. Ownership transfers exactly once, at a deliberate moment, and it never automatically flips back.
Two small workflows do the actual work. runs on push to files under
posts/, and pushes local edits back to dev.to, writing theidback on a post's very first publish. Their domains never overlap, which is the entire point.
The crosspost pipeline (and yes, this post is going through it)
Once a post is git-owned, I can opt it into crossposting by adding
crosspost: trueto its frontmatter, which republishes it as a native entry in mattstratton.com's/writing/collection and rewrites the dev.to post'scanonical_urlto point back at mattstratton.com, via to work. They ran fine without it. But they now also include it, a permanent memory layer for AI agents, so that when I'm working with an agent on content, it can pull up what it already knows about a talk or a previous session instead of me re-explaining context every single time. Nice to have, not load-bearing.
And
/fitnesspulls my workout history straight from while writing this post, because I'd apparently been assuming for a while that one already existed and it hadn't.
A couple of others I'm actually looking forward to: for
/writing/and the legacy archive, since the crosspost script currently papers over that gap by embedding the cover image inline in the post body instead of rendering it properly. There's also the usual pile of housekeeping (amastertomainrename, a Node 24 upgrade) that's real but not exactly a fun read.
None of it was urgent enough to hold up shipping the parts that already work. That's usually how the good list gets built anyway.
↗ Original-Artikel auf dev.to lesenVollständiger Original-BerichtAusführliche Details, Code-Beispiele & Hersteller-Stellungnahme auf dev.to.- Native entries in
SOCIAL SHARE CARD GENERATOR