Home / Blog / Enterprise Web Platforms

Moving an Elementor site to blocks without losing a link

In October 2026 I rebuilt this site. It went from Elementor on a Hello Elementor child theme to a block theme, a child of Exivon’s Lede base theme. It went live on 28 Oct.

The rule I set on day one was “same design first, changes after”. The rule that mattered most in practice was simpler: nothing that links to this site, or from it, may break. This post covers how I held to that, including the step where I failed and had to fix it.

Why I moved off Elementor

The site was entirely Elementor. Elementor Pro’s theme builder owned the header, footer and every template for single posts, archives, the Code Snippets archive, post cards and the 404. All 10 pages and all 42 published posts were Elementor documents. Some pages depended on Elementor-only widgets: a timeline add-on, counters and nested accordions. Core’s Details block replaced the accordions. The timeline became blocks plus CSS, and the counters became a small script.

The payoff I can measure is requests. Before launch, on the same 12 pages, CSS files dropped from 20–29 to 4–6 and JS files from 18–30 to 6–12. Total requests fell 50–65% on every one of them. I can’t give you transfer-size savings, because my baseline was taken with a warm cache and isn’t comparable.

Most of my development work is now AI-assisted, so I wanted a site my tools and I can work on directly. A site built from core blocks and theme files is plain code that is easy to update and optimise. Elementor Pro kept the site dependent on a third-party premium plugin for every template.

Inventory first

Before I touched a template I wrote down everything Elementor was doing for me:

  • Design tokens: colours, fonts and the 1200px container, read from Elementor’s kit.
  • Seven theme-builder templates, each with a block equivalent.
  • 10 pages, with their widgets counted. Home alone had 14 headings, 4 counters, 4 image boxes and a latest-posts widget.
  • 42 posts: mostly headings and text, plus 22 code-highlight widgets, 7 syntax-highlighter widgets, 18 [table “” not found /]
    shortcodes, 7 shortcodes, one video and one carousel.
  • Menus, plus the plugins that touch the migration and what each would be replaced with.

Then I captured a baseline: screenshots of 14 URLs at 1400px and 375px, a crawl of 59 URLs (status, title, canonical, one H1 each), the menus, the ad placements, the affiliate links and the Redirection rules.

The inventory paid for itself in small ways:

  • A PayPal SDK script lived in Elementor Pro’s “Body Start Code” for one page. It would have vanished with the plugin. It moved into that page’s HTML block.
  • Four old header and footer templates had no display conditions. I confirmed they were unused instead of rebuilding them.
  • The About page’s skill items linked to the literal text “Distributed Team Management”, a broken relative URL. I made them plain text.

I did the pages first and the posts second, in batches with a sign-off between each. The live site kept running on Elementor throughout. On my local copy I switched Elementor off on day one, because its theme builder overrode my block templates, so unconverted posts showed only their plain stored text until the conversion on 23 Oct. I also froze live content for the whole project. That made the dates and database steps later in this post safe.

I wrote a PHP converter that reads each post’s _elementor_data and writes blocks: headings, images, buttons, columns, shortcodes and icon lists. Rich text went through the editor, and code became Code Block Pro blocks. Old post_content was backed up first and _elementor_data was never touched, so rollback was always possible.

All 48 posts converted (42 published and 6 drafts) with 0 invalid blocks. The first pass still lost links.

Linked headings and images

An SEO review compared per-URL link counts against live. The converter had dropped the links on 63 Elementor heading widgets across 14 posts, plus 4 linked images in one post and an image-to-file link in another. Nothing looked wrong on the page. A heading is just a heading when its link is missing.

I fixed the converter, re-ran it and compared link counts per post against live again. The only differences left were pseudo-links inside code text. The image that linked to its own file now opens in core’s lightbox.

If you do one thing from this post, make it this: diff the outbound and internal link count per URL, converted against live, before you call a conversion finished. Page screenshots will not catch it.

Hard-coded IDs

My first header and footer menus were hard-coded links in the template parts. They pointed at page IDs: Home 497, About 5888, Blog 504, Security Scanner 6247, Contact 591. A navigation link to an ID that doesn’t exist disappears silently, with no error. So I put a launch check on the list: confirm on live that those five pages are published, plus six category IDs, two Advanced Ads groups, one ad and one media ID.

My launch method swapped the whole database in from my local copy, so the IDs came across with it. That is what made hard-coded IDs safe for me. If you launch by migrating content into an existing live database, treat that list as a blocker.

Redirects

The site had 84 Redirection rules. Of those, 38 were two-hop chains: old URL to an intermediate URL to the final /blog/ URL. I retargeted those 38 to the final URL, then replayed all 84 rules and checked each one reached a 200 in one hop. A block-theme move is a cheap moment to flatten chains, because you are already testing every URL.

I also repointed 26 old internal links to current URLs, for example /contact-me/ to /contact/.

A quick hop checker (adapt to your site; run on a copy first):

# prints redirect count, final status and final URL for each old URL in urls.txt
# read-only GET requests, one URL per line; the "|| [ -n ... ]" keeps a last line with no trailing newline
while read -r url || [ -n "$url" ]; do
  curl -s -o /dev/null -L -w "%{num_redirects} %{http_code} %{url_effective}\n" "$url"
done < urls.txt

One trap: after I cloned the site to my local machine, Redirection’s targets were rewritten to the local domain by the URL swap. I had that in my notes as a launch reminder. It is the kind of thing you forget at midnight.

JSON-escaped URLs

