npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

sanity-plugin-recursive-hierarchy

v2.0.4

Published

A fully featured toolset for managing complex "node & leaf" tree hierarchies as recursive lists in the Sanity Studio -- e.g. a tree of "category" nodes ending in "product" leaves.

Downloads

2,361

Readme

sanity-plugin-recursive-hierarchy

Installation

pnpm install sanity-plugin-recursive-hierarchy

Getting started

To use this plugin you need three things:

  1. Plugin configuration -- Register the plugin with your tree definitions in sanity.config.ts. See Plugin setup.
  2. Schema types -- Define node and leaf document types with the required fields and the MapUpdater input component. See Schema requirements for the field conventions and MapUpdater component for wiring up live map sync.
  3. Initial value templates -- Use the provided helpers to generate and register templates for each tree so that documents created from within the tree in the Sanity Structure Tool are pre-filled with the correct initial field values. See Initial value templates.
  4. Structure builder calls -- Add calls to recursiveListSingleOrMultiLocale to your Sanity Structure Tool config. You should use recursiveListSingleOrMultiLocale once each for "browse" mode and "manage" mode for each tree, so the tree(s) are visible and editable in the Studio. See Structure builders.

Features

  • Multiple independent trees: Register one or more tree configurations (e.g. sections/articles AND categories/products) in a single plugin instance.
  • Browse mode: Navigate a tree of "node" documents (e.g. sections) to find "leaf" documents (e.g. articles) nested under them.
  • Manage mode: Navigate and manage the node hierarchy itself, creating, editing, and reorganizing parent-child relationships.
  • Multi-locale support: Optionally scope each tree per locale with a locale picker, or run without language filtering. Different trees can have different language configurations.
  • Live map updates: An in-memory map of each tree is maintained and incrementally updated as documents change, keeping the structure pane in sync without full reloads.
  • Duplicate action wrapping: The built-in duplicate action is wrapped so that duplicated documents are correctly placed in the tree and the map is updated.
  • **MapUpdater input component**: Embed in node/leaf schema types to trigger map recalculations when parent references, titles, or slugs change.

Schema requirements

For the plugin to work correctly, node and leaf documents must follow these conventions (see the Fields and Generators section below for implementation helpers):

  • Node documents (e.g. section):
    • Must have title and slug fields.
    • Must have a parent reference field pointing to another node document (or be undefined for root nodes).
    • Must have a topLevelNode boolean field. Top-level nodes are identified by topLevelNode == true or !defined(parent).
    • Optionally have a language field (name configurable via languageFieldName per tree).
  • Leaf documents (e.g. article):
    • Must have title and slug fields.
    • Must have a parents field that is an array of references to node documents.
    • Must have a parentTitles field (use the exported leafParentTitlesField constant). This is a system-managed, read-only string field used for sorting leaves by their parent hierarchy.
    • Optionally have a language field (name configurable via languageFieldName per tree and added to your schema types using the generateLanguageField generator).

Fields and Generators

The plugin exports pre-built field definitions so you don't need to define these fields manually. Import them from the plugin entry point:

import {
  titleField,
  topLevelNodeField,
  leafParentTitlesField,
  generateLanguageField,
  generateNodeParentField,
  generateSlugField,
  generateLeafParentNodesField,
} from 'sanity-plugin-recursive-hierarchy'

| Export | Kind | Applies to | Description | | ------------------------------------------------------------------------------------ | --------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | titleField | Constant | Node & leaf (required) | title string field with validation. | | generateSlugField({treeId, basePath?, languageFieldName?}) | Generator | Node & leaf (required) | slug field of type urlSlug with URL prefix resolution for the given tree. Optional basePath prepends a static path segment (e.g. 'store'). Optional languageFieldName reads the document's language field to include in the prefix (must match tree config). | | topLevelNodeField | Constant | Node only (required) | Hidden topLevelNode boolean field (default true). | | generateNodeParentField({nodeType, title?, description?, languageFieldName?}) | Generator | Node only (required) | parent reference field, hidden when topLevelNode is true. Optional languageFieldName filters the reference picker to show only nodes matching the current document's language. | | generateLeafParentNodesField({nodeType, title?, description?, languageFieldName?}) | Generator | Leaf only (required) | parents array-of-references field pointing to the given node type. Optional languageFieldName filters the reference picker to show only nodes matching the current document's language. | | leafParentTitlesField | Constant | Leaf only (required) | Read-only parentTitles string field, system-managed. Stores parent node titles for sorting. To hide from editors: defineField({ ...leafParentTitlesField, hidden: true }). | | generateLanguageField({languageFieldName}) | Generator | Node & leaf (optional) | Hidden, read-only language string field. The languageFieldName param sets the field name (must match tree config). You should use this if you have a tree that is managing internationalized documents (with or without the @sanity/document-internationalization plugin). |

