Most long-running WordPress sites land in the same spot eventually. There’s a place they need to get to: modern WordPress, Full Site Editing, something that won’t be a maintenance burden in three years. And then there’s the day-to-day reality of the site they actually have. Editors are publishing on a schedule. Campaigns are running. A page builder is holding together layouts that took months (if not years) to settle. The business side isn’t interested in a six-month rebuild or a content freeze.
The catch is that WordPress has never really offered a middle ground at the theme layer: one theme is active site-wide, period. Migrating meant a one-shot rebuild, scheduled for the calmest day you could find.
After my many years working in WordPress, I’d mostly accepted that as the shape of the problem. Then I came across ThemeSwitcher Pro (TSP), a plugin that lets two themes run side-by-side on the same WordPress site, routing some URLs to Theme A and others to Theme B. Entire content types (or single pieces of content) can migrate at whatever pace the editorial team can actually absorb. This is a genuinely different way of thinking about theme migrations, and what made this project workable.
Running two themes does mean two rendering paths for the same site, though. For this project, that wasn’t a problem on its own. Different templates were free to keep their own designs during the migration, and nobody was asking for visual parity across the board. The exception was the header. The client wanted it to look and behave the same on every page, regardless of which theme was rendering underneath.
I want to be clear about what “the same header” actually meant here: We didn’t build it twice (once in each theme) and try to keep two copies in sync. There’s one header, one codebase, one source of truth, rendered by both themes. That distinction drove the architectural decisions that followed.
This case study walks through how we got there, and what shipping the header as the first piece of the migration surfaced along the way.
The setup
The site is a long-running WordPress property. Heavy traffic, a long trail of templates and integrations, and an editorial workflow that’s part page builder and part classic editor. The current presentation layer is Elementor (Pro plus a page builder parent theme). The destination is Ollie, a full-site-editing block theme.
Running two themes side-by-side answers the bigger migration question. It also introduces the smaller, messier problems this post is mostly about: what happens at the seams between two themes.
The architecture decision
Three early decisions shaped everything that came after.
1. The header goes first
The header was the client’s one firm requirement. Whatever else happened during the migration, the header needed to look and behave the same everywhere.
That gave the project a clear starting point and a useful test case: could one shared piece of the interface work across both themes without being duplicated?
That was the real problem to solve. Not “can we keep the header in Elementor?” and not “can we recreate that header in Ollie?” The question was whether both themes could render the same header from the same source of truth.
With that answered, every template that came after had a baseline to build from.
2. The header lives in a plugin
The first version of the header was built in Ollie’s Site Editor. If you’re designing a header for a block theme, that’s the natural place to start.
But this header couldn’t belong to Ollie.
It had to render in Ollie and in the existing Elementor-based theme. If the header stayed inside Ollie, the Elementor side would need its own copy or some way to reach into Ollie and borrow it. Neither option was great. Two copies create drift. One theme reaching into another creates a dependency that’s likely to break later.
So the header moved into a plugin.
That gave both themes the same header without making either theme responsible for it. The work from the Site Editor still mattered. It became the visual and structural reference for what the plugin needed to render.
3. Split the content and code
A header has a design, but it also has content.
The layout, markup, spacing, responsive behavior, and supporting code belong in git. Those are structural decisions, and I wanted them reviewed and deployed like the rest of the project code.
The navigation items are different. Labels change. Links change. Editors need to manage them without a developer touching a file every time the menu changes. That content belongs in the WordPress admin, which means it lives in the database.
So the split looked like this:
- In git:
theme.json, template parts, pattern markup, the plugin code. - In the database: the nav items, links, and labels, exported from WordPress as WXR for the initial cutover.
That boundary mattered more than I expected. It kept the header reusable across both themes, but it also meant the deployment had two moving parts: the code and the database content.
Challenge 1: The header doesn’t render in a vacuum
One header, one codebase, both themes rendering it identically. That was the whole premise.
It works right up until a third plugin decides to render alongside you. 😅
In modern WordPress, block hooks let plugins extend block templates automatically, no theme edits required. It’s a useful feature when it’s the one you want. It’s a surprise when it isn’t.
For this project, WooCommerce was the surprise. Whenever WooCommerce is active, it injects its mini-cart and customer-account blocks into any header template part that contains a navigation block. From WooCommerce’s perspective, you have a header with a nav, so you must want shopping affordances next to it. By design, not a bug.
The header needed to render the same way under both themes, with no extra markup from anyone else. The fix was small: declare which hooked blocks the template part is opting out of. Quick to apply, but it took me a minute to figure out why it was happening.
The opt-out lives in the block’s metadata, applied per nav block in the header pattern:
<!-- wp:navigation {
"metadata": {
"ignoredHookedBlocks": [
"woocommerce/customer-account",
"woocommerce/mini-cart"
]
}
} /-->
The takeaway is broader than WooCommerce. Any plugin can register block hooks, and they fire automatically against templates that the theme, or a plugin like this one, has already shipped. If the goal is a header that renders identically across themes, the header has to declare what it owns and what it doesn’t. Otherwise any plugin with block hooks is silently part of your design system.
Challenge 2: Elementor and Ollie disagree about the cascade
This one was harder to pin down because there wasn’t an obvious bug.
The header markup was correct. The block-level style declarations should have worked under FSE. But when Elementor Pro was active, some of the expected CSS never showed up.
Nothing broke loudly. Nothing in the editor pointed to a problem. The styles just failed quietly, which is its own special kind of fun.
That kind of issue is expensive because the first instinct is to assume your own markup is wrong. So you rewrite. Then you inspect. Then you rewrite again. Eventually you realize the markup was fine, but two rendering systems were producing competing CSS for the same elements, and one of them was winning (ahem, Elementor kit styles).
The fix was to make the header’s CSS less dependent on either system.
Instead of relying on framework-generated styles for anything load-bearing, the header styles became more explicit and tightly scoped to the plugin. That way, the header didn’t depend on which system’s CSS loaded first or which set of generated rules happened to win the cascade.
There’s still one small issue in the shipped version: a font-swap shift on first paint. It’s minor enough to live with for now, and it should resolve as more of the pages move under FSE.
The bigger takeaway is the one worth planning for: two themes can coexist, but coexistence has a cost. It may be much smaller than the cost of a full rewrite or a content freeze, but it still shows up somewhere. In this case, it showed up as diagnostic time.
The next few challenges connect back to starting with the header. The header didn’t just need to render correctly. It needed to render correctly everywhere TSP could send a visitor.
Challenge 3: ThemeSwitcher routing doesn’t cover everything
I expected the TSP switchover to be straightforward. Configure the routes, assign the theme, and onward we go.
Not quite.
During QA testing, the first sign was visual: the header didn’t match on certain URLs, specifically: search results, author archives, and CPT archives. Same site, same header code, but the wrong theme was rendering underneath.
That’s when I had to look more closely at what TSP routes and what it doesn’t.

