GuideWixNext.js

How to Migrate From Wix to Payload CMS

Payload's convertHTMLToLexical does not upload images, it drops every img tag silently. That, plus turning denormalised CSV strings into relationship IDs, is most of the real work in a Wix to Payload migration. Here is the field-level mapping and the order to run it in.

M

MigrateLab Team

Migration Experts

13 min read· Last updated
Wix to Payload CMS: Mapping Flat CSV Into a Typed Content Model

The two conversions that actually cost you

A Wix to Payload migration is not difficult because Wix is closed. It is difficult because the two things you most want to carry across, rich text with images inside it and repeated values like author and category, are exactly the two things that do not survive a naive import.

Payload's own documentation is explicit about the first one. When converting HTML to Lexical, <img> tags are not automatically uploaded, because Payload cannot know which uploads-enabled collection to put them in. Nothing is flagged, queued or logged. The images are dropped silently. The post imports, the title is correct, the body reads fine in the admin list view, and every image in the article is gone. On a large blog you can reach client review before anyone notices.

The second failure is quieter. A Wix CSV carries the author's name as a plain string on every single row. Payload's relationship field stores a document ID. Handing it "Jane Doe" where it expects an ID gives you a validation error at best, and at worst a document that saves happily with an empty relation.

Everything below is the field-level mapping between the two models, and the order of operations that avoids both failures.

What Wix actually hands you, briefly

Wix has no code export. A CMS collection exports to CSV, capped at 1GB and 50,000 items, and store products export to a separate CSV. There is no blog export feature, and the RSS feed at /blog-feed.xml is capped at 20 truncated excerpts, so the archive has to be recovered by crawling the rendered pages listed in the sitemap.

That is the extent of what you have to work with. The full detail on what exports, what has to be crawled, and how to build the URL inventory lives in the Wix to code migration guide. This page assumes you have already got the CSVs and the crawled bodies in hand, and picks up at the point where you have to decide what shape they take inside Payload.

One dated fact worth carrying into the decision, because it is the clearest evidence of what a closed platform can do with your content: Wix discontinued ADI on 10 November 2024, and unpublished ADI sites that had been inactive for six months or more were deleted.

The two content models, side by side

Payload's hierarchy is Config, then Collections, then Fields. A Collection is the schema; there is no separate content-type layer sitting above it. Globals are singletons for things like site settings.

The field type values are a fixed list: array, blocks, checkbox, code, date, email, group, json, number, point, radio, relationship, richText, select, tabs, text, textarea, upload, plus presentational ones (collapsible, row, ui) and the virtual join.

Note the spelling: it is richText, camelCase. rich-text is only the docs URL slug, and typing it into a config is a common first-hour mistake.

Wix gives you the opposite shape. Every column is a scalar. Rich text arrives as an HTML blob or with its formatting already flattened. Images are absolute Wix CDN URLs, often carrying query-string transform parameters. Repeated values are denormalised onto every row. There are no stable IDs you can rely on across separate exports.

Column-by-column mapping

| What the Wix CSV gives you | Payload field | What you have to do to it | |---|---|---| | Title column | text | Trim. Wix exports frequently carry trailing whitespace. | | URL or slug column | text, indexed and unique | Keep the original Wix path verbatim in its own field too, for the redirect map. | | Short description | textarea | Direct. | | Post body HTML blob | richText (Lexical) | Run the image pre-pass first, then convertHTMLToLexical. See below. | | Date column | date | Cast explicitly. CSV gives you a string, and the format varies by locale setting. | | TRUE / FALSE column | checkbox | Cast explicitly. The string "FALSE" is truthy in JavaScript. | | Numeric column | number | Strip currency symbols and thousands separators before casting. | | Single-choice column | select | Only if the option set is closed. If editors must add options, model it as a relationship instead. | | Comma-separated tags in one cell | relationship, hasMany: true | Split, normalise, dedupe, create the tag documents in pass one. | | Author name string | relationship to an authors collection | Two passes. It cannot go in directly. | | Category name string | relationship to a categories collection | Two passes. | | Featured image (absolute Wix CDN URL) | upload | Download first. payload.create() takes filePath or file, not a remote URL. | | Inline images inside the body HTML | media documents plus rewritten <img> tags | The failure case. See the next section. | | Wix item identifier | text, indexed | Keep whatever identifier the export gives you. It is your reconciliation and resume key. | | Layout divs, inline styles, embedded widgets | no direct equivalent | Decide per construct, before the run: map to a blocks entry or drop it deliberately. |

