Headless CMS skill for Codex
Typed content, instant publish, safe previews. Codex wires a website to Sanity, Contentful, Strapi 5 or Payload 3 with generated types, queries that resolve references in one request, and webhook or hook driven tag revalidation so published edits show within seconds. Draft preview runs server side without exposing tokens, rich text goes through the CMS renderer, and content model changes ship as migrations tested on a copy first.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- headless-cms Installs to ~/.agents/skills/headless-cms/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install headless-cms The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project headless-cms Installs to .agents/skills/headless-cms/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/headless-cms.zipunzip -o headless-cms.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) headless-cms PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $headless-cms, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Integrates headless CMSs - Sanity (GROQ, next-sanity, TypeGen), Contentful (Delivery, Preview and Management APIs, migrations, environments), Strapi 5 (REST with documentId, populate, status) and Payload 3 (Local API inside Next.js) - with typed content, draft and preview mode, webhook-driven cache revalidation, rich text rendering and content model migrations as code. Use when asked to connect a website or app to a CMS, choose between these CMSs, add preview of drafts, fix stale content after publishing, model content types, or migrate Strapi v4 to v5.
Use it when
- A site or app needs content from Sanity, Contentful, Strapi or Payload
- Published CMS changes take too long to appear or never appear
- Editors need to preview drafts on the real front end
- The content model must change without breaking production
Use something else when
- The site runs on WordPress itself: use wordpress-plugins
- You are using Notion as an internal wiki rather than a site CMS: use notion-api
How to check the result
Type check against generated content types, publish a test edit and confirm the page updates via the expected cache tag, and confirm drafts are visible only through preview.
The mistake it prevents
Reading from a CDN-cached client right after publishing, so revalidation re-caches the old content.
Done well means: content is typed end to end, the front end fetches only the fields it renders (references resolved in the query, not in loops), published pages update within seconds of a publish through webhook revalidation, editors can preview drafts without leaking preview tokens to the browser, and content model changes ship as reviewed migrations.
Choose (or confirm) the CMS
| Need | Default | Why |
|---|---|---|
| Structured content, custom editorial UI, real-time collaboration, hosted | Sanity | Schema in code, GROQ projections, Studio is a React app you own |
| Enterprise governance, many locales, environments and roles | Contentful | Mature environments and aliases, migrations tooling |
| Self-hosted Node CMS with admin panel out of the box | Strapi 5 | Open source, REST/GraphQL, your database |
| CMS inside the Next.js app itself, code-first, SQL database | Payload 3 | Installs into /app, Local API with no HTTP hop |
If the project already has one, keep it; this skill is about integrating well, not migrating.
Read AGENTS.md, package.json and the CMS config (sanity.config.ts, payload.config.ts,
src/api/**/content-types, or Contentful space settings) before changing anything.
Integration checklist
- [ ] Separate read-only delivery tokens (server only) from preview and write tokens
- [ ] Generate types from the content model; no hand-written `any` content shapes
- [ ] Queries select fields and resolve references in one request
- [ ] Fetch with cache tags; a CMS webhook calls a revalidation route on publish/unpublish
- [ ] Draft mode route enables preview with a secret, using the CMS's draft perspective/API
- [ ] Rich text rendered through the CMS's renderer, never raw HTML injection
- [ ] Images served through the CMS image pipeline with width, format and quality params
- [ ] Content model changes as migrations, tested on a copy (dataset, environment, DB)
Sanity
// lib/sanity.ts
import { createClient, defineQuery } from "next-sanity";
export const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
apiVersion: "2025-02-19", // pin a date; bump deliberately
useCdn: true, // false for drafts and read-after-write
});
export const POST_QUERY = defineQuery(`
*[_type == "post" && slug.current == $slug][0]{
_id, title, publishedAt,
"author": author->{name, "avatar": image.asset->url},
body,
"cover": mainImage{asset->{url, metadata{dimensions, lqip}}, alt}
}`);
export async function getPost(slug: string) {
return client.fetch(POST_QUERY, { slug }, { next: { tags: [`post:${slug}`, "post"] } });
}
- Types:
npx sanity schema extract && npx sanity typegen generateproduces result types for eachdefineQuery. - Always pass user input as GROQ params (
$slug), never by string interpolation. - Drafts: query with
perspective: "drafts"and a viewer token on the server only; Visual Editing / Presentation tool builds on this. - Rich text is Portable Text: render with
@portabletext/reactand explicit components for custom block types and marks. - Webhooks: GROQ-powered webhooks with a filter (
_type == "post") and projection ({slug}); verify with@sanity/webhook(isValidSignature) before revalidating.
Contentful
import { createClient } from "contentful";
const delivery = createClient({
space: process.env.CONTENTFUL_SPACE_ID!,
environment: process.env.CONTENTFUL_ENVIRONMENT ?? "master",
accessToken: process.env.CONTENTFUL_DELIVERY_TOKEN!,
});
const preview = createClient({
space: process.env.CONTENTFUL_SPACE_ID!,
environment: process.env.CONTENTFUL_ENVIRONMENT ?? "master",
accessToken: process.env.CONTENTFUL_PREVIEW_TOKEN!,
host: "preview.contentful.com",
});
const res = await delivery.getEntries({
content_type: "blogPost",
"fields.slug": slug,
include: 2, // resolve linked entries two levels deep
select: ["fields.title", "fields.slug", "fields.body", "fields.author"],
limit: 1,
});
- Types: generate from the space (for example with
cf-content-types-generator) and use the SDK’s generic entry types. - Rich text:
@contentful/rich-text-react-rendererwith render options for embedded entries and assets. - Model changes: write
contentful-migrationscripts and run them with the Contentful CLI against a new environment cloned from master; test, then point themasteralias at it. Running a migration directly on production content needs explicit user confirmation. - The Management API is tightly rate limited (a few requests per second); batch and back off. Delivery API responses are CDN cached and generous.
Strapi 5
const qs = new URLSearchParams({
"filters[slug][$eq]": slug,
"populate[cover][fields][0]": "url",
"populate[author][fields][0]": "name",
"fields[0]": "title",
"fields[1]": "body",
status: "published", // "draft" for preview
});
const res = await fetch(`${process.env.STRAPI_URL}/api/articles?${qs}`, {
headers: { Authorization: `Bearer ${process.env.STRAPI_READ_TOKEN}` },
next: { tags: [`article:${slug}`] },
});
const { data } = await res.json(); // v5: flat objects with documentId, no data.attributes
| Strapi v4 | Strapi v5 |
|---|---|
data[].attributes.title | data[].title (flattened) |
numeric id as stable key | documentId (string) is the stable key across locales and drafts |
publicationState=preview | status=draft |
| Entity Service API | Document Service API (strapi.documents("api::article.article")) |
Use read-only API tokens for the front end, full-access tokens only in migration scripts.
populate=* is one level only and over-fetches; populate explicitly.
Payload 3
// app/(site)/posts/[slug]/page.tsx
import { getPayload } from "payload";
import config from "@payload-config";
import { draftMode } from "next/headers";
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const { isEnabled: draft } = await draftMode();
const payload = await getPayload({ config });
const { docs } = await payload.find({
collection: "posts",
where: { slug: { equals: slug } },
depth: 1,
draft,
limit: 1,
overrideAccess: false, // enforce collection access rules
});
...
}
- The Local API bypasses access control by default (
overrideAccess: true); passoverrideAccess: false(anduser) whenever results reach end users. - Types:
payload generate:types. Postgres schema changes:payload migrate:create, review the SQL, runpayload migratein deploy. - Revalidate in collection
afterChangehooks withrevalidateTaginstead of webhooks.
Revalidation and preview (Next.js)
// app/api/revalidate/route.ts
import { revalidateTag } from "next/cache";
export async function POST(req: Request) {
if (req.headers.get("x-revalidate-secret") !== process.env.REVALIDATE_SECRET) {
return new Response("Unauthorized", { status: 401 });
}
const { type, slug } = await req.json();
revalidateTag(type);
if (slug) revalidateTag(`${type}:${slug}`);
return Response.json({ revalidated: true });
}
Prefer the CMS’s signature verification (Sanity, Contentful and Strapi all support secret
headers or signatures) over a shared header when available. Check the revalidateTag
signature for the project’s Next.js version; newer versions accept a cache-life profile argument.
Draft mode route: verify a secret, call (await draftMode()).enable(), redirect to the
requested path (validate it is a relative path to avoid open redirects). In draft mode, use the
preview client or draft perspective and disable caching for those requests.
Gotchas
- Stale content after publish: CDN client (
useCdn: true) read right after a write, or tags in fetch do not match the tags revalidated. - Reference fetching in loops (N+1): resolve with GROQ
->, Contentfulinclude, Strapipopulate, Payloaddepth. - Locales: decide fallback behaviour explicitly; missing translations otherwise render empty.
- Preview or write tokens in
NEXT_PUBLIC_*variables leak to every visitor. - Deleting content types or fields drops data for every entry; confirm and migrate first.
- Slugs are not unique unless the model enforces it; validate in the schema.
Verification
- Type check passes with generated types; no
anyin content access. - Publish a change in the CMS and confirm the page updates within the expected seconds; check the revalidation route’s logs for the matching tag.
- Open a draft through the preview link, then confirm the public page does not show it.
- Run migrations against a copy (dataset, environment, database branch) before production.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Headless CMS"
short_description: "Typed content, instant publish, safe previews"
default_prompt: "$headless-cms Connect our Next.js site to Sanity with typed queries and instant revalidation on publish" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.