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.