Clean Your GROQ

Clean Your GROQ

How to structure GROQ queries that stay readable as your Sanity project grows. Fragment-based patterns with code examples.


Have you written a GROQ query that felt like Walter White cooking in an RV? Powerful, slightly dangerous, and held together with hope and duct tape. The moment your queries go from "simple" to "nested twelve levels deep with filters, joins, conditionals, projections, and vibes," you feel it. But GROQ isn't the problem. It's your architecture. Here's how to turn unmaintainable queries into code your future self won't want to git blame.

Modern GROQ query optimization

GROQ is Sanity's open-source query language, and it's basically what you get when you ask, "What if querying content didn't feel like wrestling a REST endpoint at 2 a.m.?" It lets you filter, sort, transform, and reshape data. You can filter October events, stitch together five document types, and reshape data before your frontend even wakes up, but at scale, it becomes the spiritual successor to "that one regex nobody wants to touch."

It's genuinely good at pulling together complex, multi-document structures and turning them into neat little JSON objects. For big datasets and intricate content models, GROQ feels like cheating.

The thing is, GROQ gives you total freedom. You can ask it precisely what you want, and it gives you exactly that. No overfetching, no REST gymnastics, no GraphQL boilerplate sermons. Just raw, precise querying. Which is great… right up until your query becomes a 70-line monster that nobody wants to maintain.

GROQ complexity starts with scaling

As we said before, the moment you scale past a couple of tidy queries, things go from "elegant data language" to "who wrote this and are they okay?" You end up with dense, repetitive query chains that stretch across half your screen, nested filters you're scared to touch, and projections so long they need their own scroll bar. Modify one line and you feel like you're making a Jenga move in front of your entire engineering team.

We obviously don't work like this, but you definitely don't want this kind of chaos spreading across your repo or onboarding a new developer into this cinematic universe of pain.

Consider this nightmare scenario:

const pageQuery = groq`
*[_type == "page" && slug.current == $slug][0]{
  ...,
  pageBuilder[]{
    ...,
    buttons[]{
      ...,
      url{
        ...,
        internal->{
          slug
        }
      }
    },
    categories->{
      title
    },
    richText[]{
      ...,
      markDefs[]{
        ...,
        customLink{
          ...,
          internal->{
            slug
          }
        }
      }
    },
    link{
      ...,
      internal->{
        slug
      }
    },
    image{
      ...,
      "alt": coalesce(asset->altText, asset->originalFilename, "Image-Broken"),
      "blurData": asset->metadata.lqip,
    },
    "faqs": faqs[]->{
      title,
      richText[]{
        ...,
        markDefs[]{
          ...,
          customLink{
            ...,
            internal->{
              slug
            }
          }
        }
      }
    },
    "members": members[]->{
      name,
      position,
      image{
        ...,
        "alt": coalesce(asset->altText, asset->originalFilename, "Image-Broken"),
        "blurData": asset->metadata.lqip,
      }
    }
  }
}`;

Now, this query is extremely difficult to comprehend, debug, and maintain. But we are here to say that there's a better way.

The modern solution: fragment-based architecture

GROQ is just text. Plain JavaScript strings, which means we can actually structure them. Once that clicked, we rebuilt our entire approach around fragments. Small, and reusable building blocks that turn sprawling, uneditable query monsters into clean, modular pieces you can rearrange, extend, and fix without sweating. Here's the exact pattern or cheat sheet we now use to keep GROQ readable, scalable, and onboarding-friendly:

1. Start with atomic fragments

Begin by identifying the smallest reusable units in your queries. These become your atomic fragments:

const imageFragment = /* groq */ `
  image{
    ...,
    "alt": coalesce(asset->altText, asset->originalFilename, "Image-Broken"),
    "blurData": asset->metadata.lqip,
    "dominantColor": asset->metadata.palette.dominant.background,
  }
`;

const customLinkFragment = /* groq */ `
  ...customLink{
    openInNewTab,
    "href": select(
      type == "internal" => select(
        internal->_type == "headlessCms" => "/migration/" + internal->slug.current,
        internal->slug.current
      ),
      type == "external" => external,
      "#"
    ),
  }
`;

const buttonsFragment = /* groq */ `
  "buttons": array::compact(buttons[]{
    text,
    variant,
    _key,
    _type,
    "openInNewTab": url.openInNewTab,
    "href": select(
      url.type == "internal" => url.internal->slug.current,
      url.type == "external" => url.external,
      url.href
    ),
  })
`;

Each of these fragments does one thing well. imageFragment handles image resolution with alt text fallbacks and blur data. customLinkFragment resolves internal and external links with smart routing. buttonsFragment maps button arrays into clean, frontend-ready objects. They're small, readable, and you'll reuse them everywhere.

