Sanity's default desk structure is fine when a project has three document types. Once you're past a dozen schemas, editors start complaining that the sidebar is a wall of alphabetical noise. Here's exactly how I configure Structure Builder — custom ordering, grouped sections, and hidden system types — so that every Studio I ship feels deliberate rather than auto-generated.
What Sanity structure builder custom ordering actually controls
Structure Builder lives inside sanity.config.ts via the structureTool plugin (formerly deskTool). It lets you replace the auto-generated document list with a hand-crafted sidebar tree. The key APIs are:
-
S.documentTypeList(schemaType)— renders all documents of a given type -
S.listItem()— a single navigable row -
S.list()— a named group of items -
S.divider()— a visual separator -
defaultOrderingondocumentTypeList— controls how documents are sorted before editors touch them
The default ordering is by _updatedAt descending. That works for a blog, but it's wrong for a settings singleton or a navigation document that almost never changes.
Setting up the structure file
I always pull the structure function into its own file to keep sanity.config.ts readable.
// sanity/structure.ts
import type { StructureResolver } from 'sanity/structure'
export const structure: StructureResolver = (S) =>
S.list()
.title('Content')
.items([
// ── Singletons ──────────────────────────────────────────────
S.listItem()
.title('Site settings')
.id('siteSettings')
.child(
S.document()
.schemaType('siteSettings')
.documentId('siteSettings')
),
S.listItem()
.title('Navigation')
.id('navigation')
.child(
S.document()
.schemaType('navigation')
.documentId('navigation')
),
S.divider(),
// ── Editorial ───────────────────────────────────────────────
S.listItem()
.title('Posts')
.child(
S.documentTypeList('post')
.title('Posts')
.defaultOrdering([
{ field: 'publishedAt', direction: 'desc' },
])
),
S.listItem()
.title('Authors')
.child(
S.documentTypeList('author')
.title('Authors')
.defaultOrdering([
{ field: 'name', direction: 'asc' },
])
),
S.divider(),
// ── Products ────────────────────────────────────────────────
S.listItem()
.title('Products')
.child(
S.documentTypeList('product')
.title('Products')
.defaultOrdering([
{ field: 'title', direction: 'asc' },
])
),
S.listItem()
.title('Categories')
.child(
S.documentTypeList('category')
.title('Categories')
.defaultOrdering([
{ field: 'title', direction: 'asc' },
])
),
])
Then in sanity.config.ts:
// sanity.config.ts
import { defineConfig } from 'sanity'
import { structureTool } from 'sanity/structure'
import { structure } from './sanity/structure'
import { schemaTypes } from './sanity/schemas'
export default defineConfig({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
plugins: [
structureTool({ structure }),
],
schema: { types: schemaTypes },
})
The structureTool plugin accepts a structure option — just pass the resolver directly. No extra wiring needed.
Hiding types you don't want editors to see
Sanity will surface every registered schema type unless you exclude it. Two common cases:
-
Media plugin types (
sanity.imageAsset,sanity.fileAsset) — Sanity handles these internally. They should never appear in the sidebar. -
Sub-documents — if you have a
blockContentorfigureobject type that only ever lives inside another document, listing it standalone confuses editors.
The cleanest approach is to build an explicit allowlist rather than a denylist. Because I'm constructing the entire tree manually in structure.ts, any schema type I omit simply never appears. There's nothing to suppress.
If you want to be defensive — say a new developer adds a schema and forgets to wire it into the structure — you can log a warning in development:
// sanity/structure.ts (add at the top of the resolver)
import type { StructureResolver } from 'sanity/structure'
const MANAGED_TYPES = new Set([
'siteSettings',
'navigation',
'post',
'author',
'product',
'category',
])
export const structure: StructureResolver = (S, context) => {
if (process.env.NODE_ENV === 'development') {
const allTypes = context.schema
.getTypeNames()
.filter((n) => !n.startsWith('sanity.') && !n.startsWith('assist.'))
const unmanaged = allTypes.filter((t) => !MANAGED_TYPES.has(t))
if (unmanaged.length > 0) {
console.warn('[structure] Unmanaged schema types:', unmanaged)
}
}
return S.list()
.title('Content')
.items([
// ... same as before
])
}
This gives you a console warning every time Studio hot-reloads if a schema type is unaccounted for. It's saved me from missed wiring at least three times on larger projects.
Grouping into nested lists
On projects with 20+ document types I group related schemas under a parent list item rather than flattening everything. The S.list() builder nests cleanly:
S.listItem()
.title('Commerce')
.child(
S.list()
.title('Commerce')
.items([
S.listItem()
.title('Products')
.child(
S.documentTypeList('product')
.defaultOrdering([{ field: 'title', direction: 'asc' }])
),
S.listItem()
.title('Collections')
.child(
S.documentTypeList('collection')
.defaultOrdering([{ field: 'title', direction: 'asc' }])
),
S.listItem()
.title('Discount codes')
.child(
S.documentTypeList('discountCode')
.defaultOrdering([{ field: '_createdAt', direction: 'desc' }])
),
])
),
Nesting beyond two levels starts to feel maze-like for editors. I keep it to one level of nesting maximum.
Default ordering field choices by document type
A few patterns I've settled on across projects:
| Document type | Field | Direction | Reason |
|---|---|---|---|
| Blog post | publishedAt |
desc | Most recent post on top |
| Author / person | name |
asc | Alphabetical scan |
| Product | title |
asc | Alphabetical scan |
| Event | startDate |
asc | Upcoming first |
| Redirect | _createdAt |
desc | Newest entry likely needs review |
| Singleton | N/A | N/A | Single document, no list |
The field must exist on the schema and be indexed for GROQ ordering to work in the Studio. Computed or derived fields won't work here — stick to top-level scalars.
Icons make a real difference
The sidebar becomes scannable fast when each list item has an icon. Sanity ships @sanity/icons and you can pass any React component to .icon():
import { DocumentTextIcon, TagIcon, CogIcon } from '@sanity/icons'
S.listItem()
.title('Posts')
.icon(DocumentTextIcon)
.child(S.documentTypeList('post').defaultOrdering([...]))
This is a ten-minute addition that editors notice immediately. I do it on every project by default now.
Where this breaks down
Structure Builder is purely UI-side configuration. It doesn't restrict API access, it doesn't enforce permissions, and it doesn't change what GROQ queries return. If you need to hide document types from specific roles rather than just from the sidebar, that's a job for Sanity's access control rules — a separate layer entirely.
Also worth noting: the structure is evaluated client-side inside the Studio bundle. Keep the resolver function synchronous and cheap. I've seen projects that fetched data inside the structure resolver to build dynamic lists — it works, but it makes Studio load sluggish and is hard to cache properly.