Keystatic + Astro
Keystatic writes Git-tracked files; Astro reads them via the Content Layer (getCollection, getEntry, render). Do not use createReader.
Match @astrojs/* and @keystatic/* APIs to the versions in the project's package.json / lockfile.
Official docs (check these before guessing APIs):
| Docs | LLM / agent access | |
|---|---|---|
| Keystatic | keystatic.com/docs · Astro integration | No official llms.txt — fetch doc pages or search the docs site |
| Astro | docs.astro.build | Astro Docs MCP server — use search_astro_docs when the MCP is enabled (preferred over static exports). Setup: Building with AI |
Do not duplicate upstream Keystatic or Astro API reference in this skill.
When to apply
- Adding or changing a Keystatic collection or singleton
- Wiring
keystatic.config.*orsrc/content.config.ts - Choosing content vs data vs JSON format, folder layout under
src/content/ - Adding MDX custom blocks or image handling
- Fetching external data into Astro collections at build time
- Splitting local CMS content from API-backed content
Config locations
| Purpose | Typical path | Notes |
|---|---|---|
| Keystatic root | keystatic.config.ts or .tsx |
Storage, UI nav, GitHub repo, brand — site values inline here |
| Astro content registry | src/content.config.ts |
Exports collections = { ... } from all *Astro* definitions |
| Per-collection Keystatic schema | cms/{name}.keystatic.ts(x) |
collection() / singleton() |
| Per-collection Astro schema | cms/{name}.astro.ts(x) |
defineCollection() + loader + Zod schema |
| Astro integration | astro.config.mjs |
Start from astro.config.example.mjs |
| Glob loader helper | cms/utils/loader.ts |
Wraps glob() from astro/loaders |
Astro: Content collections · Keystatic: Configuration
Astro config & environment
Copy examples/astro.config.example.mjs into the project root as astro.config.mjs and extend with site-specific integrations. It covers:
loadEnvfrom Vite (.envis not auto-loaded intoastro.config.mjs)output: 'static'— Astro v5+ removedhybrid; static sites can still use on-demand routes with an adapter (on-demand rendering)- Conditional
keystatic()viaASTRO_ENABLE_KEYSTATIC— off for static production CI builds - Netlify
adapterviaASTRO_USE_NETLIFY_ADAPTER env.schemafor typed env vars used in config and client code
Pair with the matching .env example for each deploy target:
| File | Use |
|---|---|
.env.example.local |
Local dev — Keystatic local, ASTRO_ENABLE_KEYSTATIC=true |
.env.example.netlify |
CMS host — Keystatic github, adapter enabled |
.env.example.ionos |
Static production build — Keystatic off, values for CI only |
KEYSTATIC_STORAGE_KIND must be public + client context in env.schema so keystatic.config.* can import it from astro:env/client.
Astro: Environment variables · On-demand rendering · Keystatic & Astro · Configuring Astro
Dual-file pattern (required)
Every content type gets two paired files:
| File | Exports | Role |
|---|---|---|
{name}.keystatic.ts(x) |
keystatic{Name}Config, basePath / contentBase, shared enums |
Keystatic schema, paths, CMS labels |
{name}.astro.ts(x) |
astro{Name}Definition |
Astro defineCollection, Zod schema, required loader |
Register both in root configs:
keystatic.config.*→ imports allkeystatic*Configsrc/content.config.ts→ imports allastro*Definitionintoexport const collections
Every Astro collection needs a loader (typically glob() or file() from astro/loaders, or a custom async loader). Import z from astro/zod.
Export naming:
- Keystatic:
keystaticFaqsConfig,keystaticItemsConfig - Astro:
astroFaqsDefinition,astroItemsDefinition
Export shared enums and paths from the Keystatic file; import them in Astro:
// faqs.keystatic.ts
export const categoryEnumAndOrder = ['Allgemein', 'Planung & Bau', ...] as const
export const basePath = 'src/content/faqs'
// faqs.astro.ts
import { defineCollection } from 'astro:content'
import { z } from 'astro/zod'
import { basePath, categoryEnumAndOrder } from './faqs.keystatic'
import { loader } from './utils/loader'
export const astroFaqsDefinition = defineCollection({
loader: loader(basePath, 'mdx'),
schema: z.object({ category: z.enum(categoryEnumAndOrder), ... }),
})
Astro: Defining build-time collections · Keystatic: Collections · Astro integration
File splitting
All collection and singleton definitions live in cms/ at the project root.
Supporting files per concern
| Concern | Location | Pattern |
|---|---|---|
| Shared Zod (API + Astro) | {name}.schema.ts |
API response + collection validation |
| Local variant of remote schema | {name}Schema.ts |
extends/omits/transforms remote schema for CMS |
| Glob loader helper | cms/utils/loader.ts |
reused by all local file collections |
| MDX block registry (CMS) | cms/components/mdxComponentsKeystatic.ts |
mdxComponentsKeystatic('faqs') per context |
| MDX block registry (site) | cms/components/mdxComponentsAstro.astro |
passed to <Content components={...} /> |
| Per-block files | cms/components/{Block}/ |
keystatic.{block}.config.ts, {Block}.astro, optional KeystaticPreview.tsx |
| Custom Keystatic fields | cms/components/keystaticComponents/ |
e.g. map picker, color picker |
MDX blocks (three layers)
components/{Block}/
├── keystatic.{Block}.config.ts # block() schema + ContentView preview
├── {Block}.astro # frontend render
└── KeystaticPreview.tsx # optional CMS image preview
Disable built-in MDX images everywhere: fields.mdx({ options: { image: false } }). Use custom block() components for images instead.
Keystatic: MDX field · Content components · Astro: MDX integration
Content structure
Folder layout under src/content/
Paths in Keystatic path must match on-disk layout. Collection key in content.config.ts is camelCase (e.g. homepageFacts → src/content/homepageFacts/).
| Type | Keystatic | Astro | Files |
|---|---|---|---|
| MDX collection | collection, format: { contentField: 'body' } |
loader(basePath, 'mdx') |
*.mdx |
| YAML data collection | collection, no contentField |
loader(..., 'yaml') |
*.yaml |
| JSON data collection | collection, format: { data: 'json' } |
loader(..., 'json') |
*.json |
| Singleton | singleton, one folder |
registered in collections; access via getEntry('name', 'index') |
index.mdx, index.yaml, or multi-file |
Singleton multi-file pattern
One logical singleton can produce multiple files in one folder:
src/content/homepage/
├── index.mdx # frontmatter + bodyBeforeQuotes
├── bodyAfterQuotes.mdx
└── bodyAfterMilestones.mdx
Load each part separately: getEntry('homepage', 'index'), getEntry('homepage', 'bodyaftermilestones').
Relationships
Use fields.relationship({ collection: 'otherCollection' }) in Keystatic. In Astro, relationship values resolve to entry slugs:
apiSyncedItems (JSON, fetch-to-disk)
↑ relationship (externalId)
items (MDX)
↑ relationship (parent)
itemDetails (MDX)
CMS list ergonomics
slugFieldfor slug-driven collections (title,name,imageAlt)columns: ['title', 'position', 'externalId']for list viewentryLayout: 'form'on content-heavy singletons[READONLY]labels for API-synced collections editable only via fetch
Keystatic: Collections · Singletons · Relationship field · Astro: Collection references
Astro integration
Loading content
| API | Use for |
|---|---|
getEntry(collection, id) |
Singletons ('index'), multi-file singleton parts |
getCollection(name) |
All entries; sort/filter in page or component |
getCollection(name, filter) |
Filtered load |
render(entry) |
MDX: const { Content } = await render(entry) from astro:content |
getStaticPaths + getCollection |
Dynamic routes ([slug].astro) |
Pass shared MDX components on every render:
import { render } from 'astro:content'
const { Content } = await render(entry)
<Content components={mdxComponentsAstro} />
Loaders
Every collection declares loader in defineCollection. For on-disk Keystatic content, use glob() via the shared helper:
// cms/utils/loader.ts
import { glob } from 'astro/loaders'
export const loader = (contentBase: string, format: 'mdx' | 'json' | 'yaml') => {
const base = contentBase.startsWith('/') ? `.${contentBase}` : contentBase
return glob({ base, pattern: `**\/[^_]*.${format}` })
}
Pattern excludes _-prefixed files (partials/drafts). For a single JSON/YAML file with many entries, use file() instead.
Type generation
Run astro sync after schema changes → .astro/content.d.ts.
Astro: Querying collections · Rendering body content · Generating routes · glob() loader · MDX components in content
Remote / fetched collections
Pick a pattern based on CMS visibility, API reliability at build time, and whether editors need a local override.
| Pattern | Keystatic? | Disk cache? | When |
|---|---|---|---|
| A. In-memory fetch | No | No | Pure API data, no editor workflow |
| B. Fetch + JSON fallback | No | Committed fallback file | API unreliable; offline builds OK |
| C. Fetch-to-disk + glob | Yes (readonly) | Written each build | Editors reference slugs; data visible in CMS |
| D. Local mirror + remote merge | Local only | Local JSON in src/content/ |
Same page template for CMS and API sources |
Remote collections register only in src/content.config.ts, not in keystatic.config.
For implementation details, decision flow, and merge patterns, see remote-collections.md.
Astro: Custom build-time loaders · Content Loader API
Images
| Image type | Keystatic directory |
Astro |
|---|---|---|
| Content-colocated (hero, logos) | src/content/{collection} |
image() in Zod → Picture from astro:assets |
| MDX block images | src/assets/{context}/ |
getImage() + import.meta.glob (Keystatic Astro images recipe) |
| Public downloads | public/downloads/{path}/ |
Direct URL, not fingerprinted |
Astro: Images in content collections · Keystatic: Astro images recipe
Checklist: add a new local collection
- Create
{name}.keystatic.ts— exportbasePath, enums,keystatic{Name}Config - Create
{name}.astro.ts— import shared exports,defineCollectionwithloader(basePath, format)and Zod schema - Register in
keystatic.config.*(collections or singletons) andsrc/content.config.ts - Add UI navigation entry in
keystatic.config.*ui.navigation - Create
src/content/{name}/folder (or let Keystatic create on first save) - Run
astro syncand verify types - Consume via
getCollection/getEntry/renderin pages
Checklist: add a remote collection
- Create Zod schema (
{name}Schema.ts) — validate API response - Create
{name}Astro.tswith asyncloader: async () => { fetch → parse → return entries } - Register in
src/content.config.tsonly - Decide fail-fast vs fallback (see remote-collections.md)
- If merging with local CMS data: share schema, merge in page via
[...local, ...remote]
Anti-patterns
- Using
createReader— use Astro Content Layer instead - Duplicating enums/paths between Keystatic and Astro files — export from
.keystatic.ts - Enabling
@keystatic/astroin static production builds - Native MDX images when using custom blocks — always
options: { image: false } - Adding remote-only collections to Keystatic unless editors need readonly visibility (pattern C)