% cd ..

Trying Keystatic as a CMS for Local Content Editing

Trying Keystatic as a CMS for Local Content Editing

On this blog, I manage articles as Markdown files with Git and GitHub, without using a database for content management. While directly editing .md files in an editor is fine since I'm an engineer, I previously set up Front Matter CMS because it seemed like a good way to manage blog metadata using frontmatter.

As the name suggests, Front Matter CMS is laser-focused on UI management of the frontmatter YAML portion of content. In a sense, its design philosophy leaves content editing to VS Code. So I don't yet have a CMS with full content-editing features. I decided to try Keystatic, which was near the top of my earlier CMS candidate list.

What is Keystatic?

Keystatic is a Git-based CMS created by Thinkmill, a software company from Sydney, Australia, which also develops Keystone CMS. This company's core business is high-level software design and development consulting, and they are a major player in their home country, Australia. Keystatic also seems to be highly regarded as a "Headless CMS that can be managed with Git," favored by engineers.

Compared to Decap CMS, another Git-based CMS that was on my candidate list, Keystatic has strengths like type-safe schema descriptions, a modern UI, and an active development team.

  • Git-based content management: Stores content like Markdown, JSON, and YAML in a Git repository.
  • Supports both File System and GitHub API: Allows local file editing during development and direct commits to the repository via the GitHub API after deployment.
  • Browser-based management UI: Accessing /keystatic provides a dashboard, article lists, and GUI editors for each field.
  • Lightweight and simple content editing: A content editor that leans more towards the meaning (structure) of Markdown syntax rather than WYSIWYG.

Markdown has fragmented into a bunch of dialects over the years — more warring factions than unified standard. Keystatic lets you pick Markdoc or MDX for the schema, but plain GFM/CommonMark isn't an option, which gave me pause. Still, I figured I'd give it a shot.

Setup

npm install @keystatic/core @keystatic/next

This blog assumes the Next.js App Router is being used (it also works with the Pages Router, but the route configuration differs). For an App Router setup, you'll need the following files:

  • keystatic.config.ts: Collection definitions and storage settings.
  • src/app/keystatic/keystatic.tsx: The main component that creates the admin UI SPA using makePage(config).
  • src/app/keystatic/layout.tsx: Simply renders the SPA above.
  • src/app/keystatic/[[...params]]/page.tsx: Empty (return null) — a placeholder for Next.js routing matching.
  • src/app/api/keystatic/[...params]/route.ts: API route.

The unconventional pattern of placing the SPA in layout.tsx and leaving page.tsx empty is specific to Keystatic, and it's done to prevent the SPA from remounting during URL transitions. For the specific content of each file, please refer to the Keystatic official Next.js installation guide. In this article, I'll focus on the points where I actually got stuck.

Running Locally

The .mdoc Default Problem

Keystatic's default format is Markdoc, and the default file extension is .mdoc. To recognize existing .md files, you need to explicitly specify the extension.

content: fields.markdoc({
  label: "Content",
  extension: "md",  // ← This
  // ...
}),

Without this setting, the article list in the collection will be empty, and if you don't notice, you'll wonder, "Why aren't my articles showing up in Keystatic...?"

Separating UI with Next.js Route Groups

Keystatic integrates as a Next.js route (e.g., /keystatic). If you add it the obvious way, it ends up wrapped in your site's root layout — header, sidebar, and all. The CMS admin UI ends up surrounded by your blog's header and tag list, which is just noise when you're editing content.

This was resolved by modifying Next.js's Route Groups. By creating a directory enclosed in parentheses, like (site), you can separate the layouts without affecting the URL.

src/app/
├── layout.tsx           ← root layout (html/body, common fonts, analytics only)
├── (site)/
│   ├── layout.tsx      ← site layout (header, sidebar, footer)
│   ├── [lang]/
│   ├── credits/
│   └── page.tsx
└── keystatic/           ← only root layout is applied
    ├── keystatic.tsxmain admin UI SPA (makePage(config))
    ├── layout.tsx       ← just renders the SPA
    └── [[...params]]/
        └── page.tsx     ← empty placeholder

Since (site) doesn't appear in the URL, existing URLs remain unchanged. For Keystatic, only the root layout is applied, so the blog's sidebar doesn't display, achieving a clean separation.

slugField is Removed from Frontmatter

Fields designated as slugField in Keystatic are automatically deleted from the frontmatter on save, even if defined in the schema. The deleted value is not lost; it's retained as the filename (part of the path). Keystatic's philosophy is that "a slug is the filename and shouldn't be in the frontmatter" (to avoid duplicate management).

The detailed background and design decisions for this are also explained in my previous article on Front Matter CMS, How to Manage Slugs. In the world of SSG and Git-based CMS, "filename = slug" is the default convention, and Keystatic faithfully implements this.

My blog also adopts this same philosophy (slugs are not written in frontmatter), so Keystatic's stripping behavior isn't an issue. In keystatic.config.ts, the slug field type remains in the schema, and it's handled as the filename via slugField: "slug".

const postSchema = {
  title: fields.text({ /* ... */ }),
  slug: fields.text({
    label: "Slug",
    description: "Derived from file path. This value is ignored by posts.ts.",
    validation: { length: { min: 10 } },
  }),
  // ... other fields
};

