Keystatic
Build with Keystatic for git-based content management. Use this skill when the project needs a CMS that stores content in markdown, MDX, JSON, or YAML files committed to Git. Covers local mode, GitHub mode, content collections, singletons, component blocks, and integration with Next.js and Astro.
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: 361 linesKeystatic 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
localstorage in development andgithubstorage in production - Define
slugFieldon 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.imagewith explicitdirectoryandpublicPathto control where images land - Use
fields.relationshipto 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.conditionalfor 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
publicdirectory — 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
Related Skills
Notion As Cms
Leverage Notion as a flexible, collaborative content management system for your web applications. Use this skill when you need a user-friendly editor, rich content types, and programmatic access to structured data stored in Notion databases and pages.
Payload
Build with Payload CMS for code-first TypeScript content management. Use this skill when the project needs Payload integration for collections, fields, hooks, access control, local API, admin panel, or Next.js native embedding. Covers Payload 3.x with its Next.js-native architecture.
Prismic
Prismic is a headless CMS focused on content modeling and multi-channel delivery. Use this skill when projects require flexible content structures, rich text editing, internationalization, and a robust GraphQL/REST API for publishing digital experiences across various front-ends.
Sanity
Build with Sanity CMS for structured content management. Use this skill when the project needs a headless CMS with real-time collaboration, GROQ queries, Portable Text rendering, image pipelines, or Next.js integration. Covers schema definitions, content modeling, Studio customization, and webhooks.
Storyblok
Build with Storyblok as a visual headless CMS. Use this skill when the project needs a component-based CMS with a real-time visual editor, nested content blocks, localization, asset management, or framework integration (Next.js, Nuxt, Astro). Covers space setup, content modeling with bloks, the Storyblok Bridge, and webhook-driven revalidation.
Strapi
Build with Strapi for self-hosted headless CMS. Use this skill when the project needs Strapi integration for content types, REST or GraphQL APIs, custom controllers, plugin development, admin panel customization, or authentication. Covers Strapi v5 patterns and deployment.