2. Build composite fragments

Combine atomic fragments into more complex, reusable components:

const markDefsFragment = /* groq */ `
  markDefs[]{
    ...,
    ${customLinkFragment}
  }
`;

const richTextFragment = /* groq */ `
  richText[]{
    ...,
    ${markDefsFragment},
    _type == "imageGrid" => {
      ...,
      columns,
      "items": array::compact(items[]{
        ...,
        ${imageFragment}
      })
    }
  }
`;

const blogAuthorFragment = /* groq */ `
  authors[0]->{
    _id,
    name,
    position,
    "socials": socials{
      linkedin,
      twitter,
      peerlist
    },
    ${imageFragment}
  }
`;

See how richTextFragment composes markDefsFragment and imageFragment? Each layer builds on the one below. You never repeat yourself, and when you update imageFragment, every query that uses it gets the fix automatically.

3. Create block-level fragments

For page builders and complex content structures, create block-level fragments that handle entire components:

const heroBlock = /* groq */ `
  _type == "hero" => {
    ...,
    ${imageFragment},
    ${buttonsFragment},
    ${richTextFragment}
  }
`;

const ctaBlock = /* groq */ `
  _type == "cta" => {
    ...,
    ${richTextFragment},
    ${buttonsFragment},
  }
`;

const faqAccordionBlock = /* groq */ `
  _type == "faqAccordion" => {
    ...,
    "faqs": array::compact(faqs[]->{
      title,
      _id,
      _type,
      ${richTextFragment}
    })
  }
`;

const teamBlock = /* groq */ `
  _type == "team" => {
    ...,
    ${richTextFragment},
    "members": members[]->{
      _id,
      name,
      position,
      bio,
      "socials": socials{ linkedin, twitter, peerlist },
      ${imageFragment}
    }
  }
`;

Each block uses the _type == "..." => { ... } conditional projection pattern. This means a single pageBuilder[] array query can handle every block type polymorphically, no frontend switch statements needed.

4. Compose page-level queries

Finally, combine all fragments into clean, readable page queries:

const pageBuilderFragment = /* groq */ `
  pageBuilder[]{
    ...,
    _type,
    ${heroBlock},
    ${ctaBlock},
    ${faqAccordionBlock},
    ${teamBlock},
    ${reviewsBlock},
    ${imageGridBlock},
    ${mediaBlock},
    ${newsletterBlock},
  }
`;

export const queryHomePageData = defineQuery(/* groq */ `
  *[_type == "homePage" && _id == "homePage"][0]{
    ...,
    _id,
    _type,
    "slug": slug.current,
    title,
    description,
    ${pageBuilderFragment}
  }
`);

export const queryBlogSlugPageData = defineQuery(/* groq */ `
  *[_type == "blog" && slug.current == $slug][0]{
    ...,
    "slug": slug.current,
    ${blogAuthorFragment},
    ${imageFragment},
    ${richTextFragment},
    overview,
    publishedAt,
    _updatedAt,
    "faqs": faqs[]{
      _key,
      question,
      "answer": answer[]{
        ...,
        ${markDefsFragment}
      }
    },
    ${pageBuilderFragment}
  }
`);

Look at that queryBlogSlugPageData. It pulls in author data, images, rich text, FAQs, and an entire page builder, and it's still readable. You can see exactly what data you're fetching at a glance. Try doing that with a 120-line inline query.

Best practices for production GROQ

If you've made it this far, you already know GROQ can either be a joy or a long-term liability. A few production habits save you from debugging nightmares six months later when no one remembers who wrote that query or why.

Type-safe query definitions

Do yourself a favour and always wrap queries with defineQuery from next-sanity. It keeps your types in sync, catches silly mistakes before they hit prod, and stops your editor from acting like it has no idea what data you're asking for.

import { defineQuery } from "next-sanity";

// Types are automatically inferred from the query
export const getPageQuery = defineQuery(/* groq */ `
  *[_type == "page" && slug.current == $slug][0]{
    title,
    description,
    ${pageBuilderFragment}
  }
`);

Smart data transformation

GROQ gives you built-ins that can do half your cleanup before the data even reaches your frontend. Use them. Format dates, slice arrays, merge fields, and filter edge cases right inside the query. It keeps your components simple and stops you from writing 14 helper functions called formatSomething().

// Let GROQ do the heavy lifting
"href": select(
  url.type == "internal" => url.internal->slug.current,
  url.type == "external" => url.external,
  url.href
),
"alt": coalesce(asset->altText, asset->originalFilename, "Image-Broken"),
"buttons": array::compact(buttons[]{...})

Conditional block rendering