postsJa: collection({
  path: "posts/ja/**",
  slugField: "slug",
  // ...
});

Keeping the slug field in the schema allows displaying and editing the slug (the file path part) in the Keystatic UI. When creating a new article from Keystatic, if you enter something like 2026/04/my-first-post in the Slug field, it will be saved with .md appended as the filename and won't remain as a field in the frontmatter.

Empty Optional Fields Are Omitted

If you save optional fields like description or cover_image as empty in Keystatic, they will be omitted from the frontmatter. This isn't a critical issue as they are written back when a value is entered, but it's a point to note as a difference compared to Front Matter CMS, which retains the key even for empty strings (e.g., description: "").

The ## Heading Turns into <h2> Problem (With All Options)

Keystatic's native format for content fields is Markdoc (a structured document language created by Stripe). It's positioned as a superset of Markdown but has a significantly different design philosophy.

Markdown (CommonMark)Markdoc
PhilosophyForgiving parsing of anything written textuallyStrict acceptance of only nodes permitted by the schema
Unknown HTMLPassed through as isTreated as a string
Use CaseFreeform writingCMS/documentation sites where structure needs to be guaranteed

Since Keystatic is a CMS, it adopts the Markdoc philosophy (i.e., explicitly permitted by the schema). The options in fields.markdoc treat only permitted elements as "structured headings/images/links." Anything else is retained as raw HTML.

This means that if you use options with almost nothing enabled, basic Markdown syntax like ## Heading will be treated as "not permitted," and the moment you open and save it in Keystatic, it gets rewritten as raw HTML <h2>Heading</h2> — and that HTML is what gets written back to the file.

To address this, I enabled all CommonMark + GFM-equivalent syntax supported by Markdoc in the keystatic.config.ts settings.

content: fields.markdoc({
  label: "Content",
  extension: "md",
  options: {
    // Inline formatting
    bold: true,
    italic: true,
    strikethrough: true,
    code: true,
    link: true,

    // Block elements
    heading: [1, 2, 3, 4, 5, 6],
    blockquote: true,
    orderedList: true,
    unorderedList: true,
    table: true,
    divider: true,
    codeBlock: true,

    // Images (with configuration)
    image: {
      directory: "public/images/posts",
      publicPath: "/images/posts/",
    },
  },
}),

Now, ## headings, lists, code blocks, and tables are correctly recognized as structures by Keystatic. Side note: there's no single flag for "allow everything Markdown supports" — you have to list each one.

However, even with all options enabled, a problem remained with tables written using the pipe symbol |, familiar from GFM (GitHub Flavored Markdown). When table: true is set, Markdown including pipe tables is correctly loaded in Keystatic and displayed as tables in the UI. However, upon saving, it's converted to Markdoc's proprietary {% table %} syntax and written back. While {% table %} is a structured notation, it looks quite different from pipe tables, where the table structure is discernible by looking at the source. In environments without a Markdoc renderer (like GitHub's md preview and many editors), it won't render as a table.

This is precisely an example of the "Markdown dialect problem" I was concerned about at the beginning, and this behavior cannot be changed with Keystatic's settings (there's no option to switch the serialization format). I've summarized the digging into workarounds and more in a separate article, The Markdoc Table Problem.

New Article Paths Require Slashes in the Slug

This blog uses year/month subdirectories like posts/ja/2026/04/hello-world.md. The ** wildcard (glob notation, matching subdirectories at any depth) in the config's path: "posts/ja/**" correctly recognizes existing files.

The problem is with new creations. If you just enter test in the Slug field, it gets placed flat under the directory of the configured path, resulting in posts/ja/test.md, and doesn't go into the year/month directory. Front Matter CMS can automatically sort articles with templates like {{year}}/{{month}}, but Keystatic has no equivalent.

The solution was simply to enter a value with slashes in the Slug field. The ** in path interprets this as subdirectories (it's also correctly documented in the official documentation).

File Path: 2026/04/my-new-post
  → posts/ja/2026/04/my-new-post.md

File Path: 2026/05/another-post
  → posts/ja/2026/05/another-post.md (directories are also automatically created)

You still need to manually input the year and month, but as long as the files land in the right place, it's good enough in practice.

Summary (Local Edition)

The local setup for Keystatic alone mainly involved Route Groups for separation, enabling all options, and addressing the slugField trap, which was relatively straightforward up to this point.

The Markdown dialect differences I was worried about up front showed up as concrete problems once I actually started using it: ## headings turning into <h2>, and GFM pipe tables converted to {% table %}.

I originally intended to use Keystatic in conjunction with Front Matter CMS. But once I tried editing the same Markdown files from both CMSs, more pain points surfaced beyond the Markdoc dialect issue — Keystatic's draft handling, its SPA cache silently clobbering external edits, and other fallout from the two tools' different design philosophies. I've summarized these in a separate article, Sharing Front Matter CMS and Keystatic Revealed Design Philosophy Differences.

This completes the setup for running Keystatic locally. Deploying to Vercel Preview to enable "writing from mobile or on the go" required a separate set of tasks, including separating environment variables, setting up GitHub Apps, and access control with middleware. I'll be documenting these in another article, Deploying Keystatic to Vercel Preview.