Type something to search...
Sanity Content Lake Explained

Sanity Content Lake Explained

The Sanity Content Lake is the hosted, real-time database where all content in a Sanity project is stored. It holds your content as JSON documents inside one or more datasets, keeps drafts and published versions side by side, records every change, and exposes everything through APIs for querying (GROQ or GraphQL), writing (mutations and actions), listening for changes and serving assets. You never install or scale it yourself. Sanity Studio writes to it as editors type, and your website reads from it through api.sanity.io or the cached apicdn.sanity.io.

Understanding the Content Lake explains a lot of Sanity behaviour that otherwise seems surprising, such as why there's no save button, why drafts don't appear on your site and why documents can hold fields that aren't in your schema. This article covers projects and datasets, the anatomy of a document, IDs and paths, drafts and versions, perspectives, the APIs for reading and writing, the API CDN, real-time features, assets, security and moving data in and out.

Where the Content Lake Fits

A Sanity project has three layers:

LayerWhat it isWho runs it
Sanity StudioThe editing appYou configure it; you or Sanity host it
Content LakeThe datastore and APIsSanity
Your front endWebsite, app or anything elseYou

The Studio and your front end are both just clients of the Content Lake. That's why you can replace either without touching the other, and why scripts, migrations and other apps can work with the same content. For the bigger picture, see what is Sanity.

Projects and Datasets

Every Sanity project has an ID, such as abc123xy, and contains one or more datasets. A dataset is a self-contained collection of documents. You can think of it as a database, with document types playing the role of tables.

Common reasons to use more than one dataset:

  • Environments: production, staging and development with the same schema.
  • Separate content: different sites or regions that share users and billing.
  • Safe experiments: try a migration on a copy before running it on production.

Queries and references work within a dataset, not across them (cross-dataset references are a separate, plan-dependent feature). Dataset names must be 1 to 64 characters of lowercase letters, numbers, hyphens and underscores, and must start and end with a letter or number.

npx sanity datasets list
npx sanity datasets create staging

Public vs Private Datasets

  • Public: anyone can query published documents without a token. This is the default and is fine for website content.
  • Private: every read needs a valid token. Private datasets are a paid feature.

An unauthenticated query to a private dataset doesn't return an error. It returns an empty result in the expected shape, such as [], which is a common source of confusion. Also note that image and file assets are served publicly from the asset CDN even when the dataset is private, though their URLs contain a content hash and aren't guessable.

The Anatomy of a Document

A document is a JSON object. The Content Lake only requires a handful of system fields, and everything else comes from your schema:

{
  "_id": "200e44f2-14a9-4c7a-a621-a4ca4d9b559c",
  "_type": "author",
  "_rev": "DOcXr0KgQH6faBzvMdKRNc",
  "_createdAt": "2026-10-25T15:03:54Z",
  "_updatedAt": "2026-11-02T20:44:48Z",
  "name": "Sajjad",
  "bio": [ ... ]
}
FieldMeaning
_idUnique ID within the dataset. Can't be changed after creation
_typeThe document type, taken from the schema type's name
_revRevision ID, which changes on every write. Useful for optimistic locking
_createdAtWhen the document was first created
_updatedAtWhen it was last written to, by anyone or anything

One important point: the Content Lake doesn't know about your schema. The schema lives in the Studio and shapes the editing forms. The datastore accepts any valid JSON with an _id and _type. That means a script can write fields the Studio doesn't show, and removing a field from the schema doesn't delete the data. Content modelling is covered in content modelling best practices.

Don't Rely on _updatedAt as a Publish Date

_updatedAt changes on every write, including script edits and migrations, so it isn't a reliable "published on" date. Add your own publishedAt datetime field and fall back gracefully in queries:

*[_type == "post"] | order(coalesce(publishedAt, _createdAt) desc)

IDs and Paths

Document IDs can be up to 128 characters using a-z, A-Z, 0-9, ., _ and -. They can't start with a dash or contain two consecutive dots. The Studio generates random UUIDs, but you can set your own ID when creating documents through the API, such as siteSettings for a singleton.

Dots turn an ID into a path. Sanity uses this for internal data:

  • drafts.<id> holds the draft of a document.
  • versions.<releaseId>.<id> holds a version of a document in a content release.

