Setting Up Keystatic CMS: A Messy Proof of Concept
Most of what I write on this site gets created the same way: I open Claude Code, describe what I want to say, iterate inline, and push to GitHub. Cloudflare Pages deploys it in ninety seconds.
That pipeline works well when I’m at my desk. It doesn’t work at all when I want to fix a typo from my phone, or when I’m thinking about the dozen websites I help manage at work — where asking a content editor to open a terminal is obviously a non-starter.
The question I started with: can I bolt a proper web-based editor onto this site without adding a database, a separate service, or anything that needs to be deployed independently?
Why I’m actually doing this
Two reasons, one personal and one professional.
Personal: I’ve been chasing the “edit from anywhere” case for a while. I can do a lot from Telegram and mobile Claude sessions, but drafting and publishing a blog post is still a workflow that essentially requires a computer. A CMS admin at a URL changes that.
Professional: I manage content for several Drupal sites at work — including a recent project where we built a CMS-to-DAM integration for content authors. Every site we build that targets non-technical editors needs a content editing surface. I wanted to understand a lightweight alternative to full Drupal/WordPress CMS deployments for simpler use cases. Keystatic is one option on that spectrum.
This is a proof of concept for both.
First try: Pages CMS
I started with Pages CMS — a CMS designed specifically for static sites, with a GitHub backend and a Cloudflare Pages deployment model. The pitch matched the requirements: open source, no database, deploys as a separate Pages project, visual editor over your repo’s markdown files.
I got ten minutes into setup before I hit the fork. Pages CMS uses Drizzle ORM. It expects a DATABASE_URL. The Cloudflare Pages deployment path requires a D1 database or an external Postgres connection.
That’s not a dealbreaker in isolation, but it’s a different project than “add an editor to the existing site.” You’re standing up a separate application with its own database, its own deployment, its own env vars. For the sites I was thinking about at work — simple content collections, a handful of editors — that’s a meaningful operational burden for something that’s supposed to remove operational burden.
I pivoted to Keystatic.
Keystatic: the right architecture
Keystatic is a git-based CMS designed natively for Astro (and Next.js). It installs as a package, injects its admin routes directly into your site, and uses a GitHub App for auth. No separate deployment. No database. The “backend” is your GitHub repo — it reads and writes files through the GitHub GraphQL API, and commits go through the App like any other push.
The admin UI lives at /keystatic. That means the same Cloudflare Pages deployment serves both the public site and the CMS. One project, one deploy, one set of env vars.
For quevin.com specifically, this is the right tradeoff. The content is already in Git. The deployment pipeline already exists. Adding a visual layer over what’s already there is exactly what I wanted.
What I got wrong, in order
None of this was a straight line.
output: 'hybrid' no longer exists in Astro 5. The Keystatic docs reference hybrid output mode, which was how Astro 4 mixed static pages with server-rendered routes. Astro 5 removed hybrid entirely — you’re either static or server. Keystatic’s integration handles this transparently: it auto-injects server-rendered API and admin routes even when the rest of the site is output: 'static'. The fix was to not change the output mode at all.
format: { frontmatter: 'yaml' } is silently wrong. Keystatic’s Format type only accepts { data?: 'json' | 'yaml'; contentField?: string }. The key is data, not frontmatter. I had copied a config example that used frontmatter — not a valid key, not a TypeScript error in the version I was running, just silently ignored. Keystatic defaulted to looking for .yaml files instead of .md files. The blog post list loaded as “No results” with no indication of why. Found the actual type definition in node_modules before I figured it out.
fields.text() is not a content field. Once I corrected the format, I got a new error: Content field for "content" is not a content field (collections.blog). Only fields.document(), fields.mdx(), and fields.markdoc() qualify as contentField targets. Using fields.mdx({ extension: 'md' }) is the right choice here — it reads and writes plain markdown, keeps the .md file extension, and is fully backward-compatible with the existing posts.
Localhost callback URL needs to be registered separately. The GitHub App setup wizard creates a callback URL for the production domain. It doesn’t automatically add localhost. If you want to test locally — which you absolutely should, since the production auth loop is painful to debug — you have to manually add http://127.0.0.1:4321/api/keystatic/github/oauth/callback to the GitHub App’s list of allowed callback URLs.
PUBLIC_* env vars are build-time inlined. When Keystatic sets up the GitHub App, it generates a PUBLIC_KEYSTATIC_GITHUB_APP_SLUG env var. In Astro, PUBLIC_* variables are baked into the client bundle at build time. Adding them to Cloudflare Pages and redeploying the same commit doesn’t pick them up — you need a fresh build that has access to the updated env vars. Obvious in retrospect. Annoying in the moment.
Where this fits alongside claude_bot
This doesn’t replace any part of my existing workflow with Claude Code. It supplements one specific gap in it.
For code, content architecture, and anything that involves more than editing markdown — Claude Code is still the right surface. It has my full codebase context, can push branches, run builds, orchestrate multi-file changes.
Keystatic is for the cases where the change is just content: fixing a date, correcting a sentence, updating a description, drafting a short post from a phone. Things that don’t need a code agent because they’re not code problems. Right now, those cases either wait until I’m at a computer or I work around them with mobile Claude sessions that feel clunky for this specific job.
The clearest professional analogy: this is what a CMS is for. The engineering team builds and maintains the site structure. The content team edits what lives inside it. Those are different people with different tools on different schedules. I happen to be the same person in both roles — but the appropriate tool is still different depending on which job I’m doing.
The honest assessment
Getting this working took most of an afternoon, across three different types of config error and two framework pivots. If I were recommending this to someone standing up a new Astro site from scratch, I’d say the integration is solid once you understand how the pieces fit. If I were recommending it for an existing Astro 5 project where the docs lag the framework, I’d budget extra time for the edge cases above.
The result is exactly what I wanted: a visual editor accessible at a URL, backed by Git, with no operational overhead beyond what was already there. Whether it’s the right choice for work sites specifically depends on the team — the GitHub-as-backend model makes sense for developer-adjacent content, less so for teams that have never touched a repo.
More testing before I’d call it a recommendation. This post is part of that testing.
Related Reading
- Don’t Automate the Rube Goldberg Machine — The broader arc of finding the right layer for each job
- Building a CKEditor Plugin for Aprimo + Drupal 10 — The work context this testing lives in
- Choosing the Right AI Coding Tool — The same evaluate-before-you-commit thinking applied to AI coding tools
About the Author
Kevin P. Davison has over 20 years of experience building websites and figuring out how to make large-scale web projects actually work. He writes about technology, AI, leadership lessons learned the hard way, and whatever else catches his attention—travel stories, weekend adventures in the Pacific Northwest like snorkeling in Puget Sound, or the occasional rabbit hole he couldn't resist.