Validate your import without a frontend
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 typesconst SINGLETON_IDS = ['homePage', 'aboutPage', 'pricingPage'] // fixed _id for each singletonconst SEO_TYPES = ['post', 'caseStudy'] // CMS types that must have seo.metaTitle
// ─── Helpers ──────────────────────────────────────────────────────────────────
let passed = 0let failed = 0let 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 typesSINGLETON_IDS— the fixed_idstrings for each singleton pageSEO_TYPES— which types must haveseo.metaTitlebefore 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.