Sanity Structure Builder: Custom Ordering & Grouping

By Nayan Kyada · · 6 min read

Part of The Sanity + Next.js Guide

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' },
            ])
        ),
    ])

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:

  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
    ])
}

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 typeFieldDirectionReason
Blog postpublishedAtdescMost recent post on top
Author / personnameascAlphabetical scan
ProducttitleascAlphabetical scan
EventstartDateascUpcoming first
Redirect_createdAtdescNewest entry likely needs review
SingletonN/AN/ASingle 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.


Working on a Sanity Studio build?

If you need a Sanity developer to configure Structure Builder, build custom Studio tools, or integrate Sanity with a Next.js frontend, I'm available for freelance projects. Fixed-price proposals, replied within 24 hours.

Frequently asked questions

How does defaultOrdering work with documentTypeList in Sanity Structure Builder?

Chain `.defaultOrdering([{ field: 'publishedAt', direction: 'desc' }])` onto `S.documentTypeList('post')` — the field must be a real, top-level scalar field on the schema; computed or derived fields won't work for Studio-side GROQ ordering. See the field-choice table below for patterns across different document types (dates desc for posts, alphabetical asc for people and products).

How do I group Sanity document types into nested sections in Structure Builder?

Nest `S.list()` inside a parent `S.listItem().child()` — each nested list can contain its own `documentTypeList` items with their own `defaultOrdering`. Keep nesting to one level deep; anything deeper starts to feel maze-like for editors clicking through the sidebar.

Does Sanity's Structure Builder restrict which editors can see or edit a document type?

No — it's UI-only. Hiding a type from the sidebar tree doesn't touch the API, GROQ query results, or permissions; a determined user can still reach it via search or a direct URL. For actual access restriction by role, see [Sanity document-level permissions with role-based access control](/blog/how-i-set-up-sanity-document-level-permissions-with-role-based-access-control), and for hiding draft noise specifically, [how I use Structure Builder to hide draft noise](/blog/how-i-use-sanitys-structure-builder-to-hide-draft-noise-and-speed-up-editor-work).