CoursesMigrating from Webflow to SanityValidate your import without a frontend
Migrating from Webflow to Sanity

Validate your import without a frontend

Run a script that checks document counts, singleton existence, map integrity, dangling references, and SEO coverage before wiring up any frontend.
Log in to mark your progress for each Lesson and Task

You don't need a running Next.js app to know whether the migration is healthy. This script queries Sanity directly and checks the things that break silently: weak references that never got resolved, CMS items with empty SEO, singleton documents that weren't imported, and artifact files that scripts downstream depend on.

Save it as scripts/validate-migration.js and run it from the migration repo root:

node scripts/validate-migration.js
// scripts/validate-migration.js
// Run after import.js, resolve-refs.js, and generate-url-map.js.
// Requires: id-map.json, url-map.json, asset-map.json, webflow/api/collections.json
// Env: SANITY_PROJECT_ID, SANITY_DATASET, SANITY_TOKEN
import { createClient } from '@sanity/client'
import fs from 'fs'
import path from 'path'
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,
})
// ─── Config ───────────────────────────────────────────────────────────────────
// Update these to match your schema types and singleton document IDs.
const CMS_TYPES = ['post', 'author', 'caseStudy', 'teamMember'] // your collection types
const SINGLETON_IDS = ['homePage', 'aboutPage', 'pricingPage'] // fixed _id for each singleton
const SEO_TYPES = ['post', 'caseStudy'] // CMS types that must have seo.metaTitle
// ─── Helpers ──────────────────────────────────────────────────────────────────
let passed = 0
let failed = 0
let warnings = 0
function pass(label) {
console.log(`${label}`)
passed++
}
function fail(label, detail = '') {
console.error(`${label}${detail ? `${detail}` : ''}`)
failed++
}
function warn(label, detail = '') {
console.warn(`${label}${detail ? `${detail}` : ''}`)
warnings++
}
function loadJson(filePath) {
if (!fs.existsSync(filePath)) return null
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
}
// ─── Checks ───────────────────────────────────────────────────────────────────
async function checkDocumentCounts() {
console.log('\n── Document counts ──')
const collections = loadJson('webflow/api/collections.json')
if (!collections) { warn('webflow/api/collections.json not found — skipping count check'); return }
const idMap = loadJson('output/id-map.json') ?? {}
const importedIds = Object.values(idMap)
for (const collection of collections.collections ?? []) {
const itemFile = `webflow/api/items/${collection.id}.ndjson`
if (!fs.existsSync(itemFile)) continue
const lines = fs.readFileSync(itemFile, 'utf8').trim().split('\n').filter(Boolean)
const webflowCount = lines.length
const sanityCount = importedIds.filter(id => id.startsWith(`${collection.slug}-`)).length
if (sanityCount >= webflowCount) {
pass(`${collection.displayName}: ${sanityCount}/${webflowCount} documents imported`)
} else {
fail(`${collection.displayName}: only ${sanityCount}/${webflowCount} documents in id-map`)
}
}
}
async function checkSingletons() {
console.log('\n── Singleton documents ──')
for (const docId of SINGLETON_IDS) {
const doc = await client.fetch(`*[_id == $id][0]{ _id, _type }`, { id: docId })
if (doc) {
pass(`${docId} exists (_type: ${doc._type})`)
} else {
fail(`${docId} not found in dataset`)
}
}
}
async function checkWeakRefs() {
console.log('\n── Unresolved placeholder references ──')
// Placeholder refs from Pass 1 use _weak: true and _ref starting with wf_
const weakRefs = await client.fetch(
`*[references(*[_id match "wf_*"]._id)]{ _id, _type }`
)
if (weakRefs.length === 0) {
pass('No documents reference placeholder wf_* IDs')
} else {
fail(`${weakRefs.length} documents still reference placeholder IDs`, 'run resolve-refs.js again')
weakRefs.slice(0, 5).forEach(d => console.error(` ${d._type} / ${d._id}`))
if (weakRefs.length > 5) console.error(` …and ${weakRefs.length - 5} more`)
}
}
async function checkSeoPopulated() {
console.log('\n── SEO coverage ──')
for (const typeName of SEO_TYPES) {
const [total, missing] = await Promise.all([
client.fetch(`count(*[_type == $t])`, { t: typeName }),
client.fetch(`count(*[_type == $t && !defined(seo.metaTitle)])`, { t: typeName }),
])
if (missing === 0) {
pass(`${typeName}: all ${total} documents have seo.metaTitle`)
} else if (missing < total * 0.1) {
warn(`${typeName}: ${missing}/${total} documents missing seo.metaTitle`)
} else {
fail(`${typeName}: ${missing}/${total} documents missing seo.metaTitle`, 'check detail template mapping')
}
}
}
async function checkCmsTypeFields() {
console.log('\n── CMS type field presence ──')
for (const typeName of CMS_TYPES) {
const [total, missingSlug, missingName] = await Promise.all([
client.fetch(`count(*[_type == $t])`, { t: typeName }),
client.fetch(`count(*[_type == $t && !defined(slug.current)])`, { t: typeName }),
client.fetch(`count(*[_type == $t && !defined(name)])`, { t: typeName }),
])
if (total === 0) { warn(`${typeName}: no documents found`); continue }
if (missingSlug === 0) {
pass(`${typeName}: all ${total} documents have slug.current`)
} else {
fail(`${typeName}: ${missingSlug}/${total} documents missing slug.current`)
}
if (missingName === 0) {
pass(`${typeName}: all ${total} documents have name`)
} else {
warn(`${typeName}: ${missingName}/${total} documents missing name (check fieldData mapping)`)
}
}
}
async function checkArtifacts() {
console.log('\n── Artifact files ──')
const required = [
['output/id-map.json', 'Maps Webflow IDs to Sanity IDs'],
['output/url-map.json', 'Maps Webflow paths to Sanity _id'],
['output/asset-map.json', 'Maps CDN/local image URLs to Sanity asset IDs'],
]
const optional = [
['output/sitemap.xml', 'Sitemap for submission to Google Search Console'],
['output/redirects.json', 'Redirect rules (Enterprise API)'],
['output/forms-audit.json', 'Forms requiring replacement'],
]
for (const [filePath, label] of required) {
if (fs.existsSync(filePath)) {
const size = fs.statSync(filePath).size
if (size < 10) {
fail(`${filePath} exists but is empty`, label)
} else {
pass(`${filePath} present (${(size / 1024).toFixed(1)} KB) — ${label}`)
}
} else {
fail(`${filePath} missing`, label)
}
}
for (const [filePath, label] of optional) {
if (fs.existsSync(filePath)) {
pass(`${filePath} present — ${label}`)
} else {
warn(`${filePath} not found — ${label}`)
}
}
}
async function checkAssetMapCoverage() {
console.log('\n── Asset map coverage ──')
const assetMap = loadJson('output/asset-map.json')
if (!assetMap) { warn('asset-map.json not found — skipping'); return }
const entries = Object.keys(assetMap).length
if (entries === 0) {
fail('asset-map.json is empty — pre-upload-assets.js may not have run')
} else {
pass(`asset-map.json has ${entries} entries`)
}
// Spot-check: do any Sanity documents still reference CDN URLs directly?
const docsWithCdnUrls = await client.fetch(
`count(*[@ match "uploads-ssl.webflow.com" || @ match "website-files.com"])`
)
if (docsWithCdnUrls === 0) {
pass('No documents reference Webflow CDN URLs directly')
} else {
warn(`${docsWithCdnUrls} documents may still reference Webflow CDN URLs`)
}
}
// ─── Run ──────────────────────────────────────────────────────────────────────
async function run() {
console.log(`\nValidating migration — dataset: ${process.env.SANITY_DATASET}\n`)
await checkDocumentCounts()
await checkSingletons()
await checkWeakRefs()
await checkSeoPopulated()
await checkCmsTypeFields()
await checkArtifacts()
await checkAssetMapCoverage()
console.log('\n─────────────────────────────────────────')
console.log(` ${passed} passed ${warnings} warnings ${failed} failed`)
if (failed > 0) {
console.error('\nFix the failures above before proceeding to launch.')
process.exit(1)
} else if (warnings > 0) {
console.warn('\nWarnings are non-blocking but worth reviewing.')
} else {
console.log('\nAll checks passed. Ready for launch checklist.')
}
}
run().catch(err => { console.error(err); process.exit(1) })

Customize before running:

  • CMS_TYPES — your Sanity collection document types
  • SINGLETON_IDS — the fixed _id strings for each singleton page
  • SEO_TYPES — which types must have seo.metaTitle before launch

The script exits with code 1 if any required check fails, so you can run it in CI or as part of a pre-launch script. Warnings are printed but non-blocking.

What each check covers:

The document count check compares NDJSON line counts from your Webflow export to entries in id-map.json. A mismatch means some items from that collection weren't imported. Usually a transform function that threw and was silently caught.

The weak reference check queries for any document that still references an ID starting with wf_. Those placeholders are written in Pass 1 and should all be swapped out by resolve-refs.js. If any remain, the ref resolution script either didn't run or encountered an ID that wasn't in id-map.json.

The SEO check uses a 10% threshold rather than zero-tolerance — a handful of items with unusual fieldData is common and fixable post-launch, but 40% missing is a structural problem with your detail template mapping.

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