To add or override Sanity field properties (fieldset, group, hidden, etc.), spread the constant or generator output into your own defineField() call — e.g. defineField({ ...topLevelNodeField, fieldset: "hierarchy" }). Be sure you understand what the field helper's defaults are doing so you avoid overwriting expected behavior for the recursive list (e.g. topLevelNode is true by default). See the MapUpdater component section for complete examples.

Plugin setup

Register the plugin in your sanity.config.ts with a trees array. Each tree gets a unique id and its own documentTypes:

import {recursiveNestedListPlugin} from 'sanity-plugin-recursive-hierarchy'

export default defineConfig({
  // ...
  plugins: [
    recursiveNestedListPlugin({
      trees: [
        {
          id: 'sections',
          documentTypes: {node: 'section', leaf: 'article'},
          languageFieldName: 'language',
          getLocales: () => [{id: 'en', title: 'English'}],
          descendentNode: {
            initialValuesFromParent: [{name: 'department', type: 'string'}],
          },
        },
        {
          id: 'categories',
          documentTypes: {node: 'category', leaf: 'product'},
          // No languageFieldName — this tree is not managing internationalized documents.
        },
      ],
    }),
    // ...other plugins
  ],
})

Tree configuration

Each entry in the trees array accepts:

| Option | Type | Required | Description | | ------------------- | ------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------- | | id | string | Yes | Unique identifier for this tree. Used in map lookups and passed to structure builders and MapUpdater. | | documentTypes | {node: string; leaf: string} | Yes | Schema type names for the parent (node) and child (leaf) documents. | | languageFieldName | string | No | Name of the language/locale field on documents. When omitted, no language filtering is applied. | | getLocales | (client: unknown) => LocaleOption[] | No | Returns available locales. Only used when languageFieldName is set. Enables per-locale trees. | | descendentNode | TreeDescendentNodeConfig | No | Configuration for child (descendent) nodes. See Descendent node config. |

Descendent node config

The optional descendentNode sub-config controls behavior when creating child nodes within the tree:

| Option | Type | Description | | ------------------------- | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | initialValuesFromParent | {name: string; type: string}[] | Fields to copy from the parent node when creating a child node. Each entry declares the field name and its Sanity type (e.g. 'string', 'number', 'boolean'). These are registered as template parameters on the child node template. Values cascade down the hierarchy — a child inherits from its parent, and when that child becomes a parent, its children inherit from it. See Parent-to-child inheritance. |

Plugin-level options

| Option | Type | Description | | ----------------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------- | | trees | TreeConfig[] | Array of tree configurations (see above). Required. | | t | (key: string, language?: string, params?: Record<string, unknown>) => string | Translation function for UI labels. Falls back to returning the key as-is. | | createStructureFilter | () => FilterBuilder | Factory for the chainable filter builder used in GROQ queries. A minimal default is provided. | | icons | {plus?: ComponentType; folderTree?: ComponentType} | Custom icons for "create" menu items and folder tree nodes. | | apiVersion | string | Sanity API version string. Defaults to SANITY_API_VERSION env var or v2026-02-01. |

Language modes

Each tree independently supports three language configurations:

  1. No language (languageFieldName omitted): A single tree is built for all documents. No language filtering is applied.
  2. Single locale (languageFieldName set, getLocales returns one locale): Documents are filtered by language but no locale picker is shown.
  3. Multi-locale (languageFieldName set, getLocales returns multiple locales): A locale picker is shown and each locale has its own tree.

Different trees can use different modes — for example, sections/articles with multi-locale and categories/products without any language field.

Using with @sanity/document-internationalization

