Querying for draft versions of referenced documents in Sanity.io

18 replies
Last updated: Mar 30, 2020
Hey all, I have an issue with a GROQ query.
There are 2 schemas:
blogPost
and
category
. Each document of type
blogPost
references to a
category
.Now, if I edit a category, a new copy of the published document is created with id
drafts.[id_of_published_doc]

I want to be able to display all the
blogPost
documents for a draft category — what is the best way to query all
blogPost
that reference a category, even when the current category is a draft and not the published document?
Before I found out about this "draft" issue, the query was relying on matching the
_ref
with the
_id


*[_type == "category"] {
	name,
	"allBlogPosts": *[_type == "blogPost" && category._ref == ^._id ] {
		title,
	}
}
So far I tried to rely of draft and published versions to have the same
slug
, but the query doesn't seem to work:
*[_type == "category"] {
	name,
	"allBlogPosts": *[_type == "blogPost" && category->slug.current == ^.slug.current ] {
		title,
	}
}

AI Update

The issue you're running into is a classic challenge with Sanity's draft/published document architecture. When a category is edited, it creates a separate draft document with the ID drafts.category-123, but your blogPost references still point to the published ID (category-123). Your original query breaks because you're trying to match drafts.category-123 with references pointing to category-123.

The best solution: Use the previewDrafts perspective

Instead of trying to manipulate IDs in your GROQ query, leverage Sanity's perspectives feature. The previewDrafts perspective is designed specifically for this use case—it automatically prioritizes draft documents over published ones when both exist.

When you query with the previewDrafts perspective, Sanity handles the draft/published logic for you. Your original simple query will work perfectly:

*[_type == "category"] {
  name,
  "allBlogPosts": *[_type == "blogPost" && category._ref == ^._id] {
    title,
  }
}

To use this perspective, add it to your API request:

// Using @sanity/client
const query = `*[_type == "category"] { ... }`
const result = await client.fetch(query, {}, { perspective: 'previewDrafts' })

With previewDrafts, when you query for categories, Sanity returns the draft version if it exists (with ID drafts.category-123), but the magic is that references automatically resolve correctly. The blogPost's category._ref pointing to category-123 will properly match the draft category because Sanity understands the draft/published relationship.

Why your slug-based query didn't work

Your second attempt using category->slug.current == ^.slug.current was close conceptually, but the dereferencing operator (->) follows the actual reference stored in the blogPost. Since references point to the published document ID, you're dereferencing to the published category, not the draft you're currently iterating over. This creates a mismatch.

