How I customise Sanity Structure Builder with custom ordering and grouping

typescript dev.to

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
  • defaultOrdering on documentTypeList — 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' },
            ])
        ),
    ])
Enter fullscreen mode Exit fullscreen mode

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 },
})
Enter fullscreen mode Exit fullscreen mode

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:

  1. Media plugin types (sanity.imageAsset, sanity.fileAsset) — Sanity handles these internally. They should never appear in the sidebar.
  2. Sub-documents — if you have a blockContent or figure object 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
    ])
}
Enter fullscreen mode Exit fullscreen mode

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' }])
          ),
      ])
  ),
Enter fullscreen mode Exit fullscreen mode

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([...]))
Enter fullscreen mode Exit fullscreen mode

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.

Source: dev.to

arrow_back Back to Tutorials