Files
johannes.gasser 57af0b8386
Build and Deploy / build-and-deploy (push) Successful in 2m53s
add skills
2026-05-18 08:39:42 +02:00

9.1 KiB

title, description
title description
GROQ Query Maintenance & Best Practices 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:

// 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
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.

// 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.

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

// 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

*[_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()

*[_type == "page"]{
  "title": coalesce(seoTitle, title, "Untitled"),
  "image": coalesce(ogImage, mainImage, defaultImage)
}

Conditionals with select()

*[_type == "product"]{
  title,
  "badge": select(
    stock == 0 => "Out of Stock",
    stock < 5 => "Low Stock",
    "In Stock"
  )
}

Aggregation with count()

// Total count
count(*[_type == "post" && defined(slug.current)])

// Count per document
*[_type == "category"]{
  title,
  "postCount": count(*[_type == "post" && references(^._id)])
}

Reverse References

*[_type == "author"]{
  name,
  "posts": *[_type == "post" && references(^._id)]{ title, slug }
}

Array Filtering

*[_type == "movie"]{
  title,
  "mainCast": castMembers[role == "lead"]->{name}
}

// Check if value exists in array
*[_type == "post" && "tech" in categories[]->slug.current]

Special Variables

// ^ = 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:

// 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:

// ❌ 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):

// 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:

// ❌ 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.

// ❌ 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:

// 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.

// ❌ 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:

*[_type == "post"]{
  title,
  author->{ name, "avatar": image.asset->url },
  categories[]->{ title, "slug": slug.current }
}

Use conditional projections for different contexts:

*[_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:

// ❌ 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:

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