Sanity CMS for Publishers: Structured Content Done Right
Sanity is a headless CMS built around a principle it calls “structured content” — the idea that content should be modeled as data first, with presentation a separate concern. For publishers whose content is genuinely complex — articles with rich metadata, multiple content types with relationships, content repurposed across channels — Sanity’s approach delivers a level of flexibility that database-backed traditional CMSes struggle to match.
It is not the simplest tool in the category, but for the use cases it is designed for, it is among the most capable.
What Makes Sanity Different
Most CMSes store content as HTML or Markdown blobs — a body field that contains the formatted text of an article. Sanity stores content as Portable Text, a structured JSON representation of rich text that is rendering-context agnostic. A Portable Text body field can be rendered as HTML for a web page, as Markdown for an email, as plain text for a search index, or as any other format a renderer supports.
This matters for publishers distributing content across multiple channels. Content written once in Sanity can be consumed by a web front end, a mobile app, and a newsletter service, each applying its own rendering logic to the same structured source.
The second distinction is the schema definition system. Sanity schemas are JavaScript/TypeScript files in your project — content models are code, version-controlled alongside the rest of your infrastructure, and deployed explicitly rather than configured through a UI.
Setting Up a Sanity Project
npm create sanity@latest
The CLI walks through project creation, workspace naming, and dataset selection. Sanity creates two things: a Studio (the editing interface, a React application you deploy) and a project on Sanity’s hosted backend (content storage and API).
The studio runs locally at http://localhost:3333 during development:
cd my-studio
npm run dev
Defining a Schema
Schemas live in schemaTypes/. Define a post type:
// schemaTypes/post.js
export default {
name: 'post',
title: 'Post',
type: 'document',
fields: [
{
name: 'title',
title: 'Title',
type: 'string',
validation: Rule => Rule.required()
},
{
name: 'slug',
title: 'Slug',
type: 'slug',
options: { source: 'title', maxLength: 96 },
validation: Rule => Rule.required()
},
{
name: 'publishedAt',
title: 'Published At',
type: 'datetime'
},
{
name: 'author',
title: 'Author',
type: 'reference',
to: [{ type: 'author' }]
},
{
name: 'categories',
title: 'Categories',
type: 'array',
of: [{ type: 'reference', to: { type: 'category' } }]
},
{
name: 'mainImage',
title: 'Main Image',
type: 'image',
options: { hotspot: true }
},
{
name: 'excerpt',
title: 'Excerpt',
type: 'text',
rows: 4
},
{
name: 'body',
title: 'Body',
type: 'blockContent' // Portable Text type defined separately
}
]
}
Define a reusable blockContent type for Portable Text:
// schemaTypes/blockContent.js
export default {
name: 'blockContent',
title: 'Block Content',
type: 'array',
of: [
{
type: 'block',
styles: [
{ title: 'Normal', value: 'normal' },
{ title: 'H2', value: 'h2' },
{ title: 'H3', value: 'h3' },
{ title: 'Quote', value: 'blockquote' },
],
marks: {
decorators: [
{ title: 'Bold', value: 'strong' },
{ title: 'Italic', value: 'em' },
],
annotations: [
{ name: 'link', type: 'object', fields: [{ name: 'href', type: 'url' }] }
]
}
},
{ type: 'image', options: { hotspot: true } },
// Add custom block types here: pullquote, callout, code, etc.
]
}
Querying Content with GROQ
Sanity uses GROQ (Graph-Relational Object Queries), its own query language, to fetch content from the API. GROQ is expressive and efficient — it supports filtering, sorting, joining references, projecting specific fields, and slicing results.
Fetch recent posts with author data:
import { createClient } from '@sanity/client'
const client = createClient({
projectId: 'your-project-id',
dataset: 'production',
useCdn: true,
apiVersion: '2024-01-01',
})
// Get 10 most recent posts with author name resolved
const posts = await client.fetch(`
*[_type == "post" && defined(slug.current)] | order(publishedAt desc) [0...10] {
_id,
title,
"slug": slug.current,
publishedAt,
excerpt,
"author": author->name,
"categories": categories[]->title,
"mainImage": mainImage.asset->url
}
`)
The -> operator follows references, resolving them inline. [0...10] slices the first 10 results. The projection { ... } selects and renames fields.
Fetch a single post by slug:
const post = await client.fetch(`
*[_type == "post" && slug.current == $slug][0] {
title,
body,
publishedAt,
"author": author-> { name, bio, "image": image.asset->url },
"categories": categories[]->{ title, slug }
}
`, { slug: 'my-post-slug' })
Rendering Portable Text
In a Next.js or React front end, use the @portabletext/react package to render Portable Text body content:
import { PortableText } from '@portabletext/react'
const components = {
types: {
image: ({ value }) => (
<img src={value.asset.url} alt={value.alt || ''} />
),
},
marks: {
link: ({ value, children }) => (
<a href={value.href} target="_blank" rel="noreferrer">{children}</a>
),
},
}
export default function Post({ post }) {
return (
<article>
<h1>{post.title}</h1>
<PortableText value={post.body} components={components} />
</article>
)
}
Custom block types (pullquotes, callouts, embedded media) are registered in the components.types object with their own rendering logic.
Deploying the Studio
The Sanity Studio is a React application that can be deployed anywhere static files are served. Deploy to Sanity’s own hosting:
npx sanity deploy
Or build and deploy to Netlify, Vercel, or Cloudflare Pages alongside your front end. Editors access the Studio at its deployed URL and authenticate through Sanity’s identity system.
Pricing
Sanity’s free tier covers one project, two non-admin users, and 500k API requests per month — sufficient for getting started and for small publications. Paid plans scale by API usage, storage, and user count.
For publishers with high content volume or API traffic, costs can accumulate. Sanity’s CDN caching reduces API request counts significantly for read-heavy publishing workloads.
When Sanity Fits
Sanity is the right choice for publishers who need a genuinely flexible content model, plan to distribute content across multiple channels, want their content schema in version control, and have developers who can set up and maintain a headless stack. The investment in setup pays off over time in content flexibility and API quality.
For simpler publishing operations, a traditional CMS or Ghost is less overhead for equivalent results.