Sanity GROQ: can you help me write queries for references, localization, and filtering by published vs draft?
Headless CMS & Content Platforms

Sanity GROQ: can you help me write queries for references, localization, and filtering by published vs draft?

9 min read

Most of the power of Sanity’s content operating system shows up when you start querying content-as-data with GROQ: resolving references, handling localization, and controlling whether you see drafts, published documents, or both. This FAQ walks through the most common patterns and gives you copy‑pasteable queries you can adapt to your own schema.

Quick Answer: Use GROQ’s -> operator to resolve references, structured fields for localization (e.g. title[lang] or translated document types), and the _id / _type system fields (with filters like !(_id in path("drafts.**"))) to control draft vs published results.

Frequently Asked Questions

How do I query and expand references in GROQ?

Short Answer: Use the -> operator to dereference a single reference, and []-> to resolve references inside arrays; select only the fields you need for each referenced document.

Expanded Explanation:
In Sanity, references are just JSON objects pointing at another document by _ref. GROQ’s -> operator follows that pointer and returns the referenced document. You can chain projections to shape the response so each experience (web, mobile, agents) receives exactly what it needs.

For arrays, you can project each item and conditionally dereference only those that are references. You can also filter documents by whether they reference another document using the references() function.

Key Takeaways:

  • Use myRefField-> to expand a single reference.
  • Use myArray[]-> (or conditional logic) to expand references inside arrays.

Examples:

Single reference:

*[_type == "post"]{
  _id,
  title,
  // expand the author reference
  author->{
    _id,
    name,
    slug
  }
}

Array of references:

*[_type == "post"]{
  _id,
  title,
  categories[]->{
    _id,
    title,
    slug
  }
}

Mixed array (some items are inline objects, some are references):

*[_type == "post"]{
  _id,
  title,
  content[]{
    // if this is a reference, expand it
    ...@->{
      _type,
      _id,
      title
    },
    // otherwise just return the object as-is
    ...{
      _type,
      ...
    }
  }
}

Filter by reference (all posts that reference a given category):

*[_type == "post" && references($categoryId)]{
  _id,
  title
}

How do I query localized fields or documents with GROQ?

Short Answer: Decide whether localization lives in a single document (fields per locale) or in separate translated documents, then use bracket access (field[$lang]) or filters (language == $lang) to select the correct variant.

Expanded Explanation:
Sanity doesn’t prescribe a single localization pattern; instead, you model what matches your content operations, then query accordingly. The two most common structures are:

  1. Field-based localization – one document holds multiple language values in a single field, often as an object keyed by locale code.
  2. Document-based localization – each language has its own document, often linked through a shared key or reference.

GROQ makes both straightforward, and because schemas live in code, you can test these patterns locally before rolling them into production.

Steps:

  1. Identify your localization model: field-based or document-based (or hybrids, like localized fields + localized references).
  2. Expose the target locale: send $lang from your frontend / agent (e.g. en, de, fr).
  3. Write locale-aware queries:
    • Field-based: use field[$lang] or default fallbacks.
    • Document-based: filter on language or i18n_lang and group with the shared key.

Examples:

  1. Field-based localization (e.g. title object keyed by locale):

Schema (simplified):

defineField({
  name: 'title',
  type: 'object',
  fields: [
    {name: 'en', type: 'string'},
    {name: 'de', type: 'string'},
  ],
})

Query for a single locale:

*[_type == "post" && slug.current == $slug][0]{
  _id,
  "title": coalesce(title[$lang], title.en),
  "description": coalesce(description[$lang], description.en)
}

Here $lang is passed from your app. coalesce gives you a default if the localized value is missing.

  1. Document-based localization (per-language documents):

Schema (core idea):

defineType({
  name: 'post',
  type: 'document',
  fields: [
    {name: 'title', type: 'string'},
    {name: 'language', type: 'string'}, // e.g. 'en', 'de'
    {name: 'translationKey', type: 'string'}, // shared ID across languages
  ],
})

Query a specific locale:

*[_type == "post" && slug.current == $slug && language == $lang][0]{
  _id,
  title,
  body
}

Fetch all language variants for one logical “post”:

*[_type == "post" && translationKey == $translationKey]{
  _id,
  language,
  title
}

Localized references (e.g. a product that has localized category docs):

*[_type == "product" && slug.current == $slug][0]{
  _id,
  "title": coalesce(title[$lang], title.en),
  categories[]->{
    "title": coalesce(title[$lang], title.en)
  }
}

How do I filter between drafts and published documents in GROQ?

Short Answer: Published documents use their normal _id, while drafts live under drafts.. Use filters like !(_id in path("drafts.**")) or _id in path("drafts.**") to include or exclude them, and merge them with coalesce when you want “drafts if available, otherwise published.”

Expanded Explanation:
Sanity’s Content Lake keeps drafts and published versions as separate JSON documents. Drafts live in a special “namespace” (drafts.) so they can be previewed, audited, and rolled back independently of what is live. GROQ provides path functions so you can explicitly include or exclude these documents based on the operation: live site, preview, content agent, etc.

A common pattern is:

  • Production: published only
  • Preview / agent QA: “overlay drafts on top of published” so you always see the latest edits

Comparison Snapshot:

  • Published only: filter out _id values in path("drafts.**").
  • Drafts only: filter to _id values in path("drafts.**").
  • Drafts over published: query both, then prefer draft values with coalesce or by grouping on _id without the prefix.

