Skip to main content
Technology & EngineeringCms Services362 lines

Keystatic

Build with Keystatic for git-based content management. Use this skill when

Quick Summary29 lines
You are a content platform specialist who integrates Keystatic into projects.
Keystatic is a git-based CMS that gives content editors a polished editing UI
while storing all content as flat files in the repository. Content lives
alongside code in Git — no external database, no third-party API, no vendor

## Key Points

- Use `local` storage in development and `github` storage in production
- Define `slugField` on collections so editors get auto-generated URL-friendly slugs
- Use `format: { contentField: 'body' }` to store the main content as the markdown file body
- Use `fields.image` with explicit `directory` and `publicPath` to control where images land
- Use `fields.relationship` to link between collections instead of duplicating data
- Use singletons for site-wide settings, navigation, and footer content
- Generate static params from the reader API for full static site generation
- Use `fields.conditional` for optional field groups that editors can toggle
- Using GitHub mode in local development — it is slow and requires auth setup for no benefit
- Storing images outside the `public` directory — they will not be served by Next.js
- Not setting `slugField` — forces editors to manually type slugs, leading to inconsistencies
- Reading content at request time without caching — for static sites, use generateStaticParams

## Quick Example

```bash
npm install @keystatic/core @keystatic/next
# For Astro:
# npm install @keystatic/core @keystatic/astro
```
skilldb get cms-services-skills/KeystaticFull skill: 362 lines
Paste into your CLAUDE.md or agent config

Keystatic Integration

You are a content platform specialist who integrates Keystatic into projects. Keystatic is a git-based CMS that gives content editors a polished editing UI while storing all content as flat files in the repository. Content lives alongside code in Git — no external database, no third-party API, no vendor lock-in.

Core Philosophy

Content in the repo

Every piece of content is a file in your Git repository — markdown, MDX, JSON, or YAML. This means content goes through the same pull request, review, and deployment workflow as code. There is no sync problem between your CMS and your repository because they are the same thing.

Two modes, same experience

In local mode, Keystatic reads and writes files directly on disk. Use this during development. In GitHub mode, Keystatic reads and writes via the GitHub API, creating commits and PRs. Use this in production so editors can manage content without cloning the repo.

Zero infrastructure

Keystatic needs no database, no server, no background process. The admin UI is a set of routes in your existing app. Content is served from the filesystem or GitHub API. Deploy anywhere that runs Next.js or Astro.

Setup

Install

npm install @keystatic/core @keystatic/next
# For Astro:
# npm install @keystatic/core @keystatic/astro

Keystatic config

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

export default config({
  storage: {
    // Local mode for development
    kind: 'local',
    // GitHub mode for production:
    // kind: 'github',
    // repo: { owner: 'your-org', name: 'your-repo' },
  },
  collections: {
    posts: collection({
      label: 'Blog Posts',
      slugField: 'title',
      path: 'content/posts/*',
      format: { contentField: 'body' },
      schema: {
        title: fields.slug({ name: { label: 'Title' } }),
        body: fields.markdoc({ label: 'Body' }),
        publishedAt: fields.date({ label: 'Published Date' }),
        author: fields.relationship({
          label: 'Author',
          collection: 'authors',
        }),
        featuredImage: fields.image({
          label: 'Featured Image',
          directory: 'public/images/posts',
          publicPath: '/images/posts',
        }),
        draft: fields.checkbox({
          label: 'Draft',
          defaultValue: false,
        }),
        tags: fields.array(fields.text({ label: 'Tag' }), {
          label: 'Tags',
          itemLabel: (props) => props.value,
        }),
      },
    }),
    authors: collection({
      label: 'Authors',
      slugField: 'name',
      path: 'content/authors/*',
      schema: {
        name: fields.slug({ name: { label: 'Name' } }),
        bio: fields.text({ label: 'Bio', multiline: true }),
        avatar: fields.image({
          label: 'Avatar',
          directory: 'public/images/authors',
          publicPath: '/images/authors',
        }),
        twitter: fields.text({ label: 'Twitter Handle' }),
      },
    }),
  },
  singletons: {
    settings: singleton({
      label: 'Site Settings',
      path: 'content/settings',
      schema: {
        siteName: fields.text({ label: 'Site Name' }),
        tagline: fields.text({ label: 'Tagline' }),
        logo: fields.image({
          label: 'Logo',
          directory: 'public/images',
          publicPath: '/images',
        }),
        socialLinks: fields.array(
          fields.object({
            platform: fields.select({
              label: 'Platform',
              options: [
                { label: 'Twitter', value: 'twitter' },
                { label: 'GitHub', value: 'github' },
                { label: 'LinkedIn', value: 'linkedin' },
              ],
              defaultValue: 'twitter',
            }),
            url: fields.url({ label: 'URL' }),
          }),
          { label: 'Social Links', itemLabel: (props) => props.fields.platform.value }
        ),
      },
    }),
  },
});

Next.js integration

// app/keystatic/layout.tsx
import KeystaticApp from './keystatic';

export default function Layout() {
  return <KeystaticApp />;
}

// app/keystatic/[[...params]]/page.tsx
import { makePage } from '@keystatic/next/ui/app';
import config from '../../../keystatic.config';

export default makePage(config);

// app/api/keystatic/[...params]/route.ts
import { makeRouteHandler } from '@keystatic/next/route-handler';
import config from '../../../../keystatic.config';

export const { POST, GET } = makeRouteHandler({ config });

Key Techniques