TSP does a solid job with the routes you configure. The easy thing to miss is that some public-facing WordPress views may still fall outside that configured surface, including:
- Author archives
- Date archives
- Search results
- Custom post type archives (aside from LearnDash and WooCommerce CPTs, which TSP can recognize)
Those aren’t bugs in TSP. They’re outside the routing rules the plugin is applying. But from a visitor’s perspective, the distinction doesn’t matter much. If search results render through a broken layout, that’s bad.
For this project, a small custom routing layer closed the gap. Those request types now route through the block theme alongside the URLs TSP already handles.
Challenge 4: A query string is enough to bypass ThemeSwitcher
The archive routing gap from Challenge 3 was the obvious kind of edge case caught in testing. The next one was the kind I didn’t see coming until traffic patterns surfaced it.
The home page started rendering under the wrong theme, but only sometimes. Direct visits looked correct. Links from the navigation looked correct. The misroute showed up on inbound traffic: people clicking through from email campaigns, social posts, and paid ads.
The common thread was the query string. UTM tags, Facebook’s fbclid, Google’s gclid. Anything that turned / into /?utm_source=… was enough to send the request to the wrong theme.
TSP’s front-page check compares the full current URL against get_home_url() as a string. If there’s a query string on the request, the comparison fails, the front-page routing rule never fires, and the request falls through to whichever theme WordPress considers active.
What made this feel like a bug rather than a configuration gap is that TSP already knows how to handle query strings elsewhere. Its post resolver explicitly strips the anchor and query string before matching. The front-page check just doesn’t do that step. Same plugin, two different functions, two different ideas about what “the URL” means.
The practical impact wasn’t small. Any inbound campaign link, by definition, carries a query string. That meant a meaningful share of the site’s most visible traffic was landing on a version of the home page the migration plan hadn’t reached yet. 😱
The workaround was a single path-only rule in the existing fallback mu-plugin (the same one that handles the archive routing from Challenge 3). The path matcher in that mu-plugin already runs through wp_parse_url(..., PHP_URL_PATH), so every querystring variant of / canonicalizes to the same path and routes through the intended theme.
The relevant entry in the fallback mu-plugin’s pattern table:
private const PATH_PATTERNS = array(
'home' => '#^/$#',
'author' => '#^/author(/|$)#',
// …other fallthrough patterns
);
When the home page eventually migrates to the block theme, this rule has to come out, or it’ll silently undo the cutover.
Challenge 5: Two plugins, two routing decisions, one request
Once the header was rendering correctly across the public-facing URLs, we started migrating individual templates underneath it.
That’s when Elementor Theme Builder made its presence known.
TSP makes its theme decision early in the request lifecycle, before WordPress has fully parsed the query. That’s the right place for it to happen. By the time WordPress is ready to load a template, the active theme has already been resolved.
Elementor Pro’s Theme Builder works at a different layer. If the site has Elementor location templates, like a global Single Post layout, Archive template, or 404 template, Elementor decides later in the request whether one of those templates should apply. It does that through template_include.
So the theme decision was initially correct, but the final template decision could still change.