The upload field is worth understanding structurally: it stores the media document's ID, so it behaves as a relationship, not as a file. That is why the media documents have to exist before anything that points at them.

Images: get the order right or lose them all

This is the single most important sequence in a Wix to Payload migration, and it is not intuitive, because the converter fails without complaining.

  1. Parse the body HTML and extract every image source. Check srcset as well as src. Strip the Wix CDN query-string transforms so two references to the same asset at different sizes resolve to one file.

  2. Download each image to disk. This is where the wall-clock time goes, not the writing.

  3. Create the media document with payload.create({ collection: 'media', filePath }) and capture the returned ID.

  4. Build a source-URL to document-ID map and persist it to disk.

  5. Rewrite each `<img>` tag in the HTML with data-lexical-upload-id set to the new document ID and data-lexical-upload-relation-to set to the uploads collection slug.

  6. Only then call `convertHTMLToLexical`.

Convert first and you get a clean-looking Lexical document with no images in it and no error to tell you.

Two further properties of the converter that shape how you run it. convertHTMLToLexical needs JSDOM as a peer dependency, so it runs in Node only and cannot be pushed into an edge function. And JSDOM normalises malformed source HTML on the way through, which with Wix output is usually a help: unclosed <p> tags wrapping block elements are the common case, and they come out sane.

There is no lossy mode. Payload will not park unrecognised HTML in a passthrough node for you to clean up later. Wix layout wrappers, inline style attributes and embedded widgets such as booking forms or gallery components simply have no matching Lexical node. That is a modelling decision you make in advance for each construct, not something the script can infer.

Denormalised strings become relationship IDs, in two passes

A relationship field stores a document ID, so it cannot be populated from the same row that names the thing it points at. That forces two passes, and the sequencing matters.

Pass one, build the reference collections. Read every CSV column that repeats, take the distinct set of values, normalise case and whitespace, and create one document per value. Skipping normalisation is how "jane doe", "Jane Doe" and "Jane Doe" become three author documents and a broken filter in the admin UI. Write the resulting name-to-ID map to disk as you go.

Pass two, create the content documents, resolving each denormalised string to the ID you recorded in pass one.

Lock the schema before pass two starts, because two flags change the stored shape of every relationship value:

| relationTo | hasMany | What is stored | |---|---|---| | string | false | a bare ID | | array | false | { relationTo, value } | | string | true | an array of IDs | | array | true | an array of { relationTo, value } |

Change either flag after the import and every document that uses that field needs rewriting. Deciding halfway through that categories should be polymorphic across two collections is not a config edit, it is a second migration.

Localization carries the same warning and is worse to discover late. It is set at project level with locales, defaultLocale and fallback, and individual fields opt in with localized: true. Turning that on changes a field's storage from a scalar to a locale-keyed object, so it has to be decided before the first import, not after. Query with locale=all to see what is actually stored.

One naming trap while you are here: Payload's migrate command handles database schema migrations. It has nothing to do with importing content. Readers conflate the two constantly, and the docs endorse the Local API for seed and import scripts instead.

The good news: throughput is not your bottleneck

The import path is the Local API, payload.create(), called directly from a script rather than over HTTP. Payload's docs endorse this explicitly for seeding. There is no rate limit. Your ceiling is whatever your database will take.

That matters more than it sounds, because it moves the bottleneck somewhere you control. The slow part of a Wix to Payload migration is downloading a few thousand images off Wix's CDN, not writing them into Payload.

So build the import as two separable stages. Stage one fetches assets: slow, retryable, and cached to disk so a failure never costs you the same download twice. Stage two writes to Payload: fast, and cheap to re-run from scratch after a schema fix. If a bad hasMany decision means starting the write over, that should be a coffee break, not a day.

Wix Stores, if you sell

Products come out as their own CSV. Wix does not support an XML store export.

The straightforward columns map exactly as above: text for name and SKU, number for price and inventory once cast from strings, richText for the description if it holds HTML, upload for product images.

