A simple guide on how to use Astro with Keystatic

A simple guide on how to use Astro with Keystatic

An ultimate guide to "static" content management

February 25, 2025development17 min read

Keystatic is an awesome, capable yet relatively simple CMS system that nicely integrates with Astro, my favourite component-based static site generator. This guide will show you how to get started with Astro and Keystatic.

Described by the authors as “content management for your codebase”, Keystatic is a tool that allows you to manage your content through Markdown, JSON and YAML files.

Astro is the web framework for content-driven websites. Its Content Collections offer way to organize and query documents, with TypeScript type-safety features and a powerful query language.

In this short tutorial, I’ll show how to setup a simple Astro website with Keystatic CMS to manage the content. I will also highlight few points that you should be aware of when using both tools together.

Prerequisites

Before we start, make sure you have the following installed:

Also, this guide assumes you have a basic understanding of Astro. If you’re new to Astro, I recommend checking out the official documentation.

Getting started

First, let’s create a new Astro project:

npm create astro@latest

The command will guide you through the process of creating a new Astro project.

Then, navigate to the project directory and install Keystatic and the required dependencies:

npx astro add react markdoc
npm install @keystatic/core @keystatic/astro

Finally, enable the keystatic integration in your Astro configuration file:

// astro.config.mjs
import { defineConfig } from "astro/config";

import react from "@astrojs/react";
import markdoc from "@astrojs/markdoc";
import keystatic from "@keystatic/astro";

// https://astro.build/config
export default defineConfig({
  integrations: [react(), markdoc(), keystatic()],
});

and create initial keystatic.config.ts file:

// keystatic.config.ts
import { config } from "@keystatic/core";

// https://keystatic.com/docs/configuration
export default config({
  storage: {
    kind: "local",
  },
});

Now, when you run your Astro project:

npm run dev

you should see the Keystatic admin panel available at http://localhost:4321/keystatic

Empty Keystatic Dashboard page

Defining content

Before we start creating content, let’s think about the structure of the content from a high level perspective. This is important because it will help plan and organize the content in a way that makes sense.

If you’d be working purely with Astro, you’d define the schema for the content in content.config.ts file. But since we’re using Keystatic, we provide the content schema in keystatic.config.ts.

Posts collection

For our tutorial example, let’s create a simple blog with posts, with each post having a title, date, summary, cover image and content.

// keystatic.config.ts
import { config, collection, fields } from "@keystatic/core";

export default config({
  // ...
  collections: {
    posts: collection({
      label: "Posts",
      path: "src/content/posts/*/",
      format: {
        contentField: "content",
      },
      slugField: "title",
      schema: {
        title: fields.slug({
          name: { label: "Title" },
        }),
        date: fields.date({ label: "Date" }),
        cover: fields.image({
          label: "Cover Image",
          directory: "src/content/posts",
          publicPath: "/src/content/posts/",
        }),
        summary: fields.text({ label: "Summary" }),
        content: fields.markdoc({
          label: "Content",
          options: {
            image: {
              directory: "src/content/posts",
              publicPath: "/src/content/posts/",
            },
          },
        }),
      },
    }),
  },
});

Let’s break down how the schema is defined.

We define a collection named posts with the following fields:

  • title: fields.slug() - a special field type that combines title with an unique generated slug
  • date: fields.date() - field to store the date of the post
  • cover: fields.image() - field that allows you to upload an image, note that we define the directory where the images will be stored and the public path
  • description: fields.text() - simple multiline text field for a short summary
  • content: fields.markdoc() - a field type that allows you to write content in Markdown format, with support for images

The content will be stored in the src/content/posts folder, with each post being assigned to separate slug folder and a .mdoc file.

For full list of available field types and their options check the Keystatic documentation.

Take a look at how the collection schema is reflected in the Keystatic admin panel, when I create a new post. Note that the editor supports different field types, like text, date, image and rich text:

Keystatic Posts Form

When you save the post in the Keystatic admin panel, it will automatically handle the task of storing the content and images in the correct folders.

This is how the content look like, stored in the src/content/posts folder, notice also how the images (both the cover and content) are also colocated in the same directory:

Keystatic Posts Content

The paths look a bit too verbose, but this is is necessary to make both Astro and Keystatic work together - especially when it comes to rendering images inside Markdown content.

