<!-- skill: Headless CMS | version: 1.0.0 | description: Sanity/Contentful models, delivery APIs and revalidation | category: Web -->
## Instructions
- Model first: content types `post/author/category` with `slug/title/excerpt/body/seo` fields, required + unique constraints, references (author→post) not nested copies; status `draft/published/archived`.
- Sanity setup: `npm create sanity@latest` studio, `schemaTypes/post.ts` (`defineField`, `slug options.source: 'title'`), GROQ `*[_type=="post" && slug.current==$slug][0]{..., author->}`, `defineLive` for draft preview.
- Contentful setup: space + environments (`dev/staging/master` aliases), content model + validations, CDA (published) vs CPA (preview) tokens, GraphQL `postCollection(where:{slug})` with `limit/skip` pagination.
- Delivery: SSG `generateStaticParams` from slugs + `revalidate: 300` ISR; client never holds management tokens; `draftMode()` + preview route for editors; image CDN (`sanity image-url` / Contentful `?w=1200&fm=avif`).
- Webhooks revalidate: CMS webhook `post.publish` → `POST /api/revalidate {slug, secret}` → `revalidateTag('posts')` / `revalidatePath('/blog/slug')`; verify `x-webhook-signature` HMAC; retry 3x + DLQ log.
- Rich text: Portable Text (`@portabletext/react` components for code/callout/embed) / Contentful RichText (`documentToReactComponents` + `BLOCKS.EMBEDDED_ASSET`); sanitize HTML, lazy embeds `loading="lazy"`.
- SEO: per-entry `seoTitle/seoDesc/ogImage` fallback to title/excerpt/hero, `generateMetadata` per slug, `sitemap.ts` from all slugs, canonical `siteUrl/blog/slug`; 404 on `archived`.
- Localization: `locale` field (`en/de`) or separate entries with `translationOf` ref, fallback chain `de→en`, per-locale webhook revalidate `/de/blog/slug`; `accept-language` redirect middleware.
- Roles: Admin/Editor/Viewer, field-level (SEO locked to Admin), environments promotion `dev→staging→master` (Contentful) / datasets (Sanity); audit log on publish/unpublish.
- Migrations: scripted `sanity migration` / `contentful-migration` versioned (add field + backfill), export `ndjson` backup nightly to S3; test migrate on `dev` dataset first.
- Performance: `next/image` remotePatterns CMS CDN, `fetch` `next.tags` caching, list pages `limit 24` + cursor, search via Algolia webhook index (not GROQ per keystroke); bundle PortableText components split.
- Editor UX: desk structure (Posts needing review, Scheduled), validation tooltips, slug auto, required alt text on images, `presentation` live preview URL per env; docs `content-guide.md` with examples.
## Examples
- Marketing blog: Sanity `post` + Portable Text callouts, Next.js ISR 300s, `revalidateTag` webhook, preview `/api/draft` with `defineLive`, Algolia search index.
- Docs i18n: Contentful `doc` en/de, GraphQL per locale, middleware locale redirect, per-locale revalidate, fallback en missing, CPA preview toolbar.
- Landing fleet: 50 pages shared `sections[]` blocks (hero/faq/cta), visual editor, scheduled publish webhook warms cache, S3 ndjson backups.
## Workflows
1. Model: types/fields/refs/validation, statuses, locales, SEO fields; seed 3 entries per type for UI testing.
2. Studio: deploy Studio (`/studio` route or hosted), desk structure, preview URLs, roles + environments.
3. Frontend: GROQ/GraphQL fetch, `generateStaticParams` + ISR tags, Portable/RichText renderers, image CDN + metadata.
4. Revalidate: signed webhook → `revalidateTag/Path`, HMAC verify, retry/DLQ, Algolia/sitemap refresh.
5. Operate: migrations versioned, nightly exports, editor guide, preview QA before publish.
## Templates
- Sanity schema + GROQ fetch:
```ts
// schemaTypes/post.ts
import { defineField, defineType } from 'sanity';
export const post = defineType({ name: 'post', type: 'document',
  fields: [defineField({ name: 'title', type: 'string', validation: (r) => r.required() }),
    defineField({ name: 'slug', type: 'slug', options: { source: 'title' }, validation: (r) => r.required() }),
    defineField({ name: 'body', type: 'array', of: [{ type: 'block' }, { type: 'image' }] })] });
// lib/posts.ts
import { createClient } from 'next-sanity';
const c = createClient({ projectId: 'abc', dataset: 'production', apiVersion: '2024-01-01', useCdn: true });
export const getPost = (slug: string) => c.fetch(
  `*[_type=="post" && slug.current==$slug][0]{title, "slug": slug.current, body, author->}`, { slug },
  { next: { tags: ['posts'] } });
```
- Contentful GraphQL + revalidate:
```ts
const q = `query($slug:String!) { postCollection(where:{slug:$slug}, limit:1) {
  items { title slug excerpt body { json } ogImage { url } } } }`;
const r = await fetch(`https://graphql.contentful.com/content/v1/spaces/${SPACE}`,
  { method: 'POST', headers: { Authorization: `Bearer ${CDA}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ query: q, variables: { slug } }), next: { tags: ['posts'] } });
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'; import { NextResponse } from 'next/server';
export async function POST(req: Request) {
  const sig = req.headers.get('x-signature');
  if (sig !== sign(await req.text())) return NextResponse.json({}, { status: 403 });
  revalidateTag('posts'); return NextResponse.json({ ok: true });
}
```
- PortableText render:
```tsx
import { PortableText } from '@portabletext/react';
<PortableText value={post.body} components={{
  types: { image: ({ value }) => <img src={urlFor(value).width(1200).format('webp').url()} loading="lazy" alt="" />,
    callout: ({ value }: any) => <aside className="callout">{value.text}</aside> } }} />
```
## Tools
- SARAH shell_run for `sanity dev/deploy/migrate`, `contentful-migration`, webhook `curl` revalidate tests.
- SARAH file_write for schemas/fetchers/revalidate-routes; web_fetch for Sanity + Contentful docs.
## Advanced
- Live preview: Sanity `defineLive + <SanityLive />` + `draftMode` cookie toolbar, Contentful CPA + `preview` param + inspector; Vercel Toolbar comments map to entry ids.
- Scheduling: `schedule.publishAt` field + cron worker publishing due entries via Management API then firing revalidate; embargo `visibleAfter` checked in query (`publishAt < now()`).
- Scale: GROQ `perspective: published` CDN, GraphQL persisted queries allowlist, webhook fan-out `revalidateTag` + sitemap ping + Algolia partial update in one signed handler with idempotency `entryId+version`.
## Documentation
- https://www.sanity.io/docs
- https://www.sanity.io/docs/groq
- https://www.contentful.com/developers/docs/
- https://www.contentful.com/developers/docs/references/graphql/
- https://nextjs.org/docs/app/api-reference/functions/revalidateTag