Variants are the awkward part, and they are awkward because a flat CSV has only two ways to express them: extra columns per option, or extra rows per combination. Neither is a data model. In Payload you either model variants as an array field with its own subfields on the product document, or as a separate collection with a relationship back to the parent. Both are defensible. Choosing after the import is not, for the same reason as above.

Payload is a CMS, not a checkout. Payments go to a dedicated service such as Stripe or a headless commerce platform, and orders and customers are a separate data task from the site migration.

Vendor status and what it costs to run

Two facts a technical buyer should have before committing. Payload was acquired by Figma in June 2025. And Payload Cloud has paused new deployments, so there is no first-party hosting product to sign up for.

The software itself is MIT licensed and free. Hosting and a database are a separate bill. Free database tiers are marginal for client work, since Neon's free plan suspends compute at its limits, which means the client's admin panel goes down. Realistically, budget $8 to $30 per month for a client-grade database, and roughly $20 to $50 per month total on Vercel with a managed database.

One trap that catches agencies and freelancers regularly: Vercel's Hobby tier is non-commercial only, and Vercel's own definition of commercial usage covers a paid employee or consultant writing the code and receiving payment to create, update or host the site. A client site on Hobby is a terms violation. Vercel Pro at $20 per user per month is the floor for client work.

For the migration itself, budget $5,000 to $25,000 for a 50 to 100 page site. On this pair the price is driven by two things: how much of the archive has to be crawled rather than exported, and how much of the body HTML is Wix widgets that need a modelling decision rather than a conversion rule.

Agent access is not the reason to move

Worth saying plainly, because it is a claim made carelessly. Wix ships an official MCP server at mcp.wix.com/mcp. An AI agent can reach a Wix site.

The difference after migrating is not access, it is the artifact. Against a Payload repo an agent gets the collection configs, the frontend and the content model in one place, can run the build to check its own work, and a bad change reverts with git. Against a hosted builder API it edits the fields the API exposes, with no build to verify against and no history to roll back. That is a real difference, but it is a difference of leverage, not of possibility.

When Payload is the wrong destination for a Wix site

If your Wix site is a dozen static pages with no CMS collections behind it, Payload gives you an admin UI nobody needs plus a database bill. Flat Markdown files in a repo do the same job for nothing.

Payload earns its cost in one specific situation: non-technical editors have to change structured, repeating content regularly, and you want that content typed, related and queryable rather than sitting in files. A blog with authors and categories, a directory, a case-study library, a product catalogue. If that is not your site, the mapping work described above is effort spent building a machine you will not use.

Where to go next

For what Wix exports and what has to be crawled, read the Wix to code migration guide. If you have not settled on Payload yet and want the destination comparison, that is covered in the headless CMS migration guide. For the redirect and ranking side, the SEO migration survival guide is the one to read before you cut DNS.

If you want a second opinion on the mapping specifically, that is what a free migration review is for. We look at your actual collections, your actual body HTML, and tell you which parts convert cleanly and which parts are a modelling decision you have to make yourself.

0 images

What HTML to Lexical Carries

Payload does not upload img tags on conversion. They are dropped without an error

2 passes

To Fill a Relationship Field

Reference documents first, then content documents resolving names to IDs

No limit

Local API Write Rate

The bottleneck is downloading assets off Wix CDN, not writing into Payload

$8-$30/mo

Client-Grade Database

Payload is MIT licensed and free, but Payload Cloud has paused new deployments

Payload's docs state that converting HTML to Lexical does not automatically upload img tags. There is no warning and no error. Upload the images and rewrite the tags with data-lexical-upload-id before you convert, or the posts import looking correct in the list view with every body image missing.

Payload as the destination for a Wix site

Pros

  • +Flat CSV columns become typed fields, so author, category and tags stop being strings repeated on every row
  • +The Local API has no rate limit, so the write stage of the import is fast and cheap to re-run
  • +Non-technical editors get a real admin UI with drafts, and the schema lives in the same repo as the frontend
  • +TypeScript end to end, and the content model is version controlled rather than clicked into a dashboard
  • +MIT licensed, so the software itself carries no per-seat cost as the team grows