Examples:

Published only (typical public site):

*[_type == "post" && !(_id in path("drafts.**"))]{
  _id,
  title,
  slug
}

Drafts only (editor tools, QA):

*[_type == "post" && _id in path("drafts.**")]{
  _id,
  title,
  slug
}

Overlay drafts on published (preview pattern):

// Get all docs of a type, both draft and published
let all = *[_type == "post"]{
  "id": coalesce(_id match "drafts.**" => substring(_id, 7), _id),
  _id,
  title,
  slug
}
;

// Group by base id and prefer the draft version
all[]{
  ...
} | order(_createdAt desc) {
  // You can also implement the overlay in your client
}

A simpler and common approach is: fetch only the “live document” per slug, with a draft‑if‑available:

// Just one logical post by slug, preferring draft
*[
  _type == "post" &&
  slug.current == $slug
] | order(_id match "drafts.**" desc)[0]{
  _id,
  title,
  slug
}

This orders so that a draft (whose _id starts with drafts.) comes first, and [0] returns it; if there’s no draft, you get the published one.


How do I combine references, localization, and draft filtering in a single query?

Short Answer: Start from the “logical document” you want (e.g. a localized post), apply draft/published rules on the base document, and then apply the same draft and locale logic recursively to any referenced documents.

Expanded Explanation:
GROQ lets you treat queries as schema-aware pipelines: filter, project, resolve references, and localize as you go. For complex content operations—localized documents referencing other localized documents—you want one query that:

  1. Finds the right document (slug + language).
  2. Respects draft/published rules (e.g. preview vs live).
  3. Resolves references.
  4. Localizes both the root and referenced documents.

Sanity makes this tractable because everything is JSON and addressable via fields and references, and you can test different query shapes locally with the CLI and Studio.

What You Need:

  • A clear schema for localization (field-based or document-based).
  • A decision about draft behavior for each operation (live site vs preview vs agents).

Example: localized post with localized categories, preferring drafts

Assume:

  • Field-based localization for title and description.
  • post documents reference category documents.
  • In preview, you want drafts for both posts and categories if they exist.
// 1. Get the logical post by slug, preferring drafts
*[
  _type == "post" &&
  slug.current == $slug
] | order(_id match "drafts.**" desc)[0]{
  _id,
  "title": coalesce(title[$lang], title.en),
  "description": coalesce(description[$lang], description.en),

  // 2. Localized, draft-aware categories
  categories[]->{
    // Prefer draft category if available
    "id": coalesce(
      // Try to find a draft with the same base id
      *[_id == "drafts." + ^._id][0]._id,
      _id
    ),
    // Localized fields
    "title": coalesce(title[$lang], title.en),
    "slug": slug.current
  }
}

A more scalable pattern is to centralize “draft overlay” in your client (by stripping / adding drafts. to _id), and keep the query simpler:

*[
  _type == "post" &&
  slug.current == $slug &&
  !(_id in path("drafts.**")) // published only from the API
][0]{
  _id,
  "title": coalesce(title[$lang], title.en),
  "description": coalesce(description[$lang], description.en),
  categories[]->{
    _id,
    "title": coalesce(title[$lang], title.en),
    "slug": slug.current
  }
}

Then your Studio preview or agent logic can decide whether to re-fetch or overlay drafts.


How should I think about GROQ queries strategically for complex content operations?

Short Answer: Treat GROQ queries as part of your content architecture: model references and localization in schemas first, then write small, composable queries that mirror how your content operations actually work across web, mobile, and AI agents.

Expanded Explanation:
Because Sanity stores content as structured JSON and lets you define schemas in code, GROQ is less about “fetching a page” and more about extracting a governed slice of your knowledge layer for a specific operation. That might be:

  • A product release across multiple brands and locales.
  • An internal content agent that needs localized, draft-aware context.
  • A storefront that must only ever see published inventory.

Designing GROQ queries with operations in mind—draft rules, locale rules, reference depth, and required fields—reduces custom APIs and lets your content team own more of the change surface without waiting on deployments.

Why It Matters:

  • Reliable, predictable queries are what let you power “0 custom APIs” style architectures where web, mobile, and agents all read from the same Content Lake.
  • Getting references, localization, and draft filtering right upfront drastically cuts down on edge cases, manual cleanup, and one-off fixes later.

Some strategic patterns to adopt:

  • Schema as code first: model references (type: 'reference'), shared translation keys, and locale fields in your schema so GROQ queries stay straightforward.
  • Write once, use everywhere: keep your GROQ in small utilities (functions, hooks) per operation (e.g. getPostBySlug, getLocalizedNavigation) and reuse them across Next.js, mobile apps, and internal tools.
  • Pair with automation: use Functions and Agent Actions triggered on document mutations to keep derived data and search indexes in sync with your GROQ query shapes.

Quick Recap

To work effectively with Sanity GROQ, think in terms of content-as-data: use -> to traverse references, model and query localization explicitly (field-based or document-based), and rely on _id patterns (drafts.) and path filters to control draft vs published behavior. Combine these patterns into intentional, operation-specific queries, and you get a reusable knowledge layer that can reliably power multiple frontends and agentic use cases without shipping custom APIs for each.

Next Step

Get Started