Elementor stores its data as JSON, which escapes slashes: http:\/\/example.local, not http://example.local. A normal search-replace won’t match that string. My local clone swapped both forms of the live URL. Going the other way at launch, the exported database still held the escaped local URL, in old Elementor data, TablePress tables with affiliate links and Yoast’s cache. A second replace pass made 366 replacements.

Adapt to your site; run on a copy first, export a backup and use --dry-run before you commit:

wp db export before-replace.sql
wp search-replace 'http://old.local' 'https://www.example.com' --all-tables --skip-columns=guid --dry-run
wp search-replace 'http:\/\/old.local' 'https:\/\/www.example.com' --all-tables --skip-columns=guid --precise --dry-run

Afterwards, grep the database and the rendered HTML for the old hostname. Zero outside guid and logs is the pass mark.

Keeping modified dates honest

Converting a post rewrites post_modified. Left alone, that tells Google and your readers that every converted page changed on the day you converted it, when only the markup did. The XML sitemap’s lastmod typically comes from the same field.

I restored the original post_modified on 57 posts and pages from the live dump, then checked that the post sitemap’s lastmod matched live exactly. When I later fixed alt text on three linked images, I used direct database updates so the date stayed put.

The reverse also matters. When I did change content after launch, for example removing closed plugins from a roundup, I updated the date on purpose and put a visible “Checked again” note on those posts. A date should mean something happened.

Save the dates before you start (adapt to your site; run on a copy first):

# change the wp_ table prefix if yours differs
wp db query "SELECT ID, post_modified, post_modified_gmt FROM wp_posts WHERE post_type IN ('post','page')" > dates-before.tsv
# after conversion, restore with a direct UPDATE per ID. Back up first (wp db export).
# Don't use wp post update: it calls wp_update_post(), which sets the modified date to now.
# UPDATE wp_posts SET post_modified='YYYY-MM-DD HH:MM:SS', post_modified_gmt='YYYY-MM-DD HH:MM:SS' WHERE ID=123;
# afterwards, flush caches and check the sitemap's lastmod values

Validating blocks

“Looks right in the browser” isn’t validation. A block can render and still be flagged invalid when you open it in the editor. Validation runs in the block editor when it loads the content, and PHP’s parse_blocks() only parses the structure. So I opened every template, template part and page in the editor and read the invalid-block count. At that stage the header was 11 blocks, the footer 17, About 162 and Security Scanner 236. The target was 0 invalid for every one, and every one showed 0 before sign-off.

On top of that I ran:

  • An SEO parity check across the 59 baseline URLs: status, title, description, canonical and Open Graph all matched.
  • axe on 14 URLs at two widths.
  • A side-by-side measurement against the Elementor baseline at 1400px and 375px, with tolerances agreed in advance.

That last one used the live site as the reference, because Elementor on my local copy rendered in fallback fonts and wrapped lines differently.

Launch gotchas

The launch was a full database swap from local, keeping the live admin user and Site Kit’s Google connection. These are the problems that only appeared on the live server:

  1. Jetpack wrote to the database mid-import. Jetpack was still installed on live. Its sync wrote an option while I was importing, and the import failed on a duplicate key with the site in maintenance mode. The fix: delete the plugins I had already approved for removal first (10 in all, Jetpack and Elementor among them), drop all tables and import again. It came back clean with 82 tables and 2,037 options. Remove the writers before you import.
  2. LiteSpeed’s CSS combine broke the cascade. After a later layout fix, every box after the first on live was 24px shorter than on local. LiteSpeed had combined my theme CSS and loaded it before WordPress’s global-styles inline CSS. A WordPress layout rule with the same specificity as my reset then won, because it came later. I turned CSS combine and async off (minify stayed on). If a theme rule ties with a core rule, load order decides, and your optimisation plugin controls load order.
  3. The CDN kept serving old pages. After launch the host’s CDN kept serving the old Home page, a copy about three days old, until I flushed it. On 6 Nov a LiteSpeed purge deleted files that CDN-cached pages still referenced, and Contact lost its CSS and JavaScript. A second purge the same day broke three more pages. Each time the fix was to flush the CDN. My rule now: after any live purge, flush the CDN.
  4. Keeping the live users table brought old data back. Restoring live wp_usermeta also restored an old author bio and two social profiles, which then appeared in the schema and Open Graph tags on every page. I fixed them on 6 Nov, and checked 59 of 59 URLs afterwards.
  5. Two Google tags. A Tag Manager container was loading through Site Kit alongside GA4. I turned Site Kit’s Tag Manager snippet off so one tag remains.

What I’d do again

  • Same design first. Changing layout and structure at the same time makes every regression ambiguous. Design changes, such as button contrast, came after.
  • Baseline before you touch anything. Screenshots, a 59-URL crawl, menus, redirects and ad placements. Every check afterwards is a comparison, not an opinion.
  • Keep the originals. Old post_content backed up, _elementor_data untouched, old theme kept until launch and a written rollback.
  • Diff link counts per URL. I’d run it before the first sign-off, not after a review found the gap.
  • Freeze live content. One read-only check on launch day showed no posts, pages, uploads or form entries had changed since the clone. Seven unapproved comments, probably spam, had arrived, and I dropped them. That is why a full database swap was acceptable.
  • Check the live server, not just local. Local passed on everything above; live is where Jetpack, LiteSpeed and the CDN showed up.

The blog archive lists every post by year and month, if you want to see what came through the move.

I used AI to help draft this post from my own idea and notes. I verified the information and edited the text myself.

Dhiren Patel

Principal Full-Stack Engineer · Founder, Exivon Tech

I’ve built and run enterprise web platforms and e-commerce for 13 years, and I built ShieldScope, a free WordPress security scanner.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.