Setting Up a Markdown Blog Writing Environment with Front Matter CMS
For this blog, I manage all posts as Markdown files in Git. Since I'm a software engineer, editing Markdown files directly in an editor and deploying them with git commands isn't a problem for me. But one of the reasons I built this blog was to get away from old-school, monolithic CMS platforms like WordPress. So, I figured I'd look around for a good CMS to edit my blog posts with.
There are tons of modern CMS options for blogs these days, but many of them are offered as SaaS and require their own unique database systems on the backend. Git-based CMSs, which store articles as Markdown files and manage them with Git (like CloudCannon CMS, which I mentioned in another post), are in the minority. The reason is simple: for non-technical users who just want to write a blog, the daily engineering task of editing Markdown files in an editor and then running git commit & push is just too high a barrier. There's just not much demand for it.
So, that's why I started looking for a free, Git-based CMS.
Exploring Git-Based CMS Options
I started looking for free Git-based CMSs. Assuming Markdown files are managed in a Git repository, here's a comparison of the main contenders.
| CMS | Type | Features | Free Tier |
|---|---|---|---|
| Front Matter CMS | VS Code Extension | Stays within the editor. Specialized for GUI editing of frontmatter. Content editing happens in your familiar VS Code. | Completely Free (OSS) |
| Keystatic | Local / GitHub App | Made by Thinkmill. Rich admin UI. Internal schema and output format are selectable (Markdoc / MDX, etc.). | Completely Free (OSS) |
| Decap CMS | Browser-based | Formerly Netlify CMS. A veteran Git-based CMS. GitHub / GitLab integration. Can be hosted outside Netlify. | Completely Free (OSS) |
| Sveltia CMS | Browser-based | Drop-in replacement for Decap CMS. Modern and fast UI. Can use Decap's config.yml directly. | Completely Free (OSS) |
| Tina CMS | SaaS + Local | Powerful visual editing. Git-backed, but operates via a cloud GraphQL API. Free for up to 2 users. | Free tier available (2 users) |
| CloudCannon | SaaS | Git-connected visual editing. Supports Hugo / Jekyll / Astro, etc. User-friendly UI for non-engineers. | Paid ($49/month~) |
By the way, CloudCannon also sponsors Pagefind, which I use for this blog's search feature. They're a company that supports great tools as OSS, so I'm interested in them, but $49 a month for a personal blog is pretty steep.
Broadly speaking, the world of Git-based CMSs falls into three categories: editor-integrated (Front Matter CMS), browser-based admin panel (Keystatic / Decap / Sveltia), and SaaS (Tina / CloudCannon).
SaaS-based options like Tina and CloudCannon are also Git-backed, so they can read and write existing Markdown files directly — no content migration needed. However, they increase dependency on external services. The benefits of a browser-based admin UI are minimal when I'm just using VS Code by myself. And since my translation pipeline writes to frontmatter directly, I'd end up managing the same data in two places. For these reasons, I ruled them out.
Ultimately, I narrowed it down based on three conditions: "self-contained locally," "doesn't break Markdown files," and "completely free." That left me with Front Matter CMS and Keystatic.
What is Front Matter CMS
Since this blog's translation pipeline and AI tagging scripts directly rewrite the frontmatter metadata at the top of Markdown files, the CMS needs to satisfy the following:
- Customizable frontmatter management
Flexible metadata configuration: automatic setup of required fields, dropdown toggles for status, automatic date updates, etc. - Doesn't silently mutate frontmatter data
Don't delete or damage frontmatter fields or values added by external tools - Doesn't transform Markdown syntax
Markdown has many dialects; converting syntax to a different form breaks downstream pipelines and renderers
Front Matter CMS turned out to be a solid tool that checked all three boxes.
Front Matter CMS is a Git-based CMS that runs as a VS Code extension. Since I already use it as my everyday editor, there was practically no barrier to entry. Everything stays within VS Code — no need to open a browser. While it doesn't have a WYSIWYG editor like WordPress, it does offer a sidebar for GUI-based manipulation of frontmatter fields and a dashboard for listing articles.
In this post, I'll share the customizations I made from installation through to day-to-day use.
Initial Setup
Installation & Initialization
Install "Front Matter CMS" from the VS Code extension marketplace. On first launch, a wizard runs. I selected Next.js as the framework, and frontmatter.json was generated automatically.
All subsequent settings go into this frontmatter.json file. They could go in VS Code's settings.json, but frontmatter.json is easier to manage as part of the repository.
pageFolders — Where Posts Are Stored
"frontMatter.content.pageFolders": [
{
"title": "Japanese Posts",
"path": "[[workspace]]/posts/ja/{{year}}/{{month}}",
"contentTypes": ["post"],
"defaultContentType": "post",
"filePrefix": ""
}
]
There are two key points here.
{{year}}/{{month}} Template: New posts are automatically sorted into subfolders like posts/ja/2026/04/. No need to manually create folders.
filePrefix: "": By default, new article filenames get a date prefix (e.g., 2026-04-09-). Since this blog's design uses the slug as the filename, I've disabled this by setting it to an empty string. Note that it needs to be an empty string "", not null.
How to Manage Slug
Before diving into field design, I want to share one important design decision I landed on for this blog.
Use the filename as the slug, with no explicit field in frontmatter. In practice: don't define a slug field in contentTypes, and let the generated slug serve as the .md file's path.
The frontmatter.json auto-generated on first setup does include a slug field, but once I dug into it, I found that removing it is not just harmless — it actually makes the design cleaner.
Filename = Slug Is the Mainstream for SSGs / Git-based CMSs
Looking at the default behavior of major static site generators (SSGs) and Git-based CMSs, almost all of them base the slug on the filename.
| System | Default slug source | Role of frontmatter slug |
|---|---|---|
| Front Matter CMS | Filename (slugTemplate) | Field definition is optional |
| Keystatic | Filename (slugField) | Field exists in the schema but is stripped on save |
| Decap CMS | Filename (template) | Generally not written |
| Tina | Mostly filename-based | Typically not written |
| Next.js (pages / app router) | Directory / file structure = URL | Not required in the first place |
| Hugo | Filename / directory | Override for exceptional cases |
| Astro (Content Collections) | Filename | Override for exceptional cases |
| Jekyll | YYYY-MM-DD-title.md pattern | Override for exceptional cases |
| Gatsby | Depends on the source plugin (typically filename) | Override for exceptional cases |
The first 4 rows are Git-based CMSs from the earlier candidate list; the next 5 are notable SSGs. The common pattern across all of them is: "the filename is the real slug, and the frontmatter slug is an escape hatch for overrides (exceptional cases)." Requiring a slug in frontmatter is actually the minority approach.
Meanwhile, DB-based CMSs (WordPress, Contentful, Sanity, Strapi, Payload, etc.) manage slug as an independent DB column. They don't have a filesystem as the source of truth, so that makes sense for them — but this style doesn't fit the Markdown + Git world. The instinct that "slug must live in frontmatter" is probably a holdover from DB-based CMS thinking.
Front Matter CMS's Specification
The official documentation (Slug) spells it out explicitly:
"If you do not define a slug in the content type, the slug will be derived from the
frontMatter.taxonomy.slugTemplatesetting, and if that setting is not defined, the page name will be used as the slug."
So slug generation follows this priority:
- Per-contentType
slugTemplate frontMatter.taxonomy.slugTemplate(global)- The filename itself
This blog originally organized posts under posts/(ja|en)/yyyy/mm/, a year/month directory structure, so going with option 2 (the global slugTemplate) fits cleanly.
"frontMatter.taxonomy.slugTemplate": "{{year}}/{{month}}/{{title}}"
On top of that, by not defining a slug field in contentTypes.fields[], the generated slug value doesn't get written back to frontmatter — it only surfaces in the file path. Translation scripts, tagging, and the renderer all derive slug from the file path, so the source of truth stays unified, not split across two places.
Field Design
Defining Frontmatter Schemas with contentTypes
Front Matter CMS defines frontmatter fields with types using contentTypes. These are the fields that show up in the CMS sidebar (the GUI panel on the right when opening an article).
Here's a list of fields for the post type on this blog:
| Field | Type | Purpose | GUI Display |
|---|---|---|---|
title | string | Article title | Visible |
created_at | datetime | Creation date | Hidden |
updated_at | datetime | Update date | Hidden |
status | choice | draft / published / archived / dismissed | Visible |
skip_translation | boolean | Translation pipeline skip flag | Visible |
cover_image | image | Cover image | Visible |
cover_credit | string | Image copyright attribution | Visible |
tags | tags | Array of tag IDs | Visible |
description | string | Meta description (SEO / OGP) | Hidden |
Note: In addition, an fmContentType: post line gets added to frontmatter automatically. This is an internal field Front Matter CMS writes to associate the file with a content type — it's not something you define in contentTypes[].fields[].
hidden: true — Hiding Pipeline-Managed Fields
All fields marked "Hidden" in the "GUI Display" column have hidden: true set.
{
"title": "Description",
"name": "description",
"type": "string",
"default": "",
"description": "Auto-generated by tags.ts pipeline if empty",
"hidden": true
}
Why hide them? On this blog, created_at / updated_at are managed automatically by Front Matter CMS when files are created and saved, and description is filled in by the AI tagging script. If they were visible in the GUI, I'd risk accidentally changing their values, so I hide anything I shouldn't touch.
I originally set title to hidden too, but as a workaround for "When Japanese Titles Break Slug Generation" (covered later), I ended up rewriting the title after creating the article — so I made it visible in the sidebar.
For a full picture of who writes which field, see the table in the Integration with Other Tools section below.
isPublishDate and isModifiedDate
I've set isPublishDate: true for created_at and isModifiedDate: true for updated_at.
{
"title": "Created at",
"name": "created_at",
"type": "datetime",
"default": "{{now}}",
"dateFormat": "yyyy-MM-dd",
"isPublishDate": true,
"hidden": true
}
When isPublishDate is set, the value of that field appears as a date on the dashboard's cards. It also auto-populates the current date via {{now}} when you create a new article.
Similarly, setting isModifiedDate automatically updates updated_at to the current date and time every time you save a file via Front Matter CMS. However, it won't update if you save directly from the VS Code editor (only when you operate through the CMS sidebar).
The status Field Design
I manage the article lifecycle with four states:
| status | Meaning |
|---|---|
draft | Draft. In progress / Under review. |
published | Published. |
archived | Archived. Once published, now unpublished. |
dismissed | Dismissed. A draft that was scrapped without being published. |
I define these directly as a Front Matter CMS choice type field:
{
"title": "Status",
"name": "status",
"type": "choice",
"choices": ["draft", "published", "archived", "dismissed"],
"default": "draft",
"required": true
}
This puts a dropdown in the sidebar, so switching from draft to published is a one-click operation, which is nice.
skip_translation — Translation Pipeline Integration
{
"title": "Skip Translation",
"name": "skip_translation",
"type": "boolean",
"default": false,
"description": "If true, the pipeline will not process this file (no translation, no tagging, no overwriting)"
}
This blog has a translation pipeline powered by GitHub Actions that automatically generates English translations, triggered by pushes to posts/ja/. This flag allows me to exclude articles I don't want automatically translated, such as content that's too culturally specific.
The fact that a CMS field can be used not just for the blog's appearance but also for backend pipeline control is something unique to a frontmatter-based design.
When Japanese Titles Break Slug Generation
When you create a new article in Front Matter CMS, you only enter the title. The slug is generated from that title, then passed to slugTemplate to form the filename.
The problem shows up with Japanese titles. If I enter something like "プロジェクト・ヘイル・メアリーを観てきました" (I watched Project Hail Mary), the filename becomes プロジェクト・ヘイル・メアリーを観てきました.md. Since I want the slug (which ends up in the URL) to be in English, that's a problem.
Slug Generation Logic and Japanese
Checking the source code (SlugHelper.ts), I found that the slug generation logic handles lowercasing, hyphen conversion, and English stop word removal. But charMap has no CJK entries, so Japanese characters pass straight through unmodified — meaning no usable English slug from a Japanese title.
Workaround: Create with an English Title, Then Rewrite to Japanese
First, enter an English candidate for the slug (e.g., Project Hail Mary) into the title field and create the article. At this point, slugTemplate generates the slug as 2026/04/project-hail-mary, and a .md file is created based on that.
Then rewrite the frontmatter title to Japanese. Since title is purely a frontmatter value, rewriting it doesn't affect the slug or filename. Not elegant, but reliable.
Dashboard Customization
The Front Matter CMS dashboard (the article list screen that appears when VS Code starts) only shows the title and date by default. As my article count grows, I want to customize things like status and sort order.
Displaying Status on Cards with draftField
On the default dashboard, the status display on cards uses Front Matter CMS's built-in draft/published detection. When using a custom status field (a 4-value choice type) like on this blog, it needs to be linked using frontMatter.content.draftField.
"frontMatter.content.draftField": {
"name": "status",
"type": "choice",
"choices": ["draft", "published", "archived", "dismissed"]
}
With this setting, draft and published (and my other statuses) will appear as badges on the dashboard cards.
sorting — Sort by Creation Date
"frontMatter.content.sorting": [
{
"title": "Created (newest first)",
"name": "created_at",
"order": "desc",
"type": "date"
},
{
"title": "Created (oldest first)",
"name": "created_at",
"order": "asc",
"type": "date"
}
],
"frontMatter.content.defaultSorting": "Created (newest first)"
By default, the newest articles appear at the top in descending order. Ascending order is also available from the dashboard's sort dropdown.
Other Dashboard Settings
"frontMatter.dashboard.openOnStart": true,
"frontMatter.dashboard.content.card.fields.description": null,
"frontMatter.panel.openOnSupportedFile": true
This automatically displays the dashboard when VS Code starts and the sidebar when a Markdown file is opened. It saves me the trouble of opening them manually every time.
Setting card.fields.description to null hides the description from the cards. Since long descriptions can make the cards huge, I'd recommend hiding it. card.fields.title and card.fields.date can also be hidden by setting them to null or false.
Integration with Other Tools
Translation Pipeline: Who Does What?
For this blog, frontmatter contains both 'fields managed by humans' and 'fields managed by the pipeline.'
| Field | Who writes it | How Front Matter CMS handles it |
|---|---|---|
title | Decided during article creation → rewritten to Japanese later | Visible in sidebar |
created_at / updated_at | Automatically managed by Front Matter CMS | Hidden |
status | Human | Visible in sidebar. Operated via dropdown. |
skip_translation | Human | Visible in sidebar. Operated via checkbox. |
cover_image / cover_credit | Human | Visible in sidebar. Image picker / text input. |
tags | AI tagging script | Visible in sidebar (for review). Generally not touched. |
description | AI tagging script | Hidden |
The way I use hidden: true directly defines the boundary between 'human territory' and 'pipeline territory.' This feeling that CMS field design directly becomes workflow design is, I think, a unique aspect of a frontmatter-based approach.
Disabling Prettier for posts/
With Prettier installed in VS Code, Markdown files get automatically formatted on save. It's mostly just aligning spaces for table pipe syntax — harmless, but almost no benefit. Since waiting a few seconds every time I save is a pain, I just disabled it for Markdown.
Disabling it required settings in two places.
1. .prettierignore — To stop Prettier from running via CLI / CI:
posts/**
2. .vscode/settings.json — To stop the editor's format-on-save:
{
"[markdown]": {
"editor.defaultFormatter": null,
"editor.formatOnSave": false
}
}
At first, I thought .prettierignore would be enough, but the VS Code Prettier extension doesn't actually consult .prettierignore, so format-on-save didn't stop. I had to explicitly disable editor-level formatting in .vscode/settings.json.
Code files like JS/TS/CSS still get Prettier formatting as usual. This just specifically excludes Markdown.
Snippets — Templates for Repeated Patterns
I often add copyright attributions to images in blog posts, so I've registered a snippet for it.
"frontMatter.content.snippets": {
"Image with copyright": {
"description": "Insert image with copyright caption",
"body": "![[[alt]]](<<image>>)\n*<<copyright>>*",
"fields": [
{ "name": "alt", "title": "Alt text", "type": "string", "default": "" },
{ "name": "image", "title": "Image", "type": "image", "default": "" },
{ "name": "copyright", "title": "Copyright", "type": "string", "default": "© " }
]
}
}
When you run "Front Matter: Insert snippet" from the command palette, dialogs for alt text, image path, and copyright appear sequentially, and Markdown like this is inserted:

*© 2026 Studio Name*
It helps reduce manual errors with image paths and keeps the copyright attribution format consistent — quietly useful.
Summary
Front Matter CMS clearly defines itself as "a CMS for people who write Markdown in VS Code," and its configuration is easy to follow. All settings live in a single frontmatter.json file, which makes it Git-manageable and easy to track what each setting does.
Especially for an environment like this blog, where translation pipelines and AI tagging directly read and write frontmatter, "the CMS mustn't break Markdown files" was the most crucial condition. Front Matter CMS only manipulates frontmatter fields via the GUI; it doesn't touch the body text at all. This "don't touch what you shouldn't" approach allows it to coexist with my pipelines.
In my next post, I'll write about Keystatic — another CMS I tried on this blog.