← Blog

WordPress

WordPress block theme migration: 14 traps from 9,400 posts

Fourteen real bugs we hit moving a 9,400-post Belgian publisher from a custom 2018 page-builder onto WordPress 6.7 block themes, ranked by what the Site Editor mangled most quietly.

Jacob Molkenboer· Founder · A Brand New Company· 13 Nov 2024· 9 min
Open leather logbook with brass key on cream card, ink pad, rubber stamp, green ribbon, wax seal fragment on ivory linen desk.

It is 23:14 in the Antwerp office. The Belgian publisher's 9,400 archived posts have just finished importing. The Site Editor reports zero errors. Half the article images are gone. The pull-quotes inside the editorial features are gone. The byline blocks are gone. The CSS validates, WP-CLI is happy, the database rolled over clean. The posts look fine in the editor. They are missing about 40 percent of their content on the frontend.

That is the experience of a block-theme rewrite at scale. The Site Editor does not throw. It rewrites. Then it ships. We spent ten weeks porting that publisher off a 2018 ACF-and-shortcode page-builder onto a custom WordPress 6.7 block theme. Below are the fourteen traps that bit hardest, ordered by how silently the editor handled them. Trap one will not appear in any log file.

What the Site Editor silently rewrote

1. Custom HTML blocks with one unbalanced tag

The legacy theme leaned on Custom HTML blocks for callouts, pull-quotes, and gallery captions. About 1,100 of them had a stray closing tag, an unwrapped <br>, or a span that never opened. The Site Editor parses every Custom HTML block through the block validator on save and on render. If the markup fails, the block gets silently rewritten to its best-guess reconstruction. No notice, no admin warning. The inspector view looks unchanged. The rendered HTML is different.

We caught this by diffing rendered post output before and after the migration with a WP-CLI script that fetched every post's final HTML and compared word counts. About 7 percent of posts lost more than 20 words this way. The fix: re-import via wp post update with sanitised HTML, then mark those blocks as Classic blocks so the validator does not touch them on next save.

2. theme.json schema version drift

The publisher's old theme shipped theme.json with "version": 2. WordPress 6.6 introduced "version": 3, and 6.7 made it the default for new themes. Mix v2 settings keys (settings.typography.fontSizes) into a v3 file and the editor parses what it understands and drops the rest. The drops are silent.

The clearest tell: a font size you set in theme.json appears in the editor toolbar but does not apply on the frontend. Or a colour palette renders in the inspector picker but the resulting class never lands on the block. The Global Settings and Styles handbook documents the v3 shape. Pin your version explicitly and keep one schema per theme.

3. Classic blocks that wrap shortcodes

When the importer cannot map old content to a block, it wraps it in a Classic block. Classic blocks render through the_content and execute shortcodes. Sounds fine. The problem: shortcodes registered by the old theme's functions.php are gone. So [old_gallery ids="1,2,3"] renders as literal text inside a paragraph tag. The Site Editor does not flag the unresolved shortcode.

We wrote a one-pass script that grepped post_content for every unresolved shortcode tag and produced a CSV mapped to the new block. A second pass replaced each one. Skip this step and you ship literal [old_gallery] strings inside live articles.

4. Template parts moved from /parts/ to /patterns/

WordPress 6.6 began promoting patterns over template parts for non-structural reusables. 6.7 surfaces patterns in the Site Editor's primary pattern panel and treats /parts/ as legacy. If you import a theme that defines, say, a "promo strip" as a template part, the Site Editor accepts it but does not show it in the user-facing pattern picker. Editors cannot insert it. They assume it does not exist and rebuild it inline. You end up with three near-identical promo strips across the site.

The fix is mechanical: move /parts/promo-strip.html to /patterns/promo-strip.php, add the Pattern Name docblock, and delete the orphaned part. WP-CLI helps for batch renames.

5. Synced pattern IDs that change on import

Synced patterns (the rebrand of reusable blocks) live in the wp_block custom post type. Each instance references a ref ID. When you import the XML dump into the new install, those IDs get reassigned. The block markup still says "ref":4821. Post 4821 in the new database is a draft article, not the synced pattern. The block renders as an empty placeholder.

The importer does not warn. Find them with wp post list --post_type=wp_block, build an old-to-new ID map, then run a search-replace across post_content for every "ref":\d+ reference. WP-CLI search-replace handles serialised PHP correctly. MySQL's plain REPLACE() does not.

wp post list --post_type=wp_block --fields=ID,post_title --format=csv > new-blocks.csv
wp search-replace '"ref":42' '"ref":918' wp_posts --include-columns=post_content --dry-run

What it silently dropped

6. Navigation block menu items stored as wp_navigation posts

The Navigation block does not write its menu structure to wp_options like classic menus did. It creates a wp_navigation post and stores menu items as inner blocks of that post. On import, if the navigation post does not come over (or comes over with a new ID), the Navigation block in your header template loads empty. The header shows the logo and a void.

This bit us twice. Once because the WXR export skipped wp_navigation CPT entries by default. Once because the new install had its own auto-created navigation post that won the ID race. Always export navigation posts explicitly. Always set the Navigation block's ref attribute after import.

7. Block hooks injecting where you did not ask

WordPress 6.4 introduced block hooks, and 6.5 expanded them. They auto-attach blocks to other blocks based on metadata. If you inherit a theme or plugin that registers hooks, the Site Editor honours them without flagging it in the visual tree.

We found a third-party plugin's "related posts" block hooked into every core/post-content. It rendered fine, but it also fired six extra database queries per page. We disabled the hook in functions.php with the hooked_block_types filter and reclaimed about 90ms per request.

8. apiVersion 3 custom blocks and the iframed editor

From WordPress 6.3 onward the post editor canvas runs in an iframe by default for any block theme. Custom blocks built against apiVersion 2 still load scripts in the parent document, not the iframe. So your block's edit script never sees the iframe's DOM and renders blank in the editor. It renders fine on the frontend, which is why this slips through QA.

Bump "apiVersion": 3 in block.json, register edit assets via wp_enqueue_block_assets, and test inside the iframe. Old apiVersion 2 blocks that worked in 6.2 will appear broken in 6.5 with no console error in the parent window.

9. Featured image sizes overridden by theme.json

Set settings.layout.contentSize and settings.layout.wideSize and you may discover that the Post Featured Image block ignores the explicit width and height attributes you set in the template. The block constrains itself to the layout's max width. We had editors uploading 2400px hero images and seeing them rendered at 720px because the template part lived inside a constrained group.

Wrap featured image blocks in an aligned (full or wide) group, or set useRootPaddingAwareAlignments deliberately. The Site Editor's inspector does not show that the constraint is being applied two ancestors up.

10. Polylang language switcher duplicated per translation

The publisher runs Dutch and French. Polylang's language switcher block sits in the header template part. Polylang treats each translated post as a separate post. The Site Editor cached the rendered template part output per post. Editing the NL version of the switcher mutated the cached output served to FR visitors on certain CDN configurations.

The fix is to render the switcher dynamically (a PHP block) rather than as a static block inside a template part, and to flush the template part cache after any switcher edit. Polylang's docs cover the dynamic block. The Site Editor does not warn you that you are caching language-aware output statically.

What broke days after launch

11. wp_global_styles revisions table bloat

Every time an editor opens Global Styles in the Site Editor and tweaks anything, WordPress writes a revision to the wp_global_styles post and a row to wp_posts. After three weeks the publisher had 1,840 revisions of their global styles post. The Site Editor took 14 seconds to load Global Styles because it loads all revisions to populate the history panel.

WordPress core does not auto-prune global styles revisions. Schedule a weekly wp post delete for revisions older than 30 days, or set WP_POST_REVISIONS to a finite number in wp-config.php. The default of unlimited will bite eventually.

12. Hardcoded wp-content URLs in pasted markup

Page-builder content from 2018 had hardcoded references to https://oldsite.be/wp-content/uploads/2019/.... The WXR importer rewrites attachment URLs for attached media, not arbitrary text-content URLs. So img src attributes inside Custom HTML blocks still pointed at the old domain after migration.

The day the old domain's DNS was redirected, those images broke. We ran wp search-replace 'oldsite.be/wp-content' 'newsite.be/wp-content' --include-columns=post_content,post_excerpt,meta_value as a launch-day step. Check the options table too: some plugins store image URLs there.

13. The Site Editor's Reset to Default button

Warning

Any user with the Site Editor capability can click "Reset to default" on a template and erase a customised version. There is no two-step confirmation. There is no per-template version history. The change writes immediately and persists on save.

We lost a custom single-post template twice before locking down the capability. Restrict edit_theme_options to senior editors only, and consider a plugin that blocks Site Editor edits on production. Track template changes via Git in the theme files, not via the editor.

14. The cache plugin serving the old theme for nine hours

The cache layer (WP Rocket in this case, but the pattern repeats with LiteSpeed Cache and W3 Total Cache) stores HTML keyed by URL. Switching themes does not invalidate that cache. The publisher went live, half the editorial team saw the new theme, and most logged-out readers kept seeing the old theme until the cache TTL expired.

Flush every cache layer in this order on theme switch: object cache, then page cache, then CDN cache. Verify with a logged-out incognito request and an unauthenticated curl -I checking the x-cache header. The Site Editor does not flush downstream caches. Nothing in the WordPress admin will tell you the public site is still on yesterday's HTML.

Patterns we now use on every block-theme migration

The cheatsheet collapses to a workflow. Diff rendered post HTML before and after, not just database tables. Lock your theme.json version explicitly. Resolve every shortcode before turning the old theme off. Export wp_navigation and wp_block posts explicitly. Bump custom blocks to apiVersion 3. Map synced pattern IDs old to new. Prune wp_global_styles revisions on a schedule. Flush every cache layer on cutover. None of this is in the official migration guide. All of it has cost us a week of post-launch firefighting at some point.

When we built the new block theme for that Belgian publisher, the hardest single bug was trap nine: 800 archived feature articles with featured images mysteriously cropped at 720px wide. We solved it by rewriting the post-content template part to wrap the featured image in a wide-aligned group, then re-saving every affected post via WP-CLI. The work was part of a broader legacy migration off a custom page-builder the publisher had outgrown.

If you are sitting at midnight watching the Site Editor accept your import with zero errors, run a content-length diff before you sleep. That single five-minute audit catches most of trap one, three, and twelve.

Key takeaway

The WordPress Site Editor does not throw on broken imports; it silently rewrites them. Diff rendered post HTML before and after every block-theme migration.

FAQ

Does the WordPress Site Editor warn you when it rewrites broken Custom HTML blocks?

No. It silently reconstructs the markup using the block validator and saves the result. Diff the rendered post HTML before and after migration to catch the loss.

How do I migrate synced patterns without losing references?

Export the wp_block CPT explicitly, build an old-to-new ID map after import, then use WP-CLI search-replace to rewrite every ref:<id> token across post_content.

What is the safest way to bump a custom block to apiVersion 3?

Set apiVersion 3 in block.json, enqueue edit assets via wp_enqueue_block_assets so they load in the iframe, then test the block inside a block theme's iframed editor.

Why did my featured image render at 720px after migrating to a block theme?

The template part probably constrains content to theme.json's contentSize. Wrap the Post Featured Image block in a wide- or full-aligned group to escape the constraint.

wordpressmigrationlegacy sitesphparchitecture

Building something?

Start a project