Alternative: Manual ID matching (if you can't use perspectives)

If you're in a situation where you can't use perspectives (like in certain webhook contexts), you can manually handle the ID matching by stripping the drafts. prefix:

*[_type == "category"] {
  name,
  "publishedId": select(
    _id match "drafts.*" => string::split(_id, "drafts.")[1],
    _id
  ),
  "allBlogPosts": *[_type == "blogPost" && category._ref == ^.publishedId] {
    title,
  }
}

This works because references in Sanity always point to the published document ID (without the drafts. prefix), even when referencing documents that only exist as drafts.

For production vs. preview environments

Use different perspectives based on your environment:

  • Production: Use the published perspective to show only published content
  • Preview/Draft: Use the previewDrafts perspective to show draft content

This gives you a clean separation without complex query logic, and it's the approach Sanity recommends for presenting and previewing content.

Hi
user S
, did you find a solution for this? I’m having the same problem.
No, I haven't. I was hoping someone from the Sanity team could help!
Yes that would be helpful! Maybe
user M
can point us in the right direction?
Hi both! Just to make sure I’m understanding the issue correctly, could you explain why you might need the draft version of the
category
document for the
blogPost
reference?
I just checked but it seems like the reference to the
category
maintains the original, non-draft
_id
and the original
category
document with the non-draft
_id
still exists too (as you said, it’s a copy), so it continues to function correctly. Also, even though a
category
is in draft status, it will still add the non-draft
_id
to the
_ref
field when adding the category as a reference.
Finally, when querying for all categories through
*[_type == "category"]{_id}
, it gives both the non-draft and the draft version. You can filter out the draft versions though:
*[_type == 'category' && !(_id in path("drafts.**"))]
What am I missing to help you guys?
🙂
P.S. If you want to query both the draft and the non-draft
_id
in the query above, you could try something like this:
*[_type == "category"] {
	name,
	"allBlogPosts": *[_type == "blogPost" && ^._id match category._ref] {
		title,
	}
}
Thanks
user M
! I’m not sure about Marco’s case, but in my situation I have an artist document and artwork objects. Artwork objects have a reference to an artist.
On an artist-page I query some data from the artist document like so
*[_type == 'artist' && slug.current == $slug][0]
, but also all artworks that have a reference to the artist. like
*[_type == "artwork" && references(^._id)]
. Now when I want to preview a draft of an artist-page like
*[_type == 'artist' && slug.current == $slug && _id in path('drafts.**')][0]
the query for the artworks refers to the draft
_id
and subsequently no artworks show up they are connected to the to the public version of the artist, and not the draft version.
So what I think I’m looking for is a way to have
^._id
but remove the
drafts.
-prefix with some kind of string manipulation. Hope that’s clear!
Hi Bauke, thanks for explaining! I think I’m with you now. I’m not sure what your artist reference field in
artwork
is called, but let’s say it’s called
artist
for this example. Then could you try replacing the artwork query by something like this?
*[_type == "artwork" && ^.id match artist._ref]
Thanks, I tried your solution, but I’m afraid it didn’t work. The artist reference is indeed called
artist
but I think the problem is the same: the
^.id
in that solution still points to the draft-id while the artwork expects the non-draft-id.
If it helps, here is the complete original query and the query with your solution:


*[_type == 'artist' && slug.current == $slug && _id in path('drafts.**')][0] {
  _id,
  someOtherStuff,
  "artworks": *[_type == "artwork"&& references(^._id)] {
    _id,
    someOtherStuff,
  }
}

*[_type == 'artist' && slug.current == $slug && _id in path('drafts.**')][0] {
  _id,
  someOtherStuff,
  "artworks": *[_type == "artwork" && ^.id match artist._ref] {
    _id,
    someOtherStuff,
  }
}
If I’m not mistaken, the
^.id
in the above queries refer to the same.
Indeed, they do refer to the same draft
_id
but
match
should let you find a match between the draft and non-draft ids, or so I thought anyway 😉 Could you try it the other way around just for fun?
... && artist._ref match ^.id
If it doesn’t work, I’ll have a closer look at this.
Alright, I’ll give that a try!
Both ways round give an Internal server error.
Hmm, that shouldn’t happen though. Do you have any repo by any chance that you could share a link to in DM? I’m happy to try and get this working for you.
I think my use case is quite similar. On non-production links, for each document I always pick the draft version (if there is one) over the published document.
In my case, each
blogPost
document holds a reference to
category
document (e.g. this blog post is in the "Travel" category).
In the website, there is a page for each category showing all of the blog posts that belong to that category.

My issue comes up when the category is the draft (and not the published version). How can I select all of the
blogPost
documents that referenced the published version of the current category draft?
Hope the explanation is clear, thank you!
user M
I tried the query that you suggested:

*[_type == "category"] {
	name,
	"allBlogPosts": *[_type == "blogPost" && ^._id match category._ref] {
		title,
	}
}
and I get an Internal Server Error too
Hi both, I was just able to reproduce the Internal Server Error that you’re both getting. Seems like the query works when you replace
^._id
with an actual draft id string but not as an enclosed record reference. I’ll share that with the team because it seems strange.
There are workarounds though, like the one Marco already kind of suggested in the original post — but with the $slug variable that Bauke has in his implementation as it won’t work with
^.slug.current
for some reason:

*[_type == 'artist' && slug.current == $slug][0] {
  _id,
  someOtherStuff,
  "artworks": *[_type == "artwork" && artist->slug.current == $slug] {
    _id,
    someOtherStuff
  }
}
Now let’s figure out a way to fix this for Marco too without needing a separate query for the slug
😉
Did some more testing. I think this is a more solid solution to the issue:
*[_type == 'artist' && slug.current == $slug] | order(_updatedAt desc)[0] {
  _id,
  someOtherStuff,
  "artworks": *[_type == "artwork" && (artist._ref == ^._id || 'drafts.' + artist._ref == ^._id)] {
    _id,
    someOtherStuff
  }
}
The
order
is specified to ensure it picks the most recently updated artist document for the current slug. Afterwards it checks for the
artist._ref
with and without a
'drafts.'
string in front of it to find a match. I couldn’t find an easy way to slice
drafts.
off the
^._id
so I added it to the
_ref
instead. Can’t beat ’em, join ‘em 🤷‍♂️
This should work in Marco’s case as well:

*[_type == "category"] {
	name,
	"allBlogPosts": *[_type == "blogPost" && (category._ref == ^._id || 'drafts.' + category._ref == ^._id)] {
		title,
	}
}
Hey Peter, I just tried the query that you suggested and it worked! I didn't know I could do string concatenation like that in a GROQ query.
Thank you so much again for looking into this — hopefully we helped finding some edge case-related bug that will improve Sanity overall!
To be honest, I didn’t know either until I tried today 😄 I think you might not need the parentheses and even
_type == "blogPost"
in the
allBlogPosts
query but it doesn’t harm to keep them.
Thanks both for reporting this and insisting to have it looked at! We won’t find all these edge cases without you

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?