Claude Code for Keystatic: File-Based CMS for Next.js and Astro — Claude Skills 360 Blog
Blog / Backend / Claude Code for Keystatic: File-Based CMS for Next.js and Astro
Backend

Claude Code for Keystatic: File-Based CMS for Next.js and Astro

Published: May 25, 2027
Read time: 6 min read
By: Claude Skills 360

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.

Keep Reading

Backend

Claude Code for Bun: Fast JavaScript Runtime and Toolkit

Build with Bun and Claude Code — Bun.serve for HTTP servers, Bun.file for fast file I/O, Bun.$ for shell commands, Bun.sql for SQLite and PostgreSQL, Bun.build for bundling, bun:test for testing, Bun.hash for hashing, bun.lock for deterministic installs, bun run for package.json scripts, hot reloading with --hot, bun init for project scaffolding, and compatibility with Node.js modules.

6 min read Jun 13, 2027
Backend

Claude Code for Express.js Advanced: Patterns for Production APIs

Advanced Express.js patterns with Claude Code — typed request handlers with RequestHandler generics, async error handling middleware, Zod validation middleware factory, rate limiting with express-rate-limit and Redis store, helmet security middleware, compression, dependency injection with tsyringe, file upload with multer and S3, pagination utilities, JWT middleware, and structured logging with pino.

6 min read Jun 8, 2027
Backend

Claude Code for KeystoneJS: Node.js CMS and App Framework

Build full-stack apps with KeystoneJS and Claude Code — config with lists, fields.text and fields.relationship for schema definition, access control with isAuthenticated and isAdmin functions, hooks with beforeOperation and afterOperation, GraphQL API auto-generation from schema, AdminUI for content management, session with statelessSessions, Prisma adapter for database, file storage with images and files fields, and custom REST endpoints.

6 min read Jun 7, 2027

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free