Model navigation in Sanity
For sites with a single navigation structure, storing it in siteSettings is a reasonable choice: one document, straightforward to query. When your site has multiple named navigations (primary, footer, mobile, utility) or serves multiple channels, a dedicated navigationMenu document type is the better fit: each menu is independently editable, individually queryable, and easy to extend without touching global settings.
The approach below uses a separate document type. If your site has only one nav, you can simplify by replacing this with a navigation field directly on siteSettings.
Navigation data comes from two places, and conflating them causes empty dropdowns. Menu structure, labels, static links, and nesting depth come from the HTML export: the "Tags" dropdown exists, the Blog link has an href. Dropdown children populated from a CMS collection come from the collection item NDJSON files plus id-map.json: the individual tag, attribute, or blog links themselves.
Webflow's static HTML export renders CMS-driven dropdown lists as empty w-dyn-list placeholders. The actual items only render when the site runs with live CMS binding. If your nav has dropdowns populated from a CMS collection, the HTML gives you the structure but not the content.
@webflow/export/index.html @sanity/schemaTypes/index.ts Extract all navigation menus from the HTML file — identify each distinct nav (primary, footer, mobile, etc.) along with its items, labels, links, and any dropdown nesting. Then write two Sanity schemas in TypeScript: a navItem object type with a label field, a link reference field pointing to all document types with slug fields (from index.ts), an externalUrl field, and a recursive children array of navItem; and a navigationMenu document type with a title, an identifier string field, and an items array of navItem. Save navItem to /sanity/schemaTypes/objects/navItem.ts and navigationMenu to /sanity/schemaTypes/documents/navigationMenu.ts. Update index.ts to import and register both.Every navItem object at every nesting level must include a _key field so Studio doesn't show "Missing keys" validation errors. Use the item's Webflow ID, slug, or a stable string as the key.
If your nav has dropdowns populated from a CMS collection (Tags, Categories, Products, etc.), generate a seeding script after import is complete and id-map.json exists:
@webflow/export/index.html @webflow/api/items/ @webflow/api/id-map.json @sanity/schemaTypes/documents/navigationMenu.ts
The main nav has CMS-driven dropdowns. The HTML export shows the dropdown structure but w-dyn-list placeholders instead of actual items. Read the relevant collection NDJSON files to get the actual items. Build a seed-navigation.js script that:
1. Reads the HTML to determine which dropdowns correspond to which collections2. Loads the relevant NDJSON items and maps their Webflow IDs through id-map.json to Sanity _id values3. Constructs recursive navItem arrays with { _key, label, link: { _type: 'reference', _ref } } leaves and { _key, label, children: [...] } parents4. Upserts navigationMenu documents with fixed _id values (e.g. navigationMenu.main-nav, navigationMenu.footer)5. Saves to scripts/seed-navigation.jsThe AI reads your actual nav structure and generates reference targets from your real document types. The schemas follow this shape:
// sanity/schemaTypes/objects/navItem.tsexport const navItem = defineType({ name: 'navItem', title: 'Navigation Item', type: 'object', fields: [ defineField({ name: 'label', type: 'string' }), defineField({ name: 'link', type: 'reference', to: [ // Your actual document types with slug fields — not page/post { type: 'page' }, { type: 'caseStudy' }, ] }), defineField({ name: 'externalUrl', type: 'url' }), defineField({ name: 'children', title: 'Dropdown items', type: 'array', of: [{ type: 'navItem' }] }) ]})// sanity/schemaTypes/documents/navigationMenu.tsexport const navigationMenu = defineType({ name: 'navigationMenu', title: 'Navigation Menu', type: 'document', fields: [ defineField({ name: 'title', type: 'string' }), defineField({ name: 'identifier', type: 'string', description: 'Used to query this menu in code — e.g. "main-nav", "footer-nav"' }), defineField({ name: 'items', type: 'array', of: [{ type: 'navItem' }] }) ]})The identifier field is how your frontend queries a specific menu without hardcoding a document ID:
*[_type == "navigationMenu" && identifier == "main-nav"][0] { items[] { label, link->{ slug }, externalUrl, children[] { label, link->{ slug }, externalUrl } }}Create one navigationMenu document per named menu in your site. Pin them in Structure Builder so editors can find them without searching through document lists.
Next, you’ll set up a siteSettings singleton for global configuration.