
Sanity CMS Guide: How We Use It to Publish in 10 Languages
We've published over 400 content pieces across 4 websites and 10 languages through Sanity CMS. Here's what we've learned, from schema design to automated multilingual publishing.
Sanity CMS is a headless content platform built around structured content, a real-time Content Lake, and a customizable React-based editor called Sanity Studio. It uses GROQ for querying, Portable Text for rich content, and schema-as-code for content modeling. This guide covers setup, schema design, GROQ, Portable Text, multilingual architecture, and pricing.
What Is Sanity CMS?
Sanity is a structured content platform, what the team at Sanity.io calls a "content operating system." Unlike traditional CMSes that store HTML blobs in a database, Sanity stores every piece of content as structured JSON in a managed backend called the Content Lake. You query it with GROQ or GraphQL, and render the content in any frontend you want: Next.js, React Native, Svelte, a mobile app, a CLI tool, anything.
The companies using it span the full range. Nike, Figma, Puma, and Cloudflare run Sanity at enterprise scale. Startups use it because the free tier is genuinely usable (more on pricing later). We use it because nothing else gave us the flexibility to build a fully automated 10-language publishing pipeline.
Content Lake Architecture
The Content Lake is Sanity's managed backend. Think of it as a hosted document store that syncs in real time across all connected clients. When an editor changes a paragraph in Sanity Studio, another editor sees it instantly, no save button, no merge conflicts, no database migrations.
Under the hood, documents are stored as structured JSON with typed fields. Every mutation is tracked through a transaction log, so you get full version history by default. The real-time sync uses a listener-based architecture (described in Sanity's GitHub architecture docs) that pushes changes to all subscribers via RxJS observables.
What makes this different from, say, a PostgreSQL database with a REST API? The Content Lake handles content modeling, access control, CDN caching, image transformations, and real-time collaboration as a single managed service. You don't run migrations. You don't manage replicas. You just define schemas and query content.
Sanity Studio: Your Customizable Editor
Sanity Studio is an open-source React application that serves as your editing interface. It's not a hosted admin panel, it's a React app that lives in your codebase. You can customize every aspect of it: custom input components, conditional fields, document actions, structure builder patterns, and plugins.
Real-time collaboration is built in. Multiple editors can work on the same document simultaneously with presence indicators and live updates. If you've used Google Docs, the experience is similar, you see other people's cursors and changes in real time.
We deploy our Studio with npx sanity deploy, which hosts it on Sanity's CDN at a custom subdomain. You can also self-host it since it's just a React app. We ranked Sanity highly in our headless CMS comparison largely because of Studio's flexibility.
How to Set Up a Sanity Project
To set up Sanity CMS, install the CLI with npm create sanity@latest, choose a project template, configure your schema files, and run npx sanity dev to launch Studio locally. The entire process takes under 5 minutes.
Prerequisites and Installation
You need Node.js 18+ and npm (or pnpm). That's it. Run the init command:
npm create sanity@latest
# You'll be prompted for:
# - Login method (Google, GitHub, email)
# - Project name
# - Dataset name (default: "production")
# - Project template (blog, ecommerce, clean)
# - TypeScript? (recommended: yes)The CLI scaffolds a project with everything you need. Here's what the project structure looks like:
Project Structure Explained
my-sanity-project/
├── schemas/ # Your content schemas (this is where you'll spend time)
│ ├── index.ts # Schema registry -- imports and exports all types
│ ├── post.ts # Document type definitions
│ └── blockContent.ts # Rich text / Portable Text config
├── sanity.config.ts # Main config -- plugins, Studio structure, dataset
├── sanity.cli.ts # CLI config -- project ID, dataset
├── package.json
└── tsconfig.jsonThe sanity.config.ts file is your entry point. Here's a minimal one:
// sanity.config.ts
import { defineConfig } from 'sanity'
import { structureTool } from 'sanity/structure'
import { visionTool } from '@sanity/vision'
import { schemaTypes } from './schemas'
export default defineConfig({
name: 'default',
title: 'My Blog',
projectId: 'your-project-id',
dataset: 'production',
plugins: [structureTool(), visionTool()],
schema: { types: schemaTypes },
})The visionTool() plugin gives you an in-Studio GROQ playground, you'll use it constantly during development.
Deploying Your Studio
Launch locally with npx sanity dev (runs on localhost:3333). When you're ready to share with editors, deploy to Sanity's CDN:
npx sanity deploy
# Prompts for a hostname, e.g., "my-blog"
# Deploys to https://my-blog.sanity.studioPro tip: run npx sanity@latest schema deploy after any schema change. This uploads your schema to Sanity's API, which enables features like the GraphQL API and schema-aware tooling (including the MCP server we'll cover later).
Schema Design in Sanity CMS
Sanity schemas are defined as JavaScript or TypeScript objects in your codebase. Each schema specifies a document type with fields, validation rules, and custom input components. Changes to schemas are instant, no database migrations required. This is the "schema-as-code" approach, and it's the thing that sold us on Sanity over Contentful.
Field Types and Validation
Sanity ships with a rich set of field types. Here are the ones we use most:
| Field Type | Use Case | Example |
|---|---|---|
string | Short text, titles, slugs | Post title, author name |
text | Multi-line plain text | Excerpts, descriptions |
number | Integers, floats | Read time, sort order |
boolean | Toggles | Featured flag, draft status |
array | Lists, rich text (Portable Text) | Body content, tags |
reference | Links to other documents | Author, category |
image | Images with metadata | Cover image with alt text |
slug | URL-friendly strings | Auto-generated from title |
object | Nested field groups | SEO fields (metaTitle + metaDescription) |
date / datetime | Dates | Published date |
Every field supports validation via a validation callback. You can enforce required fields, min/max values, regex patterns, and custom rules:
defineField({
name: 'seoDescription',
title: 'Meta Description',
type: 'string',
validation: (Rule) =>
Rule.required()
.min(145)
.max(160)
.warning('Meta description should be 145-160 characters'),
})Custom Block Types (Our Production Examples)
Here's where Sanity gets interesting, and where 0 out of 6 competing guides show any code. In our production schema, we define five custom block types inside the body array: block (standard text), table, codeBlock, chartBlock, and inlineImage.
Here's our codeBlock definition:
// schemas/objects/codeBlock.ts
import { defineType } from 'sanity'
export const codeBlock = defineType({
name: 'codeBlock',
title: 'Code Block',
type: 'object',
fields: [
{
name: 'language',
title: 'Language',
type: 'string',
options: {
list: [
{ title: 'JavaScript', value: 'javascript' },
{ title: 'TypeScript', value: 'typescript' },
{ title: 'Python', value: 'python' },
{ title: 'Bash', value: 'bash' },
{ title: 'JSON', value: 'json' },
{ title: 'GROQ', value: 'groq' },
],
},
},
{
name: 'code',
title: 'Code',
type: 'text',
},
],
})And here's how the body field references all our custom types together:
// schemas/fields/body.ts
defineField({
name: 'body',
title: 'Body',
type: 'array',
of: [
{ type: 'block' }, // Standard Portable Text (paragraphs, headings, lists)
{ type: 'table' }, // @sanity/table plugin
{ type: 'codeBlock' }, // Our custom code block
{ type: 'chartBlock' }, // Data visualization (bar, line, pie)
{ type: 'inlineImage' }, // Images with alt text and captions
],
})This gives our editors a rich content toolkit while keeping every element typed and queryable. A chartBlock is not just an opaque HTML embed, it's structured data with chartType, title, dataPoints, and dataLabels fields. That matters when you're trying to render the same content across web, email, and mobile.
Schema Organization Best Practices
Keep schemas modular. We split ours across files by type: schemas/documents/post.ts, schemas/objects/codeBlock.ts, schemas/objects/chartBlock.ts. Import them all in schemas/index.ts:
// schemas/index.ts
import { post } from './documents/post'
import { codeBlock } from './objects/codeBlock'
import { chartBlock } from './objects/chartBlock'
import { inlineImage } from './objects/inlineImage'
export const schemaTypes = [post, codeBlock, chartBlock, inlineImage]The key insight we gained from working with structured content: your schema IS your content model. If you think of it as context engineering for your content team, you'll make better design decisions. Every field you add should serve a purpose, either for editors, for rendering, or for querying.
GROQ: Sanity's Query Language
GROQ (Graph-Relational Object Queries) is Sanity's open-source query language for filtering, joining, and projecting JSON documents. The basic syntax is *[filter]{projection}, select all documents matching a filter, then shape the output. It's more concise than GraphQL for Sanity-specific queries and, in our experience, faster to learn.
Basic Queries: Filter and Project
The simplest query fetches all documents of a type:
// Fetch all posts -- just title and slug
*[_type == "post"]{
title,
"slug": slug.current
}
// Filter by language, expand author reference
*[_type == "post" && language == "en"]{
title,
"slug": slug.current,
"authorName": author->name,
"authorImage": author->image,
"categoryTitle": category->title,
publishedAt
}The -> operator follows references. author->name means "follow the author reference and return the name field." No separate queries, no N+1 problems, no JOINs, it's all one expression.
Joins, Ordering, and Pagination
For our blog index pages, we need ordered, paginated posts with expanded references:
// Paginated posts with full metadata
*[_type == "post" && language == "en"] | order(publishedAt desc) [0...10] {
title,
"slug": slug.current,
excerpt,
publishedAt,
readTime,
"author": author->{name, image},
"category": category->{title, "slug": slug.current},
"coverImage": coverImage{
"src": asset->url,
alt
}
}[0...10] gives you the first 10 results (0-indexed, exclusive end). | order(publishedAt desc) sorts newest first. The projection shapes the output to include exactly what your frontend needs, nothing more.
You can test all of these queries interactively using the Vision plugin inside Sanity Studio. It's invaluable during development. For more patterns, check the GROQ cheat sheet.
GROQ vs GraphQL
Sanity supports both GROQ and GraphQL. When should you use which?
GROQ is Sanity's native language. It handles joins, projections, and computed fields in a single query string. It's what the Content Lake is optimized for.
GraphQL is available after you deploy your schema (npx sanity@latest schema deploy). Use it when you need standardized tooling, for example, if your frontend already uses Apollo Client or if your team knows GraphQL but not GROQ.
We use GROQ exclusively. It's more expressive for Sanity data, and the Vision plugin makes debugging queries trivial.
Portable Text: Rich Content Done Right
Portable Text is Sanity's specification for structured rich text. Instead of storing content as HTML strings, it stores an array of typed blocks, paragraphs, headings, images, code snippets, tables, each as a JSON object. This makes content renderable in any framework, any platform, any format.
The Data Structure
Here's what a paragraph and a code block look like as Portable Text JSON:
[
{
"_type": "block",
"_key": "a1b2c3",
"style": "normal",
"markDefs": [],
"children": [
{
"_type": "span",
"_key": "d4e5f6",
"text": "Here's an example of our pipeline config:",
"marks": []
}
]
},
{
"_type": "codeBlock",
"_key": "g7h8i9",
"language": "typescript",
"code": "export default defineConfig({ ... })"
}
]Every block has a _type and _key. Standard text blocks use "block" with children spans (which support marks like bold, italic, and links). Custom blocks, like our codeBlock, chartBlock, table, and inlineImage, use their own _type and carry structured fields.
Why does this matter? Because HTML is a rendering format, not a storage format. If you store <h2>Title</h2><p>Some <strong>text</strong></p> in your database, you've locked yourself into web rendering. You can't cleanly extract that for a mobile app, an email newsletter, a PDF, or an AI agent's context window. Portable Text separates content from presentation. The Portable Text spec is open source, it's not a Sanity lock-in.
Custom Blocks in Production
Our pipeline converts Markdown to Portable Text using a Python script (scripts/md_to_portable_text.py). The converter handles standard blocks, plus our four custom types:
table, uses the@sanity/tableplugin schema. Rows and cells stored as structured data.codeBlock, language and code as separate fields, enabling syntax highlighting on render.chartBlock, chart type, title, axis labels, series names, and data points as structured JSON. The frontend renders these with Chart.js.inlineImage, alt text, source, and optional caption as separate fields.
This structure means we can query for all code examples in our blog (*[body[]._type == "codeBlock"]), find posts with charts, or extract all images with missing alt text, all through GROQ.
Rendering Portable Text
On the frontend, use @portabletext/react (or the Svelte/Vue equivalents). You register custom components for each block type:
import { PortableText } from '@portabletext/react'
const components = {
types: {
codeBlock: ({ value }) => (
<pre className={`language-${value.language}`}>
<code>{value.code}</code>
</pre>
),
chartBlock: ({ value }) => <Chart data={value} />,
inlineImage: ({ value }) => (
<figure>
<img src={value.src} alt={value.alt} />
{value.caption && <figcaption>{value.caption}</figcaption>}
</figure>
),
},
}
// In your component:
<PortableText value={post.body} components={components} />That's the full rendering pipeline. The PortableText component handles standard blocks (paragraphs, headings, lists, marks) automatically. You only define custom components for your custom types.
Multilingual Content with Sanity CMS
Sanity supports multilingual content through document-level localization (separate documents per language linked by a canonical reference) or field-level localization (translated fields within one document). Document-level works better for SEO and large-scale publishing, that's what we use across our 10-language pipeline.
Document-Level vs Field-Level Localization
| Aspect | Document-Level | Field-Level |
|---|---|---|
| Approach | Separate document per language | All translations in one document |
| SEO | Each document has its own URL/slug | Single URL, harder to serve per-language pages |
| Query complexity | Simple filters: language == "de" | Nested field access: title.de |
| Content size | Small, focused documents | One large document with all languages |
| Best for | Blog posts, pages, SEO-driven content | Small UI strings, labels, metadata |
| Our verdict | We use this for everything | Only for shared UI strings |
We chose document-level localization because each translation gets its own slug, its own URL, and its own metadata. The Turkish version of a post about Supabase vs Firebase gets the slug supabase-firebase-karsilastirma, proper Turkish, not a URL parameter hack.
Our 10-Language Pipeline Architecture
Here's how our automated pipeline works: we write a post in English, then translate it to 9 additional languages (German, French, Dutch, Spanish, Turkish, Italian, Swedish, Norwegian, Arabic). Each translation goes through Markdown conversion, Portable Text generation, and Sanity API publishing.
The architecture looks like this:
- Write, English Markdown with YAML frontmatter
- Translate, AI translation to 9 languages (verified for completeness and diacritics)
- Convert, Python script converts each
.mdfile to Portable Text JSON - Publish, API calls to Sanity: create document, upload images, patch references
Each document has a language field and a canonicalPost reference pointing to the English original. Here's the GROQ query to fetch a post and all its translations:
// Fetch a post and all its translations
*[_type == "post" && slug.current == "sanity-cms-guide" && language == "en"][0]{
title,
language,
"translations": *[
_type == "post" &&
canonicalPost._ref == ^._id
]{
title,
language,
"slug": slug.current
}
}The schema side is straightforward, a language field with an enum of supported languages:
defineField({
name: 'language',
title: 'Language',
type: 'string',
options: {
list: [
{ title: 'English', value: 'en' },
{ title: 'German', value: 'de' },
{ title: 'French', value: 'fr' },
{ title: 'Dutch', value: 'nl' },
{ title: 'Spanish', value: 'es' },
{ title: 'Turkish', value: 'tr' },
{ title: 'Italian', value: 'it' },
{ title: 'Swedish', value: 'sv' },
{ title: 'Norwegian', value: 'no' },
{ title: 'Arabic', value: 'ar' },
],
},
validation: (Rule) => Rule.required(),
})One gotcha we learned the hard way: publish the English document first, then patch canonicalPost references on translations using the published document ID, not the drafts. prefix. Sanity treats draft and published documents as separate entities internally.
For more details on how this pipeline connects to the Model Context Protocol, see the next section.
Sanity AI Features: MCP, Canvas, and Agent Context
Sanity positions itself as the content operating system for the AI era. Key AI features include an MCP server for AI agents to read and write content, Canvas for AI-assisted editing inside Studio, and Agent Context for production AI agents to query structured content with schema awareness.
MCP Server Integration
The Sanity MCP server lets AI agents, Claude Code, Cursor, Windsurf, and others, interact with your Sanity workspace programmatically. Agents can read schemas, execute GROQ queries, create documents, and manage content without custom API wrappers.
We use the Sanity MCP server daily in our content pipeline. Our AI agents query the schema to understand document structure, fetch existing posts to find internal linking opportunities, and publish new documents. The MCP protocol gives agents schema awareness, they know what fields exist, what types they expect, and what validation rules apply. If you're building AI agents for business workflows, this is a powerful pattern.
Agent Context for Production AI
Agent Context is a separate feature for production-grade AI integrations. Unlike the MCP server (which is designed for developer tools), Agent Context provides read-only, scoped access for AI agents that need to query your content at runtime, think chatbots, recommendation engines, or content personalization systems.
The difference matters: MCP is for build-time and editorial workflows (schema-aware development tools), while Agent Context is for runtime content access with proper authentication and rate limiting.
Sanity's structured content gives it a real advantage here. A WordPress site stores content as HTML blobs, an AI agent has to parse HTML to understand the content. Sanity stores typed JSON documents with defined schemas. An agent can query *[_type == "product" && category == "electronics"]{name, price, features} and get clean, structured data back. No scraping, no parsing, no guessing.
How We Use Sanity at Techsy
This isn't a hypothetical section. We run Sanity CMS across 4 production websites, publishing in 10 languages with an automated pipeline we built over the past year. Here's the architecture.
Our Content Pipeline Architecture
The pipeline goes from research to published post across all 10 languages:
- Research, keyword analysis, competitor gap identification, SERP patterns
- Brief, structured writing spec with section guidance, word counts, internal links
- Write, produce English Markdown with YAML frontmatter
- Convert, Python script transforms Markdown to Portable Text JSON with our 5 custom block types
- Publish, API calls to Sanity:
createOrReplacedocument, upload images to Sanity CDN, patch author/category references - Translate, AI translation to 9 languages, verified for completeness
- Publish translations, same convert/publish flow per language, with
canonicalPostreference patched to English original
The custom schema supports block, table, codeBlock, chartBlock, and inlineImage types, all defined as production Sanity schema objects with validation rules. Among the AI tools for startups we've tested, this Sanity-based pipeline has been the most reliable for structured content at scale.
Lessons from 400+ Published Pieces
A few things we wish someone had told us:
Reference patching order matters. Sanity references can't point to documents that don't exist yet. Publish the English post first, then create translations with canonicalPost pointing to the English document's published ID. We broke this several times early on.
Schema deployment is per-workspace. If you run multiple Sanity projects (we run 4), you need to deploy schemas to each one separately: npx sanity@latest schema deploy per project config.
The free tier is real. We ran two of our four sites on the free plan for months. 20 users, 500K API requests/month, 100K CDN requests, that's enough for a real production site, not just a toy project.
Portable Text conversion is the bottleneck. Markdown to Portable Text isn't trivial. Nested lists, tables inside blockquotes, code blocks with special characters, edge cases everywhere. We've iterated on our converter script for months.
Need help setting up Sanity for your project? We've built multilingual content pipelines for 4 production sites. Get a free consultation
Sanity CMS Pricing Breakdown
Sanity offers three plans: Free (20 users, 500K API requests/month), Growth ($15/user/month with advanced roles and scheduled drafts), and Enterprise (custom pricing with SLA and compliance features). The free tier is the most generous in the headless CMS market.
| Feature | Free | Growth ($15/user/mo) | Enterprise |
|---|---|---|---|
| Users | 20 | 50 | Unlimited |
| API requests | 500K/month | 2.5M/month | Custom |
| CDN requests | 100K/month | 500K/month | Custom |
| Roles | Admin only | Admin, Developer, Editor, Contributor | Custom roles |
| Collaboration | Real-time editing | + Scheduled publishing, drafts | + Workflows |
| Support | Community | Dedicated + SLA | |
| Compliance | , | , | SOC 2, HIPAA |
On the free tier, we run two of our sites without hitting limits. The Growth plan at $15/user/month added role-based access (important once we had non-technical editors) and scheduled publishing. Viewers are free on Growth, which is a nice touch, you're not penalized for giving stakeholders read access.
How does this compare to competitors?
| Feature | Sanity Free | Contentful Free | Strapi Cloud Free | Payload Cloud |
|---|---|---|---|---|
| Users | 20 | 1 | 1 | 1 |
| Content types | Unlimited | 48 | Unlimited | Unlimited |
| API calls | 500K/mo | Included | Included | Included |
| Custom types | Yes | Limited | Yes | Yes |
| Price to grow | $15/user/mo | $300/mo | $29/mo | $50/mo |
Sanity's 20-user free tier is exceptional. Contentful limits you to 1 user on free and jumps to $300/month for their Team plan. If you're a startup or small team, Sanity's free plan lets you run real production workloads without spending anything.
Sanity also offers a startup program that gives eligible startups one year of free Growth access. Worth applying for if you qualify.
Frequently Asked Questions
What is Sanity CMS and how does it work?
Sanity CMS is a headless content platform that stores structured JSON documents in a managed backend called the Content Lake. You edit content through Sanity Studio (a customizable React app), query it with GROQ or GraphQL, and render it in any frontend framework. Content syncs in real time across all connected clients.
Is Sanity CMS free?
Yes. Sanity's free tier includes 20 users, 500K API requests per month, and 100K CDN requests, the most generous free plan among headless CMS platforms. The Growth plan costs $15 per user per month and adds role-based access, scheduled publishing, and higher limits. Enterprise pricing is custom.
What is the difference between Sanity and Contentful?
Sanity uses schema-as-code (schemas live in your codebase), GROQ for querying, and a fully customizable open-source Studio. Contentful uses GUI-based content modeling, GraphQL, and a hosted editor with less customization. Sanity's free tier includes 20 users versus Contentful's 1. Contentful has a larger plugin marketplace.
Is Sanity CMS good for beginners?
Sanity Studio is intuitive for content editors, the editing experience requires no technical knowledge. However, setting up schemas requires JavaScript or TypeScript proficiency. Sanity provides excellent documentation, project templates, and a community Slack with active support. Start with npm create sanity@latest and a blog template.
Can I self-host Sanity?
Sanity Studio is fully self-hostable because it's an open-source React application. You can deploy it to Vercel, Netlify, or any static hosting provider. The Content Lake backend is a managed service, there is no self-hosting option for the data layer. This is a tradeoff: you get zero infrastructure management but no on-premises data control.
What type of database does Sanity use?
Sanity's Content Lake is not a traditional SQL or NoSQL database. It's a managed document store that stores content as structured JSON with a GROQ query layer on top. You don't interact with the underlying database directly, you interact through Sanity's APIs. Documents have full version history and real-time sync built in.
Is Sanity CMS open source?
Sanity Studio is open source under the MIT license, you can fork it, customize it, and self-host it. The Content Lake backend is proprietary SaaS. The GROQ query language specification is also open source, published on GitHub. The Portable Text specification is open source as well, maintained at portabletext.org.
What is Portable Text in Sanity?
Portable Text is Sanity's specification for structured rich text. Instead of storing content as HTML strings, it represents paragraphs, headings, images, and custom blocks as typed JSON objects in an array. This makes content portable across frameworks and platforms. You can define custom block types like code snippets, charts, and tables with their own structured fields.
What is GROQ and how is it different from GraphQL?
GROQ (Graph-Relational Object Queries) is Sanity's native query language. Its syntax, *[filter]{projection}, is more concise than GraphQL for Sanity data, with built-in support for joins via the -> operator and computed fields. GraphQL is also available for teams that prefer standardized tooling or already use Apollo Client.
How does Sanity handle multilingual content?
Sanity supports document-level localization (separate documents per language linked by canonical references) and field-level localization (translated fields within one document). Document-level is better for SEO because each translation gets its own URL and metadata. We use document-level localization to publish across 10 languages with automated translation and publishing pipelines.