Building This Site
A personal site is a sandbox you actually maintain. The constraint that makes it interesting: every line of CSS and every binding has to justify itself, because there’s no roadmap and no deadline pushing weak decisions through. Here’s what survived the cut.
Goals
Three rules going in:
- Sub-100ms p95 globally. The whole point of working at Cloudflare is the network — a personal site should feel that.
- Zero JavaScript by default. Hydrate when there’s a reason; don’t ship 200 KB of React for paragraphs of text.
- Markdown in git, not a CMS. Posts are files. Frontmatter is typed. Diffs are reviewable.
The stack
Reader → Cloudflare DNS → Edge (300+ POPs) → Worker (Astro SSR) → Assets / KV / D1 / R2
- Astro 5 for the framework. Content collections give me typed Markdown with Zod schemas. Islands architecture means the only client JS on most pages is the theme toggle.
- Cloudflare Workers as the runtime, via
@astrojs/cloudflare. Static routes prerender; dynamic routes run at the edge as V8 isolates. - Workers Assets serves the prerendered HTML, CSS, and Inter Variable woff2.
- Wrangler for typed bindings (
worker-configuration.d.ts) and deploys.
No Node runtime in production. No origin server. The repo, the build output, and the runtime all live inside one mental model.
Content authoring
Posts are Markdown files in src/content/blog/. The schema is enforced at build time:
const blog = defineCollection({
loader: glob({ base: './src/content/blog', pattern: '**/*.{md,mdx}' }),
schema: ({ image }) =>
z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
tags: z.array(z.string()).default([]),
heroImage: z.optional(image()),
}),
});
Adding tags to a post that doesn’t have them is a default, not an error — old posts keep working. Mistyping a date fails the build. That’s the trade I want.
Theming
Two CSS custom property layers, switched by data-theme on <html>:
:root {
--accent: #003682; /* Cloudflare indigo — links */
--accent-2: #f6821f; /* Cloudflare orange — chrome */
--bg: #fafaf7;
--text: #16161a;
}
[data-theme="dark"] {
--accent: #8aa9ff;
--accent-2: #fbad41;
--bg: #0f1115;
--text: #ececee;
}
Indigo carries link semantics (it has the contrast budget for body text). Orange is for chrome only — tag chips, the underline under the active nav item, focus rings, the blockquote bar. Putting #f6821f on a #fafaf7 background as a body link fails WCAG AA at small sizes, so it doesn’t get to be one.
The toggle lives in localStorage, with an inline script in the <head> that sets data-theme before paint to avoid a flash of the wrong theme.
Typography
Inter Variable, self-hosted via @fontsource-variable/inter. One file, every weight, no Google Fonts CDN, no external DNS lookup. Stylistic sets cv11, ss01, ss03 give it the slightly more humanist feel that the Cloudflare Orange Music demo uses.
Body sits at 18px / 1.65 on desktop, 17px on mobile. The h1 uses clamp(1.875rem, 1.4rem + 1.5vw, 2.5rem) so it scales without media queries.
Layout
The post list borrows from blog.bryanl.dev: underlined title, date, description, tag chips, separator. No card chrome. The article view drops the hero image entirely — title, date, tags, body. If the writing needs an image to sell it, the writing isn’t done.
Deploying
npm run build # astro build → dist/_worker.js + assets
npx wrangler deploy # ships to *.workers.dev / marcdose.com
wrangler.jsonc declares the bindings (assets, plus KV/D1/R2 once they’re actually wired up). npm run cf-typegen regenerates worker-configuration.d.ts so env.DB and friends are typed in the worker.
What’s next
- D1 for view counts and a small full-text search index over the posts.
- R2 for any media that doesn’t fit comfortably in git.
- Workers AI to generate per-tag summaries on
/tagsand/blogindex pages. - A small RSS-to-email digest worker, so the few people who want updates can get them without depending on a third-party newsletter.
Source is on GitHub — feedback welcome.