Export from Webflow and connect the Data API
You need two things from Webflow: the code export and API access. The export shows you how content renders; the API gives you the actual data.
The scripts and prompts in this guide assume a few structural conventions common on professionally built Webflow sites. Where your site doesn't follow one, the discrepancy is called out inline at the point it matters.
- Paid Webflow plan — code export requires a Basic plan or higher. Free-plan sites can't export HTML.
- Semantic page structure —
outline-pages.jslooks for a<main>element, and the classification prompt and component inventory treat direct children of<main>tagged<section>as top-level blocks. Sites built by non-developers, or heavily customized ones, may use<div>throughout instead. The scripts fall back for this, but classification quality will vary. - Webflow components for repeated sections — the component inventory and
extract-static-pages.jsexpect repeated sections to be Webflow components, not manually duplicated<div>blocks. Duplicated divs won't show up incomponents.jsonand need a different extraction approach. - A single Webflow site on the account — the fetch script uses
sites.sites[0], the first site the API returns. With multiple sites, check the console output confirms the right one. - Webflow CDN assets at
uploads-ssl.webflow.com— the asset scanner matches this domain. Older accounts may serve assets fromassets-global.website-files.cominstead. Ifasset-map.jsoncomes back empty, check your HTML export for the real CDN domain and update the regex inpre-upload-assets.js. - Standard export folder structure —
.htmlfiles should sit directly insidewebflow/export/. If your unzipped export nests them in a subfolder, move them up a level first.
In the Webflow Designer, go to Site Settings > Export Code and download the zip. Unzip it into /webflow/export/ in your project — you'll get HTML, CSS, JS, and page templates.
If your site uses named components, also export the Dev Link output. It generates React component code that's useful for schema inference.
You can complete Chapters 2 through 5 entirely in a repo with no Next.js app — just /sanity/, /scripts/, and /webflow/. The Studio, import scripts, and all content migration work run here. You don't need a frontend to import content.
Chapter 6 is where that split matters — some of the work applies to this migration repo now, some needs a separate frontend repo later. If you're building the frontend as a separate step or repo, generate the standalone artifacts now and hand them over once the frontend exists.
Create a folder for the migration project. The scripts, API data, and exports all live here alongside your Sanity project:
/webflow/export/ ← Webflow code export (unzip here, then move contents up so .html files sit directly in this folder, not inside a subfolder)/webflow/api/ ← API responses saved as JSON (created by scripts)/sanity/ ← your Sanity project (created in the next lesson)/lib/ sanity-client.js ← shared Sanity client (imported by all scripts)/scripts/ fetch-webflow-data.js fetch-collection-items.js pre-upload-assets.js extract-static-pages.js outline-pages.js rich-text.js component-map.js resolve-refs.js import.js.env ← environment variables (never commit this)Create .env at the project root — this is where all credentials live, read by every script in the project:
# .env
# WebflowWEBFLOW_API_TOKEN=your_webflow_api_token_hereWEBFLOW_SITE_ID=your_webflow_site_id_here
# Sanity (fill in after running npm create sanity@latest in the next lesson)SANITY_PROJECT_ID=SANITY_DATASET=stagingSANITY_TOKEN=Add .env to your .gitignore before committing anything. Open .gitignore (create it at the project root if it doesn't exist) and add:
.envScripts in this course read these values via process.env. On Node 20 or later, load the file with the built-in flag:
node --env-file=.env scripts/fetch-webflow-data.jsOn Node 18, install dotenv and add import 'dotenv/config' at the top of each script instead.
Generate an API token in your Webflow dashboard under Site Settings > Apps & Integrations > API Access. Enable read access for these scopes — the fetch script uses all of them:
- Sites — site metadata and ID resolution
- Pages — page list, slugs, SEO metadata
- CMS — collections and collection items
- Components — component definitions and configurable properties
- Custom Code — site-level and page-level script inventory
- Forms — field schemas and validation rules
Write access isn't needed for this step. Paste the generated token into WEBFLOW_API_TOKEN in your .env file.
Run the script below to fetch and save all Webflow API data to /webflow/api/. It resolves your site ID automatically, so you don't need to look it up first.
import fs from 'fs'
const TOKEN = process.env.WEBFLOW_API_TOKENif (!TOKEN) { console.error('Set WEBFLOW_API_TOKEN before running this script.') process.exit(1)}
const BASE = 'https://api.webflow.com/v2'
async function get(path) { const res = await fetch(`${BASE}${path}`, { headers: { Authorization: `Bearer ${TOKEN}`, Accept: 'application/json' } }) if (!res.ok) throw new Error(`${res.status} ${res.statusText} — ${path}`) return res.json()}
// Some endpoints (e.g. Custom Code) require Business/Enterprise plan.// This wrapper skips them gracefully if the plan doesn't support them.async function tryGet(path, filename) { try { const data = await get(path) save(filename, data) } catch (err) { if (err.message.startsWith('403')) { console.warn(`Skipped ${filename} — not available on your Webflow plan (${path})`) } else { throw err } }}
function save(filename, data) { fs.mkdirSync('webflow/api', { recursive: true }) fs.writeFileSync(`webflow/api/${filename}`, JSON.stringify(data, null, 2)) console.log(`Saved webflow/api/${filename}`)}
async function run() { // Fetch all sites and use the first one (or match by name if you have multiple) const sites = await get('/sites') save('sites.json', sites)
const site = sites.sites[0] // If you have multiple Webflow sites, check the console output confirms the right one const siteId = site.id console.log(`Using site: ${site.displayName} (${siteId})`)
// Pages — slugs, SEO meta, noIndex flags (your URL inventory) const pages = await get(`/sites/${siteId}/pages`) save('pages.json', pages)
// CMS collections — field types, names, reference targets const collections = await get(`/sites/${siteId}/collections`) save('collections.json', collections)
// Component definitions — names, configurable properties, usage locations const components = await get(`/sites/${siteId}/components`) save('components.json', components)
// Forms — field types, labels, validation rules per form const forms = await get(`/sites/${siteId}/forms`) save('forms.json', forms)
// Custom Code — requires Business or Enterprise plan; skipped gracefully on lower plans await tryGet(`/sites/${siteId}/custom_code`, 'custom-code-site.json') await tryGet(`/sites/${siteId}/custom_code/blocks`, 'custom-code-pages.json')
// Redirects — requires Enterprise plan; skipped gracefully on lower plans await tryGet(`/sites/${siteId}/redirects`, 'redirects.json')
// Print a summary of any endpoints that were skipped due to plan limits const skipped = [] if (!fs.existsSync('webflow/api/custom-code-site.json')) skipped.push('custom-code-site.json (Business+ required)') if (!fs.existsSync('webflow/api/redirects.json')) skipped.push('redirects.json (Enterprise required)') if (skipped.length) { console.log('\n⚠ Skipped (plan limits):') skipped.forEach(s => console.log(` - ${s}`)) console.log('\nManual alternatives:') if (skipped.some(s => s.includes('custom-code'))) { console.log(' - Custom code: view source on live site or check Site Settings > Custom Code in Designer') } if (skipped.some(s => s.includes('redirects'))) { console.log(' - Redirects: Site Settings → SEO → 301 redirects → Export CSV') } }}
run().catch(err => { console.error(err.message) process.exit(1)})Run it from the project root:
node --env-file=.env scripts/fetch-webflow-data.jsThe pages list replaces any need to export an XML sitemap — it has every page slug, SEO title, meta description, and noIndex flag. Use it directly as your URL inventory.
Once content is imported into Sanity, use Sanity's Content Agent to backfill missing SEO data. Pages and collection items from Webflow often come through with thin or missing meta descriptions and image alt text — run Content Agent against the staging dataset before launch instead of filling gaps by hand.
The custom code endpoints give you a structured inventory of every script registered site-wide and per page — more reliable than parsing <head> sections out of the HTML export. If you see Skipped custom-code-site.json in the output, your Webflow plan doesn't include the Custom Code API — audit your scripts manually instead, through Site Settings > Custom Code and each page's custom code settings in the Designer.
Every migration script needs the same Sanity client config. Create it once and import it everywhere:
import { createClient } from '@sanity/client'
export const client = createClient({ projectId: process.env.SANITY_PROJECT_ID, dataset: process.env.SANITY_DATASET, token: process.env.SANITY_TOKEN, apiVersion: '2024-01-01', useCdn: false})Every script in this guide imports from this file: import { client } from '../lib/sanity-client.js'. Change the dataset or API version here, and it's the only file you touch.
Don't fetch CMS collection items yet — that happens in Chapter 4, after the schema is designed.
Next, you’ll initialize your Sanity project.