Cons

  • -Converting HTML to Lexical drops every img tag silently, so the image pipeline has to be built by hand
  • -Relationship storage shape depends on relationTo and hasMany, so the schema must be locked before the bulk import
  • -Wix layout wrappers, inline styles and embedded widgets have no Lexical equivalent and no lossy fallback mode
  • -No first-party hosting since Payload Cloud paused new deployments, so you self-host and pay for a database
  • -Overkill for a small brochure site with no repeating content, where flat files in a repo do the same job

How to migrate from Wix to Payload CMS

1

Model the collections from the CSV headers, not from the site

Open every exported CSV and decide, column by column, which Payload field type it becomes. Scalars are easy. The columns that repeat values, author, category and tags, are the ones that become relationship fields and drive the whole import order.

Tip: Decide relationTo, hasMany and any localized flags now. All three change how values are stored on disk.

2

Pass one: create the reference collections

Take the distinct set of values out of every repeating column, normalise case and whitespace so near-duplicates collapse into one, and create one document per value in its own collection. Persist the resulting name-to-ID map to disk before you write anything else.

Tip: Without normalisation, "jane doe" and "Jane Doe" become two author documents and the admin filters stop making sense.

3

Fetch and upload every image before touching the body HTML

Extract every image source from the crawled body HTML and the featured-image columns, strip the Wix CDN query-string transforms, download each file to disk, then create a media document for each with payload.create using filePath. Record a source-URL to document-ID map as you go.

Tip: Cache downloads to disk. This stage is the slow one, and you should never pay for the same download twice.

4

Rewrite the img tags, then convert to Lexical

Walk the HTML and replace each img tag with the data-lexical-upload-id and data-lexical-upload-relation-to attributes pointing at the media documents you just created. Only after that rewrite do you call convertHTMLToLexical. Converting first loses every image with no error raised.

Tip: convertHTMLToLexical needs JSDOM as a peer dependency, so this stage runs in Node, not at the edge.

5

Pass two: create the content documents

Write the actual documents, substituting each denormalised string for the ID recorded in pass one and attaching the Lexical body. Keep the original Wix identifier and URL path in indexed text fields so you can reconcile the import and build the redirect map from real data.

Tip: Re-running this stage should be cheap. If it is not, the asset stage and the write stage are too tightly coupled.

6

Reconcile, redirect, then launch

Count documents against source rows, spot-check that body images render rather than trusting the list view, map every old Wix URL to its new path with 301 redirects, and submit a fresh sitemap before cutting DNS.

Tip: Check the rendered post, not the admin list. A post with no images looks completely normal in a list of titles.

1// Wix body HTML -> Payload Lexical, with images surviving the trip.
2// The order matters: uploads and tag rewriting BOTH happen before conversion.
3import { convertHTMLToLexical, editorConfigFactory } from '@payloadcms/richtext-lexical'
4import { JSDOM } from 'jsdom'
5import config from '@payload-config'
6
7const uploadCache = new Map<string, string>() // source URL -> media doc ID
8
9async function uploadImage(payload, srcUrl: string) {
10 const key = srcUrl.split('?')[0] // drop Wix CDN transform params
11 if (uploadCache.has(key)) return uploadCache.get(key)!
12
13 const filePath = await downloadToDisk(key) // cached on disk, retryable
14 const media = await payload.create({
15 collection: 'media',
16 data: { alt: '' }, // alt is a field on YOUR media collection, not a Payload default
17 filePath,
18 })
19
20 uploadCache.set(key, String(media.id))
21 return String(media.id)
22}
23
24export async function wixBodyToLexical(payload, html: string) {
25 const dom = new JSDOM(html)
26
27 for (const img of dom.window.document.querySelectorAll('img')) {
28 const src = img.getAttribute('src')
29 if (!src) continue
30
31 const mediaId = await uploadImage(payload, src)
32 img.setAttribute('data-lexical-upload-id', mediaId)
33 img.setAttribute('data-lexical-upload-relation-to', 'media')
34 }
35
36 // Only now. Convert first and every image is dropped, silently.
37 return convertHTMLToLexical({
38 // editorConfig is required and async. Omitting it throws.
39 editorConfig: await editorConfigFactory.default({ config }),
40 html: dom.window.document.body.innerHTML,
41 JSDOM, // the imported constructor itself, not an instance
42 })
43}

