351 lines
9.1 KiB
Markdown
351 lines
9.1 KiB
Markdown
---
|
|
title: GROQ Query Maintenance & Best Practices
|
|
description: Guidelines for GROQ queries, type safety, performance optimization, and syntax highlighting.
|
|
---
|
|
|
|
# GROQ Query Maintenance & Best Practices
|
|
|
|
Use this contents list to jump to the query concern you need to solve.
|
|
|
|
## Table of Contents
|
|
|
|
- Query definition and imports
|
|
- Query fragments
|
|
- Expansion patterns
|
|
- Maintenance workflow
|
|
- Common patterns
|
|
- Performance rules
|
|
- API version best practices
|
|
|
|
## 1. Query Definition & Imports
|
|
|
|
### The `defineQuery` Function
|
|
**ALWAYS** wrap GROQ queries in `defineQuery` for TypeGen support. The import location depends on your framework:
|
|
|
|
```typescript
|
|
// Framework-agnostic (Angular, Remix, SvelteKit, Astro, vanilla)
|
|
import { defineQuery } from "groq";
|
|
|
|
// Next.js (re-exported for convenience)
|
|
import { defineQuery } from "next-sanity";
|
|
```
|
|
|
|
### Syntax Highlighting
|
|
For VS Code syntax highlighting, either:
|
|
1. Use the `groq` tagged template (recommended): `groq\`...\``
|
|
2. Or prefix with `/* groq */` comment when using `defineQuery`
|
|
|
|
```typescript
|
|
import { defineQuery } from "groq";
|
|
|
|
// ✅ Option A: groq tag (provides highlighting automatically)
|
|
import groq from "groq";
|
|
const QUERY = defineQuery(groq`*[_type == "post"]`);
|
|
|
|
// ✅ Option B: Comment prefix (for plain template literals)
|
|
const QUERY = defineQuery(/* groq */ `*[_type == "post"]`);
|
|
|
|
// ✅ Also valid: Just defineQuery (TypeGen works, but no editor highlighting)
|
|
const QUERY = defineQuery(`*[_type == "post"]`);
|
|
```
|
|
|
|
## 2. Query Fragments
|
|
Use string interpolation to reuse query logic and keep queries maintainable.
|
|
|
|
```typescript
|
|
// src/sanity/fragments/image.ts
|
|
export const imageFragment = /* groq */ `
|
|
asset->{
|
|
_id,
|
|
url,
|
|
metadata { lqip, dimensions }
|
|
},
|
|
alt
|
|
`;
|
|
|
|
// src/sanity/queries/post.ts
|
|
import { defineQuery } from "groq";
|
|
import { imageFragment } from "../fragments/image";
|
|
|
|
export const POST_QUERY = defineQuery(/* groq */ `
|
|
*[_type == "post"][0] {
|
|
title,
|
|
mainImage {
|
|
${imageFragment}
|
|
}
|
|
}
|
|
`);
|
|
```
|
|
|
|
## 3. Expansion Patterns (Page Builder)
|
|
When building a Page Builder query, expand all potential component types.
|
|
|
|
**Best Practice:** Use a `pageFields` fragment or similar strategy to keep the main query clean.
|
|
|
|
```typescript
|
|
const pageBuilderExpansion = /* groq */ `
|
|
pageBuilder[] {
|
|
...,
|
|
_type == "hero" => {
|
|
...,
|
|
cta[] { link, label }
|
|
},
|
|
_type == "gallery" => {
|
|
images[] { ${imageFragment} }
|
|
}
|
|
}
|
|
`;
|
|
```
|
|
|
|
## 4. Maintenance Workflow
|
|
When you add a new field or component to the Schema:
|
|
1. **Update the Query:** Add the new field/expansion to the relevant GROQ query immediately.
|
|
2. **Run TypeGen:** If you have `typegen.enabled: true` in `sanity.cli.ts`, types regenerate automatically during `sanity dev`/`sanity build`. Otherwise, run `npm run typegen` manually.
|
|
3. **Verify:** Ensure the new field is available in the generated types.
|
|
|
|
## 5. Common Patterns
|
|
|
|
### Ordering
|
|
```groq
|
|
// Single field
|
|
*[_type == "post"] | order(publishedAt desc)
|
|
|
|
// Multiple fields (tiebreaker)
|
|
*[_type == "post"] | order(featured desc, publishedAt desc)
|
|
|
|
// ⚠️ Order BEFORE slice, not after!
|
|
*[_type == "post"] | order(publishedAt desc)[0...10] // ✅ Correct
|
|
*[_type == "post"][0...10] | order(publishedAt desc) // ❌ Wrong order
|
|
```
|
|
|
|
### Slice Notation
|
|
```groq
|
|
*[_type == "post"][0] // Single document (object, not array)
|
|
*[_type == "post"][0...5] // First 5 (exclusive) ← Most common
|
|
*[_type == "post"][$start...$end] // Pagination with params
|
|
```
|
|
|
|
### Default Values with `coalesce()`
|
|
```groq
|
|
*[_type == "page"]{
|
|
"title": coalesce(seoTitle, title, "Untitled"),
|
|
"image": coalesce(ogImage, mainImage, defaultImage)
|
|
}
|
|
```
|
|
|
|
### Conditionals with `select()`
|
|
```groq
|
|
*[_type == "product"]{
|
|
title,
|
|
"badge": select(
|
|
stock == 0 => "Out of Stock",
|
|
stock < 5 => "Low Stock",
|
|
"In Stock"
|
|
)
|
|
}
|
|
```
|
|
|
|
### Aggregation with `count()`
|
|
```groq
|
|
// Total count
|
|
count(*[_type == "post" && defined(slug.current)])
|
|
|
|
// Count per document
|
|
*[_type == "category"]{
|
|
title,
|
|
"postCount": count(*[_type == "post" && references(^._id)])
|
|
}
|
|
```
|
|
|
|
### Reverse References
|
|
```groq
|
|
*[_type == "author"]{
|
|
name,
|
|
"posts": *[_type == "post" && references(^._id)]{ title, slug }
|
|
}
|
|
```
|
|
|
|
### Array Filtering
|
|
```groq
|
|
*[_type == "movie"]{
|
|
title,
|
|
"mainCast": castMembers[role == "lead"]->{name}
|
|
}
|
|
|
|
// Check if value exists in array
|
|
*[_type == "post" && "tech" in categories[]->slug.current]
|
|
```
|
|
|
|
### Special Variables
|
|
```groq
|
|
// ^ = parent document (in nested queries)
|
|
*[_type == "author"]{
|
|
name,
|
|
"posts": *[_type == "post" && author._ref == ^._id]
|
|
}
|
|
|
|
// @ = current item (in array operations)
|
|
*[_type == "post"]{
|
|
"tagCount": count(tags[@ != null])
|
|
}
|
|
```
|
|
|
|
## 6. Performance Rules
|
|
|
|
### Optimizable vs Non-Optimizable Filters
|
|
GROQ uses indexes for **optimizable** filters. Non-optimizable filters scan ALL documents.
|
|
|
|
| Pattern | Optimizable | Example |
|
|
|---------|-------------|---------|
|
|
| `_type == "x"` | ✅ Yes | `*[_type == "post"]` |
|
|
| `_id == "x"` | ✅ Yes | `*[_id == "abc123"]` |
|
|
| `slug.current == $slug` | ✅ Yes | `*[slug.current == "hello"]` |
|
|
| `defined(field)` | ✅ Yes | `*[defined(publishedAt)]` |
|
|
| `references($id)` | ✅ Yes | `*[references("author-123")]` |
|
|
| `field->attr == x` | ❌ No | Resolves reference for every doc |
|
|
| `fieldA < fieldB` | ❌ No | Compares two attributes |
|
|
|
|
**Fix non-optimizable filters by stacking:**
|
|
```groq
|
|
// Stack optimizable filters FIRST to reduce search space
|
|
*[_type == "product" && defined(salePrice) && salePrice < displayPrice]
|
|
```
|
|
|
|
### Avoid Joins in Filters
|
|
Reference resolution (`->`) in filters is expensive. Use `_ref` instead:
|
|
|
|
```groq
|
|
// ❌ Slow: Resolves reference for every document
|
|
*[_type == "post" && author->name == "Bob Woodward"]
|
|
|
|
// ✅ Fast: Direct _ref comparison
|
|
*[_type == "post" && author._ref == "author-bob-woodward-id"]
|
|
```
|
|
|
|
**When you need dynamic lookups** (don't know the ID upfront):
|
|
|
|
```groq
|
|
// Two-step approach:
|
|
// 1. Get the reference ID first
|
|
*[_type == "author" && name == "Bob Woodward"][0]._id
|
|
|
|
// 2. Use that ID in your main query
|
|
*[_type == "post" && author._ref == $authorId]
|
|
|
|
// Or use a subquery (still better than -> in filter):
|
|
*[_type == "post" && author._ref in *[_type == "author" && name == "Bob Woodward"]._id]
|
|
```
|
|
|
|
### Merge Repeated Reference Resolutions
|
|
Each `->` is a subquery. Don't repeat it:
|
|
|
|
```groq
|
|
// ❌ Slow: Two separate subqueries
|
|
*[_type == "category"]{
|
|
"parentTitle": parent->title,
|
|
"parentSlug": parent->slug.current
|
|
}
|
|
|
|
// ✅ Fast: Single subquery, merged
|
|
*[_type == "category"]{
|
|
...(parent->{ "parentTitle": title, "parentSlug": slug.current })
|
|
}
|
|
```
|
|
|
|
### Cursor-Based Pagination (Not Deep Slicing)
|
|
Deep slices are slow because all skipped docs must be sorted first.
|
|
|
|
```groq
|
|
// ❌ Slow: Must sort and skip 10,000 docs
|
|
*[_type == "article"] | order(_id)[10000...10020]
|
|
|
|
// ✅ Fast: Cursor-based, only fetches 20
|
|
*[_type == "article" && _id > $lastId] | order(_id)[0...20]
|
|
```
|
|
|
|
**For custom sort orders**, include the sort field in the cursor:
|
|
|
|
```groq
|
|
// Compound cursor: publishedAt + _id for deterministic pagination
|
|
*[_type == "article" && (
|
|
publishedAt < $lastDate ||
|
|
(publishedAt == $lastDate && _id > $lastId)
|
|
)] | order(publishedAt desc, _id)[0...20]
|
|
```
|
|
|
|
### Always Project Fields
|
|
Always use projections to return only the fields your application needs. Fetching entire documents wastes bandwidth and processing time.
|
|
|
|
```groq
|
|
// ❌ Returns ALL fields including unused ones, metadata, revisions
|
|
*[_type == "post"]
|
|
|
|
// ✅ Only fetch what the component needs
|
|
*[_type == "post"]{
|
|
_id,
|
|
title,
|
|
"slug": slug.current,
|
|
publishedAt,
|
|
excerpt
|
|
}
|
|
```
|
|
|
|
Apply projections at every level, including nested references:
|
|
|
|
```groq
|
|
*[_type == "post"]{
|
|
title,
|
|
author->{ name, "avatar": image.asset->url },
|
|
categories[]->{ title, "slug": slug.current }
|
|
}
|
|
```
|
|
|
|
Use conditional projections for different contexts:
|
|
|
|
```groq
|
|
*[_type == "post"]{
|
|
title,
|
|
slug,
|
|
// Only include body for single post view
|
|
$includeBody == true => { body }
|
|
}
|
|
```
|
|
|
|
### Don't Filter/Sort on Projected Values
|
|
Computed attributes can't use indexes:
|
|
|
|
```groq
|
|
// ❌ Not optimizable (computed attribute)
|
|
*[_type == "person"]{
|
|
"fullName": firstName + " " + lastName
|
|
} | order(fullName)
|
|
|
|
// ✅ Optimizable (original attribute)
|
|
*[_type == "person"] | order(firstName, lastName)
|
|
```
|
|
|
|
### Quick Checklist
|
|
| Rule | Why |
|
|
|------|-----|
|
|
| Always project `{ fields }` | Reduces data returned |
|
|
| Use `defined()` checks | Filters use indexes |
|
|
| Use `$params` not interpolation | Prevents query manipulation + enables caching |
|
|
| Order BEFORE slice | `order()[0...N]` not `[0...N] order()` |
|
|
| Use `_ref` not `->field` in filters | Avoids expensive joins |
|
|
| Merge repeated `->` calls | Single subquery vs many |
|
|
| Cursor pagination for deep pages | Avoids sorting entire dataset |
|
|
|
|
## 7. API Version Best Practices
|
|
|
|
Always use dated versions (`YYYY-MM-DD`) for consistent behavior:
|
|
|
|
```typescript
|
|
const client = createClient({
|
|
apiVersion: '2026-02-01', // Use current date for new projects
|
|
})
|
|
```
|
|
|
|
- **New projects:** Use current date (e.g., `2026-02-01`)
|
|
- **Existing projects:** Keep current version unless you need new features
|
|
- Dated versions lock behavior; `v1` or `vX` may change unexpectedly
|