See Sanity in action 👀 Join us for a live product demo + Q&A →
March 04, 2022

Creating a Parent/Child Taxonomy

By Simeon Griggs

Creating Parent / Child relationships in Sanity goes beyond a parent reference field. In this guide we'll include initial value templates, filtered document lists and guides on how to effectively use and query documents that use these taxonomy schema.

Here's what we'll be building.

  • A category schema type with Parent documents
  • A list item in the Desk for each Parent to edit their Children and
  • Initial value templates to make sure every new Child document begins with its Parent reference pre-filled
Structure builder with parent/child taxonomy relationships. The "Compose" icon here will create a new "Category" document with "Liquorice" already set in the "parent" field.

Protip

If you're looking for an even fancier drag-and-drop Hierarchical Document List, this plugin has you covered!

Taxonomy schema

First, we'll need a schema for our taxonomy. We'll call ours category for this guide. You may call yours tag, section, or something else.

This guide will focus on building a simple, two-tier, parent/child hierarchy. But the ideas here could be extended further to deeper relationships.

  • A "Parent" Category is any category document that does not have the parent field defined.
  • A "Child" Category is any category document that with a parent field reference.

Add the below to your studio files, and register it to your Studio's schema.

// ./src/schema/category.js

import {FiTag} from 'react-icons/fi'

export default {
  name: 'category',
  title: 'Category',
  type: 'document',
  icon: FiTag,
  fields: [
    {name: 'title', type: 'string'},
    {
      name: 'parent',
      type: 'reference',
      to: [{type: 'category'}],
      // This ensures we cannot select other "children"
      options: {
        filter: '!defined(parent)',
      },
    },
  ],
  // Customise the preview so parents are visualised in the studio
  preview: {
    select: {
      title: 'title',
      subtitle: 'parent.title',
    },
    prepare: ({title, subtitle}) => ({
      title,
      subtitle: subtitle ? `– ${subtitle}` : ``,
    }),
  },
}

Initial Value Templates

Before setting up the Desk Structure, make sure you have Initial Value Templates configured in the Studio.

With the right configuration, we can create Document Lists which show all Children of a specific Parent, and when creating a new document from that list pre-fill the parent reference field with that same Parent!

Here's we create a template called "Category: Child". Make sure this file is loaded from the parts in sanity.json also.

// ./src/initial-value-templates/index.js

import T from '@sanity/base/initial-value-template-builder'

export default [
  T.template({
    id: 'category-child',
    title: 'Category: Child',
    schemaType: 'category',
    parameters: [{name: `parentId`, title: `Parent ID`, type: `string`}],
    // This value will be passed-in from desk structure
    value: ({parentId}) => ({
      parent: {_type: 'reference', _ref: parentId},
    }),
  }),
  // Insert all your other Templates
  ...T.defaults(),
]

Setup Structure Builder

Desk Structure is a complex part of the Studio. The code we'll use here is no exception.

Below is a helper function to create structured parent/child lists of documents.

This could be enqueued into your Structure builder along with any other items like this:

// ./src/desk-structure/index.js

import parentChild from './parentChild'

export default () => S.list()
  .title('Content')
  .items([
    S.documentTypeListItem('product').title('Products'),
    parentChild('category'),
    S.divider(),
    S.documentTypeListItem('page').title('Pages'),
  ])

The parentChild() helper function only accepts one parameter for now – the schema type – but you could extend it further for reuse by including parameters for Titles, Icons, etc.

This desk structure item is more dynamic than most. It will query the documentStore for all parent categories and create a S.listItem() for each one. Inside those, it will show all category documents with that parent as a reference.

// ./src/desk-structure/parent-child.js

import S from '@sanity/desk-tool/structure-builder'
import documentStore from 'part:@sanity/base/datastore/document'
import {map} from 'rxjs/operators'
import {FiTag} from 'react-icons/fi'

// You may need to customise your `views` array here for adding live preview iframes, incoming references, etc
const views = [S.view.form()]

