Skip to content

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.

Mac Long
Jul 30, 20264 min read1 read

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.

typescript Copy
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.

TipIf a toolchain choice is fighting the ecosystem your dependencies live in, the toolchain is usually the thing to change — not the dependencies.

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.

typescript Copy
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.

WarningA dependency that touches your deployment's own request-handling internals is worth pinning and reading the changelog for, every time — not just at install time.

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.

What lives where
DataLives in
Log contentMarkdown in the repo
CommentsReplies to a Bluesky post
Identity, posts, blobsThe 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.

Across the AtmosphereDiscussions