I moved a 138-post blog off Wix onto a static Astro site. The migration ran with zero errors and was still quietly missing half of every tutorial: code blocks, images, the embedded video. This post is the technical version of what I learned, aimed at people who want to see the actual code, not just the takeaways. If you want the higher-level "should I even do this" write-up, that's over on my blog. This one is the engineering.
Everything here is in an open-source, dependency-free repo (Node stdlib only): github.com/alexandramartinez/wix-to-astro. I'll link the exact files as I go.
TL;DR
- A Wix post is two documents: the server HTML, and a pile of stuff Wix hydrates client-side (Gist code, the YouTube player, galleries, buttons, comments). Any migration script sees only the first. The second vanishes with no error.
- So don't summarize the page, and don't run it through an "AI to Markdown" tool. Read the raw server HTML and convert the real tags. It's lossless.
- The extractor is a single-pass tokenizer over the isolated article HTML. The one bug worth your attention is a zero-width regex match that pegs a CPU core forever. Details below, because it's a fun one.
- What's client-rendered, no script can recover. A small QA linter greps the output for the tells so you know where the by-hand work is. Never fabricate the gaps.
Why the obvious approaches fail
"Just pipe each page through an LLM and ask for Markdown." This was my first attempt. An LLM paraphrases your prose (so your exact wording is gone) and, worse, silently drops every code block. For a tutorial blog that's the entire point of the post. I ran the whole catalog through before I noticed, then had to redo it. Determinism beats cleverness here.
"Use the Wix export / a generic HTML-to-MD converter." Closer, but it trips on Wix's specific DOM shapes (nested lists, split bold runs, image blocks) and, critically, it can't recover what isn't in the HTML at all. Which brings us to the actual insight.
The core insight: server HTML vs. client hydration
Fetch a Wix post with curl and read the raw bytes. A large chunk of what a human sees in the browser isn't there. Wix ships a shell and hydrates the rest with JavaScript after load. So:
| Lives in the server HTML (recoverable by a script) | Hydrated client-side (invisible to any server-HTML reader) |
|---|---|
| Prose, headings, links, bold/italic | GitHub Gist code embeds |
| Nested lists, blockquotes | The companion YouTube player |
<pre> code that was authored inline |
Image galleries / carousels |
| Inline images (as a Wix image block) | Styled CTA buttons |
| JSON-LD metadata (title, author, dates) | The reader comment thread |
The left column is the lossless ~20% a script nails. The right column is the ~80% that has to be recovered by hand from a live browser render or your own source files. No tool that reads server HTML can recover the right column — mine included. Pretending otherwise is how you ship plausible-but-wrong tutorials.
Reading the server HTML
No headless browser, no API key. Just fetch with a UA string, then isolate the article body between two Wix markers:
const bs = raw.indexOf('data-hook="rcv-block-first"');
let be = raw.indexOf('data-hook="post-footer"');
if (be < 0) be = raw.length;
const bodyHtml = raw.slice(raw.lastIndexOf('<', bs), be);
Wix's post pages occasionally return a JS shell with the body markers absent, so the fetch retries a few times and only accepts a response once a marker is present. (Full source: src/wix-extract.mjs.)
Metadata comes from the page's JSON-LD, which is server-rendered:
const ld = raw.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/);
const meta = ld ? JSON.parse(ld[1]) : {};
// meta.headline, meta.description, meta.author, meta.datePublished, meta.dateModified
The tokenizer: sentinels + one combined regex
The naive approach — a chain of .replace() calls converting each tag type independently — loses ordering. You need images, code, and prose to come out in document order. So the extractor makes one ordered pass.
Two block types can't be matched by a simple tag regex without eating their contents (code blocks contain arbitrary characters; list items nest). Those get pulled out first and replaced with sentinel placeholders using a Unicode private-use-area character as a delimiter (so it can never collide with real content):
// code blocks -> ⟦CODE0⟧, ⟦CODE1⟧, ... (private-use char shown as ⟦⟧ for readability)
body = body.replace(/<pre\b[^>]*>([\s\S]*?)<\/pre>/gi, (_, inner) => {
codes.push(decode(inner.replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, '')));
return `⟦CODE${codes.length - 1}⟧`;
});
Then a single combined regex walks everything in order — real block tags, figures, and the placeholders — and emits a typed token stream:
const re = new RegExp(
'<(h[1-6]|blockquote|p)\\b[^>]*>([\\s\\S]*?)<\\/\\1>' + // 1,2: block + inner
'|<figure\\b[^>]*data-hook="figure-(IMAGE|VIDEO)"[\\s\\S]*?<\\/figure>' + // 3
'|⟦LIST(\\d+)⟧' + // 4
'|⟦CODE(\\d+)⟧', // 5
'gi'
);
for (const m of body.matchAll(re)) {
if (m[5] !== undefined) tokens.push({ code: +m[5] });
else if (m[4] !== undefined) tokens.push({ list: +m[4] });
else if (m[1] !== undefined) tokens.push({ block: m[1].toLowerCase(), html: m[2] });
// ... figure IMAGE -> {img}, figure VIDEO -> {vid} (client-side, dropped)
}
A second pass renders each token to Markdown. Clean, ordered, lossless.
The war story: a zero-width match that never terminates
Here's the one I want you to remember. An earlier version of that combined regex ended like this:
// DON'T do this:
'|⟦CODE(\\d+)⟧' + '|' // <- trailing empty alternative
That trailing | is an empty alternative: it matches the empty string. A regex that can match zero-width will happily match at every gap between characters. matchAll handles that safely (it force-advances past a zero-width hit), but a hand-rolled loop that advances its own index based on the match — and treats a zero-width match as "nothing to skip" — never moves forward:
let m, pos = 0;
while ((m = re.exec(body))) {
// ... handle token ...
// BUG: a zero-width match means m.index === re.lastIndex, so `pos` doesn't
// advance and the next iteration matches the SAME empty string. Forever.
pos = re.lastIndex; // stays put on a zero-width hit
}
On a short post you might not notice. On an image-heavy post (lots of gaps between <figure> blocks where the empty alternative can fire), it locks onto a zero-width match, the index stays put, and the loop spins on one position pegging a CPU core until you kill it. No error, no output, just a hot laptop.
Two lessons, both cheap:
-
Never leave an empty alternative in a combined regex. Audit for a
|with nothing between it and a)or the end of the pattern. - If you must hand-roll an
execloop over a global regex, guard zero-width matches by bumpinglastIndexyourself:if (m.index === re.lastIndex) re.lastIndex++;. Or just usematchAll, which does it for you.
The fix in the repo was to drop the empty alternative entirely, and there's a comment in the source warning the next person (me, in six months) not to "simplify" it back in.
The Wix-specific paper cuts
A generic converter gets these wrong. Each is a few lines once you know the shape.
Nested lists. Wix nests the child <ul> inside the parent <li>, before its </li>. A non-greedy <li>...</li> match stops at the child's </li>, merging parent and first child and destroying indentation. Fix: preprocess each <li> into a placeholder that carries its aria-level, then reconstruct indentation from the level.
html.replace(/<li\b([^>]*)>\s*<p\b[^>]*>([\s\S]*?)<\/p>/gi, (_, attrs, inner) => {
const level = +((attrs.match(/aria-level="(\d+)"/) || [])[1] || 1);
lists.push({ level, html: inner });
return `⟦LIST${lists.length - 1}⟧`;
});
// later: ' '.repeat(level - 1) + '- ' + inline(item.html)
Bold with the space inside the marker. Wix emits <strong>Pick this </strong>option. Trim the inner text and you get **Pick this**option, gluing the bold to the next word. And a <strong> wrapping only whitespace becomes an empty ****. So emphasis conversion keeps leading/trailing spaces outside the markers:
const lead = /^\s/.test(inner) ? ' ' : '';
const trail = /\s$/.test(inner) ? ' ' : '';
return `${lead}${marker}${core}${marker}${trail}`;
Split bold runs. Wix loves to split **Remember:** into <strong>Remember</strong><strong>:</strong>, which converts to **Remember****:**. The **** in the middle (a bold-close butting a bold-open) renders as a stray run. Collapsing every **** to nothing rejoins them.
Full-resolution images. A Wix image URL in the page is a resized transform: static.wixstatic.com/media/<id>/v1/fill/w_740,h_... . Strip the /v1/fill/... and request the bare static.wixstatic.com/media/<id> to get the original upload, not a downscaled copy.
The media prefix isn't hardcodable. Every asset on a given Wix site shares one account prefix (<prefix>_<hash>~mv2.<ext>), and it differs per account. Discover it from the hero (og:image) so the tool works for anyone:
const prefix =
(raw.match(/og:image[^>]*content="[^"]*\/([a-z0-9]+)_[a-f0-9]+~mv2\.[a-z]+/i) || [])[1] ||
'[a-z0-9]+';
Hero de-duplication. The hero image also appears as the first in-body image. Track the og:image id and skip the in-body copy so it isn't rendered twice.
Heading levels. Your post title is the page's only <h1>, so body headings should start at <h2>. The extractor finds the shallowest heading level in the body and shifts everything so the shallowest becomes ##, preserving the relative hierarchy.
What migrates but comes out wrong
Beyond what's dropped, some things survive but mangled, because the HTML-to-MD step had no rule for the shape:
-
Tables flatten into a stack of one-line paragraphs (no
<pre>/list structure to key on). I deliberately don't auto-rebuild these — inventing table structure risks fabricating a header row that wasn't there. Rebuild them by hand as GFM pipe tables, verbatim. -
Doubled blockquote quotes: many SSG typography themes add curly quotes via CSS
::before/::after, and your migrated text already has the author's quotes, so they print twice. Fix in the theme CSS, not the Markdown.
The gaps don't announce themselves, so lint for them
Because none of the dropped content throws an error, I wrote a QA linter (src/qa-check.mjs) that greps the output .md files for the known tells and prints where to look. It changes nothing and recovers nothing — it just points:
node src/qa-check.mjs ./out # add --mdx to also catch MDX build traps
./out/array-string-key-dataweave.md
9 leftover-toc? hand-authored TOC that duplicates your SSG on-page nav
20 mdx-raw-< raw "<" in prose breaks an MDX build
28 empty-alt image has no alt text: images/array-string-key-dataweave-1.png
...
It flags: empty image alt (), a "paste this in:" code lead-in with no code after it (dropped Gist), an orphaned "prefer video" line (dropped embed), a gallery lead-in with no image, a leftover "In this post:" TOC, a run of short one-line paragraphs (a flattened table), and — with --mdx — a raw < in prose or {/} in alt text. It exits non-zero on any finding, so you can gate a build on it in CI.
Every finding is a tell, not a certainty. Confirm each against the live page, then recover it from a real source.
If your target is Astro/MDX specifically
Two build traps bit me repeatedly:
-
A raw
<in prose is read as a JSX tag and fails the build. Extremely common in technical writing:Array<String>,n <= 1,</Component>. Wrap in a code span or write<. -
{or}in image alt text starts a JSX expression and fails the build. Paraphrase code-ish alt text.
The --mdx flag on the linter catches both before you hit a build error.
Migrating with an AI pair
I did this migration with an AI assistant, and the failure mode there is obvious: an AI is very willing to "recover" a dropped code block by writing one that looks right. That's the worst possible outcome for a tutorial. So the repo ships an AGENTS.md (works as CLAUDE.md or a Cursor rules file) that encodes the discipline: run the scripts, run the linter, recover only from live sources or the author's files, and when you can't, leave a visible TODO and ask — never a plausible guess. The scripts do the lossless part; the guide keeps the human-in-the-loop part honest.
Run it yourself
git clone https://github.com/alexandramartinez/wix-to-astro
cd wix-to-astro
# one post -> ./out/<slug>.md + ./out/images/<slug>-N.<ext>
node src/migrate-post.mjs --url https://YOURNAME.wixsite.com/website/post/my-post
# a whole blog: set WIX_SITE in .env (it auto-discovers posts from the sitemap)
cp .env.example .env
node --env-file=.env examples/migrate-all.mjs
# then audit what came across
node src/qa-check.mjs ./out --mdx
No dependencies, Node 18+ (the batch --env-file path wants 20.6+).
The one rule
Whatever tooling you use, mine included: never invent the missing content. Every recovered snippet, caption, table row, or comment comes verbatim from a real source, or it stays an honest gap. A tutorial with a plausible-but-wrong code block is worse than one with a visible hole, because the reader can't tell it's wrong.
Repo: github.com/alexandramartinez/wix-to-astro. If you hit a Wix shape I didn't cover, open an issue — I'd like to know about it.
Top comments (0)