How I Built This Blog — Astro, Keystatic, and Cloudflare
I wanted a blog that was fast, cheap to run, and got out of my way when I sat down to write. No database to babysit, no admin panel exposed to the internet, and no monthly bill for a CMS I'd barely use. This is the setup I landed on, and why each piece is there.
The architecture
The whole thing rests on one decision: the public site is fully static, and the editor only runs on my laptop.
- Astro turns Markdown into a fast static website.
- Keystatic is a visual editor that runs locally and saves articles as Markdown files straight into the repo.
- GitHub stores the articles and the site code — the single source of truth.
- Cloudflare Pages rebuilds and serves the site worldwide on every push.
The flow is: write in the local editor → the article is saved as a file → commit and push → Cloudflare rebuilds and publishes. There's no server running the admin in production, which means nothing to secure and nothing extra to pay for.
Why static, and why a local admin
A static site is just HTML, CSS, and a bit of JavaScript sitting on a CDN. It's about as fast and as cheap to host as the web gets, and there's almost no attack surface.
The catch with most "static + CMS" setups is that the CMS itself needs a server. Keystatic avoids that by running in two modes. In production I skip it entirely, so the build is pure static. Locally I run it, and it edits files directly. The trick is a single guard in the Astro config:
integrations: [ // ...other integrations ...(process.env.SKIP_KEYSTATIC ? [] : [keystatic()]), ]
Set SKIP_KEYSTATIC=true in the production build and the /keystatic route never gets built. The admin panel simply does not exist on the live site — visiting it returns a 404, which is exactly what I want.
Matching the content schema
The one place that needed care was making Keystatic's fields line up with the schema Astro's blog template already expected. Astro validates every article's frontmatter, so if the editor writes fields that don't match, the build fails.
In my case the collection expects title, description, and pubDate, with optional updatedDate and heroImage. Keystatic's config mirrors that field for field. I also had to teach Astro's content loader to pick up the .mdoc files Keystatic writes by adding the extension to the glob pattern:
loader: glob({ base: './src/content/blog', pattern: '**/*.{md,mdx,mdoc}' })
Without that line, posts written in the editor would save fine but never show up on the site — a quiet failure that's annoying to track down.
Deploying on Cloudflare Pages
Cloudflare watches the GitHub repo. The build settings are minimal:
- Build command:
npm run build - Output directory:
dist - Environment variables:
SKIP_KEYSTATIC=trueandNODE_VERSION=22
That NODE_VERSION matters more than you'd think — the first deploy failed because Cloudflare defaulted to Node 20, and the current Astro needs 22 or newer. One variable change and a retry fixed it.
The writing workflow
Now that everything's wired up, publishing is genuinely boring, which is the point:
npm run dev # start the local server + editor # write the article at localhost:4321/keystatic, then Save git add . git commit -m "New article" git push
Push, wait about a minute, and the live site updates. The article list and RSS feed regenerate on their own.
What's next
A few things I'll add as I go: text-based diagrams for architecture posts, and maybe a hosted version of the admin so I can publish from anywhere instead of only my laptop. But neither is worth the extra setup until I actually need it — and that restraint is sort of the whole theme of this stack.