Sanity Structure Builder: Hide Drafts, Speed Up Editors

By Nayan Kyada · · 6 min read

Part of The Sanity + Next.js Guide

The problem with default Sanity Studio structure

Sanity's default structure gives you one list per document type, every type in a single flat sidebar, and every document in each list — published, edited, and never-published drafts mixed together and sorted by last edit. (Studio merges a document's draft and published versions into one row, so you won't see duplicates, but you also can't tell at a glance which posts are live.) On a production site with hundreds of pages, dozens of blog posts, and several active editors, that gets hard to scan fast.

The default structure also surfaces internal documents — redirects, global settings, navigation singletons — that non-technical editors should never touch. A custom structure builder config is the cheapest way to head off that class of support question.

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 limits the list to documents that have a published version. When editors click one, Sanity automatically loads the draft if one exists. One caveat: a brand-new document that has never been published won't appear here at all — which is why the next section adds a dedicated Drafts list.

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. It's a simple pattern that heads off most "Where's my post?" questions.

Hiding internal document types entirely

Most projects have document types like redirect, analyticsEvent, structuredData that only developers 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'),
          ])
      ),
  ]);

"Internal" sits at the bottom as a single item. Editors only see its contents if they click into it.

Custom ordering with defaultOrdering

Sanity's default list ordering is by last edited (_updatedAt 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 large corporate site with several editors across departments, the default sidebar is exactly what gets called "too cluttered" and "confusing". Grouping pages by section ("Products", "Resources", "Legal"), separating drafts from published content, and surfacing settings as top-level items makes onboarding new editors noticeably faster and cuts down on "I can't find my page" questions.

A structure builder config like this is typically a hundred or so 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), each translation is its own document with a language field, linked through separate translation.metadata documents. Each translation has its own draft, so filter by language (for example && language == "en") or build one list per locale rather than relying on the draft filter alone.

If you have a document type with thousands of items (product SKUs, for instance), a single flat list is hard for editors to use even though Studio loads it incrementally. Split it into filtered sub-lists (by category, brand, or status) and lean on search.

Keep structure code cheap. Child resolvers can be async and context.getClient() gives you a client for dynamic lists, but every fetch in structure code slows down Studio navigation — use it sparingly.

Different trees for different roles

Once the structure is code, it can branch on the current user — and this is where Structure Builder pays for itself on multi-team Studios. The context argument exposes currentUser, so a marketing editor and a developer see different trees:

// structure.ts
import type { StructureResolver } from 'sanity/structure';
 
export const structure: StructureResolver = (S, context) => {
  const roles = context.currentUser?.roles.map((r) => r.name) ?? [];
  const isAdmin = roles.includes("administrator");
 
  const editorItems = [
    S.documentTypeListItem("post").title("Blog posts"),
    S.documentTypeListItem("page").title("Pages"),
    S.documentTypeListItem("author").title("Authors"),
  ];
 
  const adminItems = [
    S.divider(),
    S.documentTypeListItem("redirect").title("Redirects"),
    S.documentTypeListItem("siteSettings").title("Site settings"),
    S.documentTypeListItem("navigation").title("Navigation"),
  ];
 
  return S.list()
    .title("Content")
    .items(isAdmin ? [...editorItems, ...adminItems] : editorItems);
};

This is UX, not security — a curious editor can still reach hidden documents through search or direct URLs, and real enforcement needs document-level permissions on a plan that includes them. But as workflow design it's transformative: the marketing team's Studio contains exactly the three things they touch, and "what's this navigation document, can I delete it?" questions mostly go away.

The same branching works per-environment: I show a "Danger zone" group with seed and test documents in the dataset used by staging, and omit it in production. One structure file, evaluated per user and per dataset, replacing what would otherwise be tribal knowledge about which documents are safe to touch.

One caveat: role checks in structure code run when the Studio loads, so a role change requires a refresh to take effect. Editors occasionally screen-share a tree that doesn't match yours — check roles before debugging the structure code itself.

Hiding noise is half the job — the other half is making what remains scannable. I cover that side in custom ordering and grouping with Structure Builder.


Want a Sanity Studio your editors actually enjoy?

I work with startups and agencies as a freelance Sanity + Next.js specialist — Structure Builder customisation, document-level permissions, Studio configuration, and full builds. Fixed-price proposals with milestones, 24-hour response, and a live staging URL from day one. See recent projects and how I work →

Frequently asked questions

How do I use defaultOrdering with documentTypeList in Sanity's structure builder?

Chain `.defaultOrdering([{ field: 'publishedAt', direction: 'desc' }])` onto `S.documentTypeList('post')`. The `field` must match a real schema field — for FAQs or manually-sorted lists, add a numeric `order` field to the schema and order by that instead of a date. See [Sanity's official Structure Builder reference](https://www.sanity.io/docs/structure-builder-reference) for the full API.

How do I hide draft documents from a Sanity Studio document list?

Add `!(_id in path("drafts.**"))` to the `.filter()` on your `documentTypeList`. Editors will only see documents that have a published version — clicking one still loads the draft automatically if it exists. The catch: brand-new documents that have never been published disappear from that list, so pair it with a separate Drafts list.