Unauthenticated users can only read documents at the root path, so anything with a dot in its ID is private by default. That's how drafts stay hidden from public queries. GROQ's path() function lets you target these documents when you're authenticated:

*[_id in path("drafts.**")]{ _id, _type }

Avoid putting personal or sensitive data in IDs. Every ID ever written to a dataset is retained until the dataset is deleted.

Drafts, Versions and Publishing

When an editor changes a published document in the Studio, the Content Lake stores a separate draft:

DocumentIDVisible to public queries
Publishedpost-123Yes
Draftdrafts.post-123No
Release versionversions.rSpring.post-123No

A document has at most one draft and one version per release. Publishing copies the draft's content to the published ID and removes the draft. Until then, your website keeps seeing the published version. This is why the Studio can autosave every keystroke safely: autosaving only ever touches the draft.

You can turn drafts off for the whole Studio, or make individual types write directly to the published document with liveEdit: true in the schema. That suits things like settings or comments where a review step isn't needed.

Perspectives

Because drafts and versions are separate documents, a naive query for *[_type == "post"] could return duplicates. Perspectives solve this by deciding which version of each document a query sees:

PerspectiveReturns
publishedOnly published documents. The default from API version 2025-02-19
draftsDrafts where they exist, otherwise the published version. Used for previews
rawEverything side by side, including drafts and versions. Requires a token
Release IDsA stack of releases layered over published content

Set the perspective on the client:

import { createClient } from '@sanity/client'

const config = {
  projectId: 'abc123xy',
  dataset: 'production',
  apiVersion: '2026-10-01',
}

// Public site: cached, published only
export const client = createClient({ ...config, useCdn: true, perspective: 'published' })

// Preview: uncached, drafts overlaid, needs a read token
export const previewClient = createClient({
  ...config,
  useCdn: false,
  perspective: 'drafts',
  token: process.env.SANITY_API_READ_TOKEN,
})

If you're on an older apiVersion (before 2025-02-19), the default is raw, which is why older code often filtered out drafts manually with !(_id in path("drafts.**")).

Reading Data: GROQ and GraphQL

The main way to read from the Content Lake is GROQ, sent to the query endpoint:

https://abc123xy.api.sanity.io/v2026-10-01/data/query/production?query=*[_type == "post"]{title}

In practice you'll use a client library rather than raw URLs:

const posts = await client.fetch(
  `*[_type == "post" && author._ref == $authorId]{ title, "slug": slug.current }`,
  { authorId: 'author-sajjad' },
)

Parameters like $authorId keep queries safe and cacheable. The GROQ parameters post explains them, and what is GROQ introduces the language. A GraphQL API is also available, but you have to deploy it from your schema with sanity graphql deploy, and it reflects the schema at deploy time. For a comparison, see GROQ vs GraphQL.

Writing Data: Mutations and Transactions

Writes go through the Mutations API (and the newer Actions API, which the Studio uses for operations like publishing). With @sanity/client and a token that has write access:

import { createClient } from '@sanity/client'

const writeClient = createClient({
  projectId: 'abc123xy',
  dataset: 'production',
  apiVersion: '2026-10-01',
  useCdn: false,
  token: process.env.SANITY_API_WRITE_TOKEN,
})

// Create a document with a known ID
await writeClient.createOrReplace({
  _id: 'author-sajjad',
  _type: 'author',
  name: 'Sajjad',
})

// Patch specific fields
await writeClient
  .patch('author-sajjad')
  .set({ role: 'Editor' })
  .setIfMissing({ tags: [] })
  .append('tags', ['sanity'])
  .commit()

// Several changes that succeed or fail together
await writeClient
  .transaction()
  .create({ _type: 'category', title: 'CMS' })
  .patch('post-123', (p) => p.set({ featured: true }))
  .delete('post-old')
  .commit()

Mutations are atomic within a transaction, and each write produces a new _rev. Never ship a write token to the browser. Submit user-generated content through a server route or function that validates input first.

The API CDN

Every project has two read endpoints:

  • api.sanity.io: uncached, always fresh. Required for drafts, authenticated previews and reading right after a write.
  • apicdn.sanity.io: cached at the edge, faster and cheaper. Selected with useCdn: true.

The CDN caches GET requests and read-only POST queries, including authenticated ones (cached per token). It doesn't cache mutations, listeners, non-200 responses or very large responses. If the Content Lake is unreachable, the CDN can keep serving the last cached content for a while. Because CDN requests have a much larger allowance on every plan, using it for public traffic is the easiest way to stay within quotas. The Sanity pricing guide lists the numbers.

Real-Time: Listeners and the Live Content API

The Content Lake pushes changes as they happen. That's what powers live collaboration in the Studio, and you can use it too.

  • Listeners: client.listen(query) streams mutation events for matching documents. Good for scripts and internal tools.
  • Live Content API: designed for websites at scale. Your front end receives lightweight sync tags when content it depends on changes, then refetches only what's needed. It's available on all plans and next-sanity integrates it with Next.js caching.
const subscription = client
  .listen(`*[_type == "post"]`)
  .subscribe((event) => {
    console.log(event.transition, event.documentId)
  })

Assets

Images and files are stored as special asset documents (sanity.imageAsset and sanity.fileAsset) with metadata such as dimensions, a colour palette, a blur placeholder and the original filename. The binary is served from cdn.sanity.io, and images can be transformed on the fly with URL parameters for width, format, crop and quality. Your documents reference assets rather than embedding them. The Sanity images post explains the image pipeline.

History, Exports and Backups

Every transaction is recorded, so the Studio can show document history and restore earlier versions. How long that history is kept depends on your plan.

To take your content elsewhere or make a backup, export a dataset with the CLI:

npx sanity datasets export production production-backup.tar.gz

The archive is a gzipped tarball with an NDJSON file of documents plus asset binaries. Import it into another dataset with:

npx sanity datasets import production-backup.tar.gz --dataset staging

Exports count against your API request quota. These and other commands are covered in essential Sanity CLI commands.


FAQ: Sanity Content Lake

Yes, in practice. It's a hosted document store that holds JSON documents in datasets and provides query, mutation, real-time and asset APIs on top. You don't manage servers, indexes or scaling yourself.

No. The Content Lake is a hosted service run by Sanity. Sanity Studio is open source and can be self-hosted, but the datastore itself can't. You can export datasets at any time if you need a copy of your data.

Drafts are stored under the drafts path, which public queries can't read, and the default perspective for current API versions is published. To see drafts, use an authenticated client with the drafts perspective and useCdn set to false.

No. The schema lives in Sanity Studio and controls the editing forms and validation there. The Content Lake accepts any JSON document with an ID and type, so scripts can write data that doesn't match the schema.

api.sanity.io is the live, uncached API that always returns the latest data. apicdn.sanity.io is a cached edge layer that is faster and cheaper for read-heavy public traffic. Most clients switch between them with the useCdn option.

Run npx sanity datasets export with a dataset name and output file. The archive includes all documents and asset files, and you can restore it into any dataset with the datasets import command.


Conclusion

The Content Lake is the part of Sanity you never see but always use. It stores your content as schema-less JSON documents in datasets, keeps drafts and release versions under separate ID paths, uses perspectives to decide what each query sees, and offers APIs for querying, writing, listening and serving assets, with a CDN in front for public traffic.

Once you understand that the Studio and your website are both just clients of this datastore, most of Sanity's design makes sense. Pick the right perspective, use the CDN for public reads, keep write tokens on the server and export your datasets regularly, and the Content Lake will quietly do the rest.

Tags :
Share :

Related Posts

Building a Static Site With Astro and Sanity

Building a Static Site With Astro and Sanity

To build a static site with Astro and Sanity, keep Astro's default output: 'static', configure @sanity/astro with useCdn: false, and fetch

Dive Deeper
Common Sanity Mistakes and How to Fix Them

Common Sanity Mistakes and How to Fix Them

The most common Sanity mistakes are using the CDN where you need fresh data, fetching whole documents with ..., leaving slugs unvalidated, ship

Dive Deeper
How to Create a Custom Sanity Plugin

How to Create a Custom Sanity Plugin

To create a custom Sanity plugin, wrap a piece of Studio configuration in definePlugin from the sanity package and add it to the plugins ar

Dive Deeper