Validate your import in Studio
After running import scripts against the staging dataset, open Studio at localhost:3333 and check these before moving to production:
Reference fields — each reference should show the referenced document's title, not a broken icon. If broken, check id-map.json. The Webflow item ID may not have been written during pass 1.
Rich text — images should render, internal links should resolve. If images are missing, check asset-map.json against the original Webflow CDN URL.
HTML embeds — if your content had w-embed blocks, verify the htmlEmbed field contains the expected HTML string.
Slugs — confirm slugs match the fieldData.slug values from your API export. Import scripts set slug.current directly from fieldData.slug, so slugs should match exactly regardless of whether source is present on the schema.
After running resolve-refs, verify no placeholder weak references remain:
// Quick GROQ check — should return an empty arrayawait client.fetch(`*[_type in ["blog","htmlTag","cssProperty"]][0...5]{ _id, "weakAuthor": author._weak, "weakTags": tags[]._weak}`)If you find remaining _weak: true values on array reference fields (like tags), the patch approach matters. patch.unset(['tags[0]._weak']) doesn't work reliably on array sub-fields in Sanity's patch API. The correct approach is to fetch the array, remove _weak from each item in JavaScript, and replace the whole array with patch.set({ tags: cleanedArray }):
const cleanedTags = doc.tags.map(({ _weak, ...rest }) => rest)await client.patch(doc._id).set({ tags: cleanedTags }).commit()When all checks pass on a representative sample (10 to 20 documents per collection), run the full import for that collection. Repeat before cutover.
After first import, CMS documents often have empty or thin seo objects. This is expected: Webflow doesn't put SEO metadata directly in fieldData for CMS items. It lives in the collection's detail page template, configured in Webflow Designer using interpolation syntax.
To find it: open webflow/api/pages.json and filter for pages where collectionId matches your collection and the slug starts with detail_ (e.g. detail_blog, detail_faq). Each template page has a seo object with fields like:
{ "title": "{{wf {path:name} }} — My Site", "description": "{{wf {path:snippet} }}", "openGraph": { "title": "{{wf {path:name} }}", "descriptionOverride": "{{wf {path:snippet} }}", "image": "{{wf {path:og-image} }}" }}The {{wf {path:fieldName} }} tokens are Webflow's interpolation syntax — each refers to a field in the collection's fieldData. Read the template to understand which fields map to which SEO properties, then write a per-collection transform helper:
// Blog posts: name → metaTitle, snippet → metaDescription, og-image → ogImagefunction seoFromBlogItem(item) { return { metaTitle: item.name, metaDescription: item.snippet ?? '', ogImage: item['og-image'] ? imageField(item['og-image']) : null, noIndex: false, }}Some templates reference related collection fields indirectly — for example, an FAQ's OG image might come from a referenced tag item's og-image field rather than the FAQ item itself. When you see {{wf {path:someReference.fieldName} }}, you'll need a lookup: resolve the reference ID from fieldData, find that item in the related collection's NDJSON, and pull the field from there.
After updating your transform functions, re-import the affected collections and validate:
// GROQ check — should return 0 for a complete importcount(*[_type == "blog" && !defined(seo.metaTitle)])Important distinction: Content Agent is useful for backfilling generated content (alt text, missing descriptions where no source data exists). Structural SEO, title patterns tied to URL templates, should be derived at import time from the detail template, not left for AI to generate. If the template says name → metaTitle, that's an exact mapping, not a creative writing task.
A first import that passes all the checks above is a good first import, it isn't a finished import. Treat the validate step as an invitation to iterate, not a pass/fail gate. Practical things that commonly need a second pass:
- Rich text patterns you missed: your content probably has at least one HTML pattern the first version of rich-text.js didn't handle. When you find one, fix the rule, re-import that collection, re-run resolve-refs, and check again. Because the script uses createOrReplace, re-running is safe.
- SEO and metadata gaps: Webflow's API often exports sparse SEO data — missing meta descriptions, thin image alt text, blank OG images where no template exists. Run Sanity Content Agent against your staging dataset before launch to backfill these fields at scale rather than editing them individually. Use Content Agent for generated copy; use the detail template approach above for structured field mappings.
- Internal links still showing as external: if you ran the import before generating url-map.json (or before all collections were imported), a subset of internal links will be stored as plain external URLs. Running the internalLink conversion pass described in the internal links lesson fixes this without re-importing.
- Field values that need editorial cleanup: some content in Webflow was formatted for Webflow's renderer — inline styles, Webflow-specific class names embedded in rich text, or content that only makes sense in a Webflow layout. These need human review, not a script fix. Build time for that into your pre-launch timeline.
- Visual QA: scripts can verify field values but not layout. Load a sample of migrated pages in your frontend and compare them against the Webflow originals. Pay attention to rich text rendering, image proportions, and any sections that depend on specific field combinations.
The iteration loop is: fix the script, re-import the affected collection, re-run resolve-refs, check in Studio, fix the next thing. Most migrations need two or three passes before content is launch-ready.
Use this before calling the migration done. Check each item in Studio or with a GROQ query.
- CMS types use
name, page singletons usetitle,not mixed - Slug source matches the correct field (
namefor CMS,titlefor pages) if added post-migration figureImageregistered in schemaTypes/index.ts, not just inline in blockContent.ts
asset-map.jsonpopulated (check CDN URLs and local export images)- Pass 1 import complete →
id-map.jsonexists with expected entry count generate-url-map.jsrun →url-map.jsonexistsimport.jsre-run after url-map.json was generated (internal links use it)resolve-refs.jsrun after every re-import — check for any remainingwf_*placeholder refs- All array fields (
tags,categories,sections,nav items) include_keyon every item
- Singleton pages have
title+slugpopulated, not justseo - CMS items have
seo.metaTitlederived from detail page templates (not empty) - Home page
titleis not the same asseo.metaTitle. These are different fields from different sources
- CMS dropdown children seeded from NDJSON — not relying on HTML export placeholders
settings.globalsingleton document existsnavigationMenu.*documents exist with correct children
- output/sitemap.xml generated (or next-sitemap configured)
- Redirects: redirects.json from API, or CSV export from Webflow dashboard
- forms-audit.json reviewed — every form has a replacement strategy
SANITY_DATASETin .env matches an existing dataset (npx sanity datasets list)SANITY_TOKENhas write permissions (Editor or above)
Next, you’ll set up forms, redirects, and sitemap.