The visible symptom looked a lot like the routing issue from Challenge 3: a URL assigned to the block theme rendered with an Elementor template instead. TSP had routed the request correctly. Elementor’s template filter just ran later and replaced the output.
The fix was to detect when TSP had routed a request to the block theme, then remove Elementor’s template_include callback before it ran.
This was the same kind of coexistence problem, just one layer deeper. TSP and Elementor were both doing what they were built to do. They just weren’t aware of each other. In a two-theme setup, that coordination has to happen somewhere, and in this case, it happened in a custom mu-plugin.
The load-bearing logic, hooked on template_redirect — the window between TSP picking the theme and Elementor’s filter firing:
add_action( 'template_redirect', function () {
if ( 'ollie-child' !== get_stylesheet() ) {
return;
}
$module = \ElementorPro\Modules\ThemeBuilder\Module::instance();
remove_filter(
'template_include',
array( $module->get_locations_manager(), 'template_include' ),
11 // Elementor registers at priority 11
);
} );
What shipped
One header. One codebase. Two themes rendering it correctly in production. 👏
Editors manage nav items in the WordPress admin, where they expect to find them. Developers manage layout and markup in git, where those decisions can be reviewed and deployed.
The bigger value wasn’t just the header. It was what the header made possible:
- The migration paces to the business. Templates move one at a time, on release schedules that match marketing calendars and content priorities, not a forced “everything ships at once” date.
- No content freeze. Editors keep publishing, campaigns keep running, and the migration happens around them rather than blocking them.
- The hard problem is solved once. Every subsequent template that moves to FSE inherits the universal header for free.
- Rollback stays cheap. Because the header is theme-agnostic and TSP routing is per-URL, any single template migration that goes sideways can be reverted without touching the rest of the site.
That last point is the one I’d underline.
Most of the risk in a legacy migration isn’t in the individual templates. It’s in the coupling between them.
A migrate-everything-at-once theme switch ties every template’s success to every other template’s success. One bad template can hold up the entire release. This pattern decouples them. Each template can stand on its own, while the universal header keeps the site coherent as they move one by one.
The takeaway
For a mature WordPress site, “page builder or FSE” isn’t always the most useful framing. The better question is how to move forward incrementally, without committing to a single-cutover rebuild.
TSP makes running two themes on one site a workable option. A universal header is what made that option production-ready for this project.
If you have a similar project, you don’t necessarily have to start with the header, but I would suggest starting with something that gives you a lot of signal without putting the whole site at risk if things get weird.
Look for something that is:
- Visible enough to surface problems quickly, so regressions show up while the team still has the context to fix them.
- Bounded enough that a rollback is one switch, not a cascade. If the chosen piece breaks, the rest of the site keeps working independently.
- Structurally load-bearing enough that solving it forces the architectural decisions every later migration will lean on — cross-theme rendering, content/code split, plugin coexistence.
For this project, that was the header. For another project, it might be a footer, a global notification bar, or the search experience.
The playbook is the same: ship the shared layer first, migrate templates afterward, and account for the coexistence cost from the start.
This approach is not right for every project. For a small site, it’s overkill. For a fresh build, it’s irrelevant. But for long-running WordPress properties with real traffic, active editorial workflows, and integrations that can’t be disrupted, this is a practical path forward.
If you’ve been part of a “we need to get to FSE eventually” conversation that never quite turns into an actual plan, this might be an approach that makes it doable. And if you’re trying to figure out what that could look like for your site, feel free to get in touch.
*Featured image photo by Isabella Fischer on Unsplash

Leave a Reply