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.

  • Integrations
  • $headless-cms
  • 212 lines
  • 2 files
  • ~2,250 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- headless-cms

Installs to ~/.agents/skills/headless-cms/ for every project on this machine.

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.

~/.agents/skills/headless-cms/SKILL.md

--- name: headless-cms description: Integrates headless CMSs - Sanity (GROQ, next-sanity, TypeGen), Contentful (Delivery, Preview and Management APIs, migrations, environments), Strapi… license: MIT ---

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

NeedDefaultWhy
Structured content, custom editorial UI, real-time collaboration, hostedSanitySchema in code, GROQ projections, Studio is a React app you own
Enterprise governance, many locales, environments and rolesContentfulMature environments and aliases, migrations tooling
Self-hosted Node CMS with admin panel out of the boxStrapi 5Open source, REST/GraphQL, your database
CMS inside the Next.js app itself, code-first, SQL databasePayload 3Installs 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 generate produces result types for each defineQuery.
  • 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/react and 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-renderer with render options for embedded entries and assets.
  • Model changes: write contentful-migration scripts and run them with the Contentful CLI against a new environment cloned from master; test, then point the master alias 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 v4Strapi v5
data[].attributes.titledata[].title (flattened)
numeric id as stable keydocumentId (string) is the stable key across locales and drafts
publicationState=previewstatus=draft
Entity Service APIDocument 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); pass overrideAccess: false (and user) whenever results reach end users.
  • Types: payload generate:types. Postgres schema changes: payload migrate:create, review the SQL, run payload migrate in deploy.
  • Revalidate in collection afterChange hooks with revalidateTag instead 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 ->, Contentful include, Strapi populate, Payload depth.
  • 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 any in 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.yamlgenerated from this page

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.

esc