Extract static page content using the page DOM API
CMS collection items come from the collection items endpoint. Static page content, text, images, and component instances on pages not backed by a CMS collection come from the page DOM API.
Before any extraction, generate a compact structural summary from your HTML export. Raw HTML is thousands of lines per page; the outline gives AI the shape of each page in a few kilobytes of JSON.
// scripts/outline-pages.js// Run once to generate page-outlines.json before building component-map.jsimport { JSDOM } from 'jsdom'import fs from 'fs'import path from 'path'
const exportDir = './webflow/export'const output = {}
for (const file of fs.readdirSync(exportDir).filter(f => f.endsWith('.html'))) { const html = fs.readFileSync(path.join(exportDir, file), 'utf8') const doc = new JSDOM(html).window.document // Looks for <main> first, then any element with "main" in its class as a fallback. // If your export uses neither, this page is skipped. Fix: replace the selector // with the actual wrapper class from your export (e.g. '.page-wrapper'). const main = doc.querySelector('main') ?? doc.querySelector('[class*="main"]') if (!main) continue
output[file] = []
for (const child of main.children) { const tag = child.tagName.toLowerCase() if (['script', 'style', 'template'].includes(tag)) continue const classes = (child.className || '').trim() const wfId = child.getAttribute('data-wf-id') || child.getAttribute('data-wf-element-id') || '' const entry = { tag, classes, wfId, children: [] } for (const gc of child.children) { entry.children.push({ tag: gc.tagName.toLowerCase(), classes: (gc.className || '').trim(), wfId: gc.getAttribute('data-wf-id') || gc.getAttribute('data-wf-element-id') || '' }) } output[file].push(entry) }}
fs.writeFileSync('webflow/api/page-outlines.json', JSON.stringify(output, null, 2))console.log('Written to webflow/api/page-outlines.json')node --env-file=.env scripts/outline-pages.jsThis writes webflow/api/page-outlines.json. Then classify:
@webflow/api/page-outlines.json Read the page structure outlines and classify each page by its layout pattern. Group them into: (a) simple stacked sections — full-width blocks stacked in a single column inside <main>; (b) split layout — a left content column alongside a right sidebar or sticky track; (c) CMS-collection-driven — pages whose layout is a fixed template and whose content comes entirely from CMS collection records, with no unique per-page sections; (d) other complex layouts. For each group, list which pages fall into it. For CMS-driven pages, identify which collection they display. Don't generate any code yet — just classify.Most sites have one or two complex layout patterns and the rest are simple stacks.
The classification output drives your page modeling decisions.
Simple stacked sections become a page document with a sections array, the page builder, handled by the DOM API extraction below.
Split layouts get a dedicated document type, one per distinct split pattern, with separate mainContent and sidebarContent arrays rather than a generic page.
CMS-collection-driven pages don't need a page document at all. The collection type is the content, and the layout is a fixed React template with no sections array.
Custom or complex pages get a singleton document type per page, fields only, since the layout is hardcoded in Next.js.
Pages in bucket, heavily designed pages, bespoke animations, layouts that don't decompose into discrete vertical sections, are a poor fit for a page builder. Forcing them into a sections array creates a schema that's technically valid but editorially unusable.
The better model: one dedicated singleton document type per custom page (e.g. aboutPage, pricingPage), containing only the content fields that page's template actually reads. The layout is code; editors fill in the fields. These singletons live alongside your page builder page documents and are surfaced together in a "Pages" section of the Studio covered in the Structure Builder lesson at the end of Chapter 3.
Simple stacked pages: the DOM API extraction below handles these automatically.
CMS-collection-driven pages: if the classification step reveals that your pages are fixed templates rendering CMS collection data, the same hero layout, the same sidebar slot, the same content area structure on every page. You don't need a page sections array. Skip the page builder for those pages and go straight to CMS collection types. The layout lives in your React templates, not in Sanity.
A clear signal: the right-hand sidebar always shows the same CTA regardless of which page you're on. The page-specific content (title, description, body) comes from the collection record. When you encounter this pattern, the right content model is:
- CMS collection types for the actual content (
post,tag,faqItem, etc.). Each maps to a fixed-layout template in your Next.js app - A singleton settings document for the shared CTA, footer, and navigation
- A minimal
pagetype for standalone static pages (contact, privacy policy)
There is no sections array. There are no configurable layout slots.
To confirm which model applies:
@webflow/export/*.html For each page, answer two questions: (1) Is the page content driven entirely by a CMS collection record, or does it have unique sections configured per page? (2) Does the right-side column always show the same content regardless of which page you're on, or does it vary per page? List each page with your answers and conclude whether this site needs a Sanity page builder (sections array) at all, or whether fixed collection templates are sufficient.Split layouts and complex pages: for pages with a two-column layout — main content on the left, a sidebar on the right — model each column as its own sections array rather than a single flat sections field. Ask AI to generate the schema from your HTML:
@webflow/export/[your-split-layout-page].html @sanity/schemaTypes/sections/ @sanity/schemaTypes/objects/ This page has a split layout with a main content column and a sidebar. Write a splitLayoutPage document type schema with a title field, a slug field (no source), a mainContent sections array referencing the appropriate section types from the sections directory, and a sidebarContent array referencing any sidebar-appropriate section types. Save it to /sanity/schemaTypes/documents/splitLayoutPage.ts and update index.ts.Once the schema exists, extract the content for all split-layout pages in one pass:
@webflow/export/ @sanity/schemaTypes/documents/splitLayoutPage.ts Find all HTML files in the export directory that use a split layout — a main content column alongside a sidebar. For each one, extract the content into a Sanity document matching the splitLayoutPage schema: left column content into mainContent, right sidebar content into sidebarContent. Use a deterministic _id of page-{slug} for each. Output a single script that calls client.createOrReplace() for each document and saves it to scripts/import-split-pages.js.The DOM API extraction below is for simple stacked pages only.
GET /v2/pages/{page_id}/dom returns only editable content nodes — not layout, not styling. The three useful node types:
text— editable text blocks with html and text representationsimage— image references by Webflow asset IDcomponent-instance— each instance includes a propertyOverrides array with the exact content set for every configurable property on that page instance
{ "type": "component-instance", "componentId": "e3410077-d7db-43e6-8889-0d7f61184424", "propertyOverrides": [ { "label": "Headline", "type": "Plain Text", "text": { "text": "Ship faster with structured content" } }, { "label": "Subheading", "type": "Rich Text", "text": { "html": "<p>One content platform, every channel.</p>" } } ]}The DOM API returns instances in a flat list with no layout context — it cannot tell you which instances are top-level sections versus nested content components. That structural information only exists in the HTML export, which is exactly what the page outline captured.
The map uses the same Type 1/2/3 classification from the page builder lesson in Chapter 3: Type 1 page sections go in page.sections[], Type 2 nested components go in a parent section's sub-array, and Type 3 global/UI-only components map to null. The page outline gives AI the DOM hierarchy it needs to apply those categories correctly.
The strongest structural signal is the <section> HTML tag, professional Webflow developers use it for full-width, top-level content blocks by convention. If your export uses <div> for everything, the classification still works by falling back to any direct child of <main> that wraps a component, but expect more noise in the output and plan to review the inventory more carefully. Components inside <header> or <footer>, nested more than one level deep, or with class="w-dyn-list", are always Type 3.
Run this single comprehensive prompt. Give AI the structural outline, the components list, and your most content-rich pages:
@webflow/api/page-outlines.json @webflow/api/components.json @webflow/export/index.html @webflow/export/[your-most-content-rich-page].html
Analyze the page structure outlines and HTML exports to produce a complete inventory of section types for this Sanity page builder. Look at both Webflow components (from components.json) and raw HTML sections built inline without becoming components.
For Webflow components, classify each into one of three types:
- Type 1 — Page section: direct child of <main>, identified by the <section> tag or as a full-width top-level block. Goes in page.sections[].- Type 2 — Reusable content component: nested inside a section — a repeating sub-item like a card, tier, or feature item. Belongs inside a parent section's sub-array.- Type 3 — Global or UI-only: nav, footer, logo, styling wrapper — no editable content. Maps to null.
For raw HTML sections (top-level <section> blocks with no matching UUID in components.json): identify what content each contains, whether it appears on multiple pages, and propose a Sanity section type name.
Output two things:
1. scripts/component-map.js — exports COMPONENT_TYPE_MAP mapping every Webflow component UUID to its Sanity type name or null. Include a comment on each entry explaining the classification.
2. A section inventory in this format — one entry per discovered section type, including both component-based and raw HTML sections:
Type: [heroSection | featureGrid | richTextSection | etc.]Source: [Webflow component "Component Name" | Raw HTML section]Classification: [Type 1 page section | Type 2 nested component (parent: X) | Type 3 null]Fields needed: [list of fields with types]Appears on: [page list]Notes: [anything relevant — w-dyn-list, collection reference, etc.]
Do not map any component to a Sanity document type name. Only use inline object type names.Review the inventory — this is the human decision point. Read through every entry, including the proposed new section types. For each one, confirm the proposed name and fields make sense for your content model. Common things to adjust:
- Rename types that don't read clearly (richTextSection → bodySection, etc.)
- Merge similar types that share most of their fields (e.g. three "related items" variants → one
relatedItemsSectionwith a contentType field) - Drop any Type 1 sections that are pure front-end chrome with no CMS-editable content
Once you're satisfied with the inventory, move to the next step.
Run this prompt to turn the confirmed inventory, including all proposed new section types, into schema files:
Before running this prompt: save the AI's inventory output to webflow/api/section-inventory.md. The prompt references that file directly. If it doesn't exist on disk, the AI won't have the inventory to work from.
@webflow/api/section-inventory.md @sanity/schemaTypes/sections/ @sanity/schemaTypes/objects/ @sanity/schemaTypes/index.ts Using the confirmed section inventory, generate a Sanity schema file for every section type listed — including all proposed new section types. For Type 1 and Type 2 types, write defineType object schemas with the fields listed in the inventory. Save all section schemas to /sanity/schemaTypes/sections/. Register all new types in index.ts. Add all Type 1 section types to the sections array's of list on the page document type. For each Type 2 type, add it to the of list of its parent section's sub-array field.Confirm Studio compiles without errors before moving on. The most common issue here is a Type 2 schema referenced by name in a parent's of array that wasn't registered in index.ts.
The map should look like this once generated, with your actual IDs and type names:
// scripts/component-map.jsexport const COMPONENT_TYPE_MAP = { // Type 1: Page sections — direct children of <main>, go in page.sections[] 'e3410077-d7db-43e6-8889-0d7f61184424': 'heroSection', 'f1234abc-8899-44ff-aaaa-000000000001': 'featureGrid', 'f1234abc-8899-44ff-aaaa-000000000002': 'emailSignupSection', 'f1234abc-8899-44ff-aaaa-000000000008': 'testimonialsSection', // Note: richTextSection and any other raw-HTML-only sections won't appear here // because they have no Webflow component UUID — they're seeded from HTML directly
// Type 2: Reusable content components — nested inside a parent section 'f1234abc-8899-44ff-aaaa-000000000010': 'featureItem', // child of featureGrid 'f1234abc-8899-44ff-aaaa-000000000011': 'testimonialCard', // child of testimonialsSection 'f1234abc-8899-44ff-aaaa-000000000012': 'pricingTier', // child of pricingSection
// Type 3: Global and UI-only — no Sanity representation 'f1234abc-8899-44ff-aaaa-000000000003': null, // LogoComponent — decorative, hardcoded in React 'f1234abc-8899-44ff-aaaa-000000000009': null, // TagList — w-dyn-list, data from collection import 'f1234abc-8899-44ff-aaaa-000000000005': null, // NavBar — global nav 'f1234abc-8899-44ff-aaaa-000000000006': null, // Footer — global footer 'f1234abc-8899-44ff-aaaa-000000000007': null, // GlobalStyles — styling only}Raw HTML sections (Type 1 sections with no Webflow component UUID) don't appear in the DOM API response, only Webflow component instances do. Their content comes from the HTML export directly. Ask AI to extract all of them across all pages in one pass:
@webflow/export/ @webflow/api/section-inventory.md @sanity/schemaTypes/sections/ The section inventory identifies which section types have no Webflow component UUID — these are raw HTML sections whose content must be extracted from the HTML export directly. For every HTML file in the export directory, find all raw HTML sections matching the inventory, extract their content into the correct schema shape, and generate a single script saved to scripts/seed-raw-sections.js that calls client.createOrReplace() for each page document containing raw sections. Use htmlToBlocks() from @sanity/block-tools for any rich text fields. Look up image asset IDs via lookupAssetId() from ./rich-text.js.Three things to verify before running the extraction script:
First: never map a component to a Sanity document type name ('navigationMenu', 'siteSettings'). Those are top-level document types, not inline section objects. Sanity will reject them in the sections array. Global components like nav and settings are always null.
Second: confirm that every Type 2 component has a corresponding schema registered in index.ts and its parent section schema includes it in the right sub-array's of list. These schemas need to exist before the extraction script runs.
Third: if a section shows as "Untitled" with no content in Studio after extraction, either the component had no propertyOverrides (map it to null), or property labels didn't camelCase into field names your schema recognizes. A property labelled "Hero Headline" becomes heroHeadline — if your schema field is heading, it won't populate. The script logs a warning for every unmapped component instance.
The DOM API returns title, meta description, and OG image alongside page nodes, not in the HTML body, but as structured SEO metadata. Two things to confirm before generating the extraction script:
- Your
seoshared object type should already exist inschemaTypes/objects/from the schema extraction step. If it doesn't, go back and add it before continuing. - Every document type with a public URL —
page, and each CMS collection type — should have adefineField({ name: 'seo', type: 'seo' })field.
When you ask AI to generate extract-static-pages.js in the next step, include this in your prompt so it knows to extract SEO data from the API response alongside page content. For CMS collection items, per-item SEO overrides live in the page settings rather than fieldData — mention this too so the generated import script handles both correctly.
When the same Webflow component is used on multiple pages — a hero on the homepage and a hero on the contact page, each with different titles — the migration captures both correctly. The script calls /v2/pages/{pageId}/dom separately for each page. Webflow's DOM API returns propertyOverrides scoped to that specific page instance, so the homepage call returns { "Headline": "Build Beautiful Tables" } and the contact page returns { "Headline": "Get in Touch" }. Two separate calls, two separate heroSection objects, two separate Sanity page documents — each with their own field values.
One edge case: component default values. The DOM API only returns properties with an explicit per-page override. If an editor used the component's default value and never overrode it, that property won't appear in propertyOverrides. To catch this, save the component defaults at extraction time:
// Fetch component defaults once, keyed by componentIdconst componentDefaults = {}for (const component of components) { const res = await fetch( `https://api.webflow.com/v2/sites/${SITE_ID}/components/${component.id}`, { headers: { Authorization: `Bearer ${WEBFLOW_API_TOKEN}` } } ) const { properties } = await res.json() componentDefaults[component.id] = Object.fromEntries( (properties ?? []).map(p => [p.label, p]) )}Then in the section mapping, merge defaults before applying overrides:
const defaults = componentDefaults[node.componentId] ?? {}const mergedProps = { ...defaults, ...Object.fromEntries(node.propertyOverrides.map(p => [p.label, p])) }for (const prop of Object.values(mergedProps)) { fields[toCamelCase(prop.label)] = mapPropToField(prop)}Per-page overrides take precedence over defaults. Fields with neither a default nor an override will be empty — genuinely absent from the Webflow content — and can be filled in Studio.
// scripts/extract-static-pages.jsimport { client } from '../lib/sanity-client.js'import { COMPONENT_TYPE_MAP } from './component-map.js'import { convertRichText } from './rich-text.js'import fs from 'fs'
const WEBFLOW_API_TOKEN = process.env.WEBFLOW_API_TOKENconst SITE_ID = process.env.WEBFLOW_SITE_IDconst assetMap = JSON.parse(fs.readFileSync('asset-map.json', 'utf8'))
// Convert 'Hero Headline' → 'heroHeadline'function toCamelCase(label) { return label .split(' ') .map((w, i) => i === 0 ? w.toLowerCase() : w[0].toUpperCase() + w.slice(1).toLowerCase()) .join('')}
function mapPropToField(prop) { if (prop.type === 'Plain Text') return prop.text.text ?? '' if (prop.type === 'Rich Text') return convertRichText(prop.text.html ?? '') if (prop.type === 'Alt Text') return prop.text.text ?? '' return null}
async function fetchComponentDefaults() { const { components } = JSON.parse(fs.readFileSync('webflow/api/components.json', 'utf8')) const defaults = {} for (const component of components) { const res = await fetch( `https://api.webflow.com/v2/sites/${SITE_ID}/components/${component.id}`, { headers: { Authorization: `Bearer ${WEBFLOW_API_TOKEN}` } } ) const { properties } = await res.json() defaults[component.id] = Object.fromEntries( (properties ?? []).map(p => [p.label, p]) ) } return defaults}
async function run() { const { pages } = JSON.parse(fs.readFileSync('webflow/api/pages.json', 'utf8')) const componentDefaults = await fetchComponentDefaults()
for (const page of pages) { // Skip CMS collection template pages — handled via collection item import if (page.collectionId) continue
// limit=100 covers most pages. The DOM API is paginated — if a page has more // than 100 content nodes, nodes beyond the first page are silently dropped. // Content-heavy pages (long landing pages, docs) may need a pagination loop. const res = await fetch( `https://api.webflow.com/v2/pages/${page.id}/dom?limit=100`, { headers: { Authorization: `Bearer ${WEBFLOW_API_TOKEN}` } } ) const { nodes } = await res.json()
const sections = nodes .filter(node => node.type === 'component-instance') .map((node, idx) => { const sectionType = COMPONENT_TYPE_MAP[node.componentId] if (!sectionType) { console.warn(`No mapping for componentId: ${node.componentId}`) return null } // Merge component defaults with page-specific overrides. // Overrides take precedence; defaults fill in any properties // the editor left unchanged on this page. const defaults = componentDefaults[node.componentId] ?? {} const overrides = Object.fromEntries(node.propertyOverrides.map(p => [p.label, p])) const mergedProps = { ...defaults, ...overrides } const fields = {} for (const prop of Object.values(mergedProps)) { fields[toCamelCase(prop.label)] = mapPropToField(prop) } return { _key: `${node.componentId}-${node.id ?? idx}`, _type: sectionType, ...fields } }) .filter(Boolean)
// Webflow returns "" or dot-notation identifiers (e.g. "page.home") for // the home page and some system pages. Normalize these to "home" so the // slug and _id are clean. Re-running is safe — createOrReplace updates in place. const rawSlug = page.slug ?? '' const normalizedSlug = rawSlug === '' || rawSlug.startsWith('page.') ? 'home' : rawSlug const _id = `page-${normalizedSlug.replace(/\//g, '-')}` await client.createOrReplace({ _id, _type: 'page', title: page.title, slug: { _type: 'slug', current: normalizedSlug }, sections, seo: { metaTitle: page.seo?.title ?? '', metaDescription: page.seo?.description ?? '', noIndex: page.seo?.noIndex ?? false, } })
console.log(`Upserted page: ${page.slug ?? 'home'} (${sections.length} sections)`) }}
run().catch(err => { console.error(err.message); process.exit(1) })Run it from the project root:
node --env-file=.env scripts/extract-static-pages.jsWarning: url-map.json not found — using {} — expected if you haven't created url-map.json yet. Internal link hrefs are left as-is. Create the file and re-run if your pages contain rich text with internal links.
No mapping for componentId: xxxxxxxx — a component ID appearing on nearly every page is almost always your header, footer, or a global widget. Add it to component-map.js with null to suppress the warning:
export const COMPONENT_TYPE_MAP = { '08180112-1657-0593-751b-912f96c3051f': null, // header/nav — handled by navigationMenu '713b265d-c54e-b104-ce39-23abfb0cddde': null, // footer — handled by navigationMenu // ... your actual section mappings}Upserted page: home (N sections) — the homepage slug in Webflow is null or empty, so the script normalizes it to home. Verify it looks right in Studio.
If a page shows (0 sections) and you expected content, those sections used unmapped component IDs and were skipped. If a page has more than 100 content nodes, the DOM endpoint is paginated. Add the same offset-based pagination loop you used for collection items.
Next, you’ll import content into Sanity using the two-pass pattern.