Setting up this blog — Astro, MDX, Cloudflare Pages

This post documents exactly how this blog was put together. It’s mostly a note-to-future-me, but the full flow is here if you want to do the same.

The stack

Piece Choice
Framework Astro 7, static output
Content MDX via @astrojs/mdx, content collections
Fonts Inter Variable + Courier Prime, self-hosted
Hosting Cloudflare Pages (ndave.pages.dev)
Package manager Bun

Content collections

Posts live in src/content/blog/ as .mdx files with frontmatter. The schema is defined in src/content.config.ts:

const blog = defineCollection({
  loader: glob({ base: './src/content/blog', pattern: '**/*.{md,mdx}' }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    pubDate: z.coerce.date(),
    updatedDate: z.coerce.date().optional(),
    tags: z.array(z.string()).default([]),
    draft: z.boolean().default(false),
  }),
});

Invalid frontmatter fails the build — that’s a feature, not a bug.

Rendering posts

A dynamic route with getStaticPaths generates one HTML page per post at build time:

export async function getStaticPaths() {
  const posts = await getCollection('blog');
  return posts.map((post) => ({
    params: { slug: post.id },
    props: post,
  }));
}

const post = Astro.props;
const { Content } = await render(post);

Deploying

The repo lives on GitHub. Cloudflare Pages watches the master branch, runs bun install && bun run build, and publishes dist/. Every push is a deploy — there’s no server to manage and no bill at the end of the month.

Dark mode

A small inline script in the layout sets data-theme on <html> before first paint (from localStorage, falling back to the OS preference), and the whole palette swaps via CSS custom properties. No flash, no framework.