Squarespace to Storyblok Migration: Mapping WXR to Components
Squarespace exports a WordPress-oriented XML file, not a CSV, and Storyblok needs components designed before the first import. This guide maps WXR items, categories, attachments and post HTML onto Storyblok stories, blocks and assets, and is honest about where the mapping is genuinely uncertain.
MigrateLab Team
Migration Experts

The short answer
There is no Squarespace to Storyblok importer, and there is no format the two share. Squarespace hands you a WordPress-oriented XML file. Storyblok expects a set of components you designed before the import started, then one HTTP POST per story against a rate-limited Management API. Everything between those two ends is a translation script you write, and on this particular pair the script has one genuinely easy part and three genuinely awkward ones.
The easy part is the post body. The awkward parts are that Storyblok will not invent a schema for you, that the export's category and tag structure cannot be trusted without inspection, and that write throughput, not conversion, sets your calendar.
Budget $5,000 to $25,000 for a 50 to 100 page site. On this pair the number moves with how many page types the export drops, how many distinct components the content actually needs, and how many stories have to be written twice because they carry relations.
What Squarespace hands you, briefly
Settings, then Advanced, then Import / Export produces one XML file. It carries layout pages, a single blog page with its posts, text blocks, image blocks, the text from embed and Instagram blocks, and gallery pages. It drops store pages, portfolios, index, album and calendar pages, every blog page after the first, page-specific headers, footers and sidebars, drafts, style settings and custom CSS. Squarespace states you cannot export from one Squarespace site and import it into another, which is the clearest available signal about what the file is for.
That is deliberately the whole treatment here. The export's limits are the subject of the Squarespace to code migration guide, and if you have not yet scoped what the file leaves behind, read that first and come back with a page inventory. If you are still deciding whether Storyblok is the right destination at all, the headless CMS migration guide covers that comparison. This page assumes both questions are settled and asks a narrower one: how does the content that survives the export become Storyblok content?
Inside the WXR file
Open the export in a text editor and the shape is immediately familiar to anyone who has handled a WordPress migration, because it is the WordPress eXtended RSS format. Four structures matter:
<item>elements. Every unit of content is one of these. Pages, blog posts and attachments all share the same element name, so you cannot tell them apart by tag, only by what they contain.content:encoded. This holds the post or page body as a single HTML string. It is the whole body rather than an excerpt, which is the biggest advantage this export has over the flat CSV that other site builders produce.wp:postmeta. Key and value pairs hanging off an item. Whatever Squarespace chose to preserve that does not fit a standard field lands here, and the key names are the exporter's own.wp:attachment_url. Attachments are not a separate section of the file. They arrive interleaved with your posts as ordinary<item>elements that happen to carry this element.
And one structure that matters more than its size suggests: <category> elements, which do double duty for both categories and tags, distinguished only by a domain attribute. More on that below, because it is the part of this migration most likely to be got wrong quietly.
Storyblok has no schema-on-write
This is the constraint that reorders the whole project. Storyblok's hierarchy is Space, then nestable Folders, then Stories, then Blocks. There are two schema layers: components are block schemas, and content types are story schemas, with Storyblok's docs describing content types as a subset of components. A block is an instance of a component. bloks is the field type that nests blocks inside other blocks.
A story is created with POST /spaces/:id/stories against https://mapi.storyblok.com/v1, and the body needs name, slug, and a content object containing at least {"component": "..."}. That component name must already exist in the space, created through /spaces/:id/components. If it does not exist, the story is not created against a guessed schema. There is no schema-on-write anywhere in the product.
So the modelling decision happens before a single line of import code is useful, and on this pair the decision has a specific shape. The WXR export already gives you a body, so you are not inferring structure from nothing. What you are deciding is how much of that body should stay as one rich text field and how much should become composable blocks.
Import the file literally and you get a post content type with a richtext field holding the entire converted body. That technically works, and it produces a Storyblok space that does not behave like Storyblok: the editor gets one long text field and cannot compose anything. The opposite mistake is worse, which is inventing a deep tree of nestable components on day one that nothing in the source actually maps to.
The defensible middle is to read the bodies first. Find the constructs that recur across many posts, the gallery, the pull quote, the embed wrapper, the call to action, and make those nestable components. Leave everything else as rich text. That inventory is real work, and it is the reason this job is a migration rather than a script.
A minimum viable model for a typical Squarespace blog and marketing site looks like this:
A
pagecontent type (is_root: true) withtitle, SEO fields, and abloksfield for body sections.A
postcontent type (is_root: true) withtitle, arichtextbody, adatetimepublished date, anassetfeatured image, and relation fields for author and taxonomy.An
authorcontent type and a taxonomy content type, both as real stories, so relations have somewhere to point.Nestable components (
is_nestable: true) for each recurring construct you found in the bodies.
Use asset for images and multiasset for galleries. The older image and file field types are deprecated and should not go into a new space.
Post bodies are the easy part of this pair
Storyblok's rich text is JSON based on TipTap, so a root node of type: "doc" with formatting carried in a marks array on text nodes. Converting HTML into it is officially solved: @storyblok/richtext v5 ships htmlToStoryblokRichtext, along with markdownToStoryblokRichtext, and renderRichText for going the other way.
That converter handles img. It is worth being explicit about, because silent image loss during HTML to rich text conversion is a real failure mode on other CMS ingestion paths, and it is not one you have to budget for here. Feed content:encoded to htmlToStoryblokRichtext and images survive the conversion as nodes. You still have to re-host the files, which is the asset section below, but you do not have to reconstruct the body around them afterwards.
Where it stays lossy is at the edges. Squarespace's block wrappers, inline styles and embedded widgets sit outside the supported element list, and the converter has no node to put them in. Pre-process the HTML before conversion and decide per construct: strip it, flatten it, or map it to a blok node. Storyblok's rich text schema includes a blok node type, so a component can be embedded inline in a body, which is exactly where the recurring constructs you inventoried should end up.
Categories and tags: a known unknown, treated as one
In the WXR format, <category> elements carry a domain attribute that separates the two vocabularies. The obvious mapping is to branch on domain, writing categories into one field and tags into another.
Do not write that code before looking at your file. Squarespace's exact WXR dialect is not documented by Squarespace, and it is not documented by Storyblok either. No vendor publishes a specification for what Squarespace's exporter emits. Several things are therefore unknown before you open a specific export: whether domain is populated on every element, whether it is populated consistently, whether tags appear in the file at all, and whether the display name and the slug-style nicename agree with each other.
Saying so is more useful than a confident mapping table would be. The fix is cheap: audit the file first. Extract every <category> element, group by domain attribute and by value, and print the counts. It takes about ten minutes and it converts a guess into a fact about the export in front of you. The code block below does exactly that.
Then choose based on what you found rather than on what the format allows:
If
domainis populated consistently and both vocabularies are present, map them to two separate relation fields pointing at taxonomy stories.If
domainis absent or uniform, the distinction is not in the file. Recover it from the live site's URL structure if categories drive routing, or collapse both into a singleoptionsfield if they do not.If the distinction drives URLs you owe redirects on, reconcile it by hand from your crawl. Hand reconciliation of a few dozen terms is cheaper and safer than a heuristic applied wrongly to every post.
The failure this avoids is specific and quiet: a branch on domain that sorts everything into one bucket, producing a site where every tag became a category, which nobody notices until an editor goes looking for a tag page that does not exist.
Attachments are items, not posts
Because wp:attachment_url entries arrive as ordinary <item> elements, the most common early bug in a WXR import is a loop that iterates items and posts every one of them as a story. You end up with a space full of stories whose titles are image filenames.
Filter attachments out of the story pass and into an asset ledger before anything else runs. Then reconcile that ledger against the image URLs actually referenced inside content:encoded, because the two sets do not always agree: bodies can reference Squarespace CDN URLs that never appear as attachment items. Download from both sets while the Squarespace subscription is still live, because those CDN URLs stop being dependable once the site is gone.
Uploading each asset to Storyblok is three steps, not one:
A signed request to
POST /spaces/:id/assets, which returns apost_urland afieldsobject.A multipart POST to that
post_url, forwarding every key in `fields` verbatim, with the file appended last. Field order in the multipart body is a common silent failure, and the symptom does not obviously point at ordering.An optional finish step to mark the upload complete.
The throughput consequence is easy to miss. The multipart POST goes to object storage and does not consume your Storyblok API quota, but the signed request does. An asset-heavy site therefore burns the rate limit roughly twice as fast as a count of stories would suggest.
Relations, UUIDs, and why the import runs twice
Storyblok relations are UUID strings, and a story's UUID does not exist until the story has been created. That single fact forces the import order.
You cannot create a post that references its author and its category in the same request that creates those targets. So relational content needs two phases: create the stories with their relation fields empty, record every returned UUID in a ledger, then issue PUT requests to backfill the relations once every UUID is known. For content that carries relations, that doubles the request count against a limit that was already the tight part of the job.
A practical ordering minimises the second pass. Create taxonomy stories and author stories first, since they generally reference nothing, record their UUIDs, and only then create posts. Posts can then be written with most relations already resolved, and the backfill pass shrinks to post-to-post references and anything circular.
One more relation detail is worth knowing while you design listing pages rather than after. resolve_relations takes a component.field format, and Storyblok documents a hard ceiling of 50 stories resolved in a single request. A listing page that expects to resolve relations across more than 50 stories will not come back fully resolved. That is a modelling constraint to design around, not a bug to debug at delivery time.
Throughput is the schedule
There is no bulk story endpoint. One POST per story. The rate limits are 3 requests per second on Starter and 6 on Growth and above, and those requests include your asset signed requests and your backfill PUTs, not only story creation.
Put together, throughput rather than conversion quality determines how long the import takes. Work it out before you promise a date: add your story count, your relational stories a second time for the backfill pass, and your asset count for the signed requests, then divide by your plan's rate and add margin for backoff. Do that arithmetic against your own inventory rather than trusting a general estimate.
Three things the import script must have, all of which are otherwise learned the hard way:
A token bucket. Not a fixed sleep between calls. Bursts around asset uploads will trip the limit even when the average rate looks safe.
429 handling with backoff and retry. A 429 treated as a failure rather than a retry loses content silently in the middle of a long run.
Resumability. Persist a source-ID to story-ID ledger to disk after every successful write. An import that dies partway through must resume rather than restart, and without the ledger a re-run creates duplicates instead of continuing.
Internal links break quietly, not loudly
Bodies in content:encoded contain absolute links to the Squarespace site. Convert them and they remain perfectly valid URLs, so nothing errors, no test fails, and the new Storyblok-backed site quietly links its readers back to the old one. If the old site is later taken down, those links become 404s that no build step ever warned you about.
Rewrite links to Storyblok slugs before conversion, using the same old-path to new-slug map you are already building for redirects. Then assert it: fail the build if any occurrence of the old domain survives in converted content. Storyblok's multilink field stores {id, url, linktype, fieldtype, cached_url} with a linktype of either story or url, and internal links should be story links rather than URL links, so they keep resolving if a slug changes later.
Seats, before the handoff and not after
Storyblok's free tier is one seat. Not one editor plus an agency account, one seat in total. Growth is $99 a month for five seats. For a site you are handing to a client with an editorial team, the free tier is a development convenience rather than the plan you deliver on, and that belongs in the quote at the start rather than being discovered at handoff.
For comparison on the bill you are leaving behind, Squarespace starts from $19 a month on annual billing. Squarespace A/B tests its pricing, so check the current figure on their pricing page rather than trusting any number in an article, including this one.
Where AI agents fit, precisely
Squarespace has no official MCP server. That is a real difference from Webflow, Wix and WordPress, which all ship first-party ones. Third-party MCP servers built on Squarespace's public APIs do exist, so the accurate statement is that agent access to Squarespace exists but is not vendor-supplied. It is also worth noting that developer.squareup.com belongs to Square, a different company, and is not a Squarespace developer resource.
The practical consequence for this migration is small, because the source side of the work is a file on disk rather than a live API. You point an agent at the XML and the conversion script, which is an ordinary coding task in a repo, with a build and a diff to verify against. Agent access matters more afterwards: once the front end lives in a Git repo you own, changes to it are reviewable, testable and revertable in a way that editing inside a builder is not.
When not to do this
A five page Squarespace brochure with no blog does not need a component model, a two-phase import, or a $99 a month seat plan. The modelling work that makes Storyblok worthwhile only pays off when there is enough structured, repeating content to model.
The cases where this pair does make sense: a content archive that has outgrown a single blog page, an editorial team that needs a proper authoring interface rather than a page builder, a front end you want in your own repository, or content that has to serve more than one surface. If none of those describe your site, staying put is a defensible answer and a cheaper one.
Cost and running bill
Migration is $5,000 to $25,000 for a 50 to 100 page site. Running costs afterwards are two separate bills that people frequently merge into one and are then surprised by.
The front end is $0 to $20 a month on Cloudflare Pages, which carries no commercial-use restriction, or roughly $20 to $50 a month on Vercel. Vercel Hobby is non-commercial only and its terms explicitly cover a paid employee or consultant writing the code, so it is not an option for client work. Storyblok is a separate subscription on top of whichever host you choose, and that is where the seat question above lands.
$5K-$25K
Typical Migration Cost
For a 50 to 100 page site, roughly the same regardless of destination
3-6 req/s
Storyblok Write Ceiling
One POST per story, no bulk endpoint. 3 on Starter, 6 on Growth and above
1 seat
Storyblok Free Tier
Growth is $99 a month for five seats, which is the realistic client handoff plan
2 passes
For Relational Content
Relations are UUIDs that exist only after creation, so stories are created then backfilled
Squarespace's WXR export uses <category> elements for both categories and tags, separated only by a domain attribute. No vendor documents Squarespace's exact WXR dialect, so whether that attribute is present and consistent is a property of your specific file, not of the format. Audit the export before writing the mapping. Branching on domain without checking is the quiet failure on this migration: everything lands in one bucket and nobody notices until an editor looks for a tag page that was never created.
How to migrate from Squarespace to Storyblok
Crawl the live site, then export the XML
Crawl every URL first and keep the list, because the export will not tell you what it is about to leave out. Then run Settings, Advanced, Import / Export. Diff the two: whatever is on the crawl and not in the XML is rebuild work, and on most Squarespace sites that means store, portfolio, index, album and calendar pages plus every blog page after the first.
Tip: The crawl is also your redirect map and your internal-link rewrite table later. Capture it once, use it three times.
Audit the WXR before you model anything
Parse the file and count what is actually in it: how many items, how many of those are attachments carrying wp:attachment_url, which wp:postmeta keys appear, and how the category elements distribute across the domain attribute. This is ten minutes of work that determines several schema decisions, and skipping it is how those decisions get made by accident.
Tip: Also grep the bodies for the constructs that repeat across posts. Those are your candidate nestable components.
Design the components, all of them, before importing
Create your content types and nestable components through /spaces/:id/components. Storyblok has no schema-on-write, so a story POST whose content names a component that does not exist will not create one for you. Decide field types now as well: asset and multiasset rather than the deprecated image and file, richtext for bodies, bloks where sections nest.
Tip: Content types are is_root: true, nestable components are is_nestable: true, and a component can be both.
Create taxonomy and author stories first
Relations are UUID strings that do not exist until the target story does, so anything that gets referenced has to be created before the things that reference it. Taxonomy terms and authors generally reference nothing, so they go first, and their UUIDs go straight into a ledger on disk. Doing this in the right order is what keeps the backfill pass small.
Tip: Design listing pages with the 50-story resolve_relations ceiling in mind, not after you hit it.
Upload assets, then convert bodies and rewrite links
Assets are three steps each: a signed request to POST /spaces/:id/assets, a multipart POST to the returned post_url forwarding every key in fields verbatim with the file appended last, then an optional finish step. With the asset URLs known, run each content:encoded body through htmlToStoryblokRichtext from @storyblok/richtext v5, rewriting internal Squarespace links to Storyblok slugs before conversion, not after.
Tip: The signed request consumes API quota even though the multipart POST does not, so asset-heavy sites hit the rate limit faster than the story count suggests.
Import with a token bucket and a ledger
Create one story per item with POST /spaces/:id/stories, at 3 requests a second on Starter or 6 on Growth and above. Rate limit with a token bucket rather than a fixed sleep, retry 429s with backoff instead of treating them as failures, and append every source-ID to story-ID pair to a ledger file as it is written. A long import that dies partway through must resume, and without the ledger a re-run creates duplicates.
Tip: Filter attachment items out of this pass. Iterating every item and posting it as a story is the classic WXR import bug.
Backfill relations, then verify against the crawl
With every UUID recorded, PUT the stories that carry relations to fill them in. Then check the migration against the original crawl rather than against the XML: every URL accounted for by a story or a redirect, no occurrence of the old Squarespace domain left in converted content, images resolving from Storyblok's CDN rather than the old one, and the category and tag structure matching what your audit said was actually recoverable.
Tip: Make the old-domain check a build assertion. Stale absolute links are valid URLs, so nothing else will catch them.
1// Audit the <category> elements in a Squarespace WXR export BEFORE deciding2// how categories and tags map into Storyblok. Squarespace's WXR dialect is not3// documented by any vendor, so measure this file rather than assuming.4import { readFileSync } from 'node:fs'5import { XMLParser } from 'fast-xml-parser'67const parser = new XMLParser({8 ignoreAttributes: false,9 attributeNamePrefix: '@_',10 // <item> and <category> are the two elements that may legitimately appear once11 isArray: (name) => name === 'item' || name === 'category',12})1314const doc = parser.parse(readFileSync('Squarespace-Wordpress-Export.xml', 'utf8'))15const items = doc.rss.channel.item ?? []1617const byDomain = new Map<string, Set<string>>()18let noDomain = 019let attachments = 02021for (const item of items) {22 // Attachments arrive interleaved as ordinary <item> elements. Count them here23 // so they never reach the story pass and become junk stories.24 if (item['wp:attachment_url']) attachments++2526 for (const cat of item.category ?? []) {27 const domain = cat['@_domain']28 const label = typeof cat === 'string' ? cat : cat['#text']29 if (!domain) { noDomain++; continue }30 if (!byDomain.has(domain)) byDomain.set(domain, new Set())31 byDomain.get(domain)!.add(String(label))32 }33}3435console.log('items total: ', items.length)36console.log('attachment items: ', attachments)37console.log('<category> with no domain attribute:', noDomain)38for (const [domain, labels] of byDomain) {39 console.log(`domain="${domain}" -> ${labels.size} distinct values`)40 console.log(' ', [...labels].slice(0, 10).join(', '))41}4243// Read this output before writing any mapping code.44// Two domains with sane counts -> map to two relation fields.45// One domain, or none at all -> the category/tag distinction is not in the46// file. Recover it from the live site's URLs,47// or collapse both into one options field.
Attachments in a WXR export are not stored separately. They arrive as ordinary <item> elements carrying wp:attachment_url, interleaved with your posts. Filter them into an asset ledger before the story pass, then reconcile that ledger against the image URLs referenced inside the bodies, because the two sets do not always match. Download everything while the Squarespace subscription is still active.
Squarespace to Storyblok: what this pair is good and bad at
Pros
- +Post bodies are the easy part: content:encoded is HTML, and Storyblok ships an official converter that handles img
- +The export is structured XML, so items, dates, slugs and authors parse deterministically instead of being scraped
- +Storyblok rich text supports an inline blok node, so recurring Squarespace constructs can become real components rather than frozen HTML
- +Content and schema sit behind an API you can script, version and re-run, instead of a builder with no export path
- +Assets can be re-hosted onto Storyblok's CDN in the same pass that creates the stories
Cons
- -Components must be designed before the first story is written, so the modelling is human work that happens before any script runs
- -The category and tag distinction may not survive the export, and it has to be checked per file rather than assumed
- -One POST per story at 3 to 6 requests a second, with no bulk endpoint, makes throughput the schedule
- -Relational content is written twice, once to create and once to backfill UUIDs, against that same limit
- -The free tier is a single seat, so a client handoff realistically means Growth at $99 a month
- -Everything the Squarespace export drops, including store, portfolio and index pages, is rebuilt by hand whichever CMS you pick
Moving a Squarespace site to Storyblok?
Get a free migration review. We read your actual export, tell you what the XML leaves behind, audit whether the category and tag distinction survived it, and propose the component model before any code gets written.
Frequently asked questions
- Can I import the Squarespace XML into Storyblok directly?
- No. Storyblok has no importer for WordPress eXtended RSS, and no shared interchange format exists between the two. You write a script that parses the XML, converts each body to Storyblok rich text, uploads assets, and creates one story per item through the Management API at https://mapi.storyblok.com/v1. The script is not the hard part. Designing the components the stories will be written against is the part that happens first and takes longer.
- Does Storyblok handle the HTML inside content:encoded?
- Yes, and this is the strongest thing this pair has going for it. The @storyblok/richtext package at v5 ships htmlToStoryblokRichtext, an official HTML to rich text converter, alongside markdownToStoryblokRichtext and renderRichText for the return trip. It handles img, so images are not silently dropped during conversion. It is still lossy at the edges: Squarespace block wrappers, inline styles and embedded widgets fall outside the supported element list, so pre-process those and decide whether to strip them, flatten them, or map them to a blok node embedded in the rich text.
- How do I tell whether a <category> element is a category or a tag?
- In the WXR format the domain attribute is what separates the two vocabularies, but Squarespace does not document its exporter's dialect and neither does anyone else, so the honest answer is that you check rather than assume. Before writing any mapping, extract every <category> element from your export, group them by domain attribute and by value, and print the counts. If two domains appear with sensible counts, map them to two relation fields. If the attribute is missing or uniform, the distinction is not in the file, and you recover it from the live site's URL structure or collapse both into a single options field.
- How long does the import itself take?
- Long enough that you should calculate it rather than estimate it. Storyblok has no bulk story endpoint, so it is one POST per story, at 3 requests per second on Starter and 6 on Growth and above. Those same limits cover asset signed requests and the PUT requests that backfill relations. Add your story count, your relational stories a second time for the backfill pass, and your asset count, divide by your plan's rate, and add margin for 429 backoff. Because the import is long, the script needs resumability: persist a source-ID to story-ID ledger to disk after every write so a failure resumes instead of restarting or duplicating.
- Do I need a paid Storyblok plan for a client site?
- Almost certainly. The free tier is a single seat, which is enough for you to build in but not enough to hand over to a client who also needs access. Growth is $99 a month and includes five seats. Decide this at quoting time rather than at handoff, because discovering it late turns a delivery into a pricing conversation. The plan also sets your import rate: Starter allows 3 requests per second and Growth and above allow 6.
- Can an AI agent do this migration for me?
- It can do a lot of the scripting, but not through a vendor connector on the Squarespace side. Squarespace has no official MCP server, unlike Webflow, Wix and WordPress, which all ship first-party ones. Third-party MCP servers built on Squarespace's public APIs do exist, so agent access is possible but not vendor-supplied. Note that developer.squareup.com belongs to Square, a different company. In practice the source side of this job is a file on disk, so an agent works on the XML and the conversion script as an ordinary coding task, with a build and a diff to check its work against.
- What breaks if I get the migration order wrong?
- Three things, in escalating order of how quietly they fail. Import stories before the components exist and the writes are simply rejected, which is loud and easy to fix. Import posts before their authors and taxonomy stories exist and the relations cannot be written, because Storyblok relations are UUIDs that only exist after creation, so you pay for a second pass you could have avoided by ordering the taxonomy first. Convert bodies before rewriting internal links and nothing fails at all: the absolute Squarespace URLs stay valid, and the new site links its readers back to the old one until the old one is taken down and they become 404s.
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 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.

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.

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.