How I use Sanity's structure builder to hide draft noise and speed up editor workflow

May 03, 2026 · 4 min read

sanity-cmsstudio-configurationdeveloper-experiencecontent-ops

The problem with default Sanity Studio structure

Sanity's default desk structure shows every document and every draft in a flat list. On a production site with 400+ pages, 60 blog posts, and 12 active editors, that list becomes unmanageable. Editors see drafts.blog-post-title and blog-post-title side by side. They ask: "Which one is published? Why are there two?" I've had clients accidentally publish the wrong version because they clicked the wrong item in a crowded list.

The default structure also surfaces internal documents — redirects, global settings, navigation singletons — that non-technical editors should never touch. Every support ticket I avoided started with a custom structure builder config.

How I use structure builder to partition documents

I create a src/sanity/structure.ts file and export a custom structure function. I import it in sanity.config.ts like this:

// src/sanity/structure.ts
import { StructureBuilder } from 'sanity/structure';
 
export const structure = (S: StructureBuilder) =>
  S.list()
    .title('Content')
    .items([
      S.listItem()
        .title('Pages')
        .child(
          S.documentTypeList('page')
            .title('Pages')
            .filter('_type == "page" && !(_id in path("drafts.**"))')
            .defaultOrdering([{ field: 'title', direction: 'asc' }])
        ),
      S.listItem()
        .title('Blog Posts')
        .child(
          S.documentTypeList('post')
            .title('Blog Posts')
            .filter('_type == "post" && !(_id in path("drafts.**"))')
            .defaultOrdering([{ field: 'publishedAt', direction: 'desc' }])
        ),
      S.divider(),
      S.listItem()
        .title('Site Settings')
        .child(
          S.document()
            .schemaType('siteSettings')
            .documentId('siteSettings')
        ),
      S.listItem()
        .title('Navigation')
        .child(
          S.document()
            .schemaType('navigation')
            .documentId('navigation')
        ),
    ]);

The key line is .filter('_type == "page" && !(_id in path("drafts.**"))'). This GROQ filter hides all drafts from the list. Editors only see published documents. When they click a document, Sanity automatically loads the draft if one exists. This cuts visual noise by 50% immediately.

I also surface singletons — siteSettings, navigation — as direct list items instead of making editors hunt through a "Settings" document type list. One click to the doc they need.

Grouping by status with custom list panes

On a blog with 60 posts, I want editors to see "Published" and "Drafts" as separate lists. I create a child structure with two filtered lists:

S.listItem()
  .title('Blog Posts')
  .child(
    S.list()
      .title('Blog Posts')
      .items([
        S.listItem()
          .title('Published')
          .child(
            S.documentTypeList('post')
              .title('Published Posts')
              .filter('_type == "post" && !(_id in path("drafts.**")) && defined(publishedAt)')
              .defaultOrdering([{ field: 'publishedAt', direction: 'desc' }])
          ),
        S.listItem()
          .title('Drafts')
          .child(
            S.documentTypeList('post')
              .title('Draft Posts')
              .filter('_type == "post" && _id in path("drafts.**")')
              .defaultOrdering([{ field: '_updatedAt', direction: 'desc' }])
          ),
        S.listItem()
          .title('Scheduled')
          .child(
            S.documentTypeList('post')
              .title('Scheduled Posts')
              .filter('_type == "post" && !(_id in path("drafts.**")) && publishedAt > now()')
              .defaultOrdering([{ field: 'publishedAt', direction: 'asc' }])
          ),
      ])
  ),

Now editors see three buckets: Published, Drafts, Scheduled. The publishedAt > now() filter requires a publishedAt datetime field in the schema. I use this pattern on every blog I build. It reduces "Where's my post?" Slack messages to zero.

Hiding internal document types entirely

I have document types like redirect, analyticsEvent, structuredData that only I touch. I don't want them in the desk at all. I filter them out in the structure builder and add them to a hidden "Internal" list:

S.list()
  .title('Content')
  .items([
    // ... public lists
    S.divider(),
    S.listItem()
      .title('Internal')
      .child(
        S.list()
          .title('Internal')
          .items([
            S.documentTypeListItem('redirect').title('Redirects'),
            S.documentTypeListItem('structuredData').title('Structured Data'),
          ])
      ),
  ]);

I collapse "Internal" by default. Editors never see it unless they explicitly expand.

Custom ordering with defaultOrdering

Sanity's default ordering is _createdAt desc. For pages, I want alphabetical by title. For blog posts, I want newest published first. For FAQs, I want a manual order field. I set these per list:

S.documentTypeList('faq')
  .title('FAQs')
  .filter('_type == "faq" && !(_id in path("drafts.**"))')
  .defaultOrdering([{ field: 'order', direction: 'asc' }])

This requires an order number field in the faq schema. I use validation: Rule => Rule.required().min(0) to enforce it.

Why this matters for large sites

On a 400-page corporate site I shipped last year, the client had 8 content editors across 3 departments. Before I added structure builder config, they complained the desk was "too cluttered" and "confusing". After I grouped pages by section ("Products", "Resources", "Legal"), hid drafts, and surfaced settings as top-level items, onboarding time dropped from 45 minutes to 15 minutes. Support tickets about "I can't find my page" went to zero.

The structure builder config is 150 lines of TypeScript. It's the highest ROI config file in a Sanity project.

Caveats and edge cases

If you use document internationalization (@sanity/document-internationalization), drafts behave differently. Each locale creates its own draft. I handle this by adding && !defined(__i18n_base) to my filters to hide translation metadata docs.

If you have a document type with thousands of items (product SKUs, for instance), consider adding a search-based list item instead of a flat list. Sanity's default search is fast, but a list of 5,000 items will timeout in the browser.

Structure builder functions are synchronous. You can't fetch data from an external API to build dynamic lists. If you need that, use an action or a custom input component instead.

Related posts

All posts →