Building this site with Astro
Content collections, a Cloudflare Worker that's also an AT Protocol PDS, and why the identity layer turned out to be the hard part.
Static HTML, then Astro
This site started as hand-written HTML with a handful of vanilla JS components — a search box, a theme toggle, a log archive with client-side filtering. It worked, but every new Log meant hand-editing an index page and copy-pasting a template. Astro's content collections replaced that with a src/content/logs/*.md folder and a Zod schema: add a file, get a page, get it typed.
const logs = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/content/logs" }),
schema: z.object({ title: z.string(), date: z.date(), /* ... */ }),
});The rest of the site — hero, project cards, the Logs archive with its tag filtering — is Alpine.js sprinkled over server-rendered Astro components. No client-side framework runtime, no hydration mismatch to debug, and the search box that filters Logs by title and tag runs entirely off a JSON blob Astro embeds at build time.
The migration nobody sees: Deno to npm
Before any of the content work, the toolchain itself needed replacing. The project started on Deno — deno fmt, deno lint, deno install for JSR-native packages. It's a genuinely pleasant toolchain in isolation, but it put friction on every dependency that assumed npm-style resolution, which turned out to be most of the AT Protocol ecosystem. Moving to plain npm with Biome for formatting and linting removed a whole class of npm: specifier workarounds that existed purely to bridge Deno's JSR resolution into packages that expected Node's node_modules.
The interesting part: this Worker is also a PDS
maclong.dev and id.maclong.dev are the same Cloudflare Worker. The second domain runs Cirrus, a self-hosted AT Protocol Personal Data Server — the thing that actually stores my Bluesky posts, follows, and identity, instead of bsky.social doing it for me.
export default {
fetch(request, env, ctx) {
const hostname = request.headers.get("host")?.split(":")[0];
if (hostname === env.PDS_HOSTNAME) return cirrus.fetch(request, env, ctx);
return astroHandler.fetch(request, env, ctx);
},
};One worker, two identities dispatched by the Host header — the portfolio site for one, a Personal Data Server for the other. It sounds simple written that way. Getting there wasn't.
Where it actually got hard
The first PDS integration I wired up exported the right routes but never exported its Durable Object class from the built Worker — a silent failure Wrangler wouldn't catch until the class was actually invoked. The second, deeper problem: that integration's route handlers depended on an Astro API (Astro.locals.runtime.env) that a newer version of the Cloudflare adapter had removed entirely, breaking every PDS endpoint at once. Swapping to a better-maintained PDS implementation and writing the Host-header dispatch above by hand turned out to be less work than patching around either issue.
Comments, sourced from Bluesky
The Logs on this site don't have their own comment system. Each post links to a Bluesky post announcing it, and replies to that post render as comments — fetched at build time, no database, no moderation queue to maintain separately from the one Bluesky already runs.
| Data | Lives in |
|---|---|
| Log content | Markdown in the repo |
| Comments | Replies to a Bluesky post |
| Identity, posts, blobs | The PDS at id.maclong.dev |
What I'd do differently
Configure the PDS's identity correctly the first time. It's easy to stand up a personal data server against a brand-new did:web identity — much harder, and much more consequential, to migrate an existing did:plc identity with real followers onto one afterward. If you're moving an account you already have, that's the DID to configure from the very first deploy, not one to swap in later.
Why did:plc, not did:web
A did:web identity is tied to a domain — lose the domain, lose the identity, with no recovery path. A did:plc identity is portable: it's controlled by a signed operation log, not DNS, so the PDS behind it can move without breaking the identity itself. That portability is the entire point of self-hosting a PDS instead of just using a vanity handle on someone else's.
Did you enjoy this article?
Recommend it — Standard Reader surfaces well-loved writing to more readers across the network.