If you use the [@sanity/document-internationalization](https://github.com/sanity-io/document-internationalization) plugin alongside this plugin, keep the following in mind:

  • Shared language field: The languageFieldName in this plugin's tree config must match the languageField configured in @sanity/document-internationalization. Both plugins read and write the same field on the document. You can optionally use the generateLanguageField helper to add a hidden, read-only field to your schema types, or you can define the field yourself.
  • Plugin order doesn't matter: Both plugins are additive — they can be registered in any order in your sanity.config.ts.
  • Filtering @sanity/document-internationalization templates: This plugin automatically filters its own templates and the base Sanity templates (whose templateId equals the schemaType) from the new-document menus. However, @sanity/document-internationalization registers additional per-language templates (e.g. article-en, section-parameterized) for each schema type it manages. These would add extra create buttons alongside the plugin's own context-aware create button. Documents created through those templates won't have parent references or inherited values set. The sanity-plugin-recursive-hierarchy plugin exports filterDocumentInternationalizationOptions to handle this. Pass it your trees and the same languages array you use for @sanity/document-internationalization:
import {
  filterDocumentInternationalizationOptions,
  recursiveHierarchyPlugin,
} from 'sanity-plugin-recursive-hierarchy'

const LANGUAGES = [
  {id: 'en', title: 'English'},
  {id: 'fr', title: 'French'},
]

export default defineConfig({
  // ...
  plugins: [recursiveHierarchyPlugin({trees: TREES})],
  document: {
    newDocumentOptions: filterDocumentInternationalizationOptions(TREES, LANGUAGES),
  },
})

By default filterDocumentInternationalizationOptions filters in all contexts (global menu, structure, document). If you want to keep the @sanity/document-internationalization options in the global "+" menu but remove them from structure contexts, pass {filterGlobal: false}:

newDocumentOptions: filterDocumentInternationalizationOptions(TREES, LANGUAGES, {
  filterGlobal: false,
}),

The helper only targets @sanity/document-internationalization templates. It determines which of your trees are internationalized (those with languageFieldName set), then builds the set of template IDs the plugin would register for those types ({type}-parameterized and {type}-{languageId}) and filters them out.

To compose this with your own custom newDocumentOptions logic, call the returned function inside your resolver and continue filtering the result:

const filterIntlOptions = filterDocumentInternationalizationOptions(TREES, LANGUAGES)

export default defineConfig({
  // ...
  document: {
    newDocumentOptions: (prev, context) => {
      const filtered = filterIntlOptions(prev, context)
      // Optionally layer on your own filtering or just return filtered
      return filtered.filter((item) => /* your custom logic */)
    },
  },
})

Using with other plugins that add templates

  • Other plugins that add templates for the same schema types: If you use other plugins that register creation templates for tree-managed document types, you'll need to filter those out yourself in document.newDocumentOptions. Documents created through those templates won't have parent references or inherited values set by the tree structure. The safest approach for tree-managed types is to ensure the only creation paths are the tree structure (which sets parents, inherited values, and language) or the translations tool (which copies all values from the source document).
  • Creating documents outside the Studio: If you create tree-managed documents programmatically (e.g. from a migration script, a webhook handler, or a custom API route using the Sanity Client), you are responsible for setting the fields the plugin normally manages: parent (reference to parent node), topLevelNode, the language field, and any fields listed in initialValuesFromParent. Documents created without these fields will appear orphaned in the tree or be filtered out by language queries.

Initial value templates

The plugin exports createTreeTemplates and getTreeTemplateIds to generate initial value templates from your tree configuration. These templates ensure that documents created from within the tree structure are pre-filled with the correct parent reference, topLevelNode value, language field, and any inherited values.

For each tree, three templates are generated:

| Template ID | Example | Purpose | | ------------------ | -------------------- | -------------------------------------------------------------------------------------------------------------- | | {node}-top | section-top | Creates a top-level node with topLevelNode: true. | | {node}-child | section-child | Creates a child node with parent reference, topLevelNode: false, and any initialValuesFromParent values. | | {leaf}-in-{node} | article-in-section | Creates a leaf document with the parents reference pre-filled to the parent node. |

Register the generated templates in your sanity.config.ts. Define your tree configs once and reuse them for both the plugin and the templates:

import {
  createTreeTemplates,
  getTreeTemplateIds,
  recursiveNestedListPlugin,
  type TreeConfig,
} from 'sanity-plugin-recursive-hierarchy'

const TREES: TreeConfig[] = [
  {
    id: 'sections',
    documentTypes: {node: 'section', leaf: 'article'},
    languageFieldName: 'language',
    getLocales: () => LANGUAGES,
    descendentNode: {
      initialValuesFromParent: [{name: 'department', type: 'string'}],
    },
  },
  {
    id: 'categories',
    documentTypes: {node: 'category', leaf: 'product'},
  },
]

const treeTemplates = createTreeTemplates(TREES)

export default defineConfig({
  // ...
  plugins: [recursiveNestedListPlugin({trees: TREES})],
  schema: {
    types: schemaTypes,
    templates: (prev) => [...prev, ...treeTemplates],
  },
})

Use getTreeTemplateIds in your structure config to pass the correct template IDs:

import {getTreeTemplateIds} from 'sanity-plugin-recursive-hierarchy'

const sectionTemplates = getTreeTemplateIds(trees.find((t) => t.id === 'sections'))
// sectionTemplates.parentNode     → 'section-top'
// sectionTemplates.descendentNode → 'section-child'
// sectionTemplates.leaf           → 'article-in-section'

The plugin automatically filters its own templates and base type templates from the structure context's new document options, so only the plugin's context-aware create button is shown.

What the plugin manages vs. what you manage

The plugin manages these initial values automatically — you should not set these manually:

  • topLevelNode (true for top-level nodes, false for child nodes)
  • parent reference (set to the parent node when creating a child)
  • Leaf parents reference (set to the parent node when creating a leaf)
  • Language field (set to the current locale when languageFieldName is configured)
  • title (pre-filled with a placeholder like "Untitled Child of: Parent Name")
  • Fields listed in descendentNode.initialValuesFromParent (copied from the immediate parent node)

For any other initial values, use Sanity's built-in tooling:

  • defineType({ initialValue: { status: 'draft', featured: false } }) on your schema type — applies to all documents of that type regardless of how they're created
  • schema.templates in sanity.config.ts — compose onto or override specific templates for more targeted control

The plugin intentionally does not provide its own initialValue configuration because Sanity already has well-established, familiar mechanisms for this. Keeping initial values in the schema definition (where they belong) means they apply consistently whether a document is created from the tree structure, the translations tool, or any other flow.

Parent-to-child inheritance

When descendentNode.initialValuesFromParent is configured on a tree, each entry's name and type are automatically:

  1. Registered as template parameters (with the declared type) on the {node}-child template
  2. Read from the parent node when creating a child through the tree structure
  3. Included in the new child document's initial values (only if the parent has a defined value — otherwise the schema type's own initialValue default kicks in)
