Moving a Client Site Off a Page Builder to Native Blocks
A practitioner’s guide to migrate Elementor to Gutenberg: audit scripts, pattern-based rebuilds, theme.json mapping, visual regression QA and safe cleanup.
A client came to us with a 42-page WordPress site running Elementor Pro, a Theme Builder header and footer, and a homepage that shipped 1.9 MB of CSS and JS before a single image loaded. Their complaint wasn’t speed. It was that every time Elementor pushed a minor release, two sections broke and nobody on their team could tell which.
That’s usually the real reason people migrate. Performance is the headline, but the thing that actually drives the decision is that the site has become un-editable by anyone except the one freelancer who originally built it. Moving to native blocks doesn’t automatically fix that, and a badly executed migration makes it worse.
Here’s how we approach it after doing this a dozen or so times, including two we’d call failures.
- Elementor stores layout as JSON in the
elementordatapostmeta key, not inpost_content. Deactivating the plugin doesn’t degrade gracefully: it leaves pages blank or dumps raw shortcodes. Plan for a full rebuild of every builder page. - Convert the Theme Builder header, footer and archive templates first. If those use Elementor, the plugin’s frontend assets load on every URL, so fixing them alone removes the overhead sitewide even while interior pages stay on the builder.
- Gutenberg’s paste handler converts rendered HTML into core blocks reasonably well for prose, headings, lists and images. It is useless for layout. Copy content from the frontend, rebuild structure as patterns.
- Map Elementor Site Settings (global colours, typography, container widths) into
theme.jsonbefore touching a single page, or every converted page will look wrong for reasons you’ll waste hours chasing. - Keep the old postmeta until the client has signed off and the site has run for two weeks. Deleting
elementor*rows is the last step, not the first.
First decide whether it’s worth doing at all
We’ve talked two clients out of this migration and we were right both times.
Don’t migrate if the site is scheduled for a redesign inside twelve months. You’ll spend 60 to 100 hours faithfully reproducing a design that’s about to be thrown away. Redesign straight into blocks and skip the middle step.
Don’t migrate if the client’s marketing team publishes landing pages weekly, has zero technical staff, and genuinely relies on drag-and-drop. The block editor in 2026 is good, but it is not a visual page builder. Asking a non-technical marketer to work with nested group blocks and a pattern library is a real downgrade in their day. You’ll get a support ticket every Tuesday.
Do migrate when the builder is load-bearing for a design nobody edits anymore, when plugin update anxiety is blocking security patches, when the licence renewal is annual and nobody logs into the builder, or when you’re taking over maintenance and need to own the stack. That last one is the honest reason most agencies do it.

