Squarespace to Payload CMS: Mapping WXR Onto Collections and Lexical
Squarespace exports WordPress-oriented XML, not a CSV, and Payload's HTML to Lexical converter silently drops every image. Here is the field-level mapping, the order your import script has to run in, and the WXR details no vendor documents.
MigrateLab Team
Migration Experts

The short version
Squarespace does not hand you a spreadsheet. It hands you a WordPress-oriented XML file, the WXR format, and Payload does not read XML. So the migration is a script you write once, and the order of operations inside that script decides whether it works.
The order that matters most is this: parse the HTML, download the images, create the Payload media documents, rewrite the HTML so each <img> points at a media document, and only then convert the HTML into Lexical rich text. Do it in any other order and you get a set of articles that look complete in the admin list view and have no images anywhere in the body.
That single failure is why this page exists as its own thing rather than as a paragraph in a general guide. Payload's HTML to Lexical converter drops <img> tags deliberately, and the Squarespace export delivers image references in two separate places at once. Both sets have to resolve to the same media document or you get duplicates.
Budget $5,000 to $25,000 for a 50 to 100 page site. The destination barely moves that number. What moves it on this particular pair is how much of the site lives in page types the Squarespace export refuses to carry.
What Squarespace actually gives you
The export is a single WordPress-oriented XML file covering layout pages, one blog page, text and image blocks, the text from embed and Instagram blocks, and gallery pages. It drops store pages, portfolios, album, index, info and calendar pages, every blog page after the first, page-specific headers and footers, drafts, style settings and custom CSS. Squarespace also states you cannot import that file into another Squarespace site.
That is the whole of it, and it is covered properly in the Squarespace to code migration guide. The rest of this page assumes you already have the file and now have to get it into Payload.
The shape of the file, element by element
Open the XML before you write a line of code. What you are looking at is a channel containing a long list of <item> elements, and everything hangs off those:
`content:encoded` holds the post body as HTML, wrapped in CDATA. This is the payload, no pun intended, and it is where the image problem lives.
`wp:post_type`, `wp:status`, `wp:post_date`, `wp:post_name` carry the type, publication state, date and slug. The slug is the one you need for redirects, so capture it before anything else.
`wp:postmeta` entries are loose key and value pairs. There is no schema behind them. Some carry things you want, most carry things you do not.
`<category>` elements do double duty. They represent both categories and tags, distinguished only by a
domainattribute on the element.`wp:attachment_url` entries arrive as separate items in the same list, interleaved with the posts rather than nested inside them.
That last two points are the ones that generate real work, and the first of them cannot be answered from documentation.
The part nobody can tell you in advance
WXR is a WordPress format. WordPress documents it. Squarespace does not document its own dialect of it, and no vendor publishes a specification for what Squarespace writes into the domain attribute, which wp:postmeta keys it emits, or how consistently it distinguishes a category from a tag.
So do not trust any article, including this one, that tells you the exact values to branch on. Verify it empirically, per export, before you write the mapping:
Run the export and open the file.
Enumerate every distinct
domainvalue present across all<category>elements, and count how many elements carry each.Enumerate every distinct
wp:postmetakey and count occurrences.Spot check five posts against the live site. Confirm that what the site shows as a category appears with the domain you expect, and the same for tags.
If the domains turn out to be inconsistent, and on some exports they are, then the honest fallback is to reconstruct the category and tag split by crawling the live site rather than trusting the file. That is a half day of work you can plan for. Discovering it after you have imported 400 posts is not.
A named unknown you check in ten minutes is cheaper than a confident guess you find out about in production.
Mapping WXR onto Payload's content model
Payload's hierarchy is Config, then Collections, then Fields. A Collection is the schema. There is no separate content-type layer sitting above it, so what you are really doing is writing a TypeScript config that describes the shape of the WXR data you just inventoried.
The field types are exact strings: array, blocks, checkbox, code, date, email, group, json, number, point, radio, relationship, richText, select, tabs, text, textarea, upload. Note that it is richText in camelCase. The hyphenated rich-text you may have seen is only the docs URL slug, and using it in a config is a straightforward error.
The mapping itself:
<title>becomes atextfield.wp:post_namebecomes your slugtextfield. Keep the original value verbatim, because it is also your redirect map.wp:post_datebecomes adatefield.wp:statusbecomes Payload's draft state rather than a field of your own.content:encodedbecomes arichTextfield holding Lexical JSON. This is the expensive one.<category>elements becomerelationshipfields pointing at a categories collection and a tags collection, once you have established which is which.wp:attachment_urlitems become documents in a media collection, referenced byuploadfields.wp:postmetais a case by case decision. Most of it is discardable. Anything you keep is usually atextfield or agroup.
The image problem, and why it lands harder from WXR
Payload's documentation is explicit about this, and it is worth quoting because people assume it is a bug: "When converting HTML to Lexical, <img> tags are NOT automatically uploaded. This is intentional because we don't know which uploads-enabled collection to add them to."
The converter does not error. It does not warn. It produces valid Lexical JSON with the images silently absent. Your import script reports success, the admin list view shows every post present with the right titles and dates, and the bodies are text only. This is usually discovered by a client, after handoff.
Coming from a flat CSV that would be annoying. Coming from WXR it is worse, because the same image reaches you twice:
as a standalone item carrying a
wp:attachment_url, sitting in the item list next to the posts, andas an
<img>tag inside thecontent:encodedHTML of the post that displays it.
If you process both paths independently you upload each image twice and end up with a media collection where half the documents are orphans. Both paths have to reconcile to one media document, keyed on the source URL.
The correct order of operations
Parse the
content:encodedHTML for each post and extract every<img>src.Union those with the
wp:attachment_urlvalues from the attachment items, deduplicating by normalised URL. Squarespace CDN URLs frequently carry query-string parameters such as?format=, so normalise before comparing or you will treat one image as several.Download each unique image to local disk. Payload's
payload.create()takes afilePathor afile, not a remote URL, so this step is not optional.Create the media document:
payload.create({ collection: 'media', filePath }).Build a map from the original source URL to the returned media document ID.
Rewrite the HTML. Each
<img>getsdata-lexical-upload-idset to the media document ID anddata-lexical-upload-relation-to="media".Only now call
convertHTMLToLexicalon the rewritten HTML.
Step 7 last. Every time.
convertHTMLToLexical is Node-only, and it will rewrite your markup
Two practical consequences that catch people out.
First, convertHTMLToLexical requires JSDOM as a peer dependency. That makes it Node-only. It will not run in an edge runtime, and it does not belong in a serverless function you were hoping to trigger from a browser. Import scripts run locally or in a Node process, which is fine, but plan for it rather than discovering it at deploy time.
Second, JSDOM normalises malformed HTML as it parses. That is usually helpful and occasionally surprising. The common case is an unclosed <p> wrapping block-level elements: JSDOM closes the paragraph where the spec says it should close, which can move content up or down a level relative to how a browser rendered the original page.
This matters more than usual here because Squarespace's exported HTML is not consistently clean. Blocks that render fine on a Squarespace page can come out of content:encoded with structure that a strict parser reads differently. Diff a handful of converted posts against the live pages before you run the full import.
Categories and tags become IDs, which forces a two-pass import
In the XML, a category is a string. In Payload, a relationship field stores a document ID. So you cannot write posts and categories in a single pass unless you are willing to update every post afterwards.
The two passes:
Walk every
<category>element, build the distinct set, and create the category and tag documents first. Keep a map from the string to the created ID.Import the posts, resolving each category string to its ID through that map.
The reason the schema has to be locked before pass two, and not merely designed, is that Payload's relationship field stores four different shapes depending on two flags:
relationToa string withhasManyfalse stores a bare ID.relationToan array withhasManyfalse stores{ relationTo, value }.relationToa string withhasManytrue stores an array of IDs.relationToan array withhasManytrue stores an array of{ relationTo, value }objects.
Change either flag after pass two and every document you wrote holds the wrong shape. There is no in-place fix that is cheaper than re-running the import.
The same logic applies to localization, which is set at project level with locales, defaultLocale and fallback, and opted into per field with localized: true. Turning that on changes a field's storage from a scalar to a locale-keyed object. Decide it before the first document, not after. You can verify what is actually stored by querying with locale=all.
The genuine upside: there is no rate limit
Payload's Local API is the documented import path, and the docs endorse it directly for seed scripts. payload.create() runs in process against your database. There is no HTTP funnel and no requests-per-second ceiling to design around, so you do not need a token bucket, backoff logic, or a resumable ledger just to get through the write phase.
On this pair, that shifts the bottleneck somewhere useful. The slow part of a Squarespace to Payload import is downloading images from Squarespace's CDN, not writing to Payload. That is a problem you can solve with a concurrency limit and a local cache, and it is a problem you can retry cheaply.
If you want the comparison against other headless destinations, where the write path is metered and the throughput maths changes completely, that is covered in the headless CMS migration guide.
payload migrate is not a content import
This one is worth stating plainly because the naming invites the mistake. Payload's migrate command performs database schema migrations. It creates and applies changes to the underlying database structure when your collections change.
It does not import content from another CMS. It has nothing to do with your Squarespace XML. If you go looking for a "Squarespace migration" flag on it, you will not find one, and the reason is not that the feature is hidden.
What Payload actually costs
Payload was acquired by Figma in June 2025. Payload Cloud has paused new deployments, so there is no first-party hosting option for a new project. This is not a criticism of the software, but it changes the shape of the bill and a technical buyer assessing vendor risk will already know about it.
The licence is MIT, so the software is free. Hosting plus a database is a separate cost, realistically $8 to $30 a month for anything client-grade. Payload requires a database, and the free database tiers are marginal for production: Neon's free tier suspends compute at its usage ceiling, which means the client's CMS goes down.
On the hosting side, Vercel's Hobby tier is non-commercial only. Their terms name "a paid employee or consultant writing the code" and "receiving payment to create, update, or host the site" as commercial usage, so it is not a licensed option for client work. Vercel Pro at $20 per user per month is the realistic floor there, and roughly $20 to $50 a month with a managed database is the honest range for a Payload site on that stack.
When you should not do this
Some Squarespace sites are not good candidates, and it is cheaper to find that out now.
If the site is mostly a portfolio, a store, or a calendar, most of the content is not in the export at all, and the migration becomes a re-extraction project with a Payload import bolted on the end. It is still doable, it is just priced differently, and you should scope the extraction separately.
If the site is a small brochure with a handful of pages and nobody is fighting the platform, Squarespace from $19 a month on annual billing is a reasonable deal and a Payload migration will not pay for itself. Note that Squarespace A/B tests its pricing, so treat "from $19" as the shape of the number rather than a quote.
One thing that does push the other way: Squarespace ships no official MCP server, unlike Webflow, Wix and WordPress. Third-party servers built on its public APIs exist, so this is a question of first-party support rather than a total blackout, but it does mean agent access to your content is someone else's integration. (Unrelated aside worth making, since search results conflate them: developer.squareup.com belongs to Square, a different company entirely.) Once the site is a Payload project in a Git repo, an agent gets the whole artifact, can run the build, and a bad change is one revert away.
The order, one more time
If you take one thing from this page: images before conversion, categories before posts, and schema flags locked before either. Everything else in a Squarespace to Payload migration is ordinary work. Those three orderings are where the silent failures live.
$5K-$25K
Typical Migration Cost
For a 50 to 100 page site, driven by how much lives in page types the export drops
0 images
What convertHTMLToLexical Carries
Payload does not upload <img> tags, by design. They vanish with no error
2 passes
Required Import Order
Categories and tags become documents first, then posts resolve them to IDs
No limit
Local API Rate Cap
payload.create() is unmetered. The bottleneck is downloading from Squarespace CDN
Payload's docs state it directly: when converting HTML to Lexical, <img> tags are not automatically uploaded, because Payload cannot know which uploads-enabled collection to add them to. Nothing errors. The posts import, the list view looks complete, and every body is text only. Upload the media and rewrite the <img> tags before you convert, not after.
| Feature | Squarespace WXR | Payload target |
|---|---|---|
| Post body | content:encoded, HTML in CDATA | richText field, Lexical JSON |
| Slug | wp:post_name | text field, keep verbatim for redirects |
| Publish date | wp:post_date | date field |
| Draft state | wp:status | Payload's draft state, not a field of your own |
| Categories and tags | <category> elements, split by a domain attribute | relationship fields to two collections, created first |
| Images | wp:attachment_url items plus <img> in the body | media collection documents, referenced by upload fields |
| Arbitrary metadata | wp:postmeta key and value pairs, no schema | case by case: text, group, or discard |
| Write path | XML file on disk | Local API payload.create(), no rate limit |
The import order that avoids silent image loss
Inventory the export before writing any mapping
Open the XML and enumerate every distinct domain value on <category> elements and every distinct wp:postmeta key, with counts. Squarespace does not publish a specification for its WXR dialect, so this is the only reliable source of truth for how your export splits categories from tags.
Tip: Spot check five posts against the live site to confirm the split matches what the site actually displays.
Lock the Payload schema, including the flags
Write the collection config first. Decide relationTo and hasMany on every relationship field, and decide localization at project level, because localized: true changes storage from a scalar to a locale-keyed object. Both are cheap now and force a full re-import later.
Tip: Verify what is actually stored by querying with locale=all once the first documents exist.
Create categories and tags as documents
Walk every <category> element, build the distinct set, create the documents, and keep a map from the original string to the returned document ID. Posts cannot resolve their relationships until this map exists.
Tip: Persist the map to disk. If pass two fails halfway you do not want to recreate taxonomy documents.
Collect every image reference from both places
Extract every <img> src from the content:encoded HTML, then union that set with the wp:attachment_url values from the attachment items. Deduplicate on a normalised URL, stripping Squarespace CDN query-string parameters such as ?format=, or one image will look like several.
Tip: This is the step that does not exist when the source is a flat CSV. Skipping it produces duplicate media documents.
Download, then create the media documents
payload.create() takes a filePath or a file, not a remote URL, so each image has to land on local disk first. Create each one in the media collection and record the returned document ID against its original source URL.
Tip: Cache downloads locally and cap concurrency. This is the slowest phase, and the only one worth retrying rather than restarting.
Rewrite the HTML, then convert
Set data-lexical-upload-id to the media document ID and data-lexical-upload-relation-to to "media" on every <img>, then call convertHTMLToLexical on the rewritten HTML. Note that the converter needs JSDOM as a peer dependency, so it is Node-only, and JSDOM normalises malformed markup as it parses.
Tip: Squarespace's exported HTML is not always clean. Diff a handful of converted posts against the live pages before running the full import.
1import { convertHTMLToLexical, editorConfigFactory } from '@payloadcms/richtext-lexical'2import { JSDOM } from 'jsdom'3import config from '@payload-config'45// editorConfig is required by convertHTMLToLexical and is async.6// Build it once, outside the loop.7const editorConfig = await editorConfigFactory.default({ config })89// Squarespace WXR -> Payload: media first, conversion last.10// Running these steps in any other order loses every body image silently.1112const srcToMediaId = new Map<string, string>()1314// 1. Union both image sources: <img> tags in the body AND the15// wp:attachment_url items that WXR interleaves as separate posts.16const normalize = (url: string) => new URL(url).origin + new URL(url).pathname17const allImageSrcs = new Set<string>([18 ...items.flatMap((item) => extractImgSrcs(item.contentEncoded)),19 ...attachmentItems.map((a) => a.attachmentUrl),20].map(normalize))2122// 2. Download, then create. payload.create() needs a local path.23for (const src of allImageSrcs) {24 const filePath = await downloadToDisk(src)25 const media = await payload.create({ collection: 'media', filePath })26 srcToMediaId.set(src, String(media.id))27}2829// 3. Rewrite each <img> to point at its media document, THEN convert.30for (const item of items) {31 const html = rewriteImages(item.contentEncoded, (src) => ({32 'data-lexical-upload-id': srcToMediaId.get(normalize(src)),33 'data-lexical-upload-relation-to': 'media',34 }))3536 await payload.create({37 collection: 'posts',38 data: {39 title: item.title,40 slug: item.postName,41 publishedAt: item.postDate,42 // categoryIds came from pass one; relationTo and hasMany are43 // already locked, so the stored shape will not change later.44 categories: item.categories.map((c) => categoryIdByName.get(c.name)),45 content: convertHTMLToLexical({ editorConfig, html, JSDOM }),46 },47 })48}
Squarespace to Payload: what you gain and what it costs
Pros
- +The Local API has no rate limit, so a large import runs as one script bounded by your database rather than an HTTP quota
- +Payload is TypeScript end to end, so the collection config, the content model and the frontend are one artifact an agent can read and build
- +Non-technical editors get a real admin UI, which is the usual reason a Squarespace client will accept the move at all
- +MIT licence, self-hosted, with content in a database you control and a schema in version control
- +Page types Squarespace refused to model, portfolios, calendars, structured product data, become ordinary collections
Cons
- -convertHTMLToLexical drops every <img> by design, so the image pipeline is work you must build before the first import
- -Squarespace's WXR dialect is undocumented, so category and tag handling has to be verified empirically per export
- -The converter needs JSDOM, so it is Node-only, and JSDOM will normalise the messier parts of Squarespace HTML
- -Payload Cloud paused new deployments after the Figma acquisition, so there is no first-party hosting and you self-host
- -Free database tiers suspend compute, so budget $8 to $30 a month for anything a client depends on
Moving a Squarespace site into Payload?
Get a free migration review. We open the actual export, enumerate what your WXR file really contains, flag the page types Squarespace is about to drop, and tell you honestly whether Payload is the right destination or whether something simpler will do.
Frequently asked questions
- What format does Squarespace export, and can Payload read it?
- Squarespace produces a single WordPress-oriented XML file, the WXR format. Payload has no XML importer, so you write a script. The file is a channel of <item> elements: post bodies live in content:encoded as HTML wrapped in CDATA, the slug is in wp:post_name, dates in wp:post_date, loose key and value pairs in wp:postmeta, categories and tags together in <category> elements, and images arrive as separate items carrying wp:attachment_url. Your script parses that, then writes into Payload through the Local API.
- Why did my images disappear after importing into Payload?
- Because convertHTMLToLexical does not upload them, and this is documented behaviour rather than a bug. Payload's docs state that <img> tags are not automatically uploaded because Payload cannot know which uploads-enabled collection to add them to. The conversion succeeds, the Lexical JSON is valid, and the images are simply absent. The fix is ordering: download and create the media documents first, map each source URL to its new document ID, rewrite every <img> with data-lexical-upload-id and data-lexical-upload-relation-to set to your media collection, and convert only after that.
- Why are images harder from a Squarespace export than from a CSV?
- Because each image reaches you twice. WXR interleaves wp:attachment_url entries as their own items in the list, and the same image also appears as an <img> tag inside the content:encoded HTML of the post that displays it. Process the two paths independently and you upload everything twice, leaving a media collection where half the documents are orphaned. Union the two sets and deduplicate on a normalised URL before you download anything. Squarespace CDN URLs often carry query-string parameters, so normalise those away or one image will look like several.
- How do I tell a Squarespace category from a tag in the export?
- By the domain attribute on the <category> element, in principle. In practice, Squarespace does not publish a specification for its own WXR dialect, so no vendor documentation will tell you the exact values to branch on. Verify it per export: enumerate every distinct domain value and count the elements carrying each, then spot check five posts against the live site to confirm the split matches what the site displays. If the values turn out to be inconsistent, reconstruct the split by crawling the live site instead. That is a half day of planned work rather than a surprise after 400 posts are imported.
- Why does the import need two passes?
- Because a category is a string in the XML and a document ID in Payload. Pass one walks every <category> element, builds the distinct set, and creates the category and tag documents, keeping a map from string to ID. Pass two imports the posts and resolves each string through that map. The schema has to be locked before pass two because Payload's relationship field stores four different shapes depending on relationTo and hasMany: a bare ID, an object with relationTo and value, an array of IDs, or an array of those objects. Change a flag afterwards and every document holds the wrong shape.
- Does the Payload migrate command import my content?
- No, and the naming misleads people constantly. Payload's migrate command handles database schema migrations: it creates and applies changes to the underlying database structure when your collections change. It has no knowledge of external CMS exports and nothing to do with a Squarespace XML file. Content import is a separate script you write against the Local API, using payload.create(). The docs endorse the Local API explicitly for seed scripts, and it has no rate limit, so throughput is bounded by your database and by downloading assets from the source CDN.
- What will a Squarespace to Payload migration cost to build and to run?
- Budget $5,000 to $25,000 for a 50 to 100 page site. What moves the number on this pair is how much of the site lives in page types the export drops, since store, portfolio, index, album and calendar pages have to be re-extracted from the live site. Running costs are separate: Payload's licence is MIT so the software is free, but Payload Cloud paused new deployments after Figma acquired Payload in June 2025, so you self-host. Realistically that is $8 to $30 a month for hosting plus a database at client-grade quality, since free database tiers suspend compute and take the CMS down with them.
- Can AI tools edit the site after moving to Payload?
- Yes, and the change is structural rather than a matter of connectors. Squarespace ships no official MCP server as of July 2026, unlike Webflow, Wix and WordPress. Third-party servers built on Squarespace's public APIs exist, so it is not a blackout, but agent access runs through someone else's integration. A Payload project is TypeScript in a Git repo, so an agent reads the collection config, the frontend and the content model as one artifact, runs the build to verify a change, and a bad edit is one revert away rather than a support ticket.
Related Resources

Squarespace to Code: The Complete Migration Guide for 2026
Squarespace's export is a WordPress-oriented XML that drops store pages, portfolios, every blog after the first, custom CSS and all style settings. Squarespace also states you cannot import that file into another Squarespace site. Here is what survives.

Webflow to Payload CMS: The Complete Migration Guide for 2026
Migrating from Webflow CMS to Payload keeps the design-first model of Collections, references and rich text, and adds code you own with TypeScript end to end. Payload's licence is free; hosting and a database are a separate bill. Typical: $5,000 to $25,000.

Webflow to Headless CMS: Payload vs Sanity vs Storyblok (2026)
Leaving Webflow for a headless CMS comes down to Payload, Sanity and Storyblok. The destination barely changes the price, which runs $5,000 to $25,000 for a 50 to 100 page site. It changes your recurring bill, and who is on the hook when it breaks.

The SEO Migration Survival Guide: Keep Your Rankings When Switching Platforms
Platform migrations destroy SEO rankings if done wrong. Here's the checklist we use on every migration to protect (and improve) organic traffic.