Every time marketing wants to change a headline, they file a ticket and wait for an engineer to edit a file, open a PR, and deploy. It's a small tax, paid constantly, and it quietly makes your team slower than your competitors'.
So you evaluate a hosted CMS. Now your content lives in someone else's database, behind an API you call at runtime, outside your version control - and the bill grows with every editor you add. You traded one problem for three.
There's a third option most teams skip past: a Git-based CMS that gives non-technical editors a real editing UI while your content stays as plain files in your own repo. We migrated this site onto Keystatic and deleted our separate content-build step in an afternoon. Here's how it works - and the five gotchas that cost us the other afternoon.
The gap: editors that don't also read
Most "Git-based CMS" tools give you an editor and stop there. You still need a separate library to read that content into your app - a build-time compiler, a Markdown pipeline, some glue. Two tools, two mental models, two schemas to keep in sync.
Keystatic closes that gap. It gives you both: an admin UI that commits edits straight to GitHub, and a Reader API (@keystatic/core/reader) that reads those same committed files at build time. One config file drives editing and reading. That's the part worth understanding, because it lets you delete a whole layer of your stack.
One config, two jobs
You describe your content once. The same collection() and fields.* definitions the editor renders are exactly what the Reader returns - typed. No second schema.
// keystatic.config.ts
import { collection, config, fields } from "@keystatic/core";
export default config({
storage: { kind: "local" },
collections: {
posts: collection({
label: "Blog posts",
slugField: "title",
path: "content/posts/*",
format: { contentField: "content", data: "yaml" },
schema: {
title: fields.slug({ name: { label: "Title" } }),
publishDate: fields.date({ label: "Publish date" }),
content: fields.markdoc({ label: "Body" }),
},
}),
},
});
An editor opens /keystatic, edits title and the body in a rich UI, and hits save. In your app, the Reader hands you that same post as typed data:
import { createReader } from "@keystatic/core/reader";
import config from "@/keystatic.config";
const reader = createReader(process.cwd(), config);
const post = await reader.collections.posts.read("my-post");
// post.title, post.publishDate - typed, straight from the file the editor wrote
The business version of this: your marketing team edits in a proper UI, every change is a normal Git commit you can review and roll back, and your engineers never wrote or maintained a content API to make it happen.
Storage modes: local in dev, GitHub in prod
Keystatic has two storage backends, and the switch between them is the whole deployment story.
local- the admin edits files in your working copy directly, no auth. Perfect for development.github- the admin authenticates through a GitHub App and commits changes as real Git commits; only repo collaborators can edit. A non-developer edits in the browser, and the result is a normal commit (or PR) your team reviews.
The rule that saves you pain: gate github on whether the credentials are actually present, not on NODE_ENV (more on that below).
Markdoc for bodies: safe by default for non-devs
For article bodies, Keystatic uses Markdoc rather than MDX. That's a deliberate safety choice. MDX lets an author import and run React components - i.e. arbitrary code - which is fine when the only authors are engineers, and dangerous the moment they aren't. Markdoc exposes only the declarative components you allow:
Renewals process on the 1st. Update card details before then.
Editors get formatting and a fixed set of building blocks; they can't reach into your codebase. You render the Markdoc AST to your own components, so the output always matches your design system.
Structured pages vs prose
Not everything is a blog body. A landing page is a fixed set of typed slots - a hero, a feature grid, an FAQ - not free-flowing text. Keystatic handles both, and the rule of thumb is worth stating plainly:
Prose → Markdoc. A fixed set of typed slots → fields.
Model a landing page as YAML fields (hero.heading, features[], faq[]) and your editor gets labelled inputs instead of a blank document. Model a blog post as a Markdoc body and they get a writing surface. Same CMS, right tool per shape.
The Reader replaces your content compiler
This is the payoff. Because the Reader gives you typed content directly, the derived data you used a build step for - permalink, draft vs published, reading time, a table of contents - becomes a few small helper functions over the Reader's output. No separate content pipeline to run, cache, and debug. One schema, one source of truth.
The five gotchas that cost us an afternoon
The migration was quick. These are the sharp edges that weren't - worth knowing before you start.
slugFieldmust point at afields.slug, never a plain text field. Keystatic represents the slug by the filename and does not store it in the file. PointslugFieldat a plain text field and it reads backnull- the data silently disappears. Usefields.slug, and keep your human title as its own field.- Gate
githubstorage on credentials, notNODE_ENV. Otherwise a local production build, CI, or a preview with no GitHub App credentials fails at the admin route. Fall back tolocalwhen creds are absent; the Reader still reads files fine. - File extensions are strict.
.yaml(not.yml) for YAML collections,.mdoc(not.md) for Markdoc bodies. Get it wrong and the entry simply doesn't show up. - pnpm + the bundler need a nudge. Keystatic's UI (
@keystar/ui) is both a dependency and an optional peer, and it deep-importsreact-aria/react-statelysubpaths that pnpm's isolatednode_moduleshides.shamefully-hoist=trueplus pinning those deps clears the "module not found" wall. - The Reader needs the filesystem. Keep content routes static (
generateStaticParams), off the edge runtime - otherwise the Reader has nothing to read. Everything content-facing becomesasync.
None of these are in the happy-path tutorial. Together they're the difference between "this works on my machine" and "this ships."
Is it right for you?
If your content is dev-owned and small, a plain build-time compiler may be all you need (we wrote about that trade-off with Velite). The moment a non-developer needs to edit - and own - content without a deploy, a Git-based CMS earns its place: they get a real UI, you keep everything in version control, and there's no runtime content service to pay for or page.
Getting this layer right is a small architecture decision with an outsized effect on how fast a team can move. It's the kind of call we make for clients when we build custom software at Bruvora - pick the setup that lets the people who own the content actually own it, without handing away control of the codebase.
Frequently asked questions
- What is a Git-based CMS?
- A content management system where edits are saved as files committed to a Git repository, rather than rows in a hosted database. Your team edits in a UI, but the content lives in your repo with full version history, and there is no separate content API to depend on at runtime.
- What does Keystatic's Reader API do?
- It reads the same content files your editor writes, at build time, returning them as typed data driven by the same schema. That means you don't need a separate content compiler - the CMS is also your content source.
- Should non-developers edit Markdoc directly?
- They don't have to. Keystatic renders Markdoc in a rich editor, and Markdoc only exposes the components you allow - so editors get formatting and safe building blocks without touching raw syntax or running arbitrary code.
Keep reading
Velite validates your Markdown and YAML against a schema at build time and emits typed data - no CMS, no database, no runtime. Here's where it shines for teams, and the one limit that made us move on.
Corporate email scanners pre-click magic links and burn the one-time token, so your B2B users hit dead links. Here's why OTP is the safer default for business software.
The nine React UI libraries we reach for to ship beautiful, accessible interfaces fast - and a simple four-tier way to pick the right one for what you're building.