Migrating a site from Elementor to Gutenberg is a mess. There is no magic button. You have to rebuild every page by hand. Count several weeks. Rebuild from scratch. That's the consensus, it's everywhere, I read it maybe ten times before I actually started.
A client site. 114 pieces of content to migrate. Hello Elementor theme (an empty shell without the plugin), expired Pro licenses, content locked inside Elementor JSON that looks like nothing if you disable the builder. Nobody wants to pay for a multi-week technical migration.
Me neither. But you didn't seriously think I was going to do it by hand, right? 😏
TLDR: 114 pages migrated in one day, zero manual rebuilding, all URLs preserved. I'll show you exactly how to do the same thing on any Elementor project.
Why Elementor Has to Go
Elementor was a fine choice a few years ago. WordPress core was rough. Gutenberg was barely usable. A drag-and-drop builder made sense for people who didn't want to touch code. That was then.
WordPress has moved on. Gutenberg is solid now. Full Site Editing covers most of what page builders used to sell. Block themes let you design the whole site without a single plugin. Meanwhile Elementor has become dead weight: heavy, slow, paid, and it locks your content in a proprietary format that nobody else can read.
The Hello Elementor theme is the worst offender. It's an empty shell, 100% dependent on the plugin. Turn off Elementor and your pages are gone. Just gone.
The real problem is not technical though. It's economical. Nobody wants to pay for a technical migration that takes several weeks. The client needs a working site, not a refactoring project. And every guide you read about leaving Elementor says the same thing. "There is no magic button." "Several weeks of dedicated work." "Essentially the site has to be rebuilt."
That consensus kills migrations before they start. Owners stay stuck on a plugin they don't like, paying licenses they don't use, on a site they can't edit without opening Elementor one more time.
To be fair, Elementor is still decent for some cases. A one-shot landing page. A temporary event site. Something the owner will not touch in two years. The problem isn't the builder itself. It's the long-term lock-in on content you'll want to keep.
The Setup: One App Password, One Agent
Here's what every migration guide tells you to do. Spin up a staging site. Take a full backup. Install the "Elementor to Blocks" plugin for partial conversion. Rebuild each page by hand where the plugin fails. Clean up residual Elementor classes. QA everything. Push to production. Several weeks, calendar time.
Here's what I did instead. Opened wp-admin on the live site. Users > Profile > Application Passwords. Typed a name, clicked Add. Copied the generated password. Total elapsed time: 30 seconds.
That's the whole setup. No OAuth flow, no webhook, no plugin to install, no staging environment. The app password is a bearer token with the same permissions as the user that generated it. You pass it to Claude Code along with the site URL, and the agent can read and write everything the REST API exposes. Posts, pages, media, users, settings.
If you want the full walkthrough of how this works end to end, I wrote the complete setup for connecting Claude Code to any WordPress site a few weeks ago.
One sentence to launch: "Migrate this site from Elementor to native Gutenberg blocks, here's the URL and the app password, work from the REST API." That's it. No prompt engineering, no system instructions, no tool list. Claude Code explored the API by itself, figured out the stack (WordPress version, active theme, plugin list, post types), pulled a full backup of every post and page into a local JSON file, and started writing its first conversion script.
Timeline, real numbers. Launched at 8:15 in the morning. By noon there were three adjustments left. By evening, done. 114 pieces of content total (94 posts and 20 pages). The whole technical process was 100% agent-driven. The only "human intervention" was me relaying the client's messages about what she wanted kept or dropped.
How Claude Code Converts Elementor to Gutenberg
First technical decision, and it's the one that made the whole thing work. Claude Code did NOT parse _elementor_data. That's the raw JSON Elementor stores in postmeta, deeply nested, structure varies between Elementor versions, widget types change names across releases. Parsing that would be a moving target.
Instead it worked from the rendered HTML. The REST API exposes content.rendered for every post, which is the final HTML after Elementor has done its job. Simpler, more stable, portable across Elementor versions. If the HTML looks right on the frontend, the parser has something to chew on.
The first script Claude Code wrote, convert-elementor.py. Roughly what it does:
import json, re
from bs4 import BeautifulSoup
ELEMENTOR_WRAPPERS = {
"elementor-section", "elementor-container", "elementor-column",
"elementor-widget-wrap", "elementor-widget", "e-con", "e-con-inner",
"e-child", "elementor-row", "elementor-element"
}
def process_element(el):
"""Walk the tree, strip Elementor wrappers, emit Gutenberg blocks."""
if el.name is None:
return str(el)
classes = set(el.get("class", []))
if classes & ELEMENTOR_WRAPPERS:
# Skip the wrapper, recurse into children
return "".join(process_element(c) for c in el.children)
# Map widgets to native Gutenberg blocks
if "elementor-widget-heading" in classes:
return convert_heading(el)
if "elementor-widget-image" in classes:
return convert_image(el)
if "elementor-widget-text-editor" in classes:
return convert_paragraph(el)
# ... other widgets
return "".join(process_element(c) for c in el.children)
The recursive process_element function is the heart of it. It walks the DOM tree, and whenever it hits an Elementor wrapper (around a dozen different class names), it skips the wrapper and keeps walking into the children. When it hits an actual widget, it routes to a dedicated converter. Headings become wp:heading, images become wp:image, text editors become wp:paragraph, and so on. CSS classes and data-* attributes get stripped by regex on the way out.
Two concrete examples.
An Elementor heading looks like this in the rendered HTML:
<div class="elementor-widget elementor-widget-heading" data-id="a1b2c3">
<div class="elementor-widget-container">
<h2 class="elementor-heading-title elementor-size-default">
<span style="color: #333">Our Approach</span>
</h2>
</div>
</div>
After conversion:
<!-- wp:heading -->
<h2>Our Approach</h2>
<!-- /wp:heading -->
The inline span with the color style is gone. So are the Elementor classes, the wrapper divs, the data attributes. Clean Gutenberg block, ready to render in any theme.
An Elementor image is similar. The builder wraps the <img> in a figure that lives inside two or three divs with classes like elementor-widget-image. Gutenberg expects a figure with the class wp-block-image and the right block comment around it:
<!-- wp:image -->
<figure class="wp-block-image">
<img src="..." alt="..."/>
</figure>
<!-- /wp:image -->
Same pattern. Strip the wrappers, rebuild the markup clean, wrap in a Gutenberg block comment.
Last piece, pushing the converted content back to WordPress. This is where things got weird. The client site is behind Cloudflare, and Cloudflare blocked every Python HTTP library I tried (requests, urllib, httpx). The user-agent looked suspicious enough to get 403'd on every POST. Claude Code figured this out on its own, after a few failed calls, and switched to curl in a subprocess. The JSON body goes into a temp file, curl reads it with @filename, Cloudflare sees a normal curl user-agent and lets it through.
Not clean. It's a hack. But it worked for the entire run, and on a long enough timeline every "temporary workaround" becomes load-bearing infrastructure anyway.
That's the caveat I owe you: the curl workaround is tied to this specific host. On a site without Cloudflare, the normal Python HTTP client would have worked fine. Your mileage will vary depending on the CDN and host config.
What Broke (and How It Fixed Itself)
After the first pass the site was up. Every page loaded. No 500s, no white screens. But the content was riddled with problems, three different flavors of broken, and they showed up in roughly that order.
The first one was ghost spacers everywhere. Elementor uses empty paragraphs with CSS margin as visual separators, and when you parse the HTML naively, those empty paragraphs convert into wp:spacer blocks. Multiply that across 114 pages and you get dozens of spacers stacked on top of each other, creating absurd vertical holes in the content.
Sections floating alone in the middle of the page. Footers halfway down the viewport. The whole layout doing its best impression of a CSS reset gone rogue. Claude Code noticed on its own after I pointed at one page and said "this looks wrong". It wrote a second script, fix-spacers.py, that re-pulled every post, stripped the spacer blocks by regex, and replaced them with regular line breaks where appropriate. 47 pieces of content cleaned in one batch.
Then came the invalid block errors. Gutenberg validates block markup strictly. If a wp:image block has a figure without the wp-block-image class, Gutenberg throws "This block contains unexpected or invalid content." Same for a wp:heading that still has leftover Elementor spans inside. The block loads but the editor refuses to modify it, which is worse than if it were just broken visually.
The client wanted to edit her own pages, that was the whole point. Third script, fix-blocks.py. Re-parse each block, reconstruct the inner HTML from scratch using the format Gutenberg expects, push it back. 81 pieces of content fixed, split into 4 parallel batches to speed things up. Claude Code decided on the parallelism by itself. I didn't ask.
Here's the narrative point I care about. The first two problems, Claude Code solved them entirely on its own. Three scripts in total, each one written to fix the issues the previous one had created. Autonomous feedback loop, same session, no restart. I pointed at symptoms. The agent wrote the diagnostic, wrote the fix, ran the fix, verified the fix.
The third flavor of broken is the actual limit. Elementor Pro ships widgets that have no match in native Gutenberg. The homepage slider, gone (no slider block in core). The Mailchimp popup, gone. The Elementor Pro contact form, gone. The social icons widget, text preserved but the visual icons dropped. These are not conversion bugs. They are proprietary widgets that don't exist outside Elementor Pro, and no parser in the world is going to invent them.
Code it can fix. Vendor lock-in it can't.
The Last 5% You'll Do Yourself
Short anecdote first. At one point Claude Code needed to install the Twenty Twenty-Five theme as a fallback. The WordPress REST API doesn't expose theme installation (for good reasons, it's a security surface). So the agent opened a browser via its MCP tool, logged into wp-admin, navigated to Appearance > Themes > Add New, searched, clicked Install, clicked Activate. Did it by itself. I watched it happen in the logs.
I bring this up because the "95% automated" framing needs a caveat. The agent handled almost everything the REST API allowed, and when the API didn't allow something, it found another channel. What stays for the human to do isn't technical work the agent can't handle. It's business decisions the agent shouldn't be making alone.
Concretely, what I had to touch manually at the end:
- Which form plugin to install to replace the Elementor Pro forms. WPForms Lite, Contact Form 7, Fluent Forms, each has tradeoffs. Not an agent call.
- How to reintegrate the newsletter signup. Native Mailchimp embed, MC4WP plugin, or a new signup flow entirely.
- Picking a new hero image because the old one had text baked into it.
- Reviewing Rank Math SEO meta on the key landing pages to make sure nothing regressed.
- Cleaning up leftover
_elementor_*rows inpostmeta(WP-CLI or direct SQL, to lighten the database). - Removing the Hello Elementor theme files (no REST endpoint for that either).
95% automated, 5% manual. And that 5% is decision work, not reconstruction work. Before you point a coding agent at a production site, you want to define exactly what it's allowed to touch and what it has to escalate. That's the whole reason I built the execution scope discipline I use before launching an agent on production. Clear scope, clear escalation rules, clear stop conditions. Without that you're gambling on a live site and hoping the agent self-corrects before it bricks something.
The REST API itself has hard limits worth knowing. No theme deletion, no plugin code access, no PHP execution, no file system. For anything outside the API surface you need SSH or wp-admin. Define the perimeter before you launch.
The Button That Wasn't
114 pieces of content. Three scripts. One day. Typos fixed along the way, key pages reviewed, all URLs preserved. The client edits her own pages now without calling me.
Every guide is right. There is no magic button for Elementor to Gutenberg. But an agent that writes its own conversion scripts and fixes its own bugs in a continuous session, that's not a button.
It's better than a button 🚀
Sources
- WordPress REST API Handbook
- Consensus guides on Elementor to Gutenberg migration: Blog Marketing Academy, Crocoblock, Ulement ("there is no magic button", "several weeks of dedicated work")
(*) The cover is AI-generated. Turns out the only real magic button in this whole story was the one that made the header image.
Top comments (0)