Not sure how much of your Wix content converts cleanly?

Get a free migration review. We look at your actual CMS collections and your actual body HTML, and tell you which fields map straight into Payload and which parts are a modelling decision that has to be made before the import runs.

Frequently asked questions

Why do images disappear from my post bodies after importing into Payload?
Because Payload does not upload them, and does not tell you. The documentation states that when converting HTML to Lexical, img tags are not automatically uploaded, and the reason given is that Payload cannot know which uploads-enabled collection to add them to. The conversion succeeds, the document saves, and the images are simply absent. The fix is ordering: parse the HTML, extract every image source, download each one, create a media document with payload.create using filePath, build a source-URL to document-ID map, rewrite each img tag with data-lexical-upload-id and data-lexical-upload-relation-to, and run convertHTMLToLexical last.
Can I import a Wix CSV author or category column straight into a Payload relationship field?
No. A relationship field stores a document ID, and the CSV gives you a display name repeated on every row. That forces two passes. Pass one reads the distinct values out of each repeating column, normalises case and whitespace so near-duplicates collapse, creates one document per value, and records a name-to-ID map. Pass two creates the content documents, substituting the recorded ID for each string. Skipping the normalisation step is the usual cause of three author documents for one person.
Why does the Payload schema have to be locked before the bulk import?
Because relationship storage is not one shape, it is four, and two config flags decide which one you get. With relationTo as a string and hasMany false you store a bare ID. With relationTo as an array you store an object of relationTo and value. With hasMany true you store an array of either. Change a flag after importing and every document using that field has the wrong shape on disk. Localization behaves the same way: setting localized true changes a field from a scalar to a locale-keyed object, so it has to be decided before the first document is written.
How long does a Wix to Payload import actually take to run?
Less time than people expect on the writing side, and more on the fetching side. Payload imports go through the Local API with payload.create called directly from a script, which the docs endorse for seeding, and there is no rate limit. Your ceiling is the database. The real cost is downloading assets from Wix CDN one at a time. Build the script as two stages: a slow, retryable asset fetch that caches to disk, and a fast write stage you can re-run from zero after a schema change without re-downloading anything.
What does the Wix rich text actually convert into?
Payload rich text is Lexical JSON, handled by the richtext-lexical package, and note the field type is richText in camelCase rather than the hyphenated form used in the docs URL. Wix body HTML converts through convertHTMLToLexical, which requires JSDOM as a peer dependency and therefore runs in Node only. JSDOM does normalise malformed markup on the way through, which helps with Wix output where unclosed paragraph tags around block elements are common. There is no lossy fallback mode, so layout divs, inline styles and embedded Wix widgets need a decision per construct: map them into a blocks field, or drop them on purpose.
How do Wix Stores products map into Payload?
Products come out as a separate CSV, since Wix does not support an XML store export. Names and SKUs go to text fields, price and inventory to number fields after casting from strings, HTML descriptions to richText, and images to upload fields backed by a media collection. Variants are the hard part, because a flat CSV can only express them as extra columns or extra rows. In Payload you model them either as an array field on the product or as a separate collection with a relationship back, and that choice has to be made before the import for the same reason as any other schema decision. Checkout itself moves to Stripe or a headless commerce service.
What does Payload cost to run after the migration?
The software is MIT licensed and free, so the cost is hosting plus a database. Payload was acquired by Figma in June 2025, and Payload Cloud has paused new deployments, so there is no first-party hosting option to sign up for. Free database tiers are marginal for client work because compute gets suspended at the limits, which takes the admin panel offline. Budget $8 to $30 a month for a client-grade database, and roughly $20 to $50 a month on Vercel with a managed database. Vercel Hobby is non-commercial only and its terms specifically cover consultants being paid to build a site, so it is not an option for client work.
What does a Wix to Payload migration cost to have done?
Budget $5,000 to $25,000 for a 50 to 100 page site. On this specific pair the number is driven by two things rather than page count alone. First, how much of the archive has to be recovered by crawling rendered pages, since Wix has no blog export and its RSS feed is capped at 20 truncated excerpts. Second, how much of the body HTML is Wix widgets and layout wrappers, because each of those is a modelling decision about what it becomes in Payload rather than a conversion rule a script can apply.

Related Resources