Skip to content

GROQ projections that stop your blog index fetching every article body

*[_type == "post"] returns whole documents, drafts of fields included. Project explicitly, dereference in the query, and slice every list.

Sanity blog3 min readships at docs/solutions/blog-sanity/groq-projections-that-avoid-over-fetching.md

Tags: sanity · groq · performance · queries · projections

The blog index is slow, the response is two megabytes, and the page shows a title, a date and one line of text per post.

Look at the query and the reason is usually the first one anyone writes:

*[_type == "post"] | order(publishedAt desc)

That returns every field of every post: the full Portable Text body, the raw image objects, internal metadata, and any field anyone has ever added to the schema. Twenty posts with a thousand words each is a megabyte of article text sent to a page that renders none of it.

The problem is not that GROQ is slow. It is that you did not tell it what you wanted.

Project explicitly

*[_type == "post" && defined(slug.current)]
  | order(publishedAt desc)[0...$limit] {
    _id,
    title,
    "slug": slug.current,
    excerpt,
    publishedAt
  }

Four things happened there, and each one is doing work:

The projection block. Only the five fields listed come back. The body is not transferred, not parsed, and not held in memory.

"slug": slug.current. Sanity stores a slug as { _type: "slug", current: "..." }. Flattening it in the query means your TypeScript type is string instead of an object you have to reach into at every call site.

[0...$limit]. A slice. Without one, a query returns everything, and the day someone imports 4,000 posts your index page tries to render all of them. GROQ ranges are exclusive at the top: [0...20] is twenty documents.

defined(slug.current). A post with no slug has no URL, so it cannot be linked. Filtering it out in the query is cheaper than filtering in JavaScript and keeps the count honest.

Dereference in the query, not in a loop

The other half of over-fetching is under-fetching, then patching it up with extra round trips:

// two hundred requests waiting to happen
const posts = await client.fetch(POSTS_QUERY);
for (const post of posts) {
  post.author = await client.fetch(`*[_id == $id][0]{name}`, { id: post.authorRef });
}

GROQ dereferences inside the projection with ->:

{
  _id,
  title,
  "author": author->{ name, "image": image.asset->url },
  "categories": categories[]->title
}

author-> follows a single reference. categories[]-> follows an array of them. Both happen inside the one query Sanity already runs, so the cost is a join in their database rather than a network round trip per row.

Share fragments instead of duplicating projections

Once three queries return "a post card", the projections drift and one of them quietly stops selecting excerpt:

const CARD_FIELDS = /* groq */ `
  _id,
  title,
  "slug": slug.current,
  excerpt,
  publishedAt,
  "author": author->{ name }
`;

export const POSTS_QUERY = defineQuery(`
  *[_type == "post"] | order(publishedAt desc)[0...$limit] { ${CARD_FIELDS} }
`);

export const RELATED_POSTS_QUERY = defineQuery(`
  *[_type == "post" && slug.current != $slug] | order(publishedAt desc)[0...3] { ${CARD_FIELDS} }
`);

One definition, one place to add a field, one shape for the TypeScript type.

Project inside Portable Text too

A body query that stops at body gives you image blocks containing a bare reference, which forces a second lookup per image at render time. Project into the array:

body[] {
  ...,
  _type == "image" => {
    alt,
    asset->{ _id, metadata { lqip, dimensions } }
  },
  markDefs[] {
    ...,
    _type == "internalLink" => { "slug": @.reference->slug.current }
  }
}

... keeps every other field of the block as-is; the conditional projections add resolved data for the two types that need it. @ refers to the current node, which is how you reach into a mark definition.

This is the difference between a post page that renders internal links instantly and one that fetches a slug per link.

Measuring it

Use the Vision tool in your Studio (/studio, then Vision). It shows the execution time and the response size for a query, so you can compare two projections directly instead of guessing:

  • Run the unprojected query, note the size.
  • Run the projected one. On a real dataset, a well-projected index query is typically a tenth of the raw document set, often less.

Then check the fields you kept are all rendered. A projection that returns a field nothing displays is the same bug in miniature, and it is the one people add back "just in case".

The rules worth remembering

  • Never *[...] without a projection block.
  • Never select body in a list query.
  • Every list query is ordered and sliced.
  • Dereference with -> inside the query; never fetch in a loop.
  • Flatten slug.current at the query boundary.
  • Keep every query in one file so these rules can be checked in review.