into one IIFE script and one CSS file, both with content-hashed filenames. A manifest.json maps logical names to hashed URLs.
-
Preact over React - ~10 kb gzipped vs ~45 kb. Non-negotiable at a 15-20 kb budget.
-
IIFE over ES modules - works in Magento's RequireJS environment without extra config, and in any
<script> tag on any stack.
-
cssCodeSplit: false - one file, one request, no FOUC.
-
Tailwind with a prefix - scoped classes, no collision with host CSS.
-
Content-hashed URLs via manifest - immutable caching. Hosts read the manifest at render time and emit
<link href="/nav/shared-nav.abc123.css">.
pnpm build takes ~8 seconds. Hosts pick up new hashes within their cache TTL. One bugfix lands on both sites in ~1 minute.
Host integration: Magento 2.4
Around 120 lines of new PHP, three files:
-
Acme\Theme\Model\SharedNavManifest (~85 lines) - HTTP-fetches the manifest with Magento's cache backend, falls back to a non-hashed shared-nav.iife.js on fetch failure so the nav never disappears, only loses cache-busting.
-
Acme\Theme\ViewModel\SharedNavAssets (~26 lines) - the ViewModel that phtml templates talk to. CSS goes through a ViewModel rather than static <css> layout XML because the URL has a hash in it.
-
Acme\Theme\etc\frontend\di.xml (~7 lines) - wires the manifest URL through deploy config.
Two phtml partials - header and footer - emit the mount divs and asset tags. Included from default.xml, so every page type inherits the shared nav.
Host integration: Laravel 11
Around 90 lines. Smaller because the service container carries more weight.
-
App\Services\SharedNavManifest (~65 lines) - HTTP-fetches the manifest, caches via Cache::remember('shared_nav.manifest', 60, ...), logs and falls back to the unhashed bundle on fetch failure.
-
config/services.php - three lines exposing services.shared_nav.manifest_url as env-driven config.
-
Two Blade layouts - public and a secondary layout for older marketing pages - emit
<link> and <script> tags from the manifest service.
The 60-second cache TTL controls how fast a pnpm build propagates - aggressive enough for release cadence, conservative enough that manifest fetches are one request per minute per worker.
Representative code shape (abridged)
The full production classes are client code, so I am not publishing them verbatim here. But the integration should not stay abstract either. This is the shape of the two host adapters - abridged to show the contract rather than every guardrail and framework detail.
A note on the manifest keys: Vite indexes manifest.json by entry source path and asset name - src/main.tsx and style.css in our build - not by the output filename. The host lookups use those keys; the unhashed filenames (shared-nav.iife.js, shared-nav.css) are only used as fallbacks when the manifest fetch fails.
Magento 2.4 - manifest service shape:
class SharedNavManifest
{
public function getJsUrl(): string
{
return '/nav/' . ($this->manifest()['src/main.tsx']['file'] ?? $this->fallbackJs);
}
public function getCssUrl(): string
{
return '/nav/' . ($this->manifest()['style.css']['file'] ?? $this->fallbackCss);
}
// SSR fallback: fetched once from the shell, cached in Magento's cache
// backend, and inlined into the mount div at render time.
public function getHeaderHtml(): string
{
return $this->snapshotHtml('header.html');
}
public function getFooterHtml(): string
{
return $this->snapshotHtml('footer.html');
}
private function manifest(): array
{
// 1) read cached manifest
// 2) on miss, fetch remote manifest URL
// 3) cache parsed JSON
// 4) on failure, log and fall back to unhashed asset names
}
private function snapshotHtml(string $key): string
{
// 1) read cached snapshot for $key
// 2) on miss, fetch rendered HTML from the shell (e.g. /nav/header.html)
// 3) cache body with a short TTL
// 4) on failure, return '' so the shell still hydrates later
}
}
Laravel 11 - manifest service shape:
class SharedNavManifest
{
public function manifest(): array
{
return Cache::remember('shared_nav.manifest', 60, function () {
// GET config('services.shared_nav.manifest_url'), parse JSON.
// On failure, log and return [] so fallback filenames kick in.
});
}
public function jsUrl(): string
{
return '/nav/' . ($this->manifest()['src/main.tsx']['file'] ?? 'shared-nav.iife.js');
}
public function cssUrl(): string
{
return '/nav/' . ($this->manifest()['style.css']['file'] ?? 'shared-nav.css');
}
public function headerHtml(): string
{
return $this->snapshotHtml('header.html');
}
public function footerHtml(): string
{
return $this->snapshotHtml('footer.html');
}
private function snapshotHtml(string $key): string
{
return Cache::remember("shared_nav.snapshot:{$key}", 60, function () use ($key) {
// fetch rendered HTML from the shell (e.g. /nav/header.html)
// return '' on failure so the shell still hydrates later
});
}
}
That is why the line counts matter. The host code is small enough to be reviewable, boring enough to be supportable, and explicit enough that another PHP team can own it without learning a front-end platform first.
The host <-> shell contract
The two sides agree on a tiny surface:
Host provides:
<link rel="stylesheet" href="{{ $nav->cssUrl() }}">
<div id="sa-header" style="min-height: 80px;">{!! $nav->headerHtml() !!}</div>
<!-- page body -->
<div id="sa-footer">{!! $nav->footerHtml() !!}</div>
<script defer src="{{ $nav->jsUrl() }}"></script>
Shell provides:
- A
nav-fallback.html emitted at build time, split into header and footer snippets the host inlines into the mount divs (the SSR fallback).
- Client-side mount into
#sa-header and #sa-footer that replaces the SSR snapshot with the interactive tree (dropdowns, mobile menu, state).
- One CSS file, one JS file, no global pollution (IIFE scope).
- No knowledge of Magento or Laravel. No runtime config, no feature flags.
Everything else - routing, authentication, cart state, checkout - stays on the host. The nav does not know the host exists. The host does not know the nav is Preact. That is the whole integration.
The min-height: 80px on the header mount is anti-CLS insurance - the slot reserves its space before hydration, so Core Web Vitals do not punish the deferred render.
The SEO question, answered honestly
This is the part every microfrontend post skips or hand-waves. I will not.
Also, this section is intentionally based on observable crawler facts, not modelled SEO metrics. I am not claiming a ranking uplift from a synthetic score. I am showing what a crawler can and cannot see in the initial HTML before and after the fallback ships.
Without a fallback, initial HTML is two empty divs:
<div id="sa-header" style="min-height: 80px;"></div>
<div id="sa-footer"></div>
Googlebot renders JavaScript (eventually) and sees the nav - with a delay measured in days. But GPTBot, ClaudeBot, and PerplexityBot do not render JavaScript. They see the empty divs. As far as AI search is concerned, the site has no nav.
I measured this before shipping the SSR fallback. Three pages, five user-agents, identical curl invocations. Same URLs, same crawl method, same parsing rule - only the fallback changed.
Before SSR fallback:
| Metric |
Homepage |
/about |
/portfolio |
| Bytes |
35,050 |
35,050 |
97,521 |
<a href> total |
12 |
12 |
12 |
| Anchors from nav |
0 |
0 |
0 |
Twelve anchors per page, none of them structural. Every page - no matter how deep - exposed the same twelve inline body links to a non-rendering crawler. Sitemap.xml covered URL discovery, but not the four things nav does beyond discovery:
-
Link equity - a multi-level nav is hundreds of internal links per page pointing at categories. Without it, category pages lose authority.
-
Crawl budget - Googlebot prioritises pages by incoming-link density. Sitemap-only pages get crawled less often.
-
Topic hierarchy - sitemap is flat. Nav signals semantic structure ("Shop -> Men -> Shoes").
-
AI assistant context - ChatGPT and Perplexity build mental models from HTML, often ignoring sitemaps. Without nav in HTML, AI knows your URLs but not your structure.
The three-level mitigation ladder:
-
<noscript> fallback with critical links inside the mount div (hours of work).
-
SSR skeleton - Vite emits a
nav-fallback.html at build time; hosts inline it into the mount divs before hydration replaces it (a day or two).
-
Full SSR service - a Node process renders each nav request server-side (a week, plus a new production dependency).
Level 2 is the sweet spot for an ecommerce group this size. We shipped it before the first production release. Same curl invocations, four days later:
After SSR fallback:
| Metric |
Homepage |
/about |
/portfolio |
| Bytes |
98,881 |
98,881 |
161,348 |
<a href> total |
112 |
112 |
112 |
Anchors from nav (#sa-header) |
31 |
31 |
31 |
Anchors from footer (#sa-footer) |
69 |
69 |
69 |
All five user-agents received byte-identical HTML (the only per-request variance is the Laravel CSRF meta token). The nav and footer tree are in initial HTML - 100 additional anchors per page, constant across every page, visible to every crawler that can parse HTML.
That matters methodologically. A crawler can disagree with my interpretation of the SEO impact, but it cannot disagree with 35,050 -> 98,881 bytes or 12 -> 112 anchors under the same crawl conditions. This is a reusable audit method, not a one-off anecdote.
The gap closed on release day. No retroactive GSC panic, no "we measured a drop and here's how we fixed it" narrative. The honest framing is "we knew the risk, we closed it before shipping".
What this article proves today - and what it does not yet
This article proves three things:
- The integration pattern is real on two production-grade PHP stacks.
- The SEO risk is real if the shell ships with empty mount points only.
- A Level 2 fallback closes that crawler-visibility gap on day one.
What it does not prove yet is a 90-day business outcome story. I do not have a "three months later, here are CrUX and GSC deltas" chart in this draft, because that would require waiting for the post-release window to mature. I would rather publish the implementation pattern and the crawler evidence honestly than pretend I have impact numbers I do not have yet.
That makes this a build-and-ship case study, not a finished growth narrative. When post-launch search-console and field-performance data are mature enough to be worth showing, they belong in a follow-up article.
What this pattern does not solve
Not overselling: the shared nav is the minimum viable shared surface - that is its strength and its ceiling.
-
Primary page content still diverges. Magento renders products; Laravel renders marketing copy.
-
Shared checkout - not solved. Checkout lives in Magento; marketing links into it via cookies on a common parent domain.
-
Shared authentication - not solved. Cookies, redirects, OAuth handshakes - all host-specific.
-
Shared search - could be built on top of the shell, but we did not. Search UX is coupled to Magento-native catalogue data.
A shared nav is not a distributed-front-end strategy. It is a band-aid across a healed fracture. If you need a distributed front end, you need a different architecture.
When this pattern fits
Short checklist. If you check fewer than three boxes, do something else.
- You have two or more existing stacks with established teams you cannot realistically move.
- There is no budget or appetite for a full front-end unification right now.
- The primary pain is UX inconsistency, not performance or architectural debt.
- Nobody on the executive side is willing to own a "unified portal" programme.
- You need to be AI-agent ready - which means the nav must be in initial HTML, not only after JS runs.
If all five apply, the pattern pays for itself in weeks, not quarters.
What's next
The same shell is about to land on two more stacks in the same group - a greenfield Magento storefront rewrite and a full Laravel marketing rewrite. Both will consume the existing manifest.json unchanged. Zero additional shell work, the same integration footprint per host. That is the portability proof.
If the pattern looks like it might fit your stack, the interesting conversation is not "how do I build a shell" - Vite's library mode docs will get you there in an afternoon. The interesting conversation is the SEO contract and shipping Level 2 on day one instead of retrofitting it after Google Search Console punishes you.
For a mid-market team, that is usually the real decision framework:
-
Level 1:
<noscript> links when the risk window is small and the nav is shallow.
-
Level 2: build-time SSR fallback when you need full crawler-visible structure without adding a Node service.
-
Level 3: full SSR service when the nav is dynamic enough that static fallback HTML becomes a maintenance problem.
That is where outside perspective usually helps, and where I spend most of my consulting time.