descendentNode: {
  initialValuesFromParent: [
    {name: 'department', type: 'string'},
    {name: 'priority', type: 'number'},
    {name: 'featured', type: 'boolean'},
  ],
},

Values cascade naturally down the hierarchy: a child node inherits department from its parent via the template. When that child becomes a parent itself and someone creates a child under it, the structure builder reads department from the child-now-parent and passes it down.

initialValuesFromParent is supported on child nodes of a parent node because there is a 1-1 relationship and the nodes are of the same schema type. It is not supported on leaves because leaves can have multiple parents, making it ambiguous which parent's values to inherit, and because leaves are not of the same schema type as their parent nodes.

Structure builders

The plugin exports recursiveListSingleOrMultiLocale to use inside your structureTool configuration. It accepts a treeId parameter that identifies which tree to render.

recursiveListSingleOrMultiLocale

Top-level entry point that renders either a single-locale tree or a locale picker wrapping per-locale trees. Returns a S.listItem().

import {recursiveListSingleOrMultiLocale} from 'sanity-plugin-recursive-hierarchy'

recursiveListSingleOrMultiLocale({
  S,
  context,
  treeId: 'sections',
  mode: 'browse',
  rootListRecursiveListItemTitle: 'Sections',
  rootListRecursiveListItemIcon: FolderIcon,
  topLevelNodeListTitle: 'Browse by Section',
  locales: [{id: 'en', title: 'English', flag: '🇬🇧'}],
  parentNodeIcon: FolderIcon,
  parentMenuCreateItemLabel: 'Create New Section',
  descendentMenuCreateItemLabel: 'Create New Subsection',
  leafMenuCreateItemLabel: 'Create New Article',
  parentNodeDocumentType: 'section',
  parentNodeTemplate: 'section-top',
  descendentNodeTemplate: 'section-child',
  descendentListTitleSuffix: 'Subsections',
  leafMenuSortByParentsItemTitle: 'Parent Sections',
  leafDocumentType: 'article',
  leafTemplate: 'article-in-section',
  leafDocumentTypeLabel: 'Articles',
})