If your frontend is full of if/else to decide which block should show up, you're doing extra work for no reason. Push that logic into GROQ. Let the query decide what gets returned (and what doesn't), so your components only render what actually matters.

pageBuilder[]{
  _type == "hero" => { ..., ${imageFragment} },
  _type == "cta" => { ..., ${buttonsFragment} },
  _type == "faqAccordion" => { ..., ${faqFragment} },
}

Always explicit projections

The ... operator feels convenient, but what happens when shipping fields you didn't even know existed? Don't do that to yourself (or your build times). List every field you actually need. It keeps queries predictable, keeps payloads lean, and stops accidental "why is this field here?" moments.

// Bad — fetches everything, including fields you don't use
const query = groq`*[_type == "blog"][0]{ ... }`;

// Good — explicit about what you need
const query = groq`*[_type == "blog"][0]{
  title,
  "slug": slug.current,
  description,
  publishedAt,
  ${imageFragment},
  ${blogAuthorFragment}
}`;

Always use parameters, not string interpolation

String-interpolating variables into a GROQ query is how security bugs and broken queries sneak in undetected. Use $params every single time. It keeps your queries safe, predictable, and way easier to debug. If you ever catch yourself writing ${id} for a runtime value, please, please stop.

// Bad — string interpolation for runtime values
const query = groq`*[_type == "blog" && slug.current == "${slug}"][0]`;

// Good — parameterized query
const query = groq`*[_type == "blog" && slug.current == $slug][0]`;
// Then pass { slug } as params to your fetch call

Use consistent naming conventions

Name your fragments so future-you (or your team) can understand them. Stick to a simple rule like suffixing reusable bits with Fragment and full components with Block. Just remember, the name should be as descriptive as possible.

// Atomic — describes what it resolves
const imageFragment = /* groq */ `...`;
const buttonsFragment = /* groq */ `...`;
const customLinkFragment = /* groq */ `...`;

// Block-level — describes what component it powers
const heroBlock = /* groq */ `...`;
const ctaBlock = /* groq */ `...`;
const faqAccordionBlock = /* groq */ `...`;

// Page-level — uses defineQuery, prefixed with query verb
export const queryHomePageData = defineQuery(/* groq */ `...`);
export const queryBlogSlugPageData = defineQuery(/* groq */ `...`);

Organize by complexity

Keep atomic pieces together, bundle mid-level components separately, and stash full page-level blocks in their own folder. Or at minimum, keep them in order within a single file, atomic at the top, page-level exports at the bottom.

Use TypeScript comments

Use /* groq */ before your queries so your editor highlights them instead of treating them like sad multiline strings. It makes scanning, debugging, and refactoring far easier.

Performance benefits

Once you switch to a fragment-based setup, the performance difference is noticeable.

Instead of copy-pasting the same projection across seven queries (we've all done it, no shame), fragments keep everything DRY and predictable. That means the CDN can cache your responses properly instead of treating every slightly-different query as a new snowflake. You also stop over-fetching half your dataset "just in case," because each fragment forces you to be explicit about what you need. Smaller queries mean faster responses, which leads to fewer Slack messages asking "why is the page still loading?"

Development gets faster, too. When your queries are modular, page-level builds become Lego. Snap pieces together, ship, move on with your day.

What you should actually do next

If there's one thing to take away from this entire breakdown, it's this: stop treating GROQ like a dumping ground for giant, unreadable queries and start treating it like a system you can actually maintain without sacrificing your weekends.

Fragment-based architecture gets you readable queries, less duplication, and smaller payloads. Your teammates will actually be able to onboard into your codebase without a guided tour.

Start small. Build atomic fragments. Compose them into blocks. Then treat page-level queries like Lego builds instead of archaeological digs. And if you want help untangling your current GROQ situation or setting this up the right way from day one, you know where to find us.

Get in touch

Book a meeting with us to discuss how we can help or fill out a form to get in touch

Frequently asked questions

About the Authors

Jono Alford

Founder of Roboto Studio, specializing in headless CMS implementations with Sanity and Next.js. Passionate about building exceptional editorial experiences and helping teams ship faster.

Hrithik
Hrithik

Senior Full-stack Developer

Senior Full-stack Developer with expertise in React, Next.js, and Sanity CMS. Loves building performant web applications and sharing knowledge through technical content.

Sne Tripathi
Sne Tripathi

Account Executive

Account Executive at Roboto Studio, bridging the gap between client needs and technical solutions. Ensures every project delivers real business value.

Aarti Nair
Aarti Nair

Content and Brand Manager

Content and Brand Manager shaping Roboto Studio's voice and narrative. Passionate about storytelling that connects with audiences and drives engagement.

Get in touch

Book a meeting with us to discuss how we can help or fill out a form to get in touch