What’s the best way to construct GROQ query dynamically in JavaScript? Is there a sort of way to construct it in objects or any config which will then get translated to a...

5 replies
Last updated: Jan 7, 2021
What’s the best way to construct GROQ query dynamically in JavaScript? Is there a sort of way to construct it in objects or any config which will then get translated to a string query we can use?
I’m unsure whether what I’m doing right now is how it should be. As an example, I defined variables for like:


const baseQuery = (filters) => `*[ ${filters} ]`
const filters = `_type == "products" and name match ${nameKeyword}`
const query = `baseQuery(filters)`
This can go out of hand if I add in projections, additional filters, etc.
AI Update

Your approach with string concatenation can definitely get unwieldy, and you're smart to be thinking about better patterns! The good news is that GROQ parameters are the official, recommended way to handle dynamic values in queries, and they solve many of the issues you're experiencing.

Instead of building queries with JavaScript template strings, use GROQ parameters with the $ syntax. This is the native way to pass dynamic values into GROQ queries:

// ✅ Recommended: Use GROQ parameters
const query = `*[_type == "products" && name match $nameKeyword]`
const params = { nameKeyword: "searchTerm" }

const results = await sanityClient.fetch(query, params)

Why this is better than string concatenation:

  1. Security: Parameters are JSON literals, so they can't be used for GROQ injection attacks
  2. Automatic escaping: The client handles special characters like quotes properly
  3. Type safety: Values maintain their data types
  4. Cleaner code: No messy string interpolation

Building Complex Queries

For queries with multiple filters, projections, and ordering, you can still use parameters while keeping the query structure readable:

const query = `
  *[
    _type == $docType 
    && name match $nameKeyword
    && price > $minPrice
  ] | order($orderField desc) {
    _id,
    name,
    price,
    "category": category->name
  }
`

const params = {
  docType: "products",
  nameKeyword: "laptop*",
  minPrice: 100,
  orderField: "price"
}

const results = await sanityClient.fetch(query, params)

Handling Optional Filters

For conditional filters (like optional search terms), you can use GROQ's select() function or build the query conditionally in JavaScript:

// Approach 1: Conditional query building
const query = nameKeyword 
  ? `*[_type == "products" && name match $nameKeyword]`
  : `*[_type == "products"]`

const params = nameKeyword ? { nameKeyword } : {}

// Approach 2: Using GROQ select()
const query = `
  *[
    _type == "products"
    && select(
      defined($nameKeyword) => name match $nameKeyword,
      true
    )
  ]
`
const params = { nameKeyword: nameKeyword || null }

Note that if you declare a parameter in a query that might be missing, it must be present in the supplied parameters as null (not undefined).

Helper Functions for Query Structure

For truly complex queries, you can create helper functions that return query strings, but still use parameters for values:

const buildProductQuery = (options) => {
  const filters = ['_type == "products"']
  
  if (options.includeNameFilter) {
    filters.push('name match $nameKeyword')
  }
  
  if (options.includePriceFilter) {
    filters.push('price > $minPrice')
  }
  
  const projection = options.detailed 
    ? '{ _id, name, price, description, "category": category->name }'
    : '{ _id, name, price }'
  
  return `*[${filters.join(' && ')}]${projection}`
}

// Usage
const query = buildProductQuery({ 
  includeNameFilter: true, 
  detailed: true 
})
const params = { nameKeyword: "laptop*" }

TypeScript and Type Safety

If you're using TypeScript and want end-to-end type safety, consider using Sanity's native TypeGen with the defineQuery helper:

import { defineQuery } from 'next-sanity'

const query = defineQuery(`*[_type == "products" && name match $nameKeyword]`)
const results = await sanityClient.fetch(query, { nameKeyword: "laptop*" })
// results will be properly typed!

There's also groqd, a community library from FormidableLabs that provides a query builder approach with Zod-like runtime validation if you prefer building queries with a more object-oriented API rather than strings.

Bottom Line

Your instinct is right—string concatenation doesn't scale well. GROQ parameters are the official solution and handle 90% of dynamic query needs. For the remaining complexity around query structure (projections, optional filters, etc.), use helper functions that return parameterized query strings. This keeps your code maintainable while leveraging GROQ's built-in safety features.

I've seen two projects that tries to make an abstraction over GROQ:
https://www.npmjs.com/package/sanity-typed-queries
https://www.npmjs.com/package/sanity-query-helper Maybe you can take some inspiration from there?

But generally, I prefer to just write out every query, makes it a bit easier to debug and for other people to onboard to the project
I've seen two projects that tries to make an abstraction over GROQ:
https://www.npmjs.com/package/sanity-typed-queries
https://www.npmjs.com/package/sanity-query-helper Maybe you can take some inspiration from there?

But generally, I prefer to just write out every query, makes it a bit easier to debug and for other people to onboard to the project
Thanks
user Y
. 👀
The reason why I needed to construct GROQ dynamically is we are sort of creating a product configurator and we do filtering based on user selected option. I guess this wouldn’t be so hard if the data is simple. But since it’s not, we gotta push through. Hmm. Or maybe there’s another approach we could take?
Here’s a simple GROQ based on two filters. It could get more complicated as soon as more options are selected:


*[
   (_type == "product" && defined(productConfigurator[])) 
   && productConfigurator[].itemNumber match '-UWW-'
 ] {
   _id,
   _type,
   title,
   productConfigurator[itemNumber match '-UWW-'] {
     ...,
     tapeLightType->{title}
   }
 } |  [ productConfigurator[tapeLightType.title == "Reel"] ] {
   ...,
   productConfigurator[tapeLightType.title == "Reel"]
 }
Then we try to do it this way before that query turned out above:

const query = `*[
        ${baseFilter}
        ${additionalFilter}
      ] {
        ${baseProjection}
        ${additionalProjections}
      }
      `;

Sanity – Build the way you think, not the way your CMS thinks

Sanity is the developer-first content operating system that gives you complete control. Schema-as-code, GROQ queries, and real-time APIs mean no more workarounds or waiting for deployments. Free to start, scale as you grow.

Was this answer helpful?