Exploring Keystatic

Armno's avatar image
Published on January 1st, 2024
By Armno P.

Keystatic is a CMS to manage Markdown content, and it is my new favorite thing. This blog post covers my experiences integrating Keystatic into my content editing workflow for this Astro blog.

A.k.a. it's an "Astro-focused Keystatic Setup" guide for my future self.

Background

This website is built with Astro using MDX and Markdown for blog and pages content. Coding the website and writing content in Markdown are done in VSCode.

I love the simplicity of Markdown, but since I added MDX support, I keep adding HTML and custom Astro components to my content while writing my thoughts out.

At some point, I feel distracted because I was more focused on the coding part than the writing part. This is probably why I write a lot less in the recent years. There has been some friction there.

I always want to get back into "write more and worry less" mode again.

Just write.


Keystatic

I discovered Keystatic by chance from reading Astro's blog about Thinkmill. Keystatic is a Git-based CMS to manage Markdown, JSON, and YAML content. Its concepts remind me of the Netlify CMS back in the days (now Decap CMS), but looks a bit better this time.

I never heard of Keystatic or Thinkmill before, but when I saw Simon Vrachliotis (@simonswiss) is producing video tutorials there, then I know it's going to be pretty good. Or at least it will be fun to learn from him. (Hey Simon, big fan of your YT channel here!)

Screenshot Keystatic's Homepage

Adding Keystatic to my existing Astro project

At the time of writing this article, MDX was not supported in Keystatic. Now they added the support for MDX field. Check out the documentation page for more details.

My very first thought is of course to make everything work, which is not the case when adopting some new tech stack ...

Keystatic only supports YAML, JSON, and Markdoc as content, but not MDX. I tried to convert an existing article from a .mdx to a .mdoc file - it wasn't an easy process and I think it wouldn't be worth it to convert all of them from MDX to Markdoc files, just to make them work in Keystatic.

So I shifted the direction to use Keystatic only for my new content. Meaning the new content will be in Markdoc format, created and managed in Keystatic. Old content remains in MDX and I can still update them in VSCode whenever it is needed. And more importantly, old and new content can co-exist in the same setup. I try to change the Astro part as less as possible.

I follow the guides from both Keystatic's and Astro's docs. Keystatic works great Astro's content collection and adding Keystatic to the project was pretty straightforward. I didn't get errors during install.

The start command is npm run dev. This serves both Astro dev server and Keystatic dev server.

I still needed to adjust some configs to keep my current content structure working.

Basic configurations

Starting by defining a new posts collection.

