CoursesMigrating from Webflow to SanityRun the asset pre-upload pass
Migrating from Webflow to Sanity

Run the asset pre-upload pass

Scan your saved API data and Webflow HTML export for every CDN image URL, upload them to Sanity, and save the mapping file that your content scripts will reference.
Log in to mark your progress for each Lesson and Task

Assets must exist in Sanity before documents that reference them are created. Run this pass first, before any content migration.

The Webflow Assets API only returns files from the Site Assets panel. It misses images in CMS fields and rich text. Scan your saved API data and HTML export for all image URLs instead.

Images appear in three different places depending on how your Webflow site was built:

  1. Webflow CDN URLs — most images in CMS field data and rich text. Webflow uses multiple CDN domains depending on account age; scan all three:
    • uploads-ssl.webflow.com — most common
    • assets-global.website-files.com — older accounts
    • cdn.prod.website-files.com — newer accounts
  2. Local export paths — images referenced as relative paths in the HTML export (e.g. images/product-hero.png). These exist at webflow/export/images/ and need to be uploaded from disk rather than fetched from a URL.

If asset-map.json ends up with fewer entries than you expect, or if you see No asset mapping for figure image warnings during import, run this diagnostic: grep your HTML export for all src= and href= attributes and compare the domains against what the scanner matched.

// scripts/pre-upload-assets.js
import { client } from '../lib/sanity-client.js'
import { glob } from 'fs/promises'
import fs from 'fs'
import path from 'path'
// All known Webflow CDN domains — check your HTML export if you're missing assets
const CDN_PATTERNS = [
/https:\/\/uploads-ssl\.webflow\.com\/[^\s"'<>\\]+/g,
/https:\/\/assets-global\.website-files\.com\/[^\s"'<>\\]+/g,
/https:\/\/cdn\.prod\.website-files\.com\/[^\s"'<>\\]+/g,
]
// Local export image paths: src="images/foo.png" → upload from webflow/export/images/foo.png
const LOCAL_IMG_PATTERN = /(?:src|href)=["'](images\/[^"']+)["']/g
const WEBFLOW_CDN_PATTERN = CDN_PATTERNS[0] // kept for backwards compat in rich-text.js
function cleanUrl(url) {
// Strip any trailing punctuation that leaked in from HTML attribute context
return url.replace(/[\\,;]+$/, '')
}
async function run() {
const seen = new Set()
const assetMap = {}
// Scan all NDJSON item files for CDN URLs in fieldData
const ndjsonFiles = await Array.fromAsync(glob('webflow/api/items/*.ndjson'))
for (const filePath of ndjsonFiles) {
const content = fs.readFileSync(filePath, 'utf8')
for (const pattern of CDN_PATTERNS) {
for (const match of content.matchAll(new RegExp(pattern.source, 'g'))) seen.add(cleanUrl(match[0]))
}
}
// Scan HTML export files for CDN URLs and local relative paths
const htmlFiles = await Array.fromAsync(glob('webflow/export/**/*.html'))
const localPaths = new Set() // relative paths like "images/foo.png"
for (const filePath of htmlFiles) {
const content = fs.readFileSync(filePath, 'utf8')
for (const pattern of CDN_PATTERNS) {
for (const match of content.matchAll(new RegExp(pattern.source, 'g'))) seen.add(cleanUrl(match[0]))
}
// Collect local export image paths
for (const match of content.matchAll(LOCAL_IMG_PATTERN)) localPaths.add(match[1])
}
console.log(`Found ${seen.size} CDN URLs + ${localPaths.size} local paths. Uploading...`)
// Upload CDN assets
for (const url of seen) {
try {
const response = await fetch(url)
if (!response.ok) throw new Error(`HTTP ${response.status} fetching ${url}`)
const contentType = response.headers.get('content-type') ?? 'application/octet-stream'
const filename = decodeURIComponent(url.split('/').pop()?.split('?')[0] ?? 'asset')
// SVGs and other non-raster files must be uploaded as 'file', not 'image'
const assetType = /^image\/(jpeg|png|gif|webp|avif)/.test(contentType) ? 'image' : 'file'
const buffer = Buffer.from(await response.arrayBuffer())
const asset = await client.assets.upload(assetType, buffer, {
filename,
contentType,
source: { name: 'webflow-migration', id: url }
})
assetMap[url] = asset._id
console.log(`${filename}`)
} catch (err) {
console.warn(`${url}: ${err.message}`)
}
}
// Upload local export images (e.g. images/product-hero.png)
for (const relPath of localPaths) {
const diskPath = path.join('webflow/export', relPath)
if (!fs.existsSync(diskPath)) { console.warn(`✗ Local file not found: ${diskPath}`); continue }
try {
const buffer = fs.readFileSync(diskPath)
const filename = path.basename(relPath)
const asset = await client.assets.upload('image', buffer, {
filename,
source: { name: 'webflow-migration', id: relPath }
})
// Map both the relative path and a root-relative URL form so rich-text.js can look it up
assetMap[relPath] = asset._id
assetMap[`/${relPath}`] = asset._id
console.log(`${filename} (local)`)
} catch (err) {
console.warn(`${relPath}: ${err.message}`)
}
}
fs.writeFileSync('asset-map.json', JSON.stringify(assetMap, null, 2))
console.log(`Done. asset-map.json written with ${Object.keys(assetMap).length} entries.`)
if (Object.keys(assetMap).length === 0) {
console.warn('⚠ asset-map.json is empty — check CDN domains in CDN_PATTERNS against your HTML export.')
}
}
run().catch(err => { console.error(err.message); process.exit(1) })

Run it from the project root:

node --env-file=.env scripts/pre-upload-assets.js

The script logs each URL as it uploads (✓ for success, ✗ for failure) and writes asset-map.json when complete. It's safe to re-run. If interrupted, assets already uploaded are skipped on the next run because Sanity deduplicates by source ID.

Next, you’ll build the utility modules that your extraction scripts depend on.

Mark lesson as complete
You have 1 uncompleted task in this lesson
0 of 1