How to build hierarchical href paths from parent-child references in GROQ?
Building hierarchical paths in GROQ is a common challenge when working with parent-child reference structures. The key limitation is that GROQ doesn't have built-in string concatenation or recursive path-building capabilities, so you'll need to work around this.
Understanding the Core Challenge
GROQ can't dynamically build strings like "root/child/grandchild" by traversing up parent references during query execution. You have two practical approaches:
- Store the full path in each document (recommended for performance)
- Build the path client-side after fetching the data
Approach 1: Store Full Paths with Webhooks
The most performant solution is to store the complete hierarchical path in each document and keep it updated automatically. You can use webhooks to trigger path updates when documents change.
First, add a field to your schema to store the full path:
import {defineField, defineType} from 'sanity'
export default defineType({
name: 'content',
type: 'document',
fields: [
defineField({
name: 'title',
type: 'string',
}),
defineField({
name: 'parent',
type: 'reference',
to: [{type: 'content'}],
}),
defineField({
name: 'slug',
type: 'slug',
options: {
source: 'title',
maxLength: 200,
},
}),
defineField({
name: 'fullPath',
type: 'string',
readOnly: true,
description: 'Auto-generated hierarchical path',
}),
],
})Then set up a webhook that triggers an endpoint to rebuild paths when content changes. Your webhook endpoint would:
- Detect which document changed
- Recursively build its path by walking up parent references
- Update the document's
fullPathfield - Update all descendant documents' paths
Here's example logic for your webhook handler:
import {createClient} from '@sanity/client'
const client = createClient({
projectId: 'your-project-id',
dataset: 'your-dataset',
apiVersion: '2025-02-19',
token: process.env.SANITY_WRITE_TOKEN,
useCdn: false,
})
async function buildFullPath(docId: string): Promise<string> {
// Fetch this document and walk up the parent chain
const doc = await client.fetch(
`*[_id == $docId][0]{
"slug": slug.current,
"parent": parent->{
_id,
"slug": slug.current,
"parent": parent
}
}`,
{docId}
)
if (!doc || !doc.slug) return ''
// Build path by recursively walking up parents
const pathSegments = [doc.slug]
let currentParent = doc.parent
while (currentParent && currentParent.slug) {
pathSegments.unshift(currentParent.slug)
currentParent = currentParent.parent
}
return pathSegments.join('/')
}
async function updateDocumentPath(docId: string) {
const fullPath = await buildFullPath(docId)
await client.patch(docId).set({fullPath}).commit()
// Update all children recursively
const children = await client.fetch(
`*[_type == "content" && parent._ref == $docId][]._id`,
{docId}
)
for (const childId of children) {
await updateDocumentPath(childId)
}
}
// Your webhook handler
export async function POST(request: Request) {
const body = await request.json()
const docId = body._id
await updateDocumentPath(docId)
return Response.json({success: true})
}Once paths are stored, your query becomes simple and performant:
*[_type == 'content'] {
...,
"href": fullPath,
"children": *[_type == 'content' && references(^._id)] {
...,
"href": fullPath
}
}Approach 2: Build Paths Client-Side
If you prefer not to store paths, fetch the data flat and build the hierarchy in your application code. This gives you more flexibility but requires client-side processing.
*[_type == 'content'] {
_id,
title,
"slug": slug.current,
"parentId": parent._ref
}Then build paths in JavaScript:
function buildHrefMap(docs: any[]) {
const hrefMap = new Map<string, string>()
const docMap = new Map(docs.map(d => [d._id, d]))
function getHref(docId: string): string {
if (hrefMap.has(docId)) {
return hrefMap.get(docId)!
}
const doc = docMap.get(docId)
if (!doc || !doc.slug) return ''
if (!doc.parentId) {
// Root document
hrefMap.set(docId, doc.slug)
return doc.slug
}
// Build path from parent
const parentHref = getHref(doc.parentId)
const href = parentHref ? `${parentHref}/${doc.slug}` : doc.slug
hrefMap.set(docId, href)
return href
}
// Build hrefs for all documents
docs.forEach(doc => getHref(doc._id))
return hrefMap
}
// Usage
const docs = await client.fetch(`*[_type == 'content'] {...}`)
const hrefMap = buildHrefMap(docs)About GROQ's join() Function
You might have seen references to string::join() or array::join() in GROQ. While GROQ does have a join function for joining array elements into strings (like turning ["a", "b", "c"] into "a/b/c"), you can't use it to recursively traverse parent references and build hierarchical paths in a single query. The join function works on arrays you already have, not on dynamically building paths through references.
Which Approach to Choose?
Stored paths with webhooks: Best for production sites where performance matters, SEO-critical URLs, and when you need paths immediately available in queries. The webhook keeps paths synchronized automatically.
Client-side building: Better for simpler setups, internal tools, or when you need flexible path logic that changes frequently. No server-side infrastructure needed beyond your Sanity queries.
Note About Your Query
Your query uses references(^.^._id) which traverses up two scope levels. For finding direct children of a document, you typically want references(^._id) with a single parent operator. The ^.^ pattern would be looking for documents that reference the grandparent context, which usually isn't what you need for parent-child relationships.
The stored path approach gives you the best query performance and is the most common pattern for hierarchical URLs in production Sanity projects.
Show original thread32 replies
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.