Import content with the two-pass pattern
When one collection references another, referenced documents must exist before the reference can be written. Rather than sorting collections topologically, use two passes: create all documents first, resolve references second. This works regardless of reference depth.
Gotcha: Sanity validates references on write. In Pass 1, you cannot write { _type: 'reference', _ref: 'some-id-that-doesnt-exist-yet' } — the API will reject it. The fix is to include _weak: true on every placeholder reference. Weak references bypass referential integrity checks, so Sanity accepts them even when the target document doesn't yet exist. Pass 2 swaps in the real _ref and removes _weak.
// Pass 1: placeholder with _weak: truefunction placeholderRef(webflowId) { return { _type: 'reference', _ref: `wf_${webflowId}`, _weak: true }}// Pass 2: real ref, _weak removedconst resolved = { ...ref, _ref: idMap[webflowId] }delete resolved._weakGotcha: arrays need _key on every item. Any array field written via @sanity/client, references, page sections, nav items, tag lists, must include a stable _key on each object. Studio auto-generates keys when editors create content through the UI; the API doesn't. Without _key, Studio shows "Missing keys on array items" validation errors and may block publishing.
Use the Webflow item ID, a slug, or any stable unique string as the key. Apply this to both pass 1 placeholders and pass 2 resolved references:
// Pass 1: placeholder ref with _key{ _key: webflowId, _type: 'reference', _ref: `wf_${webflowId}`, _weak: true }// Pass 2: resolved ref, _key preserved{ _key: webflowId, _type: 'reference', _ref: idMap[webflowId] }// Page sections array{ _key: `section-${idx}`, _type: 'heroSection', heading: '...' }// Nav items{ _key: item.id, label: item.label, link: { _type: 'reference', _ref: idMap[item.pageId] } }Check every array in your schema, not just reference arrays. Any object in an of array needs _key.
Rather than writing transforms by hand, give AI everything it needs to generate the complete script for you.
Prompt:
I need you to generate a complete scripts/import.js file for migrating my Webflow CMS data to Sanity.
First, read these files to understand the data:
- webflow/api/collections.json — list of all Webflow collections with their IDs and field schemas- webflow/api/items/*.ndjson — for each collection, read the first 2–3 lines to see real field names and values- sanity/schemaTypes/index.ts — the Sanity document types to map into- Each schema file referenced from index.ts — to see the exact Sanity field names and, critically, the exact name value from each defineType call. That name value is the _type string every imported document must carry. Do not infer or guess type names — read them directly from the schema files.
Then generate a complete scripts/import.js that:
1. Uses import { client } from '../lib/sanity-client.js'2. Reads NDJSON line by line using an async generator3. Has an importPass1(collectionId, transformFn) that calls client.createOrReplace with a deterministic _id of ${typeName}-${item.id} (e.g. blog-664a0fba153353ffe949714b), logs each upsert, and writes id-map.json after each collection. Using the Sanity type name as the prefix means you can identify a document's type from its ID alone and filter by prefix in GROQ (*[_id match "blog-*"])4. Has an importPass2(collectionId, refFn) that patches reference fields using idMap5. Has one transform* function per collection mapping Webflow fieldData keys to Sanity field names — scalar fields only (strings, slugs, numbers, booleans, dates, images from asset-map.json)6. Has one *Refs function per collection returning only reference fields as { _type: 'reference', _ref: idMap[item.fieldData.someId] } — return {} if the collection has no references7. Has a run() function that calls importPass1 for all collections, then importPass2 for all collections, with console output labelling each phase8. Calls run().catch(err => { console.error(err.message); process.exit(1) })
For image fields, import { lookupAssetId } from ./rich-text.js and use { _type: 'image', asset: { _type: 'reference', _ref: lookupAssetId(url) } }. Skip (return null) for any image where lookupAssetId returns null.
For rich text fields, import { convertRichText } from ./rich-text.js and call it on the HTML string.
Order the importPass1 calls so that collections with no incoming references run before collections that reference them.For image fields, import { lookupAssetId } from ./rich-text.js and use { _type: 'image', asset: { _type: 'reference', _ref: lookupAssetId(url) } }. Skip (return null) for any image where lookupAssetId returns null.
For rich text fields, import { convertRichText } from ./rich-text.js and call it on the HTML string.
Order the importPass1 calls so that collections with no incoming references run before collections that reference them.
Review the generated script before running it. Check that:
- Every Sanity field name matches what's in your schema files exactly
- Every
_typestring matches thenamein its schema definition exactly. A mismatch results in "Unknown type" errors in Studio - Reference fields point to the correct collection
- Any field you don't want imported is removed
- SEO fields are nested correctly: if your schema uses a nested
seoobject (seo.ogImage,seo.metaTitle), your transform function must writeseo: { ogImage: ... }, notogImage: ...at the top level. Data written to the wrong path is silently accepted by the API and then silently absent in Studio. There's no error.
Then run it from the project root:
node --env-file=.env scripts/import.jsIf your site has collections with large rich text content, conversion via jsdom can be slow. Running all collections sequentially may time out. Add a collection-name filter so you can run one at a time:
// In run():const filter = process.argv.slice(2).map(s => s.toLowerCase())for (const col of COLLECTIONS) { if (filter.length && !filter.some(f => col.name.toLowerCase().includes(f))) continue // ...}Then run per collection, accumulating the id-map across runs:
node --env-file=.env scripts/import.js Authors Stylesnode --env-file=.env scripts/import.js "HTML Tags"node --env-file=.env scripts/import.js BlogsYou will see output like:
Pass 1: creating documents...Upserted: post-6458a2b3c4d5e6f7a8b9c0d1Upserted: post-6458a2b3c4d5e6f7a8b9c0d2...id-map.json written (47 entries)Pass 2: resolving references...Patched: post-6458a2b3c4d5e6f7a8b9c0d1...Done.The script is idempotent, running it again updates existing documents rather than creating duplicates.
After import.js completes, run the remaining scripts in this sequence:
# 1. Import all CMS collection itemsnode --env-file=.env scripts/import.js
# 2. Seed static page sections (needs id-map.json from step 1 for references to resolve)node --env-file=.env scripts/seed-raw-sections.js
# 3. Generate url-map.json now that id-map.json exists, then re-run import to fix internal linksnode --env-file=.env scripts/import.js
# 4. Resolve any remaining placeholder referencesnode --env-file=.env scripts/resolve-refs.jsIf you're not using a page builder, skip step 2. If you don't have internal links in rich text, skip steps 3a and 3b.
Now that id-map.json exists, generate the URL map that lets rich-text.js convert internal links into document references:
Read id-map.json, webflow/api/pages.json, and the first 3 lines of each file in webflow/api/items/. Generate url-map.json — a JSON object mapping every Webflow URL path to its corresponding Sanity _id. For static pages, use the page slug as the path (e.g. "/about"). For CMS collection items, construct the path from the collection's URL prefix and the item's slug field in fieldData (e.g. "/blog/my-post"). Look up each item's Webflow ID in id-map.json to get the Sanity _id. My URL changes are: [describe any slug changes, or say "no URL changes"]. Output only the JSON file.The output maps every Webflow URL path to its Sanity document _id:
{ "/about": "page-65d22c1bc71e84a616a91b30", "/blog/my-post": "blog-664b57637e4c77c9348c99c0", "/blog/another-post": "blog-664a0fba153353ffe949714b"}Once the file exists, re-run import.js (step 3b in the run order above). The upsert overwrites existing documents cleanly, replacing plain URL link marks with proper internalLink reference annotations.
Re-run resolve-refs after every re-import. Whenever you re-run import.js for any collection. To fix a bug, update a schema field, or pick up a new feature like figureImage support. Re-run resolve-refs.js immediately after. Re-importing overwrites the document with new placeholder refs (wf_*) . If you don't resolve them again, those documents will have broken references in Studio even though the same import worked fine before.
Three types of warnings are normal and don't indicate a failed import:
Warning: url-map.json not found — using {} — the script couldn't find url-map.json, so all links inside rich text fields were imported as plain external URLs, including links to your own blog posts and pages. To fix this, generate url-map.json (see the url-map lesson in Chapter 4) and re-run the import. Because the script uses createOrReplace, re-running is safe and corrects the links in place.
Unknown convert type option ID: dca69e0d5f2e406947c45b5017a0a750 — a Webflow select/option field referenced an option ID that couldn't be mapped to a display value. This happens when Webflow stores the internal UUID of the selected option rather than its label, and the collection schema doesn't include a lookup table. The field is skipped for that item. If it matters, open webflow/api/collections.json, find the collection's field definition, locate the validations.options array, and match the ID to its name — then add a lookup table to your transform function.
No Sanity ID for Webflow ID: "65d22c1bc71e84a616a91b70" — a document tried to reference another Webflow item that wasn't imported, either because it belongs to a collection you excluded, or because it was a draft/archived item filtered out during extraction. The reference field is skipped for that document. If the missing item was supposed to be imported, check whether its collection ID appears in your importPass1 calls. If it was intentionally excluded, ignore this warning.
Sequential client.create() calls are too slow at scale. 100k items at roughly 50ms per call is over an hour per collection. Use @sanity/import instead, which streams an NDJSON file and uses batch transactions. The two-pass problem is solved by pre-assigning Sanity IDs before any API calls: scan the NDJSON files, assign a UUID to every item, write id-map.json — then import everything in a single streaming pass with references already resolved.
pnpm add @sanity/import uuid// scripts/import-at-scale.jsimport { client } from '../lib/sanity-client.js'import importFromStream from '@sanity/import'import { createInterface } from 'readline'import { Readable } from 'stream'import { v4 as uuid } from 'uuid'import fs from 'fs'
async function* readNDJSON(path) { const rl = createInterface({ input: fs.createReadStream(path) }) for await (const line of rl) { if (line.trim()) yield JSON.parse(line) }}
// Step 1: Scan all NDJSON files and pre-assign a Sanity ID to every Webflow item.// No API calls — just reads files and generates UUIDs.async function buildIdMap(collectionIds) { const idMap = {} for (const id of collectionIds) { for await (const item of readNDJSON(`webflow/api/items/${id}.ndjson`)) { idMap[item.id] = uuid() } } fs.writeFileSync('id-map.json', JSON.stringify(idMap, null, 2)) console.log(`id-map.json: ${Object.keys(idMap).length} entries`) return idMap}
// Step 2: Stream-transform one collection and import via @sanity/import.// Because IDs are pre-assigned, all references — including cross-collection — resolve correctly.async function streamImportCollection(collectionId, transformItem, idMap) { async function* generate() { for await (const item of readNDJSON(`webflow/api/items/${collectionId}.ndjson`)) { const doc = transformItem(item, idMap) if (doc) yield JSON.stringify(doc) + '\n' } }
const stream = Readable.from(generate()) const { numDocs } = await importFromStream(stream, { client, operation: 'createOrReplace' }) console.log(`Imported ${numDocs} documents from ${collectionId}`)}With pre-assigned IDs, all collections can be imported in any order, or in parallel, because every reference is known before the first document hits the API.
Run it from the project root:
node --env-file=.env scripts/import-at-scale.jsGive the AI one line from your NDJSON file and your Sanity schema for that collection, then ask it to write the transformItem function (or transformSimpleFields / buildReferenceFields for the standard approach). Use resolveRef() and resolveMultiRef() from the previous lesson to handle reference fields.
Next, you’ll validate your imported content in Studio before moving to the next collection.