The locales parameter is optional. When omitted (or empty), the plugin uses a single internal default locale with no language filtering — appropriate for trees that don't use languageFieldName.

The create button labels follow the naming convention — parentMenuCreateItemLabel for top-level nodes, descendentMenuCreateItemLabel for child nodes (falls back to parentMenuCreateItemLabel if omitted), and leafMenuCreateItemLabel for leaf documents (falls back to parentMenuCreateItemLabel if omitted). In manage mode, only the node labels are used. In browse mode, only the leaf label is used for the create button.

MapUpdater component

Embed MapUpdater as a custom input component on node and leaf schema types to keep the in-memory tree map in sync as documents are edited. You must pass treeId to indicate which tree the document belongs to.

Use the exported field helpers for the required fields so you don't need to define them by hand.

Node type (minimal):

import { defineType } from "sanity";
import {
  generateNodeParentField,
  generateSlugField,
  MapUpdater,
  titleField,
  topLevelNodeField,
} from "sanity-plugin-recursive-hierarchy";

export const categoryType = defineType({
  name: "category",
  type: "document",
  components: {
    input: (props) =>
      <MapUpdater {...props} treeId="categories" documentType="category" nodeOrLeaf="node" />
  },
  fields: [
    titleField,
    generateSlugField({ treeId: "categories" }),
    topLevelNodeField,
    generateNodeParentField({
      nodeType: "category",
      title: "Parent Category",
      description: "The parent category of this category",
    }),
  ],
});

Leaf type (minimal):

import { defineField, defineType } from "sanity";
import {
  generateLeafParentNodesField,
  generateSlugField,
  leafParentTitlesField,
  MapUpdater,
  titleField,
} from "sanity-plugin-recursive-hierarchy";

export const productType = defineType({
  name: "product",
  type: "document",
  components: {
    input: (props) =>
      <MapUpdater {...props} treeId="categories" documentType="product" nodeOrLeaf="leaf" />
  },
  fields: [
    titleField,
    generateSlugField({ treeId: "categories" }),
    generateLeafParentNodesField({
      nodeType: "category",
      title: "Categories",
      description: "The categories this product belongs to",
    }),
    leafParentTitlesField,
    defineField({ name: "price", type: "number" }),
  ],
});

Customizing field helpers: To add or override Sanity field properties like fieldset, group, hidden, etc., spread the constant or generator output into your own defineField() call. Be sure you understand what the field helper's defaults are doing so you avoid overwriting expected behavior for the recursive list (e.g. topLevelNode is true by default):

import { defineField, defineType } from "sanity";
import {
  generateLanguageField,
  generateNodeParentField,
  generateSlugField,
  MapUpdater,
  titleField,
  topLevelNodeField,
} from "sanity-plugin-recursive-hierarchy";

export const sectionType = defineType({
  name: "section",
  type: "document",
  fieldsets: [{ name: "hierarchy" }],
  components: {
    input: (props) =>
      <MapUpdater {...props} treeId="sections" documentType="section" nodeOrLeaf="node" />
  },
  fields: [
    titleField,
    generateSlugField({ treeId: "sections", languageFieldName: "language" }),
    generateLanguageField({ languageFieldName: "language" }),
    defineField({ ...topLevelNodeField, fieldset: "hierarchy" }),
    defineField({
      ...generateNodeParentField({
        nodeType: "section",
        title: "Sections",
        description: "The parent section of this section",
        languageFieldName: "language",
      }),
      fieldset: "hierarchy",
    }),
  ],
});

leafParentTitlesField is a system-managed field required on leaf documents — it stores a concatenated string of parent node titles used for sorting leaves by their parent hierarchy. If you want to hide this field from editors, spread it into a defineField call with hidden: true:

defineField({...leafParentTitlesField, hidden: true})

MapUpdater listens to changes in the document's title, slug, and parent references, then triggers incremental map updates and (when appropriate) navigates the structure pane to reflect the change.

URL slug object type (optional)

The plugin ships an optional urlSlug object type that provides a slug input with async URL prefix generation. It builds the full URL path from the tree hierarchy using getUrlPrefixFromNodesDictionary, so editors see the complete path (e.g. en/store/clothing/shoes/) as they type.

This is opt-in — you must register the schema type and wire it up yourself.

What it does

  • Displays a text input with a computed URL prefix derived from the node's position in the tree.
  • Stores two fields: current (the slug segment) and fullUrl (the complete path including all parent segments).
  • Shows a loading spinner while the prefix is being resolved.
  • Provides a "Generate" button that slugifies the source field (e.g. title).
  • Warns when the source field has changed and the slug is out of sync.
  • Supports maxLength, source, and urlPrefix options.

Setup

1. Register the schema type in your sanity.config.ts (or schema index):

import {urlSlugObject} from 'sanity-plugin-recursive-hierarchy'

export default defineConfig({
  // ...
  schema: {
    types: [urlSlugObject, ...otherSchemaTypes],
  },
})

2. Use the urlSlug type on a document field. The easiest approach is to use the generateSlugField helper, which wires up urlPrefix resolution automatically:

import { defineType } from "sanity";
import {
  generateSlugField,
  MapUpdater,
  generateNodeParentField,
  titleField,
  topLevelNodeField,
} from "sanity-plugin-recursive-hierarchy";

export const categoryType = defineType({
  name: "category",
  type: "document",
  components: {
    input: (props) =>
      <MapUpdater {...props} treeId="categories" documentType="category" nodeOrLeaf="node" />
  },
  fields: [
    titleField,
    generateSlugField({ treeId: "categories" }),
    topLevelNodeField,
    generateNodeParentField({
      nodeType: "category",
      title: "Categories",
      description: "The parent category of this category",
    }),
  ],
  preview: {
    select: { title: "title", slug: "slug.fullUrl" },
    prepare({ title, slug }) {
      return { title, subtitle: slug || "No URL" };
    },
  },
});

If you need custom urlPrefix logic (e.g. for internationalized base paths), define the slug field manually using getUrlPrefixFromNodesDictionary:

import {defineField} from 'sanity'
import {getUrlPrefixFromNodesDictionary} from 'sanity-plugin-recursive-hierarchy'

defineField({
  name: 'slug',
  type: 'urlSlug',
  validation: (rule) => rule.required(),
  options: {
    source: 'title',
    maxLength: 96,
    urlPrefix: (document: {parent?: {_ref: string}}) => {
      return getUrlPrefixFromNodesDictionary({
        basePath: '',
        language: '',
        parentId: document.parent?._ref ?? '',
        treeId: 'categories',
      })
    },
  },
})

For internationalized trees, you can include basePath and language to scope URLs by locale:

urlPrefix: (document: {parent?: {_ref: string}; language?: string}) => {
  return getUrlPrefixFromNodesDictionary({
    basePath: document.language ? `${document.language}/store` : 'store',
    language: document.language ?? '',
    parentId: document.parent?._ref ?? '',
    treeId: 'sections',
  })
},

urlSlug options reference

| Option | Type | Description | | ----------- | -------- | ------------------------------------------------------------------------------------- | ----------------- | ----------------------------------------------------------------------------------------------------- | | source | string | Field name to generate the slug from (e.g. 'title'). Enables the "Generate" button. | | maxLength | number | Maximum character length for the slug segment. | | urlPrefix | string | ((document: any) => string | Promise<string>) | Static prefix string, or a function that receives the document and returns the prefix (can be async). |

How it looks in the Studio

When editing a category nested under Clothing > Shoes:

┌──────────────────────────────────────────────────────┐
│ Slug                                                 │
│ ┌────────────────────┬──────────────────┬──────────┐ │
│ │ en/store/clothing/ │ shoes            │ Generate │ │
│ └────────────────────┴──────────────────┴──────────┘ │
│ fullUrl: en/store/clothing/shoes                     │
└──────────────────────────────────────────────────────┘