Note that the uploaded images are stored in the original format. We will apply image transformations later when building the views in Astro.

Pages collection

Let’s also add another collection for managing static pages, like about, contact, etc. This is similar to the posts collection, but with a minor difference in the schema definition:

// keystatic.config.ts
// ...
export default config({
  // ...
  collections: {
    // ...
    pages: collection({
      label: "Pages",
      slugField: "title",
      path: "src/content/pages/*/",
      format: {
        contentField: "content",
      },
      schema: {
        title: fields.slug({
          name: { label: "Title" },
        }),
        content: fields.markdoc({
          label: "Content",
          options: {
            image: {
              directory: "src/content/pages",
              publicPath: "/src/content/pages/",
            },
          },
        }),
      },
    }),
  },

As you see, we just include the title and content fields, and configure different directories for the images.

Go ahead and create a new page in the Keystatic admin panel:

Keystatic Pages Form

As you’d expect, the content this time will be stored in the src/content/pages folder. Keystatic will automatically create a new directory and .mdoc file for each page you create.

There are also other format options available, like json and yaml, which you can use to store the content in different formats. Check the format options in the Keystatic documentation for more details.

You can define as many collections as you need and also define singletons for the content that should be unique, like site settings, header and footer content, etc.

Feel free to experiment with different field types and configurations to suit your needs. Once you’re done, let’s move to the next step.

Displaying content in Astro

Now that we have our content defined and stored in the local files, let’s display it in our Astro website. We can achieve this in a couple of ways, by using the Astro Content Collections or utilizing Keystatic Reader API or a combination of both.

Let’s start with the Astro Content Collections to display blog posts, since this is the most straightforward way, especially for simple use cases.

Defining blog posts with Astro Content Collections

Astro Content Collections is a powerful feature that allows you to query and render content in your Astro components. We already have our content organized in the posts collection, so let’s display the blog posts in our Astro website.

There is one more step we need to do before we can use the content collections in our Astro components. We’ve defined our content schema in the keystatic.config.ts file, but unfortunately that only works for Keystatic.

We need to define the schema in the Astro’s src/content.config.ts file as well, so that Astro can understand the content structure.

// src/content.config.ts
import { defineCollection, z } from "astro:content";
import { glob, file } from "astro/loaders";

const posts = defineCollection({
  loader: glob({ pattern: "**/*.mdoc", base: "./src/content/posts" }),
  schema: ({ image }) =>
    z.object({
      title: z.string(),
      summary: z.string(),
      date: z.date(),
      cover: image().optional(),
    }),
});

export const collections = {
  posts,
};

Let’s break down what’s happening in the code:

  • We import the necessary functions from the astro:content and astro/loaders modules.
  • We define a new collection named posts using the defineCollection function.
  • We specify the loader for the collection, which is a glob loader that loads all .mdoc files from the src/content/posts folder.
  • We define the schema for the collection, which includes the title, summary, date, and cover fields.
  • Note, we don’t need to define the content field, Astro will provide way to load and transform the content from the .mdoc files for us, we’ll get to that in a moment.
  • Finally, we export the collections object with the posts collection.

Now, we can use the posts collection in our Astro components to display the blog posts.

One last thing, after creating the config file, make sure to run the astro sync command to sync the content schema and generate type hints for the content:

npm run astro sync

This will ensure that the content schema is correctly defined and that you get full TypeScript support when working with the content collections.

Rendering blog posts with Astro

Now we’re ready to display the blog posts in our Astro website. Create a new page in the src/pages folder, for example src/pages/blog/index.astro:

// src/pages/blog/index.astro
---
import { getCollection } from "astro:content";
import { Image } from "astro:assets";
import Layout from "../../layouts/Layout.astro";

const allPosts = await getCollection("posts");
---

<Layout>
  <div class="blog-index">
    <h1 class="blog-index__title">Blog</h1>
    <p class="blog-index__lead">
      A collection of fine articles on various topics.
    </p>

    <div class="blog-index__posts">
      {
        allPosts.map((post) => (
          <div class="blog-post">
            <div class="blog-post__cover">
              {post.data.cover ? (
                <Image
                  src={post.data.cover}
                  alt={post.data.title}
                  width={320}
                  height={200}
                  class="blog-post__cover-image"
                />
              ) : null}
            </div>
            <div class="blog-post__detail">
              <a href={`/blog/${post.id}`} class="blog-post__link">
                {post.data.title}
              </a>
              <span class="blog-post__meta">
                Published on {post.data.date.toLocaleDateString()}
              </span>
              <p class="blog-post__summary">{post.data.summary}</p>
            </div>
          </div>
        ))
      }
    </div>
  </div>
</Layout>

<style>
  .blog-index {
    padding: 4rem;
    font-family: sans-serif;
    color: #555;
  }

  .blog-index__title {
    font-size: 3rem;
    margin-bottom: 0rem;
  }

  .blog-index__lead {
    font-size: 1.5rem;
    margin-top: 0.5rem;
    margin-bottom: 2rem;
  }

  .blog-post {
    margin: 1rem 0;
    border-bottom: 1px solid #ddd;
    padding: 1rem 0 2rem;
    display: flex;
    flex-direction: row;
    align-items: center;
  }

  .blog-post:last-child {
    border-bottom: none;
  }

  .blog-post__meta {
    display: block;
    font-size: 0.875em;
    color: #999;
  }

  .blog-post__cover {
    width: 320px;
    border-radius: 0.25rem;
    background: #eee;
  }

  .blog-post__cover-image {
    border-radius: 0.25rem;
  }

  .blog-post__detail {
    margin: 1rem 2rem;
  }

  .blog-post__link {
    display: inline-block;
    margin-bottom: 0.5rem;
    font-size: 1.25rem;

    text-decoration: underline;
    color: #155dfc;
  }

  .blog-post__summary {
    margin: 1rem 0;
  }
</style>

Now, that’s a lot of code, but don’t worry, it’s not as complicated as it looks. Let’s break it down:

Inside Astro’s Component Script (the code fence block in ---), we import the getCollection function from the astro:content module, which allows us to fetch the content from the posts collection. We also import some components: Image from astro:assets and Layout from our custom layout component (part of the initial setup).

In the Astro’s Component Template / JSX-like part, we display the list of posts, with the cover image, title, date and summary.

What’s nice when working with Astro content collections is the fact that we get very straightforward API to query and render the content. The data is also type-safe, thanks to TypeScript so you can be sure that the data you’re working with is correct. Because in our schema, cover image is optional, we had to add a condition to make sure it is present, and only if so, display it using the Image component.

Btw, image transformations are supported out of the box in Astro so you can easily resize and optimize the images.

In the <style> part I’ve added some basic CSS rules to make the template looks a bit nicer. Feel free to customize the Layout component to provide some global styles, or use a CSS framework like Tailwind CSS (my recommendation).

Now, when you view the project in your browser and navigate to the /blog page, you should see the list of blog posts displayed:

Astro Blog - Index page

The link to the individual post is not yet working, we’ll fix that in the next step.

Displaying individual blog post

To display individual blog post pages we need to leverage Astro’s capability to create pre-rendered routes using getStaticPaths() directive.

Create a new file in the src/pages/blog/[slug].astro:

// src/pages/blog/[slug].astro
---
import { getCollection, render } from "astro:content";
import { Image } from "astro:assets";
import Layout from "../../layouts/Layout.astro";

export async function getStaticPaths() {
  const posts = await getCollection("posts");
  return posts.map((post) => ({
    params: { slug: post.id },
    props: { post },
  }));
}

const { post } = Astro.props;
const { Content } = await render(post);
---

<Layout>
  <article class="blog-article">
    <h1 class="blog-article__title">{post.data.title}</h1>
    <span class="blog-article__meta">
      Published on {post.data.date.toLocaleDateString()}
    </span>
    {
      post.data.cover ? (
        <Image
          src={post.data.cover}
          alt={post.data.title}
          class="blog-article__cover"
          width={1024}
        />
      ) : null
    }
    <div class="blog-article__content">
      <Content />
    </div>
  </article>
</Layout>

<style>
  .blog-article {
    padding: 4rem;
    font-family: sans-serif;
    color: #555;
    max-width: 80rem;
    margin: 0 auto;
  }

  .blog-article__cover {
    width: 100%;
    height: auto;
    margin-bottom: 2rem;
    border-radius: 0.5rem;
  }

  .blog-article__title {
    font-size: 3rem;
    margin-bottom: 1rem;
  }

  .blog-article__meta {
    display: block;
    font-size: 0.875rem;
    color: #666;
    margin-bottom: 1rem;
  }

  .blog-article__content {
    line-height: 1.6;
  }

  .blog-article__content img {
    max-width: 100%;
  }
</style>

Again, a bit of code to render the individual blog post page, but let’s break it piece by piece:

First, we define the getStaticPaths() function, which returns an array of paths for the individual blog posts. This is a way to instruct Astro to generate the static pages for each post, so that they can be pre-rendered during build time.

The way it works, is that Astro will generate a separate page for each post, with the slug as the URL parameter. It will also pass the post object as a prop to the page, which we can then use to render the content.

We also use render() function from astro:content to transform and render the content part of the Markdoc file.

The template itself is quite simple, we display the title, date, cover image and content of the post. The content is rendered using special Content component, which is provided by the render() function.

Finally, few bits of CSS to display the content in a nice way:

Astro Blog - Post page

Rendering content pages with Keystatic Reader API

Now sometimes you may need more control over how the content is fetched and displayed, or you may want to use the content in a more dynamic way. This is especially useful if your content structure is complex, composed of dynamic blocks and relationships.

This is where using the Keystatic Reader API could be a good choice. It allows you to fetch and query the content in a more flexible way, and then render it in your Astro components.

Before we proceed with building Astro page templates, let’s add small helper file to create instance of the Keystatic client:

// src/utils/keystatic.ts
import { createReader } from "@keystatic/core/reader";
import keystaticConfig from "../../keystatic.config";

export const reader = createReader(process.cwd(), keystaticConfig);

Your editor TypeScript integration may complain about usage of process.cwd(), we need to install the @types/node package to fix this:

npm install @types/node

Now we’re ready to use the reader instance to fetch the content from Keystatic in our Astro components.

Building Astro pages templates

There are couple ways we can approach the templates for the content pages. We could render all pages in a dynamic way, similar to how we did with the blog posts, or we could create separate templates for each page.

Since we only have few pages and they are quite simple, we’ll go with the second approach.

Let’s start with creating a new page template for the about page, in the src/pages/about.astro file:

// src/pages/about.astro
---
import Markdoc, { type Node } from "@markdoc/markdoc";
import Layout from "../layouts/Layout.astro";
import { reader } from "../utils/keystatic";

const page = await reader.collections.pages.read("about");

if (!page) {
  throw new Error("Page not found");
}

const content = await page.content();
const contentHtml = Markdoc.renderers.html(Markdoc.transform(content.node));
---

<Layout title={page.title}>
  <div class="page">
    <h1 class="page__title">{page.title}</h1>
    <div class="page__content" set:html={contentHtml} />
  </div>
</Layout>

<style>
  .page {
    max-width: 64rem;
  }

  .page__title {
    font-size: 3rem;
    margin-bottom: 1rem;
  }

  .page__content {
    margin-top: 2rem;
  }
</style>

In this template, we fetch the about page content using the reader.collections.pages.read() method. We’re using the Keystatic reader API client that is fully typed, so you get full TypeScript support when working with the content.

Note that we don’t have to define the schema for the content (like we did with Astro content collections), because Keystatic Reader API provides a way to fetch the content directly from the Keystatic storage, using its own schema.

Displaying the content is a bit more complicated compared to the previous example. It is stored in Markdoc format, and while Keystatic offers utility function to get the content, it leaves the rendering part to the developer.

Keystatic will store content and retrieve it for you using the Reader API, but you are responsible for rendering the Markdoc content.

Luckily, since we already use Markdoc integration in Astro, we can use the Markdoc module to transform and render the content. The results of the transformation are then passed to the page__content div element using the set:html directive.

Astro About page

You can create similar templates for other pages, like contact, privacy policy, etc. and use the Keystatic Reader API to fetch and render the content.

Alternatively, you can also create catch-all template for the pages with getStaticPaths() function, similar to how we did with the blog posts, but use the Reader API to fetch the content. There are few minor differences in the data structures, but thanks to the TypeScript support, you should be able to figure it out easily.

Building and deploying the Keystatic Astro website

Hopefully the guide helped you to get started with Astro and Keystatic, and you’re now able to manage and display content in your Astro website. You can add more pages, customize layout, navigation, and styles to make the site look and feel the way you want.

The final step of the process is to build and deploy the website.

npm run build

You’ll see the output:

> astro build

[NoAdapterInstalled] Cannot use server-rendered pages without an adapter. Please install and configure the appropriate server adapter for your final deployment.
  Hint:
    See https://docs.astro.build/en/guides/on-demand-rendering/ for more information.
  Error reference:
    https://docs.astro.build/en/reference/errors/no-adapter-installed/

By default, Keystatic actually suggest to use server-side rendering (SSR) to render the content (especially when using the Reader API). If this is fine with you, feel free to follow the instructions in the Keystatic documentation to setup the SSR adapter.

Static build for Keystatic & Astro

Since we’re using Astro, it would be also nice to deploy the website as a fully static site. Let’s make a few tweaks to the Astro configuration to make it work.

Luckily, the change is quite simple. A server is required by default because Keystatic API routes in the Admin UI are doing some operations on the file system. But for the frontend part, we can safely remove the server adapter.

So, let’s modify the Astro configuration to conditionally disable the keystatic() during the build:

// astro.config.mjs
// ...
const isBuild = process.env.NODE_ENV === "production";

// https://astro.build/config
export default defineConfig({
  integrations: [react(), markdoc(), ...(isBuild ? [] : [keystatic()])],
});

Now when you run npm run build command, you should see the output with the build info about static routes and optimized images:

> [email protected] build
> astro build

20:58:34 [vite] Re-optimizing dependencies because vite config has changed
20:58:34 [content] Syncing content
20:58:34 [content] Synced content
20:58:34 [types] Generated 273ms
20:58:34 [build] output: "static"
20:58:34 [build] mode: "static"
20:58:34 [build] Collecting build info...
20:58:34 [build] ✓ Completed in 292ms.
20:58:34 [build] Building static entrypoints...
20:58:35 [vite] ✓ built in 425ms
20:58:35 [build] ✓ Completed in 443ms.

building client (vite)
20:58:35 [vite] ✓ 21 modules transformed.
20:58:35 [vite] dist/_astro/client.5ZcbI0hG.js  185.87 kB │ gzip: 58.82 kB
20:58:35 [vite] ✓ built in 340ms

generating static routes
20:58:35 src/pages/about.astro
20:58:35   └─ /about/index.html (+7ms)
20:58:35 src/pages/blog/[slug].astro
20:58:35   └─ /blog/hello-world-from-the-blog/index.html (+9ms)
20:58:35 src/pages/blog/index.astro
20:58:35   └─ /blog/index.html (+1ms)
20:58:35 src/pages/index.astro
20:58:35   └─ /index.html (+1ms)
20:58:35 Completed in 52ms.

generating optimized images
20:58:35 /_astro/cover.SRubQt6a_K5vGi.webp (reused cache entry) (+1ms) (1/3)
20:58:35 /_astro/cover.SRubQt6a_1bnaye.webp (reused cache entry) (+0ms) (2/3)
20:58:35 /_astro/annie-spratt-wAi2xdwiRYk-unsplash.BRQAFI2g_C6rMW.webp (reused cache entry) (+2ms) (3/3)
20:58:35 Completed in 3ms.

20:58:35 [build] 4 page(s) built in 1.14s
20:58:35 [build] Complete!

With that working smoothly, you can deploy and host your site on many static hosting providers, like Vercel, Netlify, Cloudflare Pages, or GitHub Pages to name a few.

Conclusion

The topics we’ve covered in this guide are good starting point to get up and running with basic Astro website that uses Keystatic for content management. You should be able now to define content schema in Keystatic, display the content in Astro using content collections, and use the Keystatic Reader API to fetch and render the content in a more dynamic way.

I think there is a great synergy between Astro and Keystatic, especially when it comes to managing content in a “static” way. Everything is neatly organized in the local files, Markdoc files are easy to work with, and the Keystatic admin panel provides a nice interface to manage the content.

Thanks to the TypeScript support in both Astro and Keystatic, you can be sure that the content is correctly defined and that you’re working with the right data types.

The content can (and should) be versioned in your Git repository, so you have full control over the content changes.

All that enables you to build a powerful, content-driven website that is fast, secure and easy to maintain.

I hope you found this guide helpful and that you’re now ready to build your own Astro website with Keystatic.

You may also like the following content