Sanity, or content stored as data
Most content management systems think in pages: you have a page, it has a title, body, and an image. Sanity thinks in documents of arbitrary structure that can be queried, related to each other, and used in several places at once.
That difference sounds theoretical and carries very practical consequences. A product description stored as data reaches a website, a mobile application, a feed for a comparison service, and a message sent to a customer, each time in a different form, with nothing copied.
The editorial panel here is an application you run yourself and configure through code. The content lives in a service the vendor manages, and you connect to it from anywhere.
A schema described in code
You define content structure in files rather than by clicking through an interface. That sets this system apart from most competitors and brings the ordinary advantages of working with code: change review, history, and the ability to recreate everything in another environment.
import { defineType, defineField } from 'sanity'
export const article = defineType({
name: 'article',
title: 'Article',
type: 'document',
fields: [
defineField({ name: 'title', type: 'string', validation: (r) => r.required() }),
defineField({ name: 'slug', type: 'slug', options: { source: 'title' } }),
defineField({ name: 'body', type: 'array', of: [{ type: 'block' }] }),
defineField({ name: 'author', type: 'reference', to: [{ type: 'person' }] }),
defineField({ name: 'publishedAt', type: 'datetime' }),
],
})The editorial panel arises from that description automatically and can be changed freely: adding custom views, rearranging fields, attaching previews and actions. That is rare, since in most systems the panel is what it is.
The advantage carries a price worth naming. Changing the content structure requires editing code and deploying the panel, so an editorial team cannot add a field for itself. With a team lacking technical support that is a problem; with a team including developers it is an advantage, since the content model does not drift after six months of clicking.
Its own query language
Rather than fetching a whole document and processing it in the application, you describe exactly what you need and receive a finished shape.
*[_type == "article" && publishedAt < now()] | order(publishedAt desc) [0...10] {
title,
"slug": slug.current,
"author": author->name,
"commentCount": count(*[_type == "comment" && article._ref == ^._id])
}That single query does four things: filters by type and date, sorts, limits the result count, and expands the author relation, while counting comments through a subquery. The result arrives in exactly the shape you described, so the application does no processing.
Reshaping data in the query rather than in code carries concrete performance value. Instead of fetching five fields and using one, you fetch one.
In code the call looks ordinary, and two things in the client configuration decide whether the page runs fast and whether it shows the right version of the content.
import { createClient } from '@sanity/client'
export const sanity = createClient({
projectId: process.env.SANITY_PROJECT_ID!,
dataset: 'production',
apiVersion: '2026-08-01',
useCdn: true
})
const articles = await sanity.fetch<Article[]>(QUERY, { limit: 10 })The API version given as a date freezes query behaviour, so a change on the vendor side will not alter your code's results without your decision. Enable the content delivery network everywhere except draft previews, since there you need fresh data rather than fast data.
The syntax is unusual and can frustrate for the first day, particularly around the arrows expanding relations and references to the parent document inside subqueries.
Pass variable values as parameters rather than by string concatenation. That is not only a readability matter.
const QUERY = `*[_type == "article" && category->slug.current == $category]
| order(publishedAt desc) [0...$limit] { title, "slug": slug.current }`
const result = await sanity.fetch(QUERY, {
category: routeParams.category,
limit: 10
})Inserting a user supplied value straight into the query text lets it change the meaning of the whole expression, exactly as with queries against a relational database. Parameters settle that once and let the query itself be cached along the way. After that first day it proves more convenient than the alternatives, since it lets you write in one query what elsewhere takes three.
An interface in a more widely known query language is available too, while being secondary to the native one and not covering everything it can do.
Formatted text as data
Editorial content is not stored here as page markup but as a structure describing paragraphs, styles, and embedded elements. That decision looks like a complication at first and solves several real problems.
The same text renders on a website, in a mobile application, and in an email, differently each time. There is no risk that markup pasted by an editor breaks the layout or lets something dangerous through, since it is not page markup.
Embedded elements can be arbitrary objects. A code block, a gallery, a pull quote, a product card: each is an entry of a defined type that you render with your own component.
The price is having to write a rendering layer. Ready libraries cover most cases, while every custom embedded element type needs its own component.
import { PortableText } from '@portabletext/react'
const components = {
types: {
codeBlock: ({ value }) => (
<pre><code className={value.language}>{value.code}</code></pre>
),
image: ({ value }) => (
<img src={urlForImage(value).width(800).url()} alt={value.alt ?? ''} />
)
},
marks: {
link: ({ value, children }) => (
<a href={value.href} rel="noreferrer">{children}</a>
)
}
}
<PortableText value={article.body} components={components} />That is worth remembering when designing the content model. Fifteen kinds of block means fifteen components to write and maintain, and the editorial team usually uses four.
Pricing and traps
The free tier here is broad and covers a few team members, tens of thousands of documents, and one dataset. For a blog, a portfolio, and a small site that suffices for a long time.
Above the tier, plans differ in member counts, dataset counts, and traffic limits. Specific rates differ between sources and change over time, so check the vendor's current price list before budgeting.
Three things can surprise you on the bill and deserve knowing in advance.
The first is content delivery traffic. Images served from their service count towards the limit, and on an image heavy site that line grows faster than the document count.
The second is queries. An application querying the service on every user request consumes considerably more than one generating pages statically and refreshing them hourly. Working with Next.js, use caching and incremental regeneration rather than querying on every visit.
The third is datasets. A separate dataset for a staging environment looks innocent and is often counted as another line in the plan.
Live collaboration and drafts
Two features that decide comfort in team work and are easy to overlook on a prototype.
The panel works in real time, so two people editing the same document see each other's changes as they happen, without locking and without conflict messages. That solves an editorial team's most irritating problem: somebody's changes vanishing because another person saved an older version.
Drafts exist alongside published documents. A document has a published state visible to readers and a draft state visible only in the panel, so preparing changes does not affect what users see.
Use that rather than your own field marking publication, since the built in mechanism also supports previewing. An application querying the service with the right permission can show the draft state, giving editors a preview of changes on the real site before publishing.
Change history is recorded automatically, so returning to an earlier version of a document is one click. How much history is retained depends on the plan, worth remembering if industry requirements mandate keeping a change record for a set period.
Images and files
The media layer here is stronger than the description suggests and deserves configuring before the editorial team uploads its first five hundred photos.
Images stored in the service can be transformed through address parameters: size, crop, format, and quality. That means you do not generate variants at upload time but request the right size at display time, and the service returns and remembers it.
A focal point selector is built in too. Editors mark what matters most in a photo, and cropping to different aspect ratios preserves that point rather than cutting the image down the middle. On portraits and products that small thing saves a great many corrections.
Make the alternative text field required from the start. It is one of the few things that cannot be added later in bulk, since the description must be written by a person looking at the photo, and the backlog grows faster than anyone clears it.
The cost caveat returns here: images served from their content delivery network count towards the traffic limit, so on an image heavy site consider your own intermediate layer, or at minimum choose sizes deliberately rather than sending full photos to fill thumbnails.
Sanity against the alternatives
| Option | Strength | Weakness | Pick it when |
|---|---|---|---|
| Sanity | Content as data, its own query language, a configurable panel | Model changes need a developer | Content used in several places at once |
| Payload | Panel inside your application, configuration in code | Deployment is yours | A Next.js project with a developer team |
| Strapi | Model built in the panel, no developer needed | A separate service to maintain | A content model changed by editors |
| Contentful | Maturity, support for large organisations | High price, a more rigid model | An enterprise with compliance requirements |
The first row wins when the same content reaches several places in different forms, since storing it as data rather than as a page repays itself immediately then.
It loses on a simple blog, where content has one destination and the editorial team needs an editor that simply works. A simpler option then saves work on rendering and on the content model.
Deploying the panel and environments
The panel is a separate application, so you must decide where and how it runs. That decision is easy to postpone and hard to reverse afterwards.
The simplest route is hosting with the vendor at an address in their domain. You deploy nothing, the panel updates itself, and the editorial team gets a working address the same day.
The second route places the panel inside your own application, under a path in your domain. It requires deploying alongside the rest of the project and gives, in return, one address for everything plus the option of attaching your own authentication and access rules.
Datasets are a separate decision worth making right away. A separate dataset for a staging environment allows experimenting without risk while counting towards the plan's limit. A middle option works on one production dataset using drafts, which suffices for a small team.
Migrating content between datasets is handled by a command line tool exporting and importing everything including files. That is good news when setting up a staging environment and bad news when moving production, since exporting a large dataset means hours of work and considerable disk space.
Plan backups too. The service is managed and has its own mechanisms, while a periodic export kept on your side protects against accidental deletion at your end, which no vendor backup will undo for you.
Common mistakes
The first is querying the service on every user request. Caching and static generation cut consumption many times over, and with content changing a few times a day they cost nothing in freshness.
The second is fetching whole documents instead of selected fields. The query language lets you state the result's shape, so fetching five fields to use one is needless transfer.
The third is an over rich content model. Fifteen kinds of embedded block means fifteen components to write and maintain, and the editorial team uses four anyway.
The fourth is promising the editorial team they can change the content structure themselves. The model is defined in code, so every change needs a developer and a panel deployment.
The fifth is overlooking image traffic costs. On an image heavy site that line can exceed everything else combined.
The sixth is keeping draft content in the same dataset as production without distinguishing its state. A drafts mechanism exists and deserves using instead of an extra field marking publication.
The seventh is having no export of your own. The service is managed and has its own mechanisms, but an accidental deletion on your side is reversible only from a copy you hold.
FAQ
Is Sanity free?
Up to a point: the free tier covers a few team members, tens of thousands of documents, and one dataset, which for a blog and a small site suffices for a long time. Above the tier, plans differ in member counts and traffic limits, and rates deserve checking on the current price list.
What is this custom query language?
A way of describing exactly what you need, with filtering, sorting, relation expansion, and subqueries in one call. The result arrives in the shape you specified, so the application does no data processing.
Why is content not stored as page markup?
Because the same content should reach many places in different forms: a website, an application, and a message. A structure describing paragraphs and embedded elements lets you render it differently in each, at the cost of writing a rendering layer.
Can an editorial team manage without a developer?
For day to day work yes, since the panel is complete and approachable. Changing the content structure requires editing code and deploying, so a team without technical support is often better served by Strapi or another option with a model built in the interface.
How do I keep costs down?
Through three things: generating pages statically rather than querying on every request, fetching only the fields you need, and deciding deliberately how many datasets you keep and where images are served from.
Documentation sits on the project site, and the query language in a separate documentation section.