The prefix (en/store/clothing/) is computed automatically from the tree hierarchy. The editor only types or generates the final segment. The fullUrl field is stored but hidden from the UI.

Multi-tree example

A complete structure/index.ts with two independent trees, each with a browse and manage view:

import type {StructureResolver} from 'sanity/structure'
import {FolderIcon, TagIcon} from '@sanity/icons'
import {recursiveListSingleOrMultiLocale} from 'sanity-plugin-recursive-hierarchy'

export const structure: StructureResolver = (S, context) => {
  const locale = {id: 'en', title: 'English', flag: '🇬🇧'}

  // Shared options for the sections/articles tree
  const sectionTreeOptions = {
    S,
    context,
    treeId: 'sections',
    rootListRecursiveListItemIcon: FolderIcon,
    topLevelNodeListTitle: 'Sections',
    locales: [locale],
    parentNodeIcon: FolderIcon,
    parentMenuCreateItemLabel: 'Create New Section',
    descendentMenuCreateItemLabel: 'Create New Subsection',
    leafMenuCreateItemLabel: 'Create New Article',
    parentNodeDocumentType: 'section',
    parentNodeTemplate: 'section-top',
    descendentNodeTemplate: 'section-child',
    descendentListTitleSuffix: 'Subsections',
    leafMenuSortByParentsItemTitle: 'Parent Sections',
    leafDocumentType: 'article',
    leafTemplate: 'article-in-section',
    leafDocumentTypeLabel: 'Articles',
  } as const

  const browseSections = recursiveListSingleOrMultiLocale({
    ...sectionTreeOptions,
    mode: 'browse',
    rootListRecursiveListItemTitle: 'Browse Sections',
  })

  const manageSections = recursiveListSingleOrMultiLocale({
    ...sectionTreeOptions,
    mode: 'manage',
    rootListRecursiveListItemTitle: 'Manage Sections',
  })

  // Shared options for the categories/products tree
  const categoryTreeOptions = {
    S,
    context,
    treeId: 'categories',
    rootListRecursiveListItemIcon: TagIcon,
    topLevelNodeListTitle: 'Categories',
    parentNodeIcon: TagIcon,
    parentMenuCreateItemLabel: 'Create New Category',
    descendentMenuCreateItemLabel: 'Create New Subcategory',
    leafMenuCreateItemLabel: 'Create New Product',
    parentNodeDocumentType: 'category',
    parentNodeTemplate: 'category-top',
    descendentNodeTemplate: 'category-child',
    descendentListTitleSuffix: 'Subcategories',
    leafMenuSortByParentsItemTitle: 'Parent Categories',
    leafDocumentType: 'product',
    leafTemplate: 'product-in-category',
    leafDocumentTypeLabel: 'Products',
  } as const

  const browseCategories = recursiveListSingleOrMultiLocale({
    ...categoryTreeOptions,
    mode: 'browse',
    rootListRecursiveListItemTitle: 'Browse Categories',
  })

  const manageCategories = recursiveListSingleOrMultiLocale({
    ...categoryTreeOptions,
    mode: 'manage',
    rootListRecursiveListItemTitle: 'Manage Categories',
  })

  return S.list()
    .title('Content')
    .items([
      browseSections,
      manageSections,
      S.divider(),
      browseCategories,
      manageCategories,
      S.divider(),
      ...S.documentTypeListItems().filter(
        (item) => !['section', 'article', 'category', 'product'].includes(item.getId() ?? ''),
      ),
    ])
}

Types

import type {
  CreateDraftParams,
  FilterBuilder,
  Locale,
  LocaleOption,
  MapUpdatePayload,
  NodeAndLeafMap,
  PluginOptions,
  SanityDocumentWithDraftInfo,
  StructureMapAndNodesDict,
  TreeConfig,
  TreeDescendentNodeConfig,
  TreeTemplateIds,
} from 'sanity-plugin-recursive-hierarchy'

Develop & test

This plugin uses @sanity/plugin-kit with default configuration for build & watch scripts.

See Testing a plugin in Sanity Studio on how to run this plugin with hotreload in the studio.

Release new version

Run "CI & Release" workflow. Make sure to select the main branch and check "Release new version".

Semantic release will only release on configured branches, so it is safe to run release on any branch.

License

MIT © Johnny Povolny