How to build hierarchical href paths from parent-child references in GROQ?

32 replies
Last updated: Oct 12, 2021
Hello there, I have a child->parent hierarchy reference setup and I would like to create some sort of href variable for the hierarchy. Let’s say that you have the root parent called “Root” which has one child and one grand child. The href for the root page would then be “root” say. Its child would have have “root/child” as href, and finally the grand child “root/child/grandchild”. I have a groq query similar to this:
*[type == 'content'] {
  ...,
  "children": *[_type == 'content' && references(^.^._id)] {
    ...,
    children[]->
  }
}
How would I add a “href” variable to this query that would be based on its parent document?
AI Update

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:

  1. Store the full path in each document (recommended for performance)
  2. 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:

  1. Detect which document changed
  2. Recursively build its path by walking up parent references
  3. Update the document's fullPath field
  4. 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 thread
32 replies
Could you add it to the document as a slug? That’s how I’ve done it, then I use a custom async slugyfier to fetch the parent and build the slug based on the parent’s slug. That way you always get a full path from root to grand child of any level automatically since each level’s slug is built on the previous one.
Ooh that’s sounds cool, never used slugs before, could you give me a hint? 🙂
Forgot to mention that you need to add a reference from child-&gt;parent for it to work
Sorry, missed that there was a magic function in the first snippet
Cool, thanks! Trying to log out input and type arguments to the console, but nothing gets put out, did you experience something similar?
Not sure if the method is running or not
sorry, missed one field
add this to the options object on the
slug
field:
source: (doc, options) => ({ doc, options }),
inside the options object?
yep
options: {
        source: (doc, options) => ({ doc, options }),
        slugify: asyncSlugifier,
      },
Some more information about the slug type: https://www.sanity.io/docs/slug-type
Great, will check it out, thanks a bunch!
No worries 🙂
I made a little write up about this and published on my blog. If you have the time I’d love your input.
For sure
Btw have you managed to create a slur automatically, updating once a parent document’s slur changes or a parent is added to a child?
Cheers mate, I appreciate it 🙂 https://maxkarlsson.dev/blog/how-to-make-hierarchical-slugs-in-sanity
Let me know if something is unclear or I missed something
I haven’t, but you could do that with webhooks and a serverless function for example. It wouldn’t work with that async slugifier function. It’s only called when you click the button in the studio so it’s not aware of any changes unless it’s called.
The method to build the slug would be the same, you just need a way to react to the changes to a document, then query all documents that refer to that parent and update them.
Great article! And a hip site also 💯
Haha cheers mate
jag tänkte väl att det var något väldigt svenskt över ditt namn 😄
läste din bio
sitter du i melbourne nu eller?
Haha jepp
sweet!
Ah, har bott här i snart 5 år totalt
varit där en gång å hälsa på släkt å vänner för massa år sen, ballt ställe
Ja, jag trivs bra här. Kom hit som backpacker för 10 år sen, åkte lite fram och tillbaka i några år tills vi flyttade ner permanent
Hi
user S
I'm using your wonderful hierarchical slug solution and I ran in to a strange problem. I noticed that pages with no parent set are displaying 'Untitled' in the Parent reference field if I open them again after the initial publish. If I then press the generate button on the slug field of any child pages, the slug breaks.
Could it be that an initialValue is set? It shouldn't automatically set the parent to anything at all unless you select it 🤔

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?