Building a directory tree structure for document organization in Sanity desk tool
Yes, you can definitely build a directory tree structure in Sanity! The key is using a parent reference field on both your directory documents and content documents, then leveraging the Structure Builder's child resolvers to create the navigation hierarchy.
Schema Setup
First, create a directory document type with a parent reference to itself:
// schemas/directory.ts
export default {
name: 'directory',
type: 'document',
title: 'Directory',
fields: [
{
name: 'title',
type: 'string',
title: 'Directory Name'
},
{
name: 'parent',
type: 'reference',
title: 'Parent Directory',
to: [{type: 'directory'}],
description: 'Leave empty for root-level directories'
}
]
}Then add a parent reference to your content documents:
// schemas/article.ts (or whatever your type A is)
export default {
name: 'article',
type: 'document',
fields: [
// ... your existing fields
{
name: 'parent',
type: 'reference',
title: 'Parent Directory',
to: [{type: 'directory'}],
description: 'Organize this document in a directory'
}
]
}Structure Builder Implementation
Here's how to build the tree view using child resolvers. The important thing to understand is that child resolvers respond to user navigation - when someone clicks on a directory, the child() function determines what shows in the next pane:
// structure/index.ts
import type {StructureResolver} from 'sanity/structure'
export const structure: StructureResolver = (S) =>
S.list()
.title('Content')
.items([
S.listItem()
.title('📁 Content Tree')
.child(
S.list()
.title('Root')
.items([
// Root-level directories
S.listItem()
.title('Directories')
.child(
S.documentList()
.title('Root Directories')
.filter('_type == "directory" && !defined(parent)')
.child((documentId) =>
// This child resolver is called when a directory is clicked
S.list()
.title('Contents')
.items([
// Subdirectories in this directory
S.listItem()
.title('📁 Subdirectories')
.child(
S.documentList()
.title('Subdirectories')
.filter(`_type == "directory" && parent._ref == $parentId`)
.params({parentId: documentId})
.child((childDocId) =>
// Recursive navigation - same structure for subdirectories
S.list()
.title('Contents')
.items([
S.listItem()
.title('📁 Subdirectories')
.child(
S.documentList()
.title('Subdirectories')
.filter(`_type == "directory" && parent._ref == $parentId`)
.params({parentId: childDocId})
// You can nest this pattern as deep as needed
),
S.listItem()
.title('📄 Documents')
.child(
S.documentList()
.title('Documents')
.filter(`_type == "article" && parent._ref == $parentId`)
.params({parentId: childDocId})
),
S.divider(),
S.documentListItem()
.id(childDocId)
.schemaType('directory')
.title('Edit this directory')
])
)
),
// Documents in this directory
S.listItem()
.title('📄 Documents')
.child(
S.documentList()
.title('Documents')
.filter(`_type == "article" && parent._ref == $parentId`)
.params({parentId: documentId})
),
S.divider(),
// Allow editing the directory itself
S.documentListItem()
.id(documentId)
.schemaType('directory')
.title('Edit this directory')
])
)
),
// Root-level documents (no parent)
S.listItem()
.title('📄 Documents')
.child(
S.documentList()
.title('Root Documents')
.filter('_type == "article" && !defined(parent)')
)
])
),
S.divider(),
// Keep flat views as backup for finding content
S.documentTypeListItem('directory').title('All Directories'),
S.documentTypeListItem('article').title('All Articles'),
])How This Works
The Structure Builder's child resolver pattern works by responding to user navigation. When someone clicks on a directory in the list:
- The
child()function receives the clicked document's ID - It creates a new list showing the contents of that directory
- Subdirectories use the same child resolver pattern, creating infinite nesting
- GROQ filters with
parent._ref == $parentIdfind children of each directory
Benefits of This Approach
- Documents stay documents - Your content items remain full documents, independently queryable and referenceable
- Infinitely nestable - As deep as you need to go
- Flexible organization - Documents without a parent live at the root level
- Easy querying - You can query the hierarchy with GROQ:
*[_type == "article" && _id == $id][0] {
_id,
title,
parent->{
title,
parent->{
title,
parent->{title}
}
}
}Practical Note
The example above shows nesting 2-3 levels deep explicitly. For deeper nesting, you'd want to extract the list-building logic into a reusable function that you can call at each level, but the pattern remains the same: use child() with the documentId to create filtered lists of subdirectories and documents.
This gives your editors a familiar folder-like browsing experience while maintaining Sanity's flexible document-based architecture!
Show original thread14 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.