Handle intents in the Structure Tool
How the Structure Tool matches intents to panes, and how to declare intent handling on lists that don't match on their own.
Intents are Studio's internal routing mechanism. When a user clicks a search result, follows an Open in Studio link from Visual Editing, or uses a Create new button, Studio fires an intent (like edit or create) with parameters such as the document ID and type. The Structure Tool resolves that intent by finding the right pane in your structure.
This guide explains how Studio decides which pane handles an intent, and shows how to declare intent handling on lists that don't match on their own.
Prerequisites
- A studio with the Structure Tool installed. New projects include it; for existing projects, install it by updating your project's configuration file.
- Familiarity with panes, lists, and child resolvers, as covered in Structure tool and Structure builder.
How Studio matches an intent to a pane
Studio matches an intent to a pane in two independent ways. Either one is enough.
First, a pane can declare what it handles with canHandleIntent. Lists built with S.documentTypeList() and S.documentTypeListItem() get a default implementation that reads the schema types named in the list's filter and matches when the intent's type parameter is one of them. Any filter that names a type counts, such as _type == "post" && defined(publishedAt) — not only the default filter.
Second, the intent resolver checks the pane directly, whatever its canHandleIntent says. A documentList matches when its schema type equals the intent's type parameter and its filter is still exactly _type == $type.
Panes that match neither way need canHandleIntent to declare which intents they handle: a custom S.list(), or a documentList whose filter doesn't name a schema type.
Adding a custom .child() resolver to a documentTypeList clears the default canHandleIntent, because Studio can't guarantee the new child handles the intent. The list keeps matching through the second route anyway, since .child() changes neither the schema type nor the filter. Change the filter as well and both routes drop out, so you have to declare canHandleIntent yourself.
What .child() does change is what opens. The intent routes to a pane for the target document ID, and your resolver decides what that pane shows. If it returns a list instead of a document node, the document editor doesn't open, and canHandleIntent won't change that.
Common symptoms of missing intent handling
If a list in your structure matches neither way, and you notice any of these, missing canHandleIntent could be the cause:
- Documents open in the wrong pane. The document opens as a bare editor instead of navigating to the correct location in your structure.
- Open in Studio links from Visual Editing don't route correctly.
- Search results land in the wrong place. Global search can bypass your custom structure entirely.
- Create new buttons may not work as expected. Custom structures that replace
documentTypeListcan lose the built-in create intent handling. AdocumentTypeListthat keeps its default filter and adds a.child()resolver has a different cause: the intent routes correctly, but the child resolver decides what opens.
When no pane in your structure matches, Studio opens the document in a fallback editor outside your structure. You can recognize it by the pane ID in the URL, which starts with __edit__.
Add intent handling
Add canHandleIntent to any list that matches neither way. The function receives the intent name and parameters, and returns true if the pane should handle the intent:
// structure.ts
import type {StructureResolver} from 'sanity/structure'
export const structure: StructureResolver = (S) =>
S.list()
.title('Content')
.items([
// Built-in documentTypeList: intent handling works automatically
S.documentTypeListItem('author').title('Authors'),
// Custom child resolver: needs canHandleIntent
S.listItem()
.title('Blog Posts')
.schemaType('post')
.child(
S.documentTypeList('post')
.title('Blog Posts')
.child((documentId) =>
S.document()
.documentId(documentId)
.schemaType('post')
)
.canHandleIntent((intentName, params) =>
['create', 'edit'].includes(intentName) && params.type === 'post'
)
),
])Custom lists need intent handling
A custom S.list(), or a documentList whose filter doesn't name a schema type, matches no intents by default. Without canHandleIntent, search results, Visual Editing links, and Create new buttons open the fallback editor instead of your structure.
For the full canHandleIntent signature and parameters, see the Structure Builder API Reference.
Route documents that share a type by a field value
canHandleIntent decides from the intent alone. It runs synchronously, receives the intent name, the intent parameters, and the pane context, and returns a boolean. It has no access to the document, so it can't check a field like section. The intents Studio fires itself, such as from global search, identify the document by id and type only. Studio doesn't fetch the document's other fields to build them.
This matters when one document type is split across sections of your structure by a field. A documentTypeList keeps its default intent handling when you add a filter, and that default matches on the schema type. A Blog posts list built with S.documentTypeList('page').filter('_type == "page" && section == "blog"') matches every page intent, so pages from other sections can open under Blog posts.
To route these documents, give the type one list that handles its intents, and let section lists match only intents you can identify:
- Canonical list: a list such as All pages, built with
S.documentTypeListItem(), handles every intent for the type. - Section lists: declare
canHandleIntentso they match only intents that carry a parameter you add yourself, such assection: 'blog'. Intents from Studio don't carry it, so they go to the canonical list. Browsing a section list still opens its documents.
// structure.ts
import type {StructureResolver} from 'sanity/structure'
export const structure: StructureResolver = (S) =>
S.list()
.title('Content')
.items([
// Section list: declines Studio's intents, accepts links that pass section: 'blog'
S.listItem()
.title('Blog posts')
.child(
S.documentTypeList('page')
.title('Blog posts')
.filter('_type == "page" && section == "blog"')
.canHandleIntent(
(intentName, params) =>
['edit', 'create'].includes(intentName) &&
params.type === 'page' &&
params.section === 'blog',
),
),
// Canonical list: handles every other intent for a page
S.documentTypeListItem('page').title('All pages'),
])canHandleIntent receives every parameter in the intent, including custom ones. Add the parameter to the intent links you build, for example with IntentLink. For more on building intent links, see Link to documents and tools from a custom component.
// BlogPageLink.tsx
import {IntentLink} from 'sanity/router'
export function BlogPageLink({id}: {id: string}) {
return (
<IntentLink intent="edit" params={{id, type: 'page', section: 'blog'}}>
Open in Blog posts
</IntentLink>
)
}When more than one list matches an intent, Studio opens the document in the list nearest the root of your structure. When matching lists are at the same depth, the one listed first wins, so place section lists before the canonical list.
Intent checkers can't be async
Don't return a Promise from canHandleIntent. Studio doesn't await it, and a Promise counts as true, so the pane claims every intent that reaches it. TypeScript rejects an async intent checker, but in JavaScript the mistake fails silently.
Next steps
- Create Studio edit intent links: build the URLs that fire an
editintent, including the ones the Vision Tool renders next to_idand_refvalues. - Get started with Structure Builder API: build the lists, panes, and child resolvers that intent handling applies to.
- Link to documents and tools from a custom component: fire
editandcreateintents from your own React components.
Was this page helpful?
Feedback submitted, thanks for helping us improve the docs. Have you tried these alternatives?