
How to Customise the Sanity Studio Desk Structure
To customise the Sanity Studio desk structure, write a StructureResolver function that receives the Structure Builder (S) and returns the list editors see in the sidebar, then pass it to structureTool({ structure }) in sanity.config.ts. With S.list(), S.listItem(), S.documentTypeList(), S.documentList() and S.divider(), you can group document types, add filtered lists such as "Posts in review", nest documents under their category, open singletons directly and hide types editors shouldn't touch. A second function, defaultDocumentNode, controls the tabs (views) each document opens with.
The default Studio sidebar is an alphabetical list of every document type. That's fine for five types and confusing for twenty. A custom structure turns the sidebar into a map of how your team actually works. This guide covers setting up the resolver, the core building blocks, grouping and ordering, filtered lists, lists nested by reference, document views, structure that adapts to the current user, and how to keep a growing structure maintainable.
How the Structure Tool Works
The Structure tool (called the Desk tool in earlier Studio v3 releases) renders a series of panes from left to right. Each pane is a node: a list of items, a list of documents, or a document editor. Clicking an item opens its child in the next pane.
| Builder method | What it renders | Typical use |
|---|---|---|
S.list() | A pane of hand-picked items | The root sidebar, a group such as "Settings" |
S.listItem() | One entry in a list, with a title, icon and child | "Blog", "Posts in review" |
S.documentTypeList(type) | Every document of one type | All posts |
S.documentList() | Documents matching a GROQ filter | Drafts in review, posts by author |
S.document() | The document editor | A singleton, or a custom set of views |
S.divider() | A horizontal rule between items | Separating content from settings |
If panes and nodes are new to you, understanding Sanity Studio explains the interface before you start rearranging it.
Set Up a Structure Resolver
Keep the structure in its own file so the config stays readable:
// structure.ts
import type { StructureResolver } from 'sanity/structure'
export const structure: StructureResolver = (S) =>
S.list()
.title('Content')
.items([
S.documentTypeListItem('post').title('Posts'),
S.documentTypeListItem('author').title('Authors'),
S.divider(),
...S.documentTypeListItems().filter(
(item) => !['post', 'author'].includes(item.getId() ?? ''),
),
])
Then register it with the Structure tool:
// sanity.config.ts
import { defineConfig } from 'sanity'
import { structureTool } from 'sanity/structure'
import { schemaTypes } from './schemaTypes'
import { structure, defaultDocumentNode } from './structure'
export default defineConfig({
name: 'default',
title: 'My Studio',
projectId: 'your-project-id',
dataset: 'production',
plugins: [structureTool({ structure, defaultDocumentNode })],
schema: { types: schemaTypes },
})
S.documentTypeListItems() returns a list item for every document type in your schema. Spreading it at the end, minus the types you've already placed, means a type added next month still appears somewhere instead of silently vanishing. Remove that line once you want full control.
Group Document Types
Most Studios benefit from a few top-level groups. A listItem whose child is another S.list() creates a folder:
// structure.ts
import type { StructureResolver } from 'sanity/structure'
import { DocumentTextIcon, CogIcon, TagIcon, UsersIcon } from '@sanity/icons'
export const structure: StructureResolver = (S) =>
S.list()
.title('Content')
.items([
S.listItem()
.title('Blog')
.icon(DocumentTextIcon)
.child(
S.list()
.title('Blog')
.items([
S.documentTypeListItem('post').title('Posts'),
S.documentTypeListItem('category').title('Categories').icon(TagIcon),
S.documentTypeListItem('author').title('Authors').icon(UsersIcon),
]),
),
S.documentTypeListItem('page').title('Pages'),
S.divider(),
S.listItem()
.title('Settings')
.icon(CogIcon)
.child(
S.list()
.title('Settings')
.items([
S.listItem().singleton('siteSettings'),
S.listItem().singleton('navigation'),
]),
),
])
S.listItem().singleton() opens one fixed document instead of a list, and works with document.singletons in Studio 6.17.0 and later. The full setup, including the manual approach for older Studios, is in creating singleton documents in Sanity.
IDs and Titles
Every list item needs a unique ID within its list. documentTypeListItem uses the type name, and listItem().title('Blog') derives blog from the title. Set .id() explicitly when two items would end up with the same derived ID, or when you want the URL to stay stable if the title changes. The ID becomes part of the Studio URL, such as /structure/blog;post;abc123, so editors' bookmarks depend on it.
Filtered Document Lists
S.documentList() takes a GROQ filter, which is where a custom structure starts to earn its keep. A review queue for an editorial workflow is a filter on a status field:
// structure.ts
import type { StructureBuilder } from 'sanity/structure'
const API_VERSION = '2025-02-19'
export const postsByStatus = (S: StructureBuilder) =>
S.listItem()
.title('Posts by status')
.child(
S.list()
.title('Status')
.items([
S.listItem()
.title('Drafts')
.child(
S.documentList()
.title('Drafts')
.apiVersion(API_VERSION)
.filter('_type == "post" && status == "draft"'),
),
S.listItem()
.title('In review')
.child(
S.documentList()
.title('In review')
.apiVersion(API_VERSION)
.filter('_type == "post" && status == $status')
.params({ status: 'review' })
.defaultOrdering([{ field: '_updatedAt', direction: 'desc' }]),
),
S.listItem()
.title('Scheduled')
.child(
S.documentList()
.title('Scheduled')
.apiVersion(API_VERSION)
.filter('_type == "post" && publishedAt > now()'),
),
]),
)
A few details matter here:
- Always set
apiVersion: lists without one fall back to an old API version and log a warning. Use2025-02-19or later so lists respect perspectives, including release perspectives. - Use
paramsfor values: the same as any GROQ query, parameters keep filters readable and safe. They also avoid quoting mistakes. - Lists include drafts: a document appears in a filtered list as soon as its draft matches, so "In review" fills up the moment an editor changes the status.
- Editors can change the sort:
defaultOrderingis only the starting point. A sort the editor picks from the menu is remembered in their browser.
To show status at a glance inside those lists, pair this with document badges. To change how each row looks, set .defaultLayout('detail') on the list, as shown in customising list previews.
Nest Documents by Reference
A child can be a function of the selected item's ID. That lets you build "Posts by category" with two panes: the categories, then the posts that reference the chosen one.
// structure.ts
export const postsByCategory = (S: StructureBuilder) =>
S.listItem()
.title('Posts by category')
.child(
S.documentTypeList('category')
.title('Categories')
.apiVersion(API_VERSION)
.child((categoryId) =>
S.documentList()
.title('Posts')
.apiVersion(API_VERSION)
.schemaType('post')
.filter('_type == "post" && $categoryId in categories[]._ref')
.params({ categoryId })
.initialValueTemplates([
S.initialValueTemplateItem('post-by-category', { categoryId }),
]),
),
)
The initialValueTemplates call makes the "Create" button in that pane start a post with the category already set. It needs a matching template in your config:
// sanity.config.ts (inside defineConfig)
schema: {
types: schemaTypes,
templates: (prev) => [
...prev,
{
id: 'post-by-category',
title: 'Post in category',
schemaType: 'post',
parameters: [{ name: 'categoryId', type: 'string' }],
value: (params: { categoryId: string }) => ({
categories: [{ _type: 'reference', _ref: params.categoryId, _key: params.categoryId }],
}),
},
],
},
The template's parameters receive whatever the structure passes, so the same template can serve every category.
Dynamic Items From Content
Because the resolver receives a context with getClient, a child can also be asynchronous. Inside (S, context) => ..., you could list one item per site in a multi-site setup:
.child(async () => {
const client = context.getClient({ apiVersion: API_VERSION })
const sites = await client.fetch<{ _id: string; title: string }[]>(
`*[_type == "site"] | order(title asc){ _id, title }`,
)
return S.list()
.title('Sites')
.items(
sites.map((site) =>
S.listItem()
.id(site._id)
.title(site.title)
.child(
S.documentList()
.title(`${site.title} pages`)
.apiVersion(API_VERSION)
.filter('_type == "page" && site._ref == $siteId')
.params({ siteId: site._id }),
),
),
)
})
This list is fetched when the pane opens, not live. That's fine for things that rarely change, such as sites or locales, but use a filtered documentList for anything editors create every day.
Document Views
When a document opens, the Structure tool shows its form. Views add tabs next to it, such as a live preview, a JSON inspector or an SEO checklist. Set them for every document type with defaultDocumentNode:
// structure.ts
import type { DefaultDocumentNodeResolver } from 'sanity/structure'
import { JsonView } from './components/JsonView'
import { SeoChecklist } from './components/SeoChecklist'
export const defaultDocumentNode: DefaultDocumentNodeResolver = (S, { schemaType }) => {
if (schemaType === 'post' || schemaType === 'page') {
return S.document().views([
S.view.form(),
S.view.component(SeoChecklist).title('SEO'),
S.view.component(JsonView).title('JSON'),
])
}
return S.document().views([S.view.form()])
}
A view component receives the document in several states:
// components/JsonView.tsx
import { Card, Code } from '@sanity/ui'
import type { UserViewComponent } from 'sanity/structure'
export const JsonView: UserViewComponent = ({ document }) => (
<Card padding={4}>
<Code language="json" size={1}>
{JSON.stringify(document.displayed, null, 2)}
</Code>
</Card>
)
document.displayed is what the editor currently sees, while document.draft and document.published let you compare the two. For a preview of the real page, the iframe pane plugin described in the best Sanity Studio plugins slots straight into S.view.component().
Views set with defaultDocumentNode apply wherever a document opens, including from search and reference links. If you set views on S.document() inside a specific list item, they only apply in that pane.
Adapt the Structure to the User
The resolver's second argument includes currentUser, so you can show a simpler sidebar to people who don't need everything:
// structure.ts
export const structure: StructureResolver = (S, context) => {
const roles = context.currentUser?.roles.map((role) => role.name) ?? []
const isAdmin = roles.includes('administrator')
const items = [
S.listItem()
.title('Blog')
.child(
S.list()
.title('Blog')
.items([postsByStatus(S), postsByCategory(S), S.documentTypeListItem('post')]),
),
S.documentTypeListItem('page').title('Pages'),
]
if (isAdmin) {
items.push(
S.divider(),
S.listItem()
.title('Settings')
.child(S.list().title('Settings').items([S.listItem().singleton('siteSettings')])),
)
}
return S.list().title('Content').items(items)
}
The helpers from earlier sections slot straight in, which is one reason to write them as small functions. Remember that hiding an item is a convenience, not a permission. A user can still reach a hidden document through search or the API. Roles and permissions in Sanity explains how to enforce access properly.
Ordering, Menus and Intents
A few more methods round out the API:
menuItems: add sort options or actions to a pane's menu, for exampleS.orderingMenuItem({ title: 'Newest first', by: [{ field: 'publishedAt', direction: 'desc' }] }).- Manual ordering: drag-and-drop ordering needs the orderable document list plugin, which replaces a list item in your structure. It's covered in the best Sanity Studio plugins.
canHandleIntent: decides which pane opens when an editor follows a link to a document. Usually the default is fine. If a document opens in an unexpected list, an intent checker on the list you prefer fixes it.showCount: in Studio 6.17.0 and later,S.listItem().showCount()shows how many documents a list contains, which is handy on a review queue.
Keep a Large Structure Maintainable
Structure files grow quickly. These habits keep them manageable:
- Split by area: export helpers such as
postsByStatus(S)from separate files and compose them in the root resolver. - Share constants: one
API_VERSIONand one list of "handled" type names, so the fallback filter stays accurate. - Keep the fallback until you're done: spreading the remaining
documentTypeListItems()protects you from forgetting a type. - Match the team, not the schema: group by task ("Publishing", "Shop", "Settings") rather than mirroring your schema folders.
- Test with an editor: watch someone find a document. If they reach for search every time, the structure isn't helping.
FAQ: Customising the Sanity Desk Structure
Yes. The Desk tool was renamed the Structure tool during Studio v3. Import structureTool from sanity/structure instead of deskTool from sanity/desk. The Structure Builder API itself is the same.
A custom structure only shows what you return. If you list a few types by hand and don't spread S.documentTypeListItems() at the end, every other type is hidden from the sidebar, although documents can still be found through search.
Use S.documentList() with a GROQ filter, such as _type == "post" && status == $status, and pass the value with params. Set apiVersion on the list to 2025-02-19 or later.
Yes. The resolver receives a context with currentUser, so you can check roles and return different items. This only changes what users see, not what they can access, so enforce permissions with roles.
Export a defaultDocumentNode resolver that returns S.document().views() with S.view.form() and an S.view.component() for your preview, then pass it to structureTool alongside structure.
No. The structure only changes how the Studio presents documents. Your content, GROQ queries and front end are untouched, so you can redesign the sidebar freely.
Conclusion
The Structure Builder turns the Studio sidebar from a list of schema types into a workspace built around your team. Start with a resolver that groups related types and keeps a fallback for everything else, then add filtered lists for the queues editors actually work from, nested lists for content that belongs together, and views that put previews and checks next to the form.
Keep the file split into small helpers, set an API version on every list, and treat role-based structure as a convenience rather than security. Once the structure fits, take the Studio further with custom input components for awkward fields and a custom Studio theme so it looks like your brand.


