Keystatic is a file-based CMS that stores content as Markdown/YAML files in your repo — config({ storage, collections, singletons }) defines the schema. collection("posts", { schema: { title: fields.text(), body: fields.markdoc() } }) creates a collection. singleton("settings", { schema: { siteName: fields.text() } }) creates a global config. createNextApiHandler({ config }) mounts the Keystatic Admin UI at /api/keystatic. Storage modes: { kind: "local" } for local dev, { kind: "github", repo: "owner/repo" } for GitHub-backed production. reader() reads content at build time: reader.collections.posts.all() fetches all posts, reader.collections.posts.read(slug) fetches one. fields.richText() outputs MDX or Markdoc. fields.image({ directory, publicPath }) stores images. fields.relationship({ collection }) links documents. Claude Code generates Keystatic schemas, reader utilities, blog pages, and Next.js admin routes.
CLAUDE.md for Keystatic
## Keystatic Stack
- Version: @keystatic/core >= 0.5, @keystatic/next >= 0.0.11
- Config: keystatic.config.ts at project root with config({ storage, collections, singletons })
- API route: app/api/keystatic/[...params]/route.ts with makeRouteHandler
- Reader: import { createReader } from "@keystatic/core/reader"; const reader = createReader(process.cwd(), keystaticConfig)
- Collections: await reader.collections.posts.all() — returns { slug, entry }[]
- Singletons: await reader.singletons.settings.read() — returns entry | null
- Storage local: { kind: "local" } — files in /content directory
- Storage GitHub: { kind: "github", repo: "owner/repo", branchPrefix: "keystatic/" }
Keystatic Config
// keystatic.config.ts — define collections and singletons
import { config, collection, singleton, fields } from "@keystatic/core"
export default config({
storage: process.env.NODE_ENV === "production"
? {
kind: "github",
repo: {
owner: process.env.GITHUB_OWNER!,
name: process.env.GITHUB_REPO!,
},
branchPrefix: "keystatic/",
}
: { kind: "local" },
ui: {
brand: { name: "My Blog CMS" },
navigation: {
content: ["posts", "authors"],
settings: ["siteSettings"],
},
},
collections: {
posts: collection({
label: "Blog Posts",
slugField: "title",
path: "content/posts/*",
format: { contentField: "content" },
entryLayout: "content",
schema: {
title: fields.slug({ name: { label: "Title" } }),
description: fields.text({ label: "Description", multiline: true }),
publishedAt: fields.date({ label: "Published Date" }),
category: fields.select({
label: "Category",
options: [
{ label: "Technology", value: "technology" },
{ label: "Design", value: "design" },
{ label: "Business", value: "business" },
],
defaultValue: "technology",
}),
author: fields.relationship({
label: "Author",
collection: "authors",
}),
coverImage: fields.image({
label: "Cover Image",
directory: "public/images/posts",
publicPath: "/images/posts/",
}),
featured: fields.checkbox({ label: "Featured", defaultValue: false }),
tags: fields.array(
fields.text({ label: "Tag" }),
{ label: "Tags", itemLabel: (props) => props.fields.value.value ?? "Tag" },
),
content: fields.markdoc({
label: "Content",
options: {
image: {
directory: "public/images/posts",
publicPath: "/images/posts/",
},
},
}),
},
}),
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" }),
github: fields.text({ label: "GitHub username" }),
},
}),
},
singletons: {
siteSettings: singleton({
label: "Site Settings",
path: "content/site-settings",
schema: {
siteName: fields.text({ label: "Site Name" }),
siteDescription: fields.text({ label: "Site Description", multiline: true }),
logo: fields.image({
label: "Logo",
directory: "public/images",
publicPath: "/images/",
}),
socialLinks: fields.object({
twitter: fields.text({ label: "Twitter URL" }),
github: fields.text({ label: "GitHub URL" }),
linkedin: fields.text({ label: "LinkedIn URL" }),
}, { label: "Social Links" }),
postsPerPage: fields.integer({ label: "Posts Per Page", defaultValue: 10 }),
},
}),
},
})
Next.js API Route
// app/api/keystatic/[...params]/route.ts — mount admin UI
import { makeRouteHandler } from "@keystatic/next/route-handler"
import keystaticConfig from "../../../../keystatic.config"
export const { POST, GET } = makeRouteHandler({ config: keystaticConfig })
// app/keystatic/layout.tsx — Admin UI layout
import KeystaticApp from "./keystatic"
export default function Layout() {
return <KeystaticApp />
}
// app/keystatic/keystatic.tsx — client component wrapper
"use client"
import { makePage } from "@keystatic/next/ui/app"
import keystaticConfig from "../../keystatic.config"
export default makePage(keystaticConfig)
Reader Utilities
// lib/keystatic/reader.ts — build-time content reader
import { createReader } from "@keystatic/core/reader"
import keystaticConfig from "../../keystatic.config"
// Reader is used at build time (generateStaticParams, page components)
export const reader = createReader(process.cwd(), keystaticConfig)
// ── Posts ──────────────────────────────────────────────────────────────────
export type PostMeta = {
slug: string
title: string
description: string
publishedAt: string
category: string
author: string | null
coverImage: string | null
featured: boolean
tags: string[]
}
export async function getAllPosts(): Promise<PostMeta[]> {
const posts = await reader.collections.posts.all()
return posts
.filter(post => post.entry.publishedAt !== null)
.sort((a, b) =>
new Date(b.entry.publishedAt!).getTime() - new Date(a.entry.publishedAt!).getTime(),
)
.map(({ slug, entry }) => ({
slug,
title: entry.title,
description: entry.description,
publishedAt: entry.publishedAt!,
category: entry.category,
author: entry.author,
coverImage: entry.coverImage,
featured: entry.featured,
tags: entry.tags,
}))
}
export async function getPost(slug: string) {
const entry = await reader.collections.posts.read(slug)
if (!entry) return null
// Render markdoc to react node
const { node } = await entry.content()
return { slug, entry, node }
}
export async function getFeaturedPosts(limit = 3): Promise<PostMeta[]> {
const posts = await getAllPosts()
return posts.filter(p => p.featured).slice(0, limit)
}
export async function getPostsByCategory(category: string): Promise<PostMeta[]> {
const posts = await getAllPosts()
return posts.filter(p => p.category === category)
}
// ── Authors ────────────────────────────────────────────────────────────────
export async function getAuthor(slug: string) {
return reader.collections.authors.read(slug)
}
// ── Singletons ─────────────────────────────────────────────────────────────
export async function getSiteSettings() {
return reader.singletons.siteSettings.read()
}
Blog Post Page
// app/blog/[slug]/page.tsx — Keystatic-powered post page
import { getAllPosts, getPost, getAuthor } from "@/lib/keystatic/reader"
import { notFound } from "next/navigation"
import { DocumentRenderer } from "@keystatic/core/renderer"
import type { Metadata } from "next"
export async function generateStaticParams() {
const posts = await getAllPosts()
return posts.map(post => ({ slug: post.slug }))
}
export async function generateMetadata({
params,
}: {
params: { slug: string }
}): Promise<Metadata> {
const postData = await getPost(params.slug)
if (!postData) return {}
return {
title: postData.entry.title,
description: postData.entry.description,
openGraph: {
images: postData.entry.coverImage
? [{ url: postData.entry.coverImage }]
: undefined,
},
}
}
export default async function BlogPostPage({ params }: { params: { slug: string } }) {
const postData = await getPost(params.slug)
if (!postData) notFound()
const { entry, node } = postData
const author = entry.author ? await getAuthor(entry.author) : null
return (
<article className="max-w-3xl mx-auto px-4 py-12">
{/* Header */}
<header className="mb-10">
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-3">
<span className="capitalize">{entry.category}</span>
<span>·</span>
<time dateTime={entry.publishedAt ?? undefined}>
{entry.publishedAt
? new Date(entry.publishedAt).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})
: "Draft"}
</time>
</div>
<h1 className="text-4xl font-bold tracking-tight mb-4">{entry.title}</h1>
<p className="text-xl text-muted-foreground">{entry.description}</p>
{author && (
<div className="flex items-center gap-3 mt-6 pt-6 border-t">
{author.avatar && (
<img
src={author.avatar}
alt={author.name}
className="size-10 rounded-full object-cover"
/>
)}
<div>
<p className="font-medium text-sm">{author.name}</p>
{author.bio && (
<p className="text-xs text-muted-foreground line-clamp-1">{author.bio}</p>
)}
</div>
</div>
)}
</header>
{/* Cover image */}
{entry.coverImage && (
<img
src={entry.coverImage}
alt={entry.title}
className="w-full aspect-video object-cover rounded-2xl mb-10"
/>
)}
{/* Content */}
<div className="prose prose-neutral dark:prose-invert max-w-none">
<DocumentRenderer document={node} />
</div>
{/* Tags */}
{entry.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mt-10 pt-6 border-t">
{entry.tags.map(tag => (
<span
key={tag}
className="px-3 py-1 rounded-full text-xs bg-muted text-muted-foreground"
>
#{tag}
</span>
))}
</div>
)}
</article>
)
}
export const revalidate = false // Static at build time
For the Sanity alternative when a database-backed CMS with real-time collaboration, GROQ query language, portable text, and a hosted content lake is needed — Sanity scales better for large teams with complex content models, while Keystatic stores everything in your Git repo making it ideal for developer-owned content and open-source projects, see the Sanity guide. For the Contentlayer alternative when a type-safe content SDK that transforms MDX/Markdown files into typed data with hot reload and zero admin UI is preferred — Contentlayer is pure build-time transformation while Keystatic adds an editing UI on top of the same file-based approach, see the Contentlayer guide. The Claude Skills 360 bundle includes Keystatic skill sets covering collections, singletons, and Next.js integration. Start with the free tier to try file-based CMS generation.