Reorganize your structure without breaking links
Rename, regroup, and re-nest Structure Builder items without breaking editors' saved links or dropping document types from the structure.
As a studio grows, its structure gets renamed, regrouped, and re-nested. These changes only affect navigation in the Structure Tool. They don't change documents, document IDs, or your schema, so a reorganization never needs a content migration. What they can break is URLs: bookmarks and shared links store the path through your structure, and a document type you forget to place drops out of the structure entirely.
This guide shows how to keep list item IDs stable through renames, give editors links that survive a reorganization, and check that every document type still has a place in your structure.
Prerequisites
- A studio with a custom structure resolver. See Get started with Structure Builder API.
- Optional: Vitest or another test runner, for the test in the "Fail CI when a document type has no place" section.
How structure URLs store the path to a pane
The Structure Tool writes every open pane to the URL as an ID, separated by semicolons. Opening a list item with the ID blogPosts, then a document inside it, gives a URL like this:
<your-studio-url>/structure/blogPosts;a1b2c3d4<your-studio-url> is your studio's address, including any base path, such as https://example.com/studio. In a studio with more than one workspace, it also includes the workspace's base path.
When a studio loads a URL, it resolves each ID in order against the current structure. If an ID no longer matches an item in its parent list, the Structure Tool opens the panes up to the last ID that still matches and stops there. The pane for the missing ID and every pane after it, including the document, don't open. In a structure built with S.list().items(), the editor doesn't see an error. The only signal is a warning in the browser console. For the example URL, if the blogPosts item no longer exists, the warning reads Pane resolution error at index 1: Pane returned no child.
Two kinds of change break saved URLs:
- Changing a list item's ID, including an ID that Studio infers from a title you edit.
- Moving a list item to a different parent list. The item keeps its ID, but the path to it changes.
Reordering items within a list, adding new items or dividers, and editing the title of an item with an explicit ID keep every saved URL working.
Pin list item IDs before renaming titles
If you don't call .id() on a list item, Studio infers the ID from the title by converting it to camelCase and transliterating characters that aren't allowed in IDs. Blog posts becomes blogPosts, and Ürün kataloğu becomes urunKatalogu. Editing the title then changes the ID and breaks every saved URL that passes through the item.
Before you rename an item, set its ID to the value it has today. To find it, open the item in Studio and copy its segment from the URL: the part after /structure/, up to the next semicolon. Then change the title:
import type {StructureResolver} from 'sanity/structure'
export const structure: StructureResolver = (S) =>
S.list()
.title('Content')
.items([
S.listItem()
// Inferred from the old title, "Blog posts". Pinning it keeps saved URLs working.
.id('blogPosts')
.title('Articles')
.child(S.documentTypeList('post').title('Articles')),
])Items created with S.documentTypeListItem() already use the schema type name as their ID, so you can change their titles without pinning anything.
Set IDs on new list items
Give every S.listItem() an explicit .id() when you create it, as the Structure Builder API reference recommends. Titles are then free to change during any later reorganization.
Share intent links instead of structure URLs
An intent link names a document, not the path to it:
<your-studio-url>/intent/edit/id=DOCUMENT_ID;type=DOCUMENT_TYPEReplace DOCUMENT_ID with the document's ID and DOCUMENT_TYPE with its schema type name, such as post. <your-studio-url> is the same studio address as in a structure URL.
When Studio opens an intent link, it searches the current structure for a pane that can show the document and opens it at the match closest to the root. If nothing in the structure matches, Studio opens the document in a fallback editor outside your structure. Because Studio resolves the path when the link is opened, an intent link keeps working after you rename, move, or regroup list items.
After an intent link resolves, Studio replaces it in the address bar with the structure URL. An editor who bookmarks the page at that point saves the path, not the intent link. Use intent links wherever you control the URL, such as dashboards, notifications, front-end edit buttons, and team wikis. For how to build them, see Create Studio edit intent links.
Intent links only open a document in a list that Studio can match to the document's type. Lists from S.documentTypeList() and S.documentTypeListItem() match when their filter names the schema type. By default, a plain S.documentList() matches when it opens directly from the root list and its filter names the type. If your reorganization nests a documentList deeper or gives it a filter that doesn't name the type, add canHandleIntent so intent links still route into it. See Handle intents in the Structure Tool.
Check that every document type has a place
Rebuilding lists by hand can drop a document type from the structure. Its documents still exist and still show up in search, but editors can't browse to them. Two patterns catch this.
List unplaced types automatically
Keep the names of the types you place in a set, and end the root list with S.documentTypeListItems() filtered to everything else. A type you forget to place still appears at the bottom of the root list.
// Every document type you place in the structure export const PLACED_TYPES = new Set(['post', 'author', 'category'])
import type {StructureResolver} from 'sanity/structure'
import {PLACED_TYPES} from './placement'
export const structure: StructureResolver = (S) =>
S.list()
.title('Content')
.items([
S.listItem()
.id('blogPosts')
.title('Articles')
.child(
S.list()
.title('Articles')
.items([
S.documentTypeListItem('post').title('Posts'),
S.documentTypeListItem('author').title('Authors'),
S.documentTypeListItem('category').title('Categories'),
]),
),
S.divider(),
...S.documentTypeListItems().filter((item) => !PLACED_TYPES.has(item.getId() ?? '')),
])S.documentTypeListItems() leaves out schema types used by a singleton registered in document.singletons, so this fallback doesn't list them. This requires sanity 6.17.0 or later. Place singletons explicitly, as shown in Create a link to a single edit page in your main document type list.
Fail CI when a document type has no place
To make placement a deliberate decision, add a test that compares the document types in your schema against the PLACED_TYPES set in structure/placement.ts. It runs in Node without starting Studio.
import {expect, test} from 'vitest'
import {schemaTypes} from '../schemaTypes'
import {PLACED_TYPES} from './placement'
test('every document type has a place in the structure', () => {
const unplaced = schemaTypes
.filter((schemaType) => schemaType.type === 'document')
.map((schemaType) => schemaType.name)
.filter((name) => !PLACED_TYPES.has(name))
expect(unplaced).toEqual([])
})When someone adds a document type without placing it, the test fails and names the missing types:
AssertionError: expected [ 'newsletter' ] to deeply equal []The test reads type and name from the schema type definitions exported by schemaTypes/index.ts. If your schema lives elsewhere, change the import path.
Next steps
- Handle intents in the Structure Tool: make filtered and nested document lists receive intent links.
- Create Studio edit intent links: build links that open a document from outside Studio.
- Structure Builder API reference: every builder method, including
.id().