Front Matter CMS and Keystatic side-by-side: where their design philosophies clash
This blog is a Git-managed Markdown setup, and I started out using Front Matter CMS (a VS Code extension) as my authoring environment (setup writeup here). Everything stays inside VS Code, so I can pound out posts with Vim keybindings — which is exactly what I wanted.
That said, there are times when a browser-based CMS is more convenient — light, image-heavy posts, or jotting something down on the go. So I figured I'd bring in Keystatic alongside it. It bills itself as a "Git-based, WordPress-style admin UI," and it's one of the stronger contenders for a local-friendly CMS.
I had a rough division of labor in mind:
- Front Matter CMS (VS Code + Vim) → tech posts, long-form, code-heavy
- Keystatic → lighter posts, visual-first, image-driven
Both are Git-based and edit the same Markdown files, so they should coexist — that was my casual assumption going in. In practice, getting them to coexist turned out to be anything but simple — that's what I came away with. I've already covered Keystatic setup itself in the local setup post and the Vercel Preview deployment post; this one focuses purely on the issues that popped up when running Keystatic alongside Front Matter CMS, and how I worked through them.
Pitfall 1: The Markdoc table problem
Keystatic's native format for content fields is Markdoc (a structured document language built by Stripe). It positions itself as a Markdown superset, but the underlying design philosophy is quite different.
| Markdown (CommonMark) | Markdoc | |
|---|---|---|
| Design philosophy | Permissive Anything that looks like text gets parsed | Strict Only nodes the schema allows are accepted |
| Unknown HTML | Passes through | Treated as a string |
| Use case | Free-form writing | CMS / docs sites where structure must be guaranteed |
If you load up all the options in Keystatic, most CommonMark + GFM syntax survives (details in the local setup post).
But anything beyond that — including some GFM constructs — still doesn't make it through.
| Syntax | Opened and saved in Keystatic | Typed fresh in Keystatic |
|---|---|---|
| GFM tables (pipe tables) | Converted to {% table %} — NG | Output in Markdoc syntax |
HTML comments <!-- --> | Passes through (preserved) — OK | -- auto-converts to an en dash –, so you can't type it — NG |
Raw HTML (<iframe>, etc.) | Passes through — OK | OK |
Image attributes {width=400px align=right} | Passes through as text — OK | — |
Normalization (_italic_ → *italic*, etc.) | Syntax gets unified (semantics unchanged) | Output in the unified form |
The only destructive transformation is GFM tables. Keystatic reads GFM tables and renders them correctly in the editor, but saves them in Markdoc's own {% table %} syntax. My blog's renderer is remark-gfm based, so it can't parse {% table %}, and the table collapses into a list.
Switching to fields.mdx() saves tables as GFM, but then HTML comments (<!-- -->) and raw HTML like <iframe> trip up MDX's JSX parser (MDX reads < as JSX). I use <iframe> for video embeds and <br> for line breaks inside table cells throughout the blog, so flipping to MDX would break a lot of existing posts. Keystatic also has no GFM-only field type like fields.gfm() — you only get markdoc / mdx / document (deprecated).
Looking for a fix on the Keystatic side
Before drawing conclusions, I checked whether Keystatic itself offers any escape hatch — anything that would let me save tables as GFM.
MarkdocEditorOptions.table: Just aboolean(on/off). No switch for serialization format.format.data/format.contentField: Only controls whether frontmatter is saved as YAML or JSON, and whether frontmatter and body are split. Doesn't affect body serialization.createMarkdocConfig'srenderoption: For Markdoc → React rendering. Not involved in save-time serialization.- GitHub Issues: No open issue requesting markdoc → GFM table output.
ContentFormField'svalidate: Exists, but there's no way to override fields generated byfields.markdoc().
The conclusion: there's currently no way to solve this purely through Keystatic configuration. Which means the fix has to live elsewhere in the stack.
Slipping a preprocessor into the Vercel build
What I landed on is a preprocessor that converts {% table %} back into GFM pipe tables (the table syntax using the | character) at build time. I implemented it as src/lib/markdoc-table-to-gfm.ts.
The code is straightforward — three steps:
- Split the body so code fences (
...) are left untouched - Pull out
{% table %}...{% /table %}blocks with a regex - Parse the contents (
*bullet lists separated by---) and convert them to| ... |form
Pipes inside cells get escaped to \|, multi-line cells are joined with <br>, and rows with uneven column counts get padded with empty cells — the practical edge cases are covered. Attributes ({% table align="left" %}) work too.
All I had to do was call this function right before markdownProcessor().process(content) in src/lib/posts.ts, and remark-gfm picks up the pipe tables like nothing happened. The renderer architecture didn't change at all.
Cleaning up the HTML source
After applying the preprocessor, I peeked at the HTML source and noticed about 35 blank lines sitting between the <p> and the <table>.
My first suspicion was the preprocessor, but after isolating it, I found this was actually rehype-raw's existing behavior — extra whitespace it leaves behind when re-parsing raw HTML. Nothing to do with my preprocessor; it had always been there whenever a GFM pipe table appeared.
The rendered page is fine, but as an engineer, I just can't leave ugly source like that alone — the engineer's curse.
Since I was cleaning things up anyway, I added rehype's official formatting plugin rehype-format to the pipeline.
The result: the HTML now reads cleanly with proper indentation.
<p>Table rendering test</p>
<table>
<thead>
<tr>
<th>Pattern</th>
...
The contents of <pre>, <code>, <textarea>, <script>, and <style> are protected, so code blocks aren't affected.
After gzip/brotli compression the size difference is basically nil, so for an engineering blog this is purely a "View Source feels nice" decision.
Rejected: a pre-commit hook that reverses the conversion to GFM
I considered an alternative I ultimately didn't ship. Worth noting why.
Since I already use a pre-commit hook to handle the readonly fields problem (see this other post), I could write a hook that automatically converts {% table %} back to GFM pipe tables on commit. Markdoc's table syntax has a regular structure, so parsing it isn't too hard.
But I didn't go this route.
- When Keystatic runs on Vercel and commits via the GitHub API, a local git hook (husky, etc.) can't catch Keystatic-originated saves
- A GitHub Actions auto-convert-on-push approach exists, but: (1) the dirty state still lands in history briefly, (2) you eat an unnecessary Vercel build, (3) you need loop prevention, and (4) the "infra to support Keystatic" snowballs
In the end, leaving the source file as {% table %} and only interpreting it as GFM at build time felt cleanest. As a bonus, since the source isn't touched, reopening the file in Keystatic still renders the table correctly in the editor (Keystatic natively displays {% table %}).
Updated operating rules
With this fix in place, I could retire the original rule of "don't save body content for posts with tables through Keystatic." Edit and save a table in Keystatic all you want — the preprocessor turns it back to GFM at build time, so rendering stays intact.
Image attribute syntax {width=400px align=right} still passes through fine, so no issue there.
It would be cleaner if Keystatic had a hook API for pre-save content inspection and warnings, but even without that, the "absorb it in the build layer" pattern keeps things practical.
Pitfall 2: Keystatic's drafts silently overwrite external changes
This was the nastiest problem when running both CMSes together.
Opening a post in Keystatic's editor auto-saves the editing state to the browser's IndexedDB. Reopening the same post later restores from the IndexedDB draft instead of reading the latest version from the filesystem. A toast saying "Restored draft from 5 minutes ago." briefly appears and disappears after 8 seconds.
Here's what that means in practice:
- Open a post in Keystatic (a draft gets saved to IndexedDB)
- Edit and save the same file via Front Matter CMS or the translation pipeline
- Reopen the post in Keystatic → the external changes are ignored, and your stale draft overwrites them
The toast says "You may want to discard the draft changes," but there's no way to actually discard it. The "Reset changes" button in the toolbar treats the draft state as the "initial state," so it's disabled. The only escape is to manually clear IndexedDB via DevTools.
Why this happens
Keystatic uses the idb-keyval library to persist drafts in IndexedDB. When you open a post, it calls getDraft() first; if a draft exists, it takes priority over the file. External change detection is done via localTreeKey !== draft.treeKey, but in a SPA, the tree cache doesn't refresh on page transitions, so detection sometimes fails.
Patching with patch-package
There's no Keystatic config option to disable drafts, so I patched the built JS with patch-package.
What the patch changes:
- Adds a [Discard draft] button to the toast: Keystatic's Toast API natively supports
actionLabel/onAction, so I used those - No timeout on external-change toasts: when
hasChangedSinceis true, the toast sticks around waiting for user input - Deletes only the draft for the current entry: drafts for other posts aren't touched
- Reloads the page after deleting the draft: due to dist-JS patching constraints,
setStateand the toast'scloserace each other, soreload()guarantees a fresh read from disk
patches/@keystatic+core+0.5.50.patch
It auto-applies via postinstall every time you run npm install.
Why I'm holding off on a PR
This patch solves a real pain point for anyone running Keystatic in local mode alongside external editors, so it'd be worth proposing as a PR to Keystatic on GitHub. But Pitfall 3 (the SPA cache) shows that Keystatic's compatibility with external edits runs deep — this blog is now leaning toward dialing back Keystatic use, so I'm holding off on filing the Issue/PR. The patch auto-applies in my own environment via patch-package, which is enough for now.
Pitfall 3: The SPA cache ignores external changes
I patched the IndexedDB draft problem in Pitfall 2, but it turns out there's another path through which external changes get silently dropped.
Reproduction
- Create and save a post in Keystatic
- Go back to the post list
- Edit and save the same file with an external editor (VS Code, vim, etc.)
- Click the post from Keystatic's list to open it
The result: the old, pre-external-edit content shows up. No toast, no Discard button.
Reload the browser (F5), then reopen — now the external edits load correctly.
The cause: in-memory file tree cache
This is unrelated to the IndexedDB draft issue. Keystatic's local mode reads the file tree into memory on page load and never re-reads from disk during in-SPA navigation. There's also no file watching (fs.watch etc.), so cache invalidation never happens when an external tool edits a file.
Why this runs deep
Taken together with Pitfall 2 (IndexedDB drafts), the pattern that emerges is: Keystatic's design fundamentally doesn't anticipate that anyone other than itself might be editing the files.
- Draft saves: assumes you're the only editor
- File reads: happen once at page load
- File watching: none
If Keystatic is the only way content gets edited, none of this is a problem — so it's a philosophical mismatch, not a bug. But the moment you mix in Front Matter CMS or a translation pipeline, it becomes a fatal trap.
Workaround
After editing files externally, reload Keystatic in the browser before opening any post. SPA-internal navigation (list → post) won't trigger a file re-read.
Front Matter CMS tweaks
Most pitfalls cluster around Keystatic, but the Front Matter CMS side needed some tuning too — hidden field config, the slug-generation issue with Japanese titles, that kind of thing. Details are in the Front Matter CMS setup post.
Authoring rules: write in line with Markdoc's normalization
After all this investigation, I learned that opening and saving a post in Keystatic normalizes the Markdown into Markdoc's preferred form. If you write that way from the start in Front Matter CMS, you can flip between the two CMSes without any diff — both can edit and save without disturbing each other. Here's the table I put together (partly as a note to myself).
| Item | OK | NG | Notes |
|---|---|---|---|
| Line break | \ + newline | Two trailing spaces | \ is visible and harder to accidentally lose. Many editors auto-strip trailing whitespace |
| Italic | *italic* | _italic_ | Keystatic normalizes to the * form |
| Bold | **bold** | __bold__ | Same as above |
| Unordered list | - | * / + | Recommend standardizing on - |
| Links | [text](url) | Reference form [text][ref] | Inline form is the safe pick |
| In-paragraph line break | Write the whole paragraph on one physical line | Insert a bare newline (soft break) | Keystatic sometimes normalizes in-paragraph soft breaks into hard breaks. Unintended line breaks may appear |
Image attributes {width=...} | Fine to use | — | Markdoc passes unknown syntax through as text, so it stays intact |
Raw HTML (<h2>, etc.) | Don't use | <h2>heading</h2> | Gets locked in as HTML; won't roll back to Markdown syntax |
The "soft breaks inside a paragraph get promoted to hard breaks" behavior is subtle but bites. While comparing before and after, I noticed that a plain newline (no \) had been replaced with \ + newline after a save. From a writer's perspective, "I hit enter, so I wanted a line break" is usually what you want, so this is friendly normalization — but if you were leaning on soft breaks intentionally, watch out.
Some of these rules can be enforced via .editorconfig or Prettier settings (trimming trailing whitespace, normalizing emphasis markers, etc.). Pushing whatever can be tooled down to the tools means fewer things to keep in your head while writing.
Bottom line: Keystatic isn't the right fit if you want freely-written Markdown
After all this, here's the takeaway: Keystatic doesn't mesh well with "a project that already has free-form Markdown." If you're starting fresh and building content around a Keystatic schema from day one, you'll probably hit fewer snags. But for a blog like this one where:
- Posts should be writable in VS Code or Obsidian too
- Translation pipelines and tagging scripts read and write Markdown files directly
- Image attribute syntax and raw HTML are used as needed
Markdoc's particular tastes will trip you up everywhere. Coming in expecting "WordPress-style admin UI, Git-based," you'll be surprised by how many constraints come along for the ride.
On top of that, as Pitfalls 2 and 3 made clear, mixing Keystatic with external tooling fundamentally clashes with its design philosophy. The draft overwriting (patched) is one thing, but the SPA cache ignoring external changes is hard to fix at the dist level — you're stuck with operational workarounds like "always reload."
So… is Keystatic unnecessary?
Honestly, my current blog workflow runs almost entirely on Front Matter CMS.
I originally wanted Keystatic to play the "browser-based UI" role, but the more I used Front Matter CMS, the more I realized that the dashboard's post list, the side-panel status toggle, the cover image settings — all the daily operations — work just fine inside VS Code. I built up a fair amount of infrastructure for Keystatic (Route Groups separation, the patch-package draft fix, the authoring conventions), but, frankly, I just don't have enough situations where Keystatic earns that cost.
I haven't decided to remove Keystatic entirely, but right now there's no compelling reason to keep using it actively. If I ever bring on a non-engineer contributor and need a browser-based CMS, I'll revisit then.
If anyone's about to fall into the same pit while picking a CMS, hopefully this post helps.
Afterword: The real cause they can't coexist — the serializer round-trip problem
I've been framing all of this as "the wall of Keystatic's design philosophy," but if you dig a bit deeper, the root cause looks like the round-trip behavior of the serializer.
Internally, Keystatic uses Markdoc's parser and serializer. Open a post and it goes "Markdown → Markdoc AST → editor's internal model"; save it and it reverses: "internal model → Markdoc AST → Markdown." That round-trip is where GFM tables turn into {% table %} (field order shuffling is harmless, but transforming the syntax itself is destructive). It's not really a Keystatic design decision — the serializer in the library it depends on just outputs that way.
In a sense, it's a reasonable trade-off.
Offering a rich WYSIWYG editor requires parsing input into an internal model, then regenerating the file from that model on save.
Writing your own file-regenerating serializer isn't worth the cost, so you reach for an existing library.
That's the cardinal rule of "don't reinvent the wheel" in action — a perfectly defensible call.
Meanwhile, Front Matter CMS doesn't touch Markdown formatting because it never parses and re-serializes in the first place. It reads and writes frontmatter as YAML, and leaves the body alone. It gave up the rich editor in exchange for making round-trip problems structurally impossible.
So the difference between these two CMSes is partly philosophical, but it's also fundamentally an architectural choice — whether or not to put a serializer in the loop.
In theory, a rich editing UI and full fidelity to the file format should be reconcilable.
Diff at the AST level, and preserve bytes untouched for anything you didn't edit.
That said, I haven't found a Git-based CMS that actually goes that far yet.
If you care about format fidelity when picking a CMS, check what the serializer does at save time — not just how the editor feels. That habit will keep you from stepping on landmines like the ones above.
One last thought.
Software engineering has the saying "don't reinvent the wheel," but when you depend on an existing library, you should also make sure the wheel is swappable.
A non-swappable wheel imposes its limits directly on the vehicle. And if it goes flat, you're stuck.
In this case, if Keystatic's serializer were a pluggable component, the community could've written a GFM serializer and put the problem to rest.
Especially in the open-source world, when "don't reinvent the wheel" and "keep the wheels swappable" come paired as design principles, that's when a richer landscape — a real community — actually starts to expand.