Audit before you touch anything
You need two numbers before you start: how many pages are actually built with the builder, and which widgets they use. The second number decides your schedule more than the first. Fifteen pages built from six repeated widget types is a two-day job. Eight pages using forty distinct widgets is a fortnight.
# Every published post or page still rendered by Elementor
wp db query "SELECT p.ID, p.posttype, p.posttitle
FROM wp_posts p
JOIN wppostmeta m ON m.postid = p.ID
WHERE m.metakey = 'elementoreditmode'
AND m.meta_value = 'builder'
AND p.post_status = 'publish'
ORDER BY p.posttype, p.posttitle;" --skip-column-names
Then count the widget types. elementordata is a JSON array, so jq does the work:
for id in $(wp post list --post_type=page,post --format=ids); do
wp post meta get "$id" elementordata 2>/dev/null
done | jq -s 'map(.. | objects | select(.widgetType) | .widgetType)
| flatten | group_by(.)
| map({widget: .[0], count: length}) | sort_by(-.count)'
On the 42-page site, that returned 19 widget types, and the top six accounted for 81 percent of all instances: Heading, text-editor, image, button, icon-box, and a pricing table. Six patterns covered most of the site. The long tail (a countdown timer used once, an animated headline on a page that got 40 visits a year) got deleted rather than rebuilt, with the client’s agreement.
Take a performance baseline too, and make it a number you can repeat. Not a Lighthouse score, which drifts. Count transferred bytes for a fixed set of URLs:
for url in / /about/ /services/ /contact/; do
total=$(curl -s "https://staging.example.com$url" \
| grep -oE '(src|href)="[^"]+\.(css|js)[^"]*"' \
| sed -E 's/.="([^"]+)"./\1/' | sort -u \
| xargs -I{} curl -sL -o /dev/null -w '%{size_download}+' {} )
echo "$url $(( $(echo "${total}0") )) bytes of CSS+JS"
done
Crude, but consistent, and it ignores images, which don’t change during the migration. Our baseline on that homepage was 1.9 MB uncompressed across 31 CSS and JS files. Post-migration it was 214 KB across 7. That’s the kind of figure a client understands.
How to migrate Elementor to Gutenberg without rebuilding every page by hand
There is no reliable automated converter. People sell them. We’ve tested four and every one produced output that needed more cleanup than a rebuild would have taken. The builder’s layout model (sections, inner sections, column widths per breakpoint, widget-level padding) doesn’t map onto group and columns blocks in any mechanical way.
What does work is splitting content from structure.
Content comes across well through the clipboard. Open the live page in a browser, select a prose region, copy, and paste into the block editor. Gutenberg’s raw handling parses the HTML and produces core paragraph, heading, list, image and quote blocks with the text intact. Links and inline formatting survive. On content-heavy pages (blog-style posts, long service descriptions, terms pages) this converted around 90 percent of the body in one paste. Budget ten minutes per page for cleanup, mostly stripping empty paragraphs and fixing image attachment IDs.
Structure you rebuild once as a block pattern and reuse. That’s the whole trick. If you have an “icon box row” appearing on eleven pages, build it once, register it as a theme pattern, and inserting it becomes a three-second operation.
<?php
/**
- Title: Services, three column
- Slug: acme/services-three-col
- Categories: acme-sections
- Description: Three icon boxes with heading and short copy.
*/
?>
<!-- wp:group {"align":"full","style":{"spacing":{"padding":{"top":"var:preset|spacing|80","bottom":"var:preset|spacing|80"}}},"layout":{"type":"constrained"}} -->
<div class="wp-block-group alignfull">
<!-- wp:columns {"style":{"spacing":{"blockGap":{"left":"var:preset|spacing|50"}}}} -->
<div class="wp-block-columns">
<!-- wp:column -->
<div class="wp-block-column">
<!-- wp:image {"width":48,"height":48,"sizeSlug":"full"} -->
<figure class="wp-block-image is-resized"><img src="" alt=""/></figure>
<!-- /wp:image -->
<!-- wp:heading {"level":3,"fontSize":"medium"} -->
<h3 class="wp-block-heading has-medium-font-size">Service name</h3>
<!-- /wp:heading -->
<!-- wp:paragraph --><p>Short description.</p><!-- /wp:paragraph -->
</div>
<!-- /wp:column -->
</div>
<!-- /wp:columns -->
</div>
<!-- /wp:group -->
Drop that in /patterns/services-three-col.php inside the theme and WordPress registers it automatically. No PHP registration call needed since 6.0. Note the var:preset|spacing|80 syntax: that pulls from the spacing scale in theme.json, so when the client later decides sections need more breathing room, you change one value.
Pick patterns, not synced patterns, for layout
Synced patterns (the old reusable blocks) are tempting and usually wrong for page sections. Edit one and you edit every instance, which is great for a CTA banner and catastrophic for a services grid where each page has different copy. Use unsynced theme patterns for structure, synced for genuinely identical content like an office address block.
The rebuild order that saves the most time
Do it in this sequence. We’ve tried it out of order and paid for it.
- theme.json first. Open Elementor Site Settings, write down every global colour and typography style, and reproduce them as presets. Colours become
settings.color.palette, type scale becomessettings.typography.fontSizes, section widths becomesettings.layout.contentSizeandwideSize. If you already run a token system in your CSS, this is the same discipline applied to WordPress, and our write-up on design tokens with CSS variables maps cleanly onto the theme.json shape. - Header, footer and global templates. Highest leverage item on the list. While any Theme Builder part is active, Elementor enqueues
elementor-frontend.min.js,swiper, the eicons font and its global stylesheets on every single URL including blog posts that contain no builder content. Converting header and footer to block template parts removed roughly 340 KB from every page on that client site before we’d touched a single page body. If you’re unsure where a given override belongs, our piece on template hierarchy in block themes covers the resolution order. - Archives and single templates. Query Loop replaces Posts widgets. Expect to lose some filtering behaviour and to write a small
pregetpostsfilter for anything non-trivial. - Patterns for the top six widget combinations.
- Pages, highest traffic first. Pull your top 20 URLs from analytics and do those before the ones nobody visits. If the project stalls at 70 percent, you want the remaining 30 percent to be the pages that don’t matter.
Forms, popups and the Pro features you’ll actually lose
This is where migrations overrun. Elementor Pro Forms, popups, and the custom CSS field per widget have no direct equivalent, and clients forget they exist until a lead stops arriving.
Before deactivating anything, export every form submission already stored in the database. Elementor keeps them in its own tables and they will not survive. Then inventory the forms themselves: fields, required flags, notification recipients, any webhook or CRM integration wired into the form’s Actions After Submit panel. That last one is the silent killer. We found a form quietly posting to a Zapier hook that fed the client’s invoicing system, documented nowhere.
For replacements you have three sane options. A block-native form plugin if the client needs to edit forms themselves. A hand-built form in a pattern posting to an endpoint, which is what we do when the form is fixed and we want zero extra plugins: WebForms gives you a POST target that routes to email, Slack and webhooks without a backend, so the markup stays plain HTML you control. Or a bespoke REST route if there’s real business logic. Whichever you pick, get the markup right the first time. Builder-generated forms are usually poor on accessibility, and this is a free opportunity to fix that. Labels, error association and focus management cost an hour and prevent a complaint.
Prove the pages match, with screenshots not vibes
Client sign-off goes badly when it’s a human scrolling two tabs. Automate the comparison. A thirty-line Playwright script captures full-page screenshots of production and staging at three viewports and gives you a diff folder to review:
// node compare.js (requires: npm i playwright pixelmatch pngjs)
import { chromium } from 'playwright';
import fs from 'node:fs';
const PATHS = ['/', '/about/', '/services/', '/contact/'];
const VIEWPORTS = [[1440, 900], [768, 1024], [390, 844]];
const SITES = { live: 'https://example.com', next: 'https://staging.example.com' };
const browser = await chromium.launch();
for (const [label, origin] of Object.entries(SITES)) {
for (const [w, h] of VIEWPORTS) {
const page = await browser.newPage({ viewport: { width: w, height: h } });
for (const path of PATHS) {
await page.goto(origin + path, { waitUntil: 'networkidle' });
// kill animations so diffs aren't just mid-transition noise
await page.addStyleTag({ content: ',::before,*::after{animation:none!important;transition:none!important}' });
await page.screenshot({
path: shots/${label}${path.replace(/\//g, '_')}-${w}.png,
fullPage: true,
});
}
await page.close();
}
}
await browser.close();
Run pixelmatch over the pairs, sort by diff percentage, and review the worst ten. Pages that differ by under about 2 percent are almost always font rendering and can be skipped. The ones at 15 percent are where you forgot a background colour.
Also diff the text. Strip tags from both versions and run diff on the output. Missing copy is far more damaging than a 4px spacing difference and far easier to miss visually.
Clean removal: what to delete and exactly when
You can safely remove the page builder from WordPress only after three things are true: every builder page is converted, the client has signed off in writing, and the site has run on the new templates for at least two weeks. Until then, deactivate the plugin on staging only and leave production alone.
When you do pull the trigger, order matters:
- Take a full database dump and keep it for six months.
wp db export pre-elementor-removal.sql. - Deactivate and delete the plugins (Elementor, Elementor Pro, and any third-party widget packs, which are usually the worst offenders for leftover shortcodes).
- Crawl the site for orphaned shortcode text. Third-party addons leave things like
[ucaddon...]rendered as literal text on the page.wp db query "SELECT ID, posttitle FROM wpposts WHERE postcontent REGEXP '\\[[a-z_]+ '"finds candidates. - Delete the generated CSS directory:
rm -rf wp-content/uploads/elementor/css. - Only now, clear the postmeta.
wp db query "DELETE FROM wppostmeta WHERE metakey LIKE '\\elementor%'". On a site with a few thousand posts this reclaims a surprising amount of table size, becauseelementor_datais verbose JSON and every revision carried a copy. - Drop the options:
wp option list --search='elementor*' --field=option_name | xargs -n1 wp option delete. - Flush object cache and any page cache, then re-run your byte-count baseline so you have a before and after to send the client.
Revisions are the hidden win here. A page edited 60 times in Elementor stores 60 copies of the full layout JSON. One site we cleaned went from a 480 MB database to 70 MB, and the nightly backup window dropped from eleven minutes to under two.
Frequently Asked Questions
Can I convert Elementor pages to Gutenberg automatically?
Not reliably. Elementor stores layout as structured JSON in elementordata with its own section, column and widget model, and no converter we’ve tested maps that onto core blocks without producing markup you’d rather rewrite. The practical hybrid is pasting rendered frontend HTML into the block editor, which Gutenberg converts into core blocks for text content, then rebuilding the layout as theme patterns.
What happens if I just deactivate Elementor?
Builder pages render as either blank content or a wall of leftover markup, because post_content for those pages is typically empty or holds only a placeholder. Theme Builder headers and footers disappear entirely, so the site loses its navigation. Never test this on production; clone to staging first.
How long does a page builder to block editor migration take?
For a 40-page brochure site with a repeatable design, plan 50 to 80 hours including theme.json setup, template parts, patterns, page rebuilds, form replacement and visual QA. The variable is widget diversity, not page count. Run the jq widget audit before quoting, because a site with 40 distinct widget types can easily double that.
Will my SEO be affected?
URLs don’t change, so the main risks are content loss and heading structure drift. Diff the stripped text of every page before and after, check that each page keeps exactly one H1 and the same heading hierarchy, and verify that anchor IDs used by in-page navigation survive (set them via the block Advanced panel). Core Web Vitals usually improve once the builder’s CSS and JS are gone.
Should I migrate to a block theme or keep my classic theme?
Go to a block theme if you’re doing this work anyway. Keeping a classic theme means managing templates in PHP while content lives in blocks, which splits the design system across two places and loses you the theme.json preset pipeline. The exception is a site with heavy custom PHP templates that already work, where converting them is a separate project you can defer.
Where to start tomorrow
Clone to staging, run the two audit commands at the top of this article, and look at the widget count. If the top six widget types cover more than 70 percent of instances, the migration is a pattern-building exercise and you can scope it confidently. If the distribution is flat across twenty-plus widgets, go back to the client with a redesign quote instead. A faithful rebuild of a sprawling custom design is the most expensive version of this job and the least satisfying.
Either way, convert the header and footer first. Even if the project stops there, every page on the site gets lighter, and you’ll have a template part structure to build everything else on.


