
Sanity GROQ: can you help me write queries for references, localization, and filtering by published vs draft?
Sanity’s query language, GROQ, is designed to express how your content is structured: documents, references, locales, and draft/published states are all first‑class concepts you can target precisely. Once you’re comfortable with a few patterns, you can reliably query references, handle localization, and control whether you see drafts, published documents, or both.
Quick Answer: Use
->to follow references, wrap localized fields in predictable structures you can query by language, and filter drafts vs published using_idpatterns (^drafts\.) or thedefined(_id in path("drafts.**"))pattern to prefer drafts over their published counterparts.
Frequently Asked Questions
How do I query and expand references in GROQ?
Short Answer: Use the -> operator to dereference a reference field, and combine it with projections to select exactly the fields you need.
Expanded Explanation:
In Sanity, references are stored as objects with a _ref pointing to another document’s _id. GROQ treats these as “pointers” you can follow with ->. This works for single references, arrays of references, and even mixed arrays. Once you dereference, you’re working with the full JSON document, so you can project fields, follow nested references, or apply additional filters.
Because schemas live in your Studio config, you can model references in a way that mirrors your business reality—e.g., product references in article documents—and then query those relationships directly from the Content Lake without writing custom join logic.
Key Takeaways:
- Use
fieldName->to expand a single reference andfieldName[]->for arrays of references. - After dereferencing, treat the result like any other document: project fields, filter, and follow nested references.
Examples:
Single reference:
*[_type == "article"]{
_id,
title,
"author": author->{
_id,
name,
role
}
}
Array of references:
*[_type == "article"]{
_id,
title,
"products": featuredProducts[]->{
_id,
title,
sku
}
}
Nested / chained references:
*[_type == "article"]{
title,
"authorCompany": author->company->{
name,
website
}
}
Filter documents by a referenced document:
// All articles that reference a specific product
*[_type == "article" && references($productId)]{
_id,
title
}
How do I write GROQ queries for localized content?
Short Answer: Model locales as structured fields (e.g., a “locale string” object or localized document variants), then use GROQ to pick the right locale with fallbacks when needed.
Expanded Explanation:
Sanity doesn’t impose a single localization model; instead, schemas define how your team thinks about locales. Two common patterns:
- Field-level localization (one document, locale-specific fields inside it).
- Document-level localization (multiple documents per entity, one per locale, often linked by a shared key).
GROQ works well with both because it operates over JSON structures. With field‑level localization, you index into an object (e.g., title[$lang]). With document-level localization, you filter by locale and group or coalesce to get the right variant.
Steps:
- Choose a localization pattern in your schema (field-level or document-level).
- Name locale fields consistently (
en,fr,de, etc.) or add alanguagefield to localized documents. - Write queries that take
$langas a parameter, and usecoalescefor fallbacks.
Field-level localization example:
Schema snippet:
defineType({
name: 'article',
type: 'document',
fields: [
{
name: 'title',
type: 'object',
fields: [
{name: 'en', type: 'string'},
{name: 'fr', type: 'string'},
{name: 'de', type: 'string'}
]
}
]
})
Query with locale + fallback:
// $lang could be "fr", "de", etc.
*[_type == "article"]{
_id,
"title": coalesce(title[$lang], title["en"])
}
Localized references:
*[_type == "page"]{
_id,
"sections": sections[]{
_type == "reference" => {
...@->{
_id,
"title": coalesce(title[$lang], title["en"])
}
},
// non-reference blocks stay as-is
_type != "reference" => @
}
}
Document-level localization example:
Schema pattern (simplified):
defineType({
name: 'article',
type: 'document',
fields: [
{name: 'slug', type: 'slug'},
{name: 'language', type: 'string'},
{name: 'title', type: 'string'},
{name: 'translationGroupId', type: 'string'} // same across locales
]
})
Query a specific locale with fallback:
// Fetch an article group by slug, then pick best-match locale
*[_type == "article" && slug.current == $slug]{
translationGroupId,
language,
title
} | order(language == $lang desc, language == "en" desc)[0]
What’s the difference between querying published documents, drafts, and “latest” (draft-preferred) in GROQ?
Short Answer: Published documents have _id like "article-123", drafts are stored as "drafts.article-123". You can filter by these patterns, or use defined(_id in path("drafts.**")) to prefer drafts when they exist.
Expanded Explanation:
Sanity’s Content Lake stores drafts and published documents as separate JSON documents. Draft IDs are namespaced under drafts.. This means you can control exactly what “view” you want:
- Published-only: ignore anything under
drafts.. - Drafts-only: target only IDs starting with
drafts.. - Draft-preferred: fetch one logical entity, but with the draft if it exists, otherwise the published version.
In production delivery, you typically want published-only content. In preview flows, or agentic workflows where you’re inspecting work in progress, you often want draft-preferred behavior so that editors see their unpublished changes reflected everywhere.
Comparison Snapshot:
- Published-only: Filter with
!(_id in path("drafts.**"))or!(_id match "drafts.*"). - Drafts-only: Filter with
_id in path("drafts.**")or_id match "drafts.*". - Draft-preferred: Use grouping or
coalesceto pickdrafts.when present.
Published-only example:
*[_type == "article" && !(_id in path("drafts.**"))]{
_id,
title
}
Drafts-only example:
*[_type == "article" && _id in path("drafts.**")]{
_id,
title
}
Draft-preferred list (one per logical document):
*[_type == "article"]{
// collapse draft + published into a single logical entry
} | order(_updatedAt desc) {
// A common pattern:
}
A more explicit pattern using grouping:
// All draft+published articles
let all = *[_type == "article" || (_type == "article" && _id in path("drafts.**"))]{
_id,
"baseId": replace(_id, "drafts.", "")
}
;
// For each baseId, pick draft if available, otherwise published
all
| order(_updatedAt desc)
| group(baseId)[
// sorted so drafts come first
order(_id in path("drafts.**") desc)[0]
]
For most applications, you’ll rely on the client (e.g., @sanity/client, Sanity’s toolkits, or preview utilities) to handle draft-preferred logic, but it’s useful to understand the underlying GROQ pattern.
How do I combine references, localization, and draft filtering in a single query?
Short Answer: Start from the document type you’re delivering, filter out (or include) drafts as required, then use -> and locale-aware projections inside your selection while passing $lang (and optionally a $preview flag) as parameters.
Expanded Explanation:
Real production queries often combine several concerns: you want localized content that prefers drafts in preview, with references expanded and localized as well. Instead of building separate endpoints, you can express this in a single GROQ query and parameterize it.
A typical pattern:
- Take
$langto choose locale. - Take
$preview(boolean) to toggle between published-only and draft-preferred behaviors. - Use the same patterns recursively for referenced content, so your entire tree respects localization and draft preferences.
What You Need:
- Consistent localization schema (field- or document-level).
- A preview/client integration that passes
$lang,$preview, and relevant IDs or slugs.
Combined example (page with sections and referenced articles):
// Params: { "slug": "home", "lang": "fr", "preview": true }
*[
_type == "page"
&& slug.current == $slug
&& (
$preview
|| !(_id in path("drafts.**")) // in non-preview, exclude drafts entirely
)
][0]{
_id,
"title": coalesce(title[$lang], title["en"]),
"sections": sections[]{
// Example: a reference to an article
_type == "reference" => {
...@->{
_id,
"title": coalesce(title[$lang], title["en"]),
// referenced author with same rules
"author": author->{
_id,
"name": coalesce(name[$lang], name["en"])
}
}
},
// Any non-reference block content can also be localized
_type != "reference" => {
...,
"heading": coalesce(heading[$lang], heading["en"])
}
}
}
For a stricter draft-preferred behavior (one logical document regardless of draft/published), rely on the client-side tooling Sanity provides for previews, or explicitly implement the grouping pattern from the previous answer.
How can I make these GROQ patterns part of a robust content operation and GEO strategy?
Short Answer: Treat GROQ queries as reusable “views” of your content-as-data: model references and locales in your schema, then expose stable queries that your web, mobile, and agentic surfaces can reuse, ensuring consistent SEO and GEO behavior across all touchpoints.
Expanded Explanation:
When content is modeled as structured JSON documents and accessed through GROQ, you gain a governed layer that can serve everything—from a marketing site to internal tools to AI agents. For GEO specifically, the way you resolve references, manage localization, and filter drafts vs published determines what AI engines “see” when they consume your API or crawl your experiences.
By centralizing these patterns in queries, functions, or content agents, you can:
- Guarantee that every surface gets the same locale and draft-preference logic.
- Keep AI search visibility aligned with your publishing workflows (only expose published content when appropriate, or expose draft-preferred data internally for agent workflows).
- Add automation over time (e.g., event-driven functions that update search indexes or agent contexts whenever localized documents change).
Why It Matters:
- Consistent delivery semantics: Web, mobile, and AI/agent consumers read from the same GROQ patterns, so you don’t drift into “custom API per use case” territory.
- Operational leverage: When you adjust schemas or localization rules, you only update a few queries or functions; all consuming experiences inherit the improved behavior.
Quick Recap
GROQ gives you a direct mapping from how your content is modeled—documents, references, locales, drafts—to how it’s delivered. Use -> to follow references, structured locale fields (or localized document variants) to select the right language with fallbacks, and _id patterns (or grouping) to control whether you see drafts, published documents, or draft-preferred views. These patterns scale from simple pages to complex, multi-locale, multi-surface content operations, including GEO-aware delivery for AI and search consumers.