Keystatic
Build with Keystatic for git-based content management. Use this skill when
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 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
Builder Io
Builder.io is a Visual Headless CMS and API that empowers developers to integrate
Caisy
caisy is a headless CMS designed for speed and scalability, empowering developers
Contentful
Build with Contentful for headless content management. Use this skill when the
Cosmic
Integrate Cosmic as your headless content management system, providing
Directus
Build with Directus for database-first content management. Use this skill
Ghost
Integrate Ghost as a powerful headless CMS or a full-featured publishing platform.