posts: collection({
  entryLayout: 'content',
  label: 'Posts',
  slugField: 'title',
  path: 'src/content/blog/*/',
  format: {
    contentField: 'content'
  },

The important part are:

Schema

Defining Keystatic's Schema is pretty much the same idea with defining Astro's Content Collection Schema, but with different tools in a different config file.

This schema config defines what the metadata in each post are, as well as defining how the editor UI would look like in Keystatic.

schema: {
  title: fields.slug({ name: { label: 'Title' } }),
  pubDate: fields.text({ label: 'Publish Date' }),
  description: fields.text({ label: 'Description' }),
  language: fields.select({
    label: 'Language',
    defaultValue: 'en',
    options: [
      { label: 'English', value: 'en', },
      { label: 'Thai', value: 'th', }
    ]
  }),
  tags: fields.array(
    fields.text({ label: 'Tag' }),
    {
      label: 'Tags',
      itemLabel: props => props.value
    }
  ),
  thumbnail: fields.image({
    label: 'Thumbnail',
    directory: 'public/images',
    publicPath: '/images',
  }),
  content: fields.document({
    label: 'Content',
    formatting: true,
    dividers: true,
    links: true,
    images: {
      directory: 'src/content/blog',
      publicPath: '../../../content/blog/',
    },
  }),
},

Each field's config is pretty straightforward, with a small tweak.

To keep images in the post content in the same directory, the content.images paths config has to be adjusted like so. This would make the images show up correctly in both Keystatic page editor and when previewing the page in the browser.

When I add an image in the content from editor's UI, Keystatic will copy the image to src/content/blog/<post-name>/<image-name> and set the image path in Markdoc file to be ../../../content/blog/<post-name>/<image-name>. At build time, the correct image paths will be handled by Astro.

Post thumbnail image

Unlike images in the content, thumbnail image of a post is a separate field in the schema. At the time of writing this, I'm not able to keep the post thumbnail image in the same directory with other images yet.

I think I would need to define the thumbnail image as a proper image field in Content Collection config in Astro. For now, only the thumbnail image of the post is stored in /public/images/<post-name>/ directory, but it is something I definitely would fix in the future.

Published Date field

I have a field pubDate in my frontmatter that holds the information of the published date of the article.

I struggled a bit to configure the pubDate field to be a proper date field. All my blog posts store published date information in ISO-8601 format. I have also defined the field in Content Collection's config to be a date field.

// src/content/config.ts
const blogCollection = defineCollection({
  schema:z.object({
    pubDate: z.union([z.string().datetime(), z.date()]),

And in Keystatic's schema config, I also initially defined as a datetime field.

// keystatic.config.ts
collections: {
  posts: collection({
    schema: {
      pubDate: fields.datetime({ label: 'Publish Date' }),

It throws an error when I try to load with ISO-8601 date object (unquoted) in the frontmatter that the date is not a string.

I try to wrap the date with quotes to make it a string. The error is gone, but Keystatic is still not able to populate the value correctly in the datetime field.

Keystatic has both Date and Datetime field types, but neither of them store the value as ISO-8601 format or the actual Date object. The Datetime type outputs the format like 2024-01-04T12:10 which would work in the page editor.

But then Astro would complain during the build time that the value is not a valid date object defined in the collection schema.

I think this is due to the limitation of <input type="datetime-local"> itself that it doesn't work with ISO-8601 format, and not about Keystatic. There is an issue already on GitHub keystatic#821.

To get away with this issue, I changed the field to be a text field in keystatic.config.ts file. Not ideal to edit the datetime value, but I can live with that.

Enable syntax highlighting for code blocks

No extra dependencies needed, but I have to add shiki() Markdoc extension in the markdoc.config.mjs file to enable syntax highlighting for code blocks created in Keystatic editor.

import shiki from '@astrojs/markdoc/shiki';

export default defineMarkdocConfig({
  ...
  extends: [
    shiki()
  ]
});

Custom Astro Components in Keystatic

Here is the fun part - I have a few of custom Astro components used in my blog posts. I want to see if I can use them in Keystatic, or if it's possible at all.

Since I will be using Markdoc from now on, I have 2 challenges:

  1. Adding custom Astro components in Markdoc content using custom tag instead of importing the components in the content file like in MDX.
  2. Displaying or editing the components in Keystatic.

1. Creating a custom Markdoc tag to render an Astro component

Astro has an official Markdoc integration. Adding it to my current Astro project is pretty simple:

npx astro add markdoc

I have a <WarningMessage> Astro component that displays a yellow block of text in the content. It has only 1 optional prop title and uses <slot> to display the content in the paragraph.

// src/components/WarningMessage.astro
---
interface Props {
  title: string;
}

const { title } = Astro.props as Props;
---
<div class="bg-yellow-100 pt-6 pb-2 px-8 my-6">
  {title && (
    <div class="font-bold">{title}</div>
  )}
  <slot />
</div>

To create a custom Markdoc tag, I create a markdoc.config.mjs in the project and define a custom warningMessage tag there. The render property points to the path of the component.

// markdoc.config.mjs
import { defineMarkdocConfig, component } from '@astrojs/markdoc/config';

export default defineMarkdocConfig({
  tags: {
    warningMessage: {
      render: component('./src/components/WarningMessage.astro'),
      attributes: {
        title: { type: String, required: false },
      }
    },
  },
});

Then I can use in my Markdoc content file with {% warningMessage %} tag. It renders properly on the page.

By default, Keystatic is not able to understand the custom tag and displays an error message when loading the page in Keystatic.

I also have to register this component in Keystatic's config file.

2. Creating a component block in Keystatic

To make Keystatic aware of the new {% warningMessage %} tag, I add componentBlocks config to the document field type of the content field.

content: fields.document({
  componentBlocks: {
    'warningMessage': component({
      label: 'Warning Message',
      schema: {
        title: fields.text({
          label: 'Title'
        }),
      },
      preview: () => null
    })
  }
}),

A new option to add the custom block will show up in the content field.

However, I'm not able to figure out yet how to make the child content (that would be the <slot> of the component) show up in Keystatic.

In this example, I could only edit the title prop here. Keystatic outputs like this in Markdoc:

What I want is the ability to add some content between opening and closing tags like:

I think the way to solve this is to use the child type. Seems to work with a Next.js setup. I couldn't figure out how to make it work with my Astro setup yet.

Funny thing aside: I'm using screenshots of the code above instead of actual code blocks in Keystatic because it would render as actual components instead of the code blocks on the page.


Building & Deploying

So far I have Keystatic setup locally. It runs alongside Astro. And I have a working content editing workflow.

As I'm the only author of my blog, I will be using only the Local Mode of Keystatic. I only need it to run locally and not on the live website. There are some small challenges and tweaks need to achieve that.

Keystatic requires hybrid mode in Astro to run. And hybrid mode requires an server-side Adapter to run, or to build at least 🤯. I have to install one even though I'm not going to use it.

I'm hosting my website on Netlify so the Netlify Adapter seems to be a safe choice.

npx astro add netlify

When it comes to build configuration, my goal is to exclude Keystatic entirely for production builds. Keystatic's docs has a recipe to disable /keystatic path from production build, but it seems to be outdated.

A simpler solution from a GitHub Discussion thread is to exclude keystatic() integration entirely for the production build.

export default defineConfig({
  integrations: [
    process.env.NODE_ENV === 'production' ? null : keystatic()
  ]
});

The downside is that I'm still not able to run astro preview command locally after a build, because the Netlify Adapter doesn't support preview command. I usually use astro preview command to double check the whole website after a production build, and I find it is pretty useful.

So to make it work locally, I also make the production build excludes both output and adapter config. My Astro config file then looks like:

const config = {
  site: "https://armno.in.th",
  integrations: [
    sitemap(),
    mdx(),
    tailwind(),
    react(),
    markdoc(),
  ],
};

if (process.env.NODE_ENV !== 'production') {
  config.output = 'hybrid';
  config.adapter = netlify();
  config.integrations = [
    ...config.integrations,
    keystatic()
  ]
}

export default defineConfig(config);

With all this set up, I have now Keystatic as a CMS for my Astro blog content. 🎉 🚀

My goal is definitely trying to write more. And I hope Keystatic will help me achieve that.


Summary

This post becomes a lot longer than I expected! But on the other hand, it has been a lot of fun. I have learned a lot about Keystatic and content management for static websites in general.

Keystatic is still in its early days (if I'm not mistaken, since its first launch in April 2023). Things are still experimental, and sometimes, are broken. It might be too early to judge if this is going to be a big thing.

But what excites me about Keystatic is that it's looks pretty simple, and also is very capable at the same time. Just by trying it out, it's quite clear to me that this thing is well made, and there might be a lot more to come. Kudos to the team behind 👍.

If you have a static site (especially built with Next.js or Astro) and still looking for a tool to help you with writing and managing your content, Keystatic might be the thing for you. Like it or not, it's definitely worthing trying out.

Happy coding writing ✌️

Resources


Bonus 1: Other things I like in Keystatic

Bonus 2: Wishlist

Here's my personal wishlist for Keystatic.

Related posts