Reading content in Next.js

// lib/content.ts
import { createReader } from '@keystatic/core/reader';
import config from '../keystatic.config';

const reader = createReader(process.cwd(), config);

export async function getPosts() {
  const slugs = await reader.collections.posts.list();
  const posts = await Promise.all(
    slugs.map(async (slug) => {
      const post = await reader.collections.posts.read(slug);
      return { slug, ...post };
    })
  );

  return posts
    .filter((post) => !post.draft)
    .sort((a, b) =>
      new Date(b.publishedAt!).getTime() - new Date(a.publishedAt!).getTime()
    );
}

export async function getPost(slug: string) {
  const post = await reader.collections.posts.read(slug);
  if (!post) return null;

  const body = await post.body();
  return { slug, ...post, body };
}

export async function getSettings() {
  return reader.singletons.settings.read();
}

Rendering Markdoc content

// components/ContentRenderer.tsx
import Markdoc from '@markdoc/markdoc';
import React from 'react';

interface ContentRendererProps {
  content: string;
}

export function ContentRenderer({ content }: ContentRendererProps) {
  const ast = Markdoc.parse(content);
  const transformed = Markdoc.transform(ast);
  return <>{Markdoc.renderers.react(transformed, React)}</>;
}

// Usage in a page
// app/blog/[slug]/page.tsx
import { getPost } from '@/lib/content';
import { ContentRenderer } from '@/components/ContentRenderer';

export default async function PostPage({
  params,
}: {
  params: { slug: string };
}) {
  const post = await getPost(params.slug);
  if (!post) return notFound();

  return (
    <article>
      <h1>{post.title}</h1>
      <time dateTime={post.publishedAt!}>{post.publishedAt}</time>
      <ContentRenderer content={post.body} />
    </article>
  );
}

Component blocks in rich text

// keystatic.config.ts — inside collection schema
body: fields.markdoc({
  label: 'Body',
  components: {
    callout: fields.object({
      type: fields.select({
        label: 'Type',
        options: [
          { label: 'Info', value: 'info' },
          { label: 'Warning', value: 'warning' },
          { label: 'Error', value: 'error' },
        ],
        defaultValue: 'info',
      }),
      content: fields.text({ label: 'Content', multiline: true }),
    }),
    codeExample: fields.object({
      language: fields.text({ label: 'Language' }),
      code: fields.text({ label: 'Code', multiline: true }),
      filename: fields.text({ label: 'Filename' }),
    }),
  },
}),

GitHub mode for production

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

export default config({
  storage:
    process.env.NODE_ENV === 'development'
      ? { kind: 'local' }
      : {
          kind: 'github',
          repo: {
            owner: process.env.GITHUB_REPO_OWNER!,
            name: process.env.GITHUB_REPO_NAME!,
          },
        },
  // ... collections and singletons
});
# Production environment variables for GitHub mode
GITHUB_REPO_OWNER=your-org
GITHUB_REPO_NAME=your-repo
KEYSTATIC_GITHUB_CLIENT_ID=your-github-oauth-app-client-id
KEYSTATIC_GITHUB_CLIENT_SECRET=your-github-oauth-app-client-secret
KEYSTATIC_SECRET=a-random-secret-for-signing

Conditional fields

fields.conditional(
  fields.checkbox({ label: 'Has CTA', defaultValue: false }),
  {
    true: fields.object({
      ctaText: fields.text({ label: 'CTA Text' }),
      ctaUrl: fields.url({ label: 'CTA URL' }),
      ctaStyle: fields.select({
        label: 'Style',
        options: [
          { label: 'Primary', value: 'primary' },
          { label: 'Secondary', value: 'secondary' },
        ],
        defaultValue: 'primary',
      }),
    }),
    false: fields.empty(),
  }
),

Static generation with generateStaticParams

// app/blog/[slug]/page.tsx
import { createReader } from '@keystatic/core/reader';
import config from '../../../keystatic.config';

const reader = createReader(process.cwd(), config);

export async function generateStaticParams() {
  const slugs = await reader.collections.posts.list();
  return slugs.map((slug) => ({ slug }));
}

export async function generateMetadata({
  params,
}: {
  params: { slug: string };
}) {
  const post = await reader.collections.posts.read(params.slug);
  return {
    title: post?.title,
    description: post?.excerpt,
  };
}

Best Practices

  • Use local storage in development and github storage in production
  • Define slugField on collections so editors get auto-generated URL-friendly slugs
  • Use format: { contentField: 'body' } to store the main content as the markdown file body
  • Use fields.image with explicit directory and publicPath to control where images land
  • Use fields.relationship to link between collections instead of duplicating data
  • Use singletons for site-wide settings, navigation, and footer content
  • Generate static params from the reader API for full static site generation
  • Use fields.conditional for optional field groups that editors can toggle

Anti-Patterns

  • Using GitHub mode in local development — it is slow and requires auth setup for no benefit
  • Storing images outside the public directory — they will not be served by Next.js
  • Not setting slugField — forces editors to manually type slugs, leading to inconsistencies
  • Reading content at request time without caching — for static sites, use generateStaticParams
  • Hardcoding content paths instead of using the reader API — breaks if you change the config
  • Creating deeply nested collection paths — keep paths simple for readable Git diffs
  • Not filtering drafts in production — draft content will be visible to users
  • Using Keystatic for content that changes per-request (user data, sessions) — it is for editorial content

Install this skill directly: skilldb add cms-services-skills

Get CLI access →