How I shipped a blog to Cloudflare Pages in an afternoon
TL;DR: I needed a blog for building Asyncbot in public. Chose Astro + Cloudflare Pages. No database, no CMS, just markdown and git. Here’s the exact setup.
Why I needed this
I’m building Asyncbot—async coordination tools for engineering teams. Rotation bots, async standups, that kind of thing. All Slack-native.
I wanted to document the build publicly. Not for followers or clout—just because writing forces clarity, and maybe someone finds it useful.
Requirements were simple:
- Markdown files, version controlled
- Fast deploys (seconds, not minutes)
- Zero ongoing maintenance
- Dark mode that just works
Why Astro over Next.js
I’ve used Next.js plenty. It’s great for apps. But for a blog? Overkill.
Next.js wants to be your entire frontend. It comes with routing opinions, API routes, image optimization, ISR, middleware… features I don’t need. Every Next.js project eventually becomes a conversation about which rendering mode to use.
Astro is different. It generates static HTML by default. No JavaScript shipped to the browser unless you explicitly add it. For a blog where pages don’t change between requests, this feels exactly right.
The mental model is simpler: markdown goes in, HTML comes out.
Why static over SSR
Astro supports server-side rendering via adapters (Cloudflare Workers, Node, Deno, etc.). I started with the Cloudflare adapter because… I don’t know, it felt modern?
Then I looked at the build output:
Total (23 modules) 469.78 KiB
470KB for three pages. Most of that was Astro’s SSR runtime—code that runs on every request to render components server-side.
For a blog with content that changes maybe once a week, this is silly. I don’t need request-time rendering. I need HTML files.
Switched to static output:
// astro.config.mjs
export default defineConfig({
site: 'https://svenmeys.dev',
output: 'static',
integrations: [sitemap()],
});
Build output dropped to just the HTML files themselves. Cloudflare Pages serves them directly from the edge. No compute, no cold starts.
If I ever need dynamic features (authenticated content, personalized pages), I can mark individual routes as server-rendered. But for now: static wins.
The content collection setup
Astro’s content collections give you type-safe frontmatter with Zod validation. This is genuinely useful—typos in frontmatter get caught at build time, not when you notice a broken page in production.
Here’s my schema:
// src/content.config.ts
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const posts = defineCollection({
loader: glob({
pattern: '**/[^_]*.{md,mdx}',
base: './src/content/posts'
}),
schema: z.object({
slug: z.string(),
type: z.enum(['build-log', 'essay', 'digest']),
title: z.string(),
date: z.coerce.date(),
status: z.enum(['draft', 'ready', 'published']),
tags: z.array(z.string()).optional(),
canonical: z.string().url(),
}),
});
export const collections = { posts };
Three post types: build logs (like this one), essays (longer takes), and digests (monthly summaries). The status field lets me have drafts in the repo without publishing them.
The z.coerce.date() is nice—it converts date strings from frontmatter into actual Date objects. No manual parsing.
The minimal layout
I wanted something that looks fine without design effort. System fonts, readable line length, automatic dark mode.
:root {
--color-text: #1a1a1a;
--color-bg: #fff;
--max-width: 680px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-text: #e5e5e5;
--color-bg: #1a1a1a;
}
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
max-width: var(--max-width);
margin: 0 auto;
}
No CSS framework, no Tailwind, no build step for styles. Just CSS variables and prefers-color-scheme. Works everywhere, maintains itself.
Deploy to Cloudflare Pages
The wrangler config is three lines:
{
"name": "svenmeys-blog",
"pages_build_output_dir": "./dist",
"compatibility_date": "2025-12-13"
}
Deploy command:
npm run build && wrangler pages deploy dist
First deploy takes maybe 30 seconds. Subsequent deploys are faster because Cloudflare dedupes unchanged files.
I also wrote a small deploy script that hashes content and only shows me new or changed posts before deploying. Prevents the “confirm the same 50 posts every time” problem as the archive grows.
What I skipped
- CMS: Don’t need one. VS Code + markdown is fine.
- Comments: Maybe later. Probably never.
- Analytics: Cloudflare has basic analytics built in. Good enough.
- Newsletter: Will add when there’s something worth subscribing to.
- Custom domain: Using svenmeys.dev, configured in Cloudflare dashboard.
The result
Build time: ~700ms for 3 pages. Most of that is TypeScript checking, not page generation.
Bundle size: Zero JavaScript shipped to readers.
Time to deploy: Under 10 seconds.
Time I’ll spend maintaining this: Approximately none.
What’s next
- First real build log about Asyncbot’s rotation bot
- Cross-post to Dev.to (with canonical URL pointing here)
- Maybe RSS if anyone asks
The blog infrastructure is done. Now I can focus on building the actual product.
I’m building Asyncbot—async coordination tools for engineering teams. Follow along here or find me on X.