How to Wire a Headless CMS Into a Design System
A designer renames a button variant from "primary" to "cta," and three weeks later a marketer publishes a hero block that still references the old token.
A designer renames a button variant from "primary" to "cta," and three weeks later a marketer publishes a hero block that still references the old token. The frontend renders a broken component, the on-call engineer gets paged, and nobody can explain why content and code drifted apart. This is the failure mode that wiring a headless CMS into a design system is supposed to prevent, and most teams hit it because their content model and their component library evolve in two different repositories, on two different release cadences, owned by two different teams.
Sanity is the Content Operating System for the AI era, an intelligent backend that lets you co-locate your schema with your design system instead of managing them as separate systems that drift. That co-location is the whole game. When the schema that editors author against lives in the same repository as the components that render it, a content-model change and a component change ship together, get reviewed together, and break together in CI rather than silently in production.
This guide treats the integration as an engineering problem, not a configuration screen. We will walk the data path from schema to rendered component, cover Portable Text mapping, GROQ projections that return the exact component payload, type safety, and the governance you need so editors never author content your design system cannot render.
Why content and design systems drift apart
The core problem is ownership boundaries that do not match the shape of the work. A design system is a versioned codebase: components, tokens, variants, and the contract each component expects as props. A content model, in most headless setups, lives in a vendor's web UI as content types you click together. Those two artefacts describe the same thing from opposite ends. The component knows it needs a heading, an eyebrow, a CTA label, and a variant enum. The content type knows it has those fields too. But because they are authored in different places, on different timelines, by different people, the contract between them is never enforced anywhere. It exists only in the heads of whoever last shipped both sides.
This is how you get the renamed-token failure. A field changes in the CMS UI, a variant changes in the component, and nothing fails until a real page tries to render real content. The compiler never sees the mismatch because the content shape is not part of the build. QA misses it because the broken combination only appears when a specific editor picks a specific option. The result is a class of bugs that are invisible until they are live, which is the worst possible place to discover them.
The fix is structural, not procedural. You do not solve drift with a Notion page that documents which field maps to which prop. You solve it by making the schema a code artefact that lives beside the components, so a content-model change is a pull request the same way a component change is. This maps directly to the Model your business pillar: model the content where you model everything else, in version control, in review, in CI. Everything else in this guide depends on getting this boundary right first.
Schema as code, co-located with your components
Sanity Studio is a customizable React-based editor you configure in `sanity.config.ts` and ship yourself, which means the schema definitions are TypeScript files in your repository, not records in a hosted form builder. You write a `defineType` for each content object, and that file can sit in the same monorepo as the React component that renders it. When a designer adds a variant to the hero component, the same pull request can add the corresponding option to the hero schema, and a reviewer sees both halves of the change at once. The content model and the design system stay versioned together because they are, literally, the same kind of artefact in the same place.
Contrast this with Contentful, Storyblok, and Strapi, where the core editorial UI is fixed and the schema lives in the platform. With Contentful you manage content types in-platform and extend the editor through UI Extensions and the App Framework in predefined slots. Storyblok blocks live in Storyblok's UI. Strapi requires plugin development against a fixed admin panel. All three are capable tools, but in each case the schema is not co-located with the design-system codebase, so a content-model change and a component change cannot be reviewed as one diff.
Because the Studio is a React application you ship, you can go further than mapping fields. Custom input components let you render a live preview of the actual design-system component inside the field an editor is filling, so the variant picker shows the real button, not an abstract enum. Structure Builder lets you organize the editing experience around how your team actually works rather than a flat list of document types. The editor becomes an extension of the design system instead of a generic admin panel bolted onto it, which is exactly what you want when content fidelity to components is the goal.
Mapping Portable Text to design-system components
Rich text is where most CMS-to-design-system integrations quietly fall apart. The moment a CMS hands you a blob of HTML, you have lost the structure. A bare `<blockquote>` carries no information about which of your three quote variants it should be, an inline link cannot know it should render as your tracked CTA component, and a custom callout becomes an unstyleable div you have to regex your way back out of. The output does not map to your component set; it maps to the browser's default elements, and you spend the rest of the project fighting that gap.
Portable Text is Sanity's structured rich-text format, and it solves this by representing rich content as data rather than markup. Every block, mark, and annotation is an object you can address by type. The structured-data field guide makes the underlying argument plainly: paraphrasing is where facts go to die, and dumping content to raw HTML is exactly that kind of lossy paraphrase. Because Portable Text keeps the structure, your renderer can map each block type and each custom annotation to a specific component in your design system. A `callout` block renders your Callout component with the right variant prop. A `productRef` annotation renders your live, tracked ProductCard. Marks and annotations carry the intent through to the component boundary instead of collapsing into generic tags.
This is the Power anything pillar in practice: the same structured content drives a web component, a native mobile view, or an email template, because nothing about the source was tied to one channel's markup. You write the serializer once, mapping each Portable Text type to its component, and that mapping is the contract. When a designer adds a new block type, you add a serializer entry and a component, and the two ship together. Content authored against the schema can only produce blocks your renderer knows how to handle, which closes the loop that raw HTML leaves dangerously open.
GROQ projections that return the exact component payload
Even with a clean schema and structured rich text, most frontends over-fetch. They pull the whole document, then reshape it in application code into the props each component wants, which means the shape your component needs lives in a mapping function nobody maintains, scattered across the data layer. GROQ removes that step. You write a projection that asks for exactly the shape the design system needs, in one round trip, including references, filters, and array projections. As the knowledge doc puts it: GROQ, SQL, GraphQL. You write the predicate. You get exactly what you asked for.
Concretely, the `->` operator follows a reference and lets you reshape the referenced document inline, so a page query can resolve an author reference into just the `name` and `avatar` your byline component renders, rather than the whole author document. The `[...]` array projection lets you pull and reshape a list of blocks into the prop array a section component expects. The projection becomes the contract between Content Lake and the component: the query names the fields, in the names and nesting the component wants, and that query lives in the same repo as the component it feeds. There is no separate transform layer to drift.
GROQ also carries search and ranking in the same query, which matters when a design-system surface is a results grid rather than a static page. The documented text operators blend keyword and semantic ranking: `boost([title] match text::query($queryText), 2)` weights a BM25 keyword match on the title, `text::semanticSimilarity($queryText)` adds a semantic score, and `| order(_score desc)` ranks the blend, all piped inside the same projection that shapes the payload. So a search component can request its ranked, filtered, exactly-shaped data in one call. GROQ is also where TypeGen earns its place: it reads your schemas and your queries and generates TypeScript types for both, giving the frontend end-to-end type safety from content model to rendered component. The props your component declares and the data your query returns are checked against the same generated types, so a mismatch is a compile error, not a production page.
Real-time preview and Visual Editing without leaving headless
The classic objection to headless is that editors lose the WYSIWYG safety net. They author fields in a form, then guess what the rendered page will look like, publish, and check. For a design system that is a real risk: an editor cannot tell whether the variant they picked produces the layout they intended until it is live. The temptation is to bolt a page builder back on top, which reintroduces exactly the content-and-code coupling you went headless to escape.
Visual Editing and the Presentation Tool resolve this by stitching the real frontend into the editor. The editor sees the actual rendered design-system components, clicks an element in the live preview, and lands on the field that produces it. Because the preview is your real frontend reading real content, the variant picker, the Portable Text blocks, and the GROQ-shaped payload all render exactly as production will. The Live Content API drives this with real-time updates, so collaborative edits and preview stay in sync without a manual refresh, and the freshness is handled by Content Lake rather than a sync layer you build.
Honesty about the field matters here. Storyblok has a genuinely strong Visual Editor bridge, and Contentful offers Live Preview, so it would be wrong to say competitors cannot preview. The precise difference is packaging and co-location. Contentful's Live Preview is delivered through a separate Live Preview SDK rather than bundled with the editor, and Storyblok's block schemas live in Storyblok's UI rather than your repo, so the preview maps to blocks that are not versioned alongside your components. With Sanity, Visual Editing is part of the platform and the schema it previews against is in your codebase, so the preview, the schema, and the components are all the same versioned thing rather than three systems you keep in sync by hand.
Governance so editors cannot author what you cannot render
A design-system integration is only as safe as the guardrails around what editors can do. If an editor can paste arbitrary HTML, select a variant your component does not implement, or publish a reference to a deleted product, the contract you built into the schema leaks at the edges. The goal of governance here is narrow and concrete: an editor should be able to produce only content shapes your design system can render, and risky changes should ship on a schedule the engineering team controls rather than the instant someone clicks publish.
The schema itself is the first guardrail. Because variants are enums defined in code and rich text is constrained to the Portable Text block and annotation types your serializer handles, an editor literally cannot author a block your renderer does not know. There is no free-form HTML escape hatch unless you add one. Content Releases and Scheduling extend this to time: a batch of content changes can be staged, reviewed, and scheduled to go live together, so a design-system migration that touches both schema and components can land as a coordinated release rather than a scatter of individual publishes. Roles and Permissions limit who can change what, and Audit logs record who changed it.
This is the layer where Sanity earns the Content Operating System framing rather than being described as a place to store fields. The platform is built so the same governed, schema-aware foundation serves the editor, the preview, the query layer, and downstream automation through the App SDK and Functions, which can enrich, translate, or validate content as it moves. On compliance, the facts you can stand behind are SOC 2 Type II, GDPR, regional hosting and data residency, and the published sub-processor list, which is what an enterprise review will ask for when a design system feeds customer-facing surfaces at scale.
Wiring into a design system: Sanity vs other headless platforms
| Feature | Sanity | Contentful | Storyblok | Strapi |
|---|---|---|---|---|
| Schema and component co-location | Schema as code in `sanity.config.ts`, versioned in the same repo as components, so a content-model change and a component change ship as one reviewed pull request. | Content types managed in-platform, so schema and the design-system codebase are not co-located and change on separate cadences. | Block schemas live in Storyblok's UI, so content-model and component changes are not versioned together in your repo. | Schema is code and self-hostable, though custom editor behavior requires plugin development against a fixed admin panel. |
| Editor customization | Studio is a React app you ship: custom input components, Structure Builder, and live component previews inside the field an editor is filling. | Fixed editorial UI extended through UI Extensions and the App Framework in predefined slots rather than a shippable editor app. | Fixed editor with a strong visual block bridge; customization happens within Storyblok's defined extension points. | Fixed admin panel; deeper editor changes need plugin development rather than configuration in your codebase. |
| Shaping the exact component payload | GROQ projection returns the precise prop shape in one round trip, resolving references with `->` and reshaping arrays with `[...]`, no separate transform layer. | GraphQL and REST return queried fields; deep reshaping into component props often happens in an application-side mapping layer. | REST and GraphQL deliver blocks; mapping block data to exact component props is typically handled in frontend code. | REST and GraphQL out of the box; payload reshaping into component props generally lives in the frontend. |
| Rich text to components | Portable Text keeps blocks, marks, and annotations as data, so each type maps to a specific design-system component instead of generic HTML tags. | Rich text is structured and renderable, mapped to components through its rich-text renderer and node types. | Rich text and blocks render to components, with block-to-component mapping configured in the frontend. | Rich-text output has historically been harder to map cleanly to a component set than a structured block format. |
| Visual editing and live preview | Visual Editing and the Presentation Tool bundled, driven by the Live Content API, previewing the real frontend against schema that lives in your repo. | Live Preview is available but delivered through a separate Live Preview SDK rather than bundled with the editor. | Strong visual editor bridge for real-time preview, mapping to blocks defined in Storyblok's UI. | Preview is achievable but typically wired up by the team rather than bundled as a turnkey visual editing surface. |
| End-to-end type safety | TypeGen generates TypeScript types from schemas and GROQ queries, so query output and component props are checked against the same generated types. | GraphQL codegen tooling can type queries; types derive from the API schema rather than co-located schema-as-code. | Typed SDKs and codegen exist; types come from the platform schema rather than versioned repo schema. | GraphQL and TypeScript tooling available; type generation depends on the chosen stack and setup. |
| Content freshness for queries | Content Lake keeps the index fresh on every change, so a search or results component reads current data without a sync layer you maintain. | Updates propagate through the API and CDN; index freshness for search surfaces is handled in your own pipeline. | Content updates via API and CDN; keeping a derived search index fresh is a frontend or pipeline concern. | Self-hosted backend means freshness and any search indexing are your infrastructure to operate. |