> For AI agents: the complete Sanity documentation index is available at [https://www.sanity.io/docs/llms.txt](https://www.sanity.io/docs/llms.txt).

# Sync tags

How Content Lake generates sync tags for each query, the limit on tags per response, and how to write queries that keep live updates precise.

Sync tags are opaque identifiers that Content Lake attaches to every query response to describe which content the response depends on. When content changes, the [Live Content API](https://www.sanity.io/docs/content-lake/live-content-api) emits the sync tags for that change, and any client or cache holding a response with a matching tag refetches it.

How specific a response's sync tags are decides how often it refetches. A response tagged with one document ID refetches when that document changes. A response tagged with a whole document type, or with the whole dataset, refetches on every change in that scope. This article explains how Content Lake derives sync tags from a query, the limit on how many a response can carry, and how to write queries that produce precise tags.

## Get the sync tags for a query

Query responses include a `syncTags` array on API version `v2021-03-25` or later. With `@sanity/client`, set `filterResponse: false` to receive the tags alongside the result:

```typescript
import {createClient} from '@sanity/client'

const client = createClient({
  projectId: 'YOUR_PROJECT_ID',
  dataset: 'production',
  apiVersion: '2026-03-01',
  useCdn: true,
})

const {result, syncTags} = await client.fetch(
  `*[_type == "post" && slug.current == $slug][0]`,
  {slug: 'hello-world'},
  {filterResponse: false},
)
```

Treat each tag as an opaque string. Tags are hashed per project and dataset, so you can compare them for equality, but you can't read a document ID or type back out of one. next-sanity prefixes each tag with `sanity:` and passes it to Next.js as a cache tag: through `cacheTag` when Cache Components is enabled, and through the `next.tags` fetch option otherwise. Next.js stores the tags with the cached response and decides when to invalidate it.

## How Content Lake generates sync tags

Content Lake tries to give each response the narrowest set of tags that still catches every change that could affect it. Narrower tags mean fewer refetches and a higher cache hit rate. To find them, Content Lake looks at how the query selects documents and predicts which documents would change the result if they were created, edited, or deleted. Three kinds of filter let it name those documents by value:

- `_id == $id` or `_id in $ids`: one tag per document ID.
- `slug.current == $slug` or `slug.current in $slugs`: one tag per slug value. Slugs are usually unique, so Content Lake treats them like document IDs. This applies to any field path that ends in `slug.current`, such as `store.slug.current`.
- `_type == "post"` or `_type in ["post", "author"]`: one tag per document type. A change to any document of that type matches it, so a type tag is much broader than an ID or slug tag.

Filters on anything else can't produce a value-specific tag. That includes your own fields, `references()`, `match`, and `defined()`. If the same filter also checks `_type`, the response gets the type tag. If it doesn't, the response gets a dataset-wide tag that matches every change in the dataset. A query with no filter at all, like `*[0]`, also gets the dataset-wide tag.

When a filter combines conditions with `&&`, the response keeps the most specific tag: a document ID wins over a slug, and a slug wins over a type. When a filter combines conditions with `||`, the response gets the tags from both sides.

Every part of a query that reads documents contributes tags. Each subquery adds the tags for its own filter, and each reference you follow with `->` adds a tag for the referenced document's ID:

```groq
// One tag for the post's ID, plus one tag per referenced author
*[_type == "post" && _id == $id][0]{
  title,
  "authors": authors[]->{name}
}
```

Tags come from the values in the query, not from the documents it finds. `*[slug.current == "hello-world"]` gets the tag for the slug `hello-world` even when no document has that slug yet, and the response refetches only when a document with that slug is created, edited, or deleted. References work the same way: following a reference with `->` looks up its `_ref` as a document ID, so the response gets a tag for that ID even when the document doesn't exist yet, such as a weak reference to a document that hasn't been published.

Perspectives add tags. Under the `drafts` perspective, each document ID produces two tags: one for the published ID and one for the draft ID. Under a release perspective, each ID also produces one tag per release in the perspective, plus one for the draft ID when the perspective includes drafts.

## The sync tag limit

A query response carries fewer than 100 sync tags. Every value-specific tag counts toward the limit, including the type tag from a `_type` filter and the extra tags that perspectives add. When a query would need 100 or more tags, Content Lake replaces the precise tags with broader ones:

- If the filter checks `_type`, the response collapses to the type tag.
- If it doesn't, the response collapses to type tags for the documents it read where Content Lake can determine them, and otherwise to the dataset-wide tag.

For example, `*[_id in $ids]` with 99 IDs gets 99 tags, and with 100 IDs it collapses. Adding `_type == "post" &&` to the filter adds one tag, so the query collapses to the `post` type tag at 99 IDs. Under the `drafts` perspective, each ID counts twice, so `*[_id in $ids]` collapses at 50 IDs.

A collapsed response still updates correctly, but it refetches on every change to any document of that type, or anywhere in the dataset.

## Write queries that produce precise sync tags

Use these patterns to keep each response's sync tags narrow and under the limit:

- Filter by `_type` in every query. It costs one tag, and it guarantees that a long `_id in` list that goes over the limit falls back to a type tag rather than the dataset-wide tag.
- Select documents by `_id` or `slug.current` where you can. For a list of known documents, `_type == "post" && _id in $ids` is the most precise shape.
- Keep lists of IDs under the limit. Count the type tag, and count two tags per ID under the `drafts` perspective.
- Fetch broad content in its own query. A "latest posts" list like `*[_type == "post"] | order(_createdAt desc)[0...10]` gets the `post` type tag, so it refetches on every post change. Fetched in the same query as a single post, it makes the whole response refetch on every post change. Splitting queries sends more requests, so weigh the trade-off described in [High-performance GROQ](https://www.sanity.io/docs/developer-guides/high-performance-groq).

## How sync tags affect API usage

Live Content API connections don't request data. Holding a connection open, for example by rendering `<SanityLive>` in a Next.js root layout, counts toward Live Content API usage but doesn't send queries on its own. The refetches that matching sync tags trigger are ordinary queries, and they count toward your API request usage.

Two things decide how many refetches a change causes:

- How many cached responses carry a matching tag. A type tag matches every cached response for that type, and the dataset-wide tag matches every response that carries it, whatever changed.
- Where the refetch happens. With next-sanity, revalidation happens in the Next.js cache on your server, so the number of refetches follows the number of affected cache entries. See [Caching and revalidation in Next.js](https://www.sanity.io/docs/nextjs/caching-and-revalidation-in-nextjs).

Sync tag events come from any write to a document, not only from publishing in Sanity Studio. Imports, migrations, and scripts that write to published documents emit events like any other edit, and every cached response with a matching tag refetches.

> [!WARNING]
> On Next.js 16, next-sanity v12 can multiply request volume when `<SanityLive>` is rendered. next-sanity v13 changes the default invalidation behavior to avoid this. See [Next.js 16 and SanityLive: avoiding request overages](https://www.sanity.io/docs/help/nextjs-16-sanitylive-status).

## Reduce refetch spikes from large imports and migrations

A bulk write to a production dataset emits sync tag events for every document it changes, and responses with type or dataset-wide tags refetch for each of them. To soften the spike:

- Use next-sanity v13 or later. Its `<SanityLive>` revalidates with a stale-while-revalidate profile by default, so invalidated entries refetch when they're next requested instead of all at once. See [Add live content to your application](https://www.sanity.io/docs/developer-guides/live-content-guide).
- Run large scripts off-peak, when fewer visitors request the invalidated entries.
- Centralize invalidation with a [Sync Tag Invalidate Function](https://www.sanity.io/docs/functions/sync-tag-function-quickstart) and set `waitFor="function"` on `<SanityLive>` (next-sanity ignores `waitFor` when `includeDrafts` is enabled). The function must call `done()` for every event. Until it does, clients that wait on the function don't receive the change.