export default function parentChild(schemaType = 'category') {
  const categoryParents = `_type == "${schemaType}" && !defined(parent) && !(_id in path("drafts.**"))`

  return S.listItem(schemaType)
    .title('Categories')
    .icon(FiTag)
    .child(() =>
      documentStore.listenQuery(`*[${categoryParents}]`).pipe(
        map((parents) =>
          S.list()
            .title('All Categories')
            .menuItems([
              S.menuItem()
                .title('Add Category')
                .icon(FiTag)
                .intent({type: 'create', params: {type: schemaType}}),
            ])
            .items([
              S.listItem()
                .title('Parent Categories')
                .schemaType(schemaType)
                .child(() =>
                  S.documentList()
                    .schemaType(schemaType)
                    .title('Parent Categories')
                    .filter(categoryParents)
                    .canHandleIntent(() => S.documentTypeList(schemaType).getCanHandleIntent())
                    .child((id) => S.document().documentId(id).views(views).schemaType(schemaType))
                ),
              S.divider(),
              ...parents.map((parent) =>
                S.listItem({
                  id: parent._id,
                  title: parent.title,
                  schemaType,
                  child: () =>
                    S.documentTypeList(schemaType)
                      .title('Child Categories')
                      .filter(`_type == "${schemaType}" && parent._ref == $parentId`)
                      .params({parentId: parent._id})
                      .initialValueTemplates([
                        S.initialValueTemplateItem('category-child', {
                          parentId: parent._id,
                        }),
                      ]),
                })
              ),
            ])
        )
      )
    )
}

Note that accessing the documentStore directly like this is not common and on a larger dataset may produce undesirable results.

Pre-flight check

  • Now you should be able to view and edit a list of Parent documents, as well as clicking into Parents individually to see a list of Child documents.
  • Test the Initial Value Template by creating a new Category document while viewing a list of Children documents, the parent reference should be pre-filled.

Using taxonomy references

Consider when using these taxonomies to restrict references to Children.

For example in a schema of post, instead of an array of references where the author may add Parent and Child category references – have them select only "Child" documents.

// ./src/schema/post.js

import {FiFileText} from 'react-icons/fi'

export default {
  name: 'post',
  title: 'Post',
  type: 'document',
  icon: FiFileText,
  fields: [
    {
      name: 'category',
      type: 'reference',
      to: [{type: 'category'}],
      options: {filter: 'defined(parent)'},
    },
    // ...other fields
  ],
}

Then when querying for a post, "follow" the Child category up to retrieve its parent.

*[_type == "post"]{
  category->{
    parent->
  }
}

Dynamically creating Parent and Child slugs

Each category document has a slug, but in a hierarchal website structure you may wish for Children to be nested inside Parents.

With some a clever GROQ function, we can do that from inside our query.

Here's a basic query for all category titles and slugs:

*[_type == "category"]{
  title,
  "parentSlug": parent->slug.current,
  "slug": slug.current
}

The response will look something like this. Which has the right data, but requires us to post-process the results to build the slug we need.

[
  {
    title: "Liquorice",
    slug: "liquorice"
  },
  {
    title: "Dutch",
    parentSlug: "liquorice",
    slug: "dutch"
  }  
]

Instead, using the select function in GROQ allows us to return a different value depending on a condition. In this case, whether a category has a parent field or not.

select works by returning whichever condition returns true first, and resolves the last item if nothing returns true.

The first condition defined(parent) will be true for any Child category. Otherwise, the fallback is the document's own slug.

*[_type == "category"]{
  title,
  "slug": select(
    defined(parent) => parent->slug.current + "/" + slug.current,
    slug.current
  )
}

This would now instead return data that looks like this:

[
  {
    title: "Liquorice"
    slug: "liquorice"
  },
  {
    title: "Dutch"
    slug: "liquorice/dutch"
  }  
]

Conclusion

Hierarchical document schema like categories express the power of structured content, strong references and GROQ queries.

Your authors should now be able to create and use these taxonomical documents throughout your content with confidence!

Other guides by author

How to implement Multi-tenancy with Sanity

In this guide, you’ll see how Sanity separates organizations, projects, datasets, and members by working through a hypothetical example of a growing company that can expand its content model as they grow – without needing a complete overhaul.

Simeon Griggs
Go to How to implement Multi-tenancy with Sanity