Type something to search...
How to Query Nested Arrays in GROQ

How to Query Nested Arrays in GROQ

To query nested arrays in GROQ, use the [] traversal operator to step into each array, then add a filter or projection at the level you need. For example, sections[].items[].tags[] walks through every section, every item in each section and every tag on each item, returning one flat list of tags. Add a filter inside the brackets, like items[price > 10], to keep only matching elements, and add a projection, like items[]{title, price}, to choose which fields come back. To filter whole documents by what's inside their arrays, combine the traversal with in or count(), such as *[_type == "product" && "sale" in variants[].tags[]].

Sanity documents often contain arrays inside arrays: page builders with sections and blocks, products with variants and options, menus with groups and links. Getting exactly the right data out of that structure is where many GROQ queries go wrong. This guide explains how [] traversal and flattening work, how to filter and project at each level, how to reach parent data with ^, how to follow references inside arrays, and which functions help you summarise nested data.

An Example Schema

The examples use a page-builder style page document with an array of sections, each holding an array of items:

// schemaTypes/page.ts
import { defineArrayMember, defineField, defineType } from 'sanity';

export const page = defineType({
  name: 'page',
  title: 'Page',
  type: 'document',
  fields: [
    defineField({ name: 'title', type: 'string' }),
    defineField({ name: 'slug', type: 'slug', options: { source: 'title' } }),
    defineField({
      name: 'sections',
      type: 'array',
      of: [
        defineArrayMember({
          name: 'section',
          type: 'object',
          fields: [
            defineField({ name: 'heading', type: 'string' }),
            defineField({
              name: 'items',
              type: 'array',
              of: [
                defineArrayMember({
                  name: 'item',
                  type: 'object',
                  fields: [
                    defineField({ name: 'title', type: 'string' }),
                    defineField({ name: 'price', type: 'number' }),
                    defineField({
                      name: 'tags',
                      type: 'array',
                      of: [defineArrayMember({ type: 'string' })],
                      options: { layout: 'tags' },
                    }),
                    defineField({
                      name: 'author',
                      type: 'reference',
                      to: [{ type: 'author' }],
                    }),
                  ],
                }),
              ],
            }),
          ],
        }),
      ],
    }),
  ],
});

Stored data looks like this. Note that every object in an array gets a _key, which Sanity uses to identify it:

{
  "_type": "page",
  "title": "Spring Menu",
  "sections": [
    {
      "_key": "a1",
      "_type": "section",
      "heading": "Starters",
      "items": [
        { "_key": "i1", "_type": "item", "title": "Soup", "price": 8, "tags": ["vegan", "hot"] },
        { "_key": "i2", "_type": "item", "title": "Salad", "price": 12, "tags": ["vegan"] }
      ]
    },
    {
      "_key": "a2",
      "_type": "section",
      "heading": "Mains",
      "items": [
        { "_key": "i3", "_type": "item", "title": "Risotto", "price": 22, "tags": ["vegetarian"] }
      ]
    }
  ]
}

If you're still designing structures like this, working with arrays in Sanity schemas covers the schema side.

How [] Traversal Works

The [] operator means "for each element of this array". Whatever follows it is applied to every element.

*[_type == "page" && slug.current == $slug][0].sections[].heading
["Starters", "Mains"]

When you chain traversals, GROQ flattens the result, so you get one list instead of an array of arrays:

*[_type == "page" && slug.current == $slug][0].sections[].items[].title
["Soup", "Salad", "Risotto"]

Going one level deeper:

*[_type == "page" && slug.current == $slug][0].sections[].items[].tags[]
["vegan", "hot", "vegan", "vegetarian"]

The [] after tags matters. Without it, you get each item's tags array as a separate element:

*[_type == "page" && slug.current == $slug][0].sections[].items[].tags
[["vegan", "hot"], ["vegan"], ["vegetarian"]]

A good rule of thumb: add [] to an array when you want to work with its elements, and leave it off when you want the array itself.

Filtering Inside an Array

Put a condition inside the brackets to keep only some elements. Inside the filter, field names refer to the current element:

*[_type == "page" && slug.current == $slug][0].sections[].items[price > 10]{
  title,
  price
}
[
  { "title": "Salad", "price": 12 },
  { "title": "Risotto", "price": 22 }
]

You can filter at more than one level. This returns only items tagged "vegan", and only from sections with the heading "Starters":

*[_type == "page" && slug.current == $slug][0]
  .sections[heading == "Starters"]
  .items["vegan" in tags]{
    title
  }

Some common array filters:

FilterKeeps
items[price > 10]Elements whose field meets a condition
items["vegan" in tags]Elements whose own array contains a value
items[defined(author)]Elements where a field is set
content[_type == "image"]Elements of one type in a mixed array
items[_key == $key]One specific element by its key

Filtering by _type is especially useful for Portable Text and page builders, where one array holds many kinds of object. For the full set of operators, see how to filter and sort data with GROQ.

Projecting Nested Data

To keep the shape of the document but trim each level, nest projections inside each other:

*[_type == "page" && slug.current == $slug][0]{
  title,
  sections[]{
    _key,
    heading,
    items[]{
      _key,
      title,
      price,
      tags
    }
  }
}

The sections[]{...} syntax is shorthand for "sections": sections[]{...}. Each level only returns the fields you list, so large nested structures stay lean. Keep _key in the output when you'll render lists in React, as it makes a stable key prop.

Projections can also rename and compute fields at any level:

*[_type == "page" && slug.current == $slug][0]{
  title,
  "sections": sections[]{
    heading,
    "itemCount": count(items),
    "cheapest": items | order(price asc)[0].title
  }
}

More projection techniques are in using projections in GROQ queries.

Sorting and Slicing Nested Arrays

The pipe operator and order() work on any array, not only on the top-level document set. Slices work too:

*[_type == "page" && slug.current == $slug][0]{
  sections[]{
    heading,
    "topItems": items | order(price desc)[0...3]{ title, price }
  }
}

This returns, for each section, its three most expensive items. Write the array, pipe it to order(), slice it, then project.

Accessing the Parent With ^

Inside a nested projection or filter, field names refer to the current element. To reach a field on the enclosing level, use ^:

*[_type == "page" && slug.current == $slug][0]{
  title,
  "items": sections[].items[]{
    title,
    "pageTitle": ^.title
  }
}

Each ^ moves up one scope. In more deeply nested queries you can chain them, such as ^.^.title, though deep chains get hard to read quickly. When a query needs a lot of parent data, it's often clearer to keep the nested shape and let your front end combine levels.

The ^ operator is also how you write sub-queries that relate to the current document, for example finding other pages that share a tag:

*[_type == "page" && slug.current == $slug][0]{
  title,
  "related": *[
    _type == "page" &&
    _id != ^._id &&
    array::intersects(sections[].items[].tags[], ^.sections[].items[].tags[])
  ][0...3]{ title }
}

Inside the sub-query's filter, plain field names belong to the candidate page, and ^ points back to the page you started with. array::intersects() returns true when the two tag lists share at least one value. Sub-queries like this scan every page, so measure them on large datasets before relying on them.

Filtering Documents by Nested Values

So far the filters have trimmed arrays inside one document. Often you want the opposite: find documents whose nested arrays contain something.

Use in with a flattened traversal:

*[_type == "page" && "vegan" in sections[].items[].tags[]]{
  title
}

Use count() with an inner filter when the condition involves more than one field:

*[_type == "page" && count(sections[].items[price > 20 && "vegetarian" in tags]) > 0]{
  title
}

The count() approach is the GROQ equivalent of "where any element matches". It's also useful for the reverse case, such as pages where no item has a price:

*[_type == "page" && count(sections[].items[!defined(price)]) > 0]{
  title
}

Following References Inside Arrays

When an array element holds a reference, add -> to fetch the referenced document. For an array that's made only of references, []-> dereferences every element:

*[_type == "post"][0]{
  title,
  "authors": authors[]->{ name, "slug": slug.current }
}

For a reference field on objects inside an array, dereference that field within the nested projection:

*[_type == "page" && slug.current == $slug][0]{
  sections[]{
    heading,
    items[]{
      title,
      "author": author->{ name }
    }
  }
}

In mixed arrays, conditional projections let you dereference only the elements that need it:

*[_type == "page" && slug.current == $slug][0]{
  content[]{
    ...,
    _type == "productReference" => @->{ _id, title, price },
    _type == "image" => { "url": asset->url }
  }
}

The @ refers to the current element. The full story on -> is in how to dereference documents in GROQ.

Useful Functions for Nested Data

GROQ has several functions that pair well with flattened arrays:

  • count(): the number of elements, such as count(sections[].items) for the total number of items.
  • array::unique(): removes duplicates, such as array::unique(sections[].items[].tags[]) for a tag list.
  • array::compact(): removes null values, useful after projecting a field that's sometimes missing.
  • math::sum(), math::avg(), math::min(), math::max(): aggregate numbers, such as math::sum(sections[].items[].price).
  • defined(): checks whether a value exists, handy inside array filters.

Putting a few together:

*[_type == "page" && slug.current == $slug][0]{
  title,
  "totalItems": count(sections[].items[]),
  "allTags": array::unique(sections[].items[].tags[]),
  "averagePrice": math::avg(sections[].items[].price)
}
{
  "title": "Spring Menu",
  "totalItems": 3,
  "allTags": ["vegan", "hot", "vegetarian"],
  "averagePrice": 14
}

The GROQ functions guide covers these in more depth.

Common Mistakes

  • Forgetting the trailing []: items[].tags gives an array of arrays, items[].tags[] gives a flat list.
  • Expecting an object instead of an array: sections[heading == "Mains"] returns an array even when only one element matches. Add [0] if you need the single object.
  • Projecting without []: sections{heading} tries to project the array as a single object. Write sections[]{heading}.
  • Losing _key: if you rebuild arrays in a projection, include _key so your front end has stable keys.
  • Over-fetching with ...: spreading every field at every level of a large page builder can return far more data than you render.

FAQ: Nested Arrays in GROQ

It traverses an array, applying whatever follows to each element. Chaining traversals like sections[].items[] flattens the results into a single list instead of an array of arrays.

Put a condition inside the brackets, such as items[price > 10] or content[_type == "image"]. Field names inside the brackets refer to each array element, and the result is a new array containing only the matches.

Use in with a flattened traversal for simple values, such as "vegan" in sections[].items[].tags[], or count() with an inner filter for complex conditions, such as count(sections[].items[price > 20]) > 0.

Use the caret operator. Inside a nested projection, ^.title reads the title from the enclosing scope, and each additional caret moves up another level.

Yes. Pipe the array to order() inside a projection, for example "items": items | order(price asc). You can slice and project the result as well.

Flatten the tags with traversal and wrap the result in array::unique(), for example array::unique(sections[].items[].tags[]). Add array::compact() if some values might be null.


Conclusion

Querying nested arrays in GROQ comes down to a few building blocks. Use [] to step into an array and flatten chained levels, add a filter inside the brackets to keep only some elements, and nest projections to shape each level. ^ reaches parent fields, -> follows references in array elements, and in or count() let you filter whole documents by what's deep inside them.

Start with the shape you want your front end to receive, then write the query level by level, checking each step in Vision. With those habits, even large page builders and product catalogues turn into small, predictable responses.

Tags :
Share :

Related Posts

Building a Static Site With Astro and Sanity

Building a Static Site With Astro and Sanity

To build a static site with Astro and Sanity, keep Astro's default output: 'static', configure @sanity/astro with useCdn: false, and fetch

Dive Deeper
Common Sanity Mistakes and How to Fix Them

Common Sanity Mistakes and How to Fix Them

The most common Sanity mistakes are using the CDN where you need fresh data, fetching whole documents with ..., leaving slugs unvalidated, ship

Dive Deeper
How to Create a Custom Sanity Plugin

How to Create a Custom Sanity Plugin

To create a custom Sanity plugin, wrap a piece of Studio configuration in definePlugin from the sanity package and add it to the plugins ar

Dive Deeper