Building Trailhead - A Rainy Day Micro-Frontend Experiment
It was a rainy Sunday and I had an itch. What would a super simple, barebones micro-frontend framework look like? No webpack magic, no complex tooling, just the browser's native module system and some common sense.
A day later, I had Trailhead - a proof of concept that's surprisingly close to production-ready. This isn't a replacement for Single-SPA or Module Federation. But it's not completely crazy either.
- The Problem
- What I Built
- Architecture Goals
- Design Decisions
- Performance: Page Reloads vs Client-Side Routing
- Deployment on AWS
- The React Exit Strategy
- Production Readiness
- At Scale: 80+ Modules
The Problem
You're building a SaaS application. It starts small - a few features, one team, manageable. Fast forward a couple of years: 80+ modules, 5+ teams, and your React SPA has become a deployment nightmare.
Every change requires rebuilding the entire application. One team's bug breaks another team's feature. Deployment takes forever and you're terrified every time. Your bundle size is measured in megabytes. CloudFront configuration is a maze of URL rewrite rules that nobody understands.
Sound familiar?
The promise of micro-frontends is appealing: independent deployment, team autonomy, technology flexibility. But the implementations? Webpack Module Federation feels like black magic. Shared React runtimes via CDN? Good luck debugging CORS errors at 2 AM.
There has to be a simpler way.
What I Built
Trailhead is a micro-frontend framework built on a simple premise: what if we just used the browser's native module system?
No webpack plugins. No runtime magic. No framework lock-in. Just:
- A vanilla TypeScript shell (21 KB)
- Independent apps (use React, Svelte, Vue, or vanilla JS)
- Web components for shared UI (Shoelace)
- Build-time i18n (zero runtime overhead)
- Real page navigation (no URL rewrite rules)
The result? A framework that's simple to understand, trivial to deploy, and scales to 80+ modules without breaking a sweat.
I used React for the demo apps because it's popular and demonstrates the "framework migration" problem. But Trailhead works with any framework - or no framework at all.
Project structure:
trailhead/
├── core/
│ ├── shell/ # Vanilla TypeScript shell
│ └── contracts/ # Shell API contracts
├── apps/
│ ├── demo/ # Example app
│ └── saas-demo/ # SaaS example
└── tools/
├── vite-i18n-plugin/ # Build-time i18n
└── preview-server/ # Local preview
Architecture Goals
When I started this proof of concept, I had clear goals:
1. True Isolation
Each app is completely independent. Different React versions? No problem. One app on React 19, another still on React 18? Works fine. Want to try Vue in one app? Svelte? Vanilla JavaScript? Go ahead.
Trailhead is framework agnostic. The shell doesn't care what you use to build your apps.
This isn't just theoretical flexibility - it's a migration strategy. You can upgrade frameworks module by module, testing each one independently. No big bang migrations. Or start with no framework and add one later.
2. Independent Deployment
Deploy one app without touching the other 79. No coordination. No waiting. No risk of breaking unrelated features.
# Deploy just the customer module
cd modules/customer
npm run build
aws s3 sync dist/ s3://app/modules/customer/
# Done. Other modules unaffected.
3. Simple Deployment
No URL rewrite rules. No CloudFront error page configuration. No Apache mod_rewrite. Just upload files to S3 and it works.
Why? Because I use real page navigation. Each route is an actual page load. The browser requests /customers, gets index.html, which loads the shell, which loads the customer app.
Simple. Reliable. Works everywhere.
4. Reasonable Bundle Sizes
Yes, each react app re-bundles React (~67 KB gzipped). But you save more than that by:
- Caching Shoelace once (1.2 MB, shared across all apps)
- No massive CSS bundle (each app has minimal CSS)
- No state management library
- Excellent browser caching
Users download only what they use. Visit 5 modules? Download 5 × 74 KB = 370 KB. Compare that to a typical SPA's 2-6 MB initial bundle.
5. Team Autonomy
Each team owns their module. Independent repos (or monorepo with independent builds). No merge conflicts. No coordination. Clear boundaries.
Design Decisions
The Shell: Your App's Service Layer
The shell is vanilla TypeScript. No React. Why?
Because the shell provides the common infrastructure: page layout, navigation menu, and services. This is the key insight: apps don't need to worry about these things.
Navigation: The shell manages the menu and routing. Apps just declare their routes in a JSON file:
{
"id": "customers",
"path": "/customers",
"app": "customers",
"icon": "people",
"label": "Customers"
}
The shell builds the navigation menu automatically. Add a new app? Just add a JSON entry. No code changes.
HTTP Client: Apps call window.shell.http.get() instead of fetch():
// In your app
const result = await window.shell.http.get('/api/users');
if (result.success) {
setUsers(result.data);
} else {
// Shell already showed error toast to user
}
The shell handles:
- Loading indicators (automatic busy overlay)
- Error handling (shows error toasts)
- Authentication (adds tokens automatically)
- Retries and timeouts
Apps just get clean success/error responses.
User Feedback: Apps call simple feedback methods:
// Show success toast
window.shell.feedback.success('User saved!');
// Show error
window.shell.feedback.error('Failed to save');
// Show loading overlay
window.shell.feedback.busy('Loading...');
window.shell.feedback.clear();
// Show confirmation dialog
const confirmed = await window.shell.feedback.confirm('Delete this user?');
if (confirmed) {
// delete user
}
No need to import toast libraries, build modal components, or manage loading states. The shell handles it all consistently.
Shoelace Components: The shell loads Shoelace once. All apps get 50+ web components for free:
// In any app - no imports needed
<sl-button variant="primary">Save</sl-button>
<sl-input label="Name" />
<sl-dialog label="Edit User">...</sl-dialog>
The result? Apps are incredibly simple. They focus on business logic, not infrastructure.
21 KB shell. Loads once, cached forever. Every app benefits.
Apps duplicate framework code. (React)
This was the controversial decision. "But you're duplicating React!" Yes. 67 KB per app.
Here's why it's worth it:
Simplicity: No CDN configuration. No CORS debugging. No "why is React undefined?" at runtime. It just works.
Flexibility: Different apps can use different React versions. Gradual upgrades. No forced migrations.
Isolation: Apps truly can't interfere with each other. No shared runtime state. No version conflicts.
Reality Check: Modern React SPAs bundle 2-6 MB. I'm bundling 74 KB per app. Users visit 5-10 modules typically. That's 370-740 KB total. Still smaller than most SPAs.
Build-Time i18n
No i18next. No Lingui. No runtime overhead.
Instead, I use a simple Vite plugin that does string replacement at build time:
// In your code
<button>{t("Save")}</button>
// English build → "Save"
// German build → "Speichern"
Zero runtime cost. Separate bundle per language. Simple extraction workflow.
Can't switch language at runtime? Correct. But for most SaaS apps, users pick a language once. The tradeoff is worth it.
Web Components for Shared UI
Shoelace provides 50+ web components. The shell loads it once. All apps use it. No imports needed.
// In any app - no imports!
<sl-button variant="primary">
{t("Save")}
</sl-button>
Web components work across any framework version. Customer app on React 19? Sales app on Svelte? Same components work in both.
This is your shared component library without the coordination nightmare between tech stacks/app frameworks.
Real Page Navigation
No React Router in the shell. Navigation = page reload.
"But that's slow!" Actually, no. With proper caching:
- Shell: cached
- Shoelace: cached
- Previous app: cached
- New app: 74 KB download
Modern browsers make this fast. And you get:
- No URL rewrite rules needed
- Works on any static file server
- Simple CloudFront configuration
- Browser back/forward just works
Performance: Page Reloads vs Client-Side Routing
The big question: "Isn't page reload slower than client-side routing?"
Let's look at the actual numbers.
The Timing Breakdown
Trailhead (Page Reload):
1. Browser requests /sales
2. Server returns index.html (cached) ~0ms (304 Not Modified)
3. Browser loads shell.js (cached) ~0ms (from disk cache)
4. Browser loads shoelace (cached) ~0ms (from disk cache)
5. Browser loads sales/app.js (cached) ~0ms (from disk cache)
6. React hydrates ~50-100ms
7. App renders ~20-50ms
Total: 70-150ms
Traditional SPA (Client-Side Routing):
1. Click link
2. React Router updates URL ~5ms
3. Unmount old app ~10-20ms
4. Mount new app ~30-50ms
5. App renders ~20-50ms
Total: 65-125ms
The difference: 5-25ms
Real-World Performance
| Network | Trailhead (Reload) | SPA (Client-Side) | Difference |
|---|---|---|---|
| Local network (1 Gbps) | 75ms | 70ms | +5ms |
| High-speed (100 Mbps) | 85ms | 70ms | +15ms |
| Medium (50 Mbps) | 120ms | 70ms | +50ms |
| Slow (25 Mbps) | 200ms | 70ms | +130ms |
| Shitty (5 Mbps) | 800ms | 70ms | +730ms |
On fast connections, humans can't perceive the difference.
On slow connections, page reloads are noticeably slower. If your users are on slow internet, client-side routing wins. But with proper caching (after first load), even slow connections only download what changed (~74 KB per app).
What Users Actually Notice
Trailhead advantages:
- ✅ App doesn't slow down after hours of use (no memory leaks)
- ✅ Consistent performance all day (fresh every time)
- ✅ No "app is slow, need to refresh" complaints
SPA advantages:
- ✅ Fancy route transitions (fade, slide)
- ✅ Preserves scroll position
- ✅ 15ms faster (imperceptible)
The Caching Advantage
First visit to any app:
Download: Shell (21 KB) + Shoelace (1.2 MB) + App (74 KB)
Total: ~1.3 MB
Every subsequent navigation:
Download: 0 KB (everything cached)
Time: 70-150ms (perceived as instant)
Browser caching is incredibly effective because:
- Each app is a separate file
- Cache headers are simple
- No cache invalidation complexity
- Works offline after first load
CSS and JavaScript Isolation: Free!
Here's the kicker: page reloads give you isolation for free.
Traditional micro-frontends need:
- Shadow DOM for CSS isolation
- JavaScript sandboxing
- Complex cleanup logic
- Memory leak prevention
Trailhead gets this automatically:
- Page reload = DOM completely cleared
- Previous app's CSS removed
- Previous app's JavaScript cleared
- Fresh
windowobject - Zero conflicts possible
Interesting side effect: The hardest problems in micro-frontends are solved by the browser.
Production Readiness
Let's be honest: this is a proof of concept. But it's surprisingly close to production-ready.
What Makes It Interesting
Simplicity: No webpack configuration hell. No complex build pipelines. Just Vite and esbuild.
Build-time i18n: Zero runtime overhead. Translations are compiled into the bundle. No loading translation files at runtime.
Deployment simplicity: No URL rewrite rules. No CloudFront error page configuration. Upload files and it works.
Framework flexibility: Different React versions per app. Gradual migrations. No forced upgrades.
Automatic isolation: Page reloads clear CSS and JavaScript. No Shadow DOM needed. No memory leaks.
Shared infrastructure: Navigation, HTTP, feedback all handled by the shell. Apps stay simple.
Is It Production Ready?
For a learning exercise? Absolutely. It demonstrates that micro-frontends don't need to be complex.
For a real project? Maybe. You'd want to add error handling, monitoring, and proper testing. But the core architecture is sound.
For an enterprise? Probably not as-is. But the principles are solid and could inform your architecture decisions.
Architectural Tradeoffs
What you gain:
- Simplicity (no webpack hell)
- Deployment simplicity (no URL rewrites)
- Framework flexibility (different React versions)
- Automatic isolation (page reloads)
- Build-time i18n (zero runtime cost)
- Shared services (navigation, HTTP, feedback)
What you give up:
- Fancy route transitions (fade, slide)
- Scroll position preservation
- 15ms faster navigation (imperceptible)
- Shared React runtime (saves ~67 KB per app)
These tradeoffs made sense for this experiment. Your mileage may vary.
Deployment on AWS
This is where Trailhead shines. Deployment is trivially simple.
S3 + CloudFront Setup
# Create S3 bucket
aws s3 mb s3://your-app
# Upload everything
aws s3 sync dist/ s3://your-app/
# Create CloudFront distribution
aws cloudfront create-distribution \
--origin-domain-name your-app.s3.amazonaws.com \
--default-root-object index.html
That's it. No URL rewrite rules. No error page configuration. No Lambda@Edge functions.
The React Exit Strategy
Here's something nobody talks about: what if React isn't the future?
Maybe it's Svelte. Maybe it's Solid. Maybe it's something that doesn't exist yet.
But here's the thing: Trailhead doesn't require React at all. The shell is vanilla TypeScript. Apps can be built with any framework - or no framework.
I used React for the demo because:
- It's popular (most developers know it)
- It demonstrates the "exit strategy" problem
- It shows how you'd migrate away from React if needed
But you could build your entire application with Svelte, Vue, Solid, or vanilla JavaScript. The shell doesn't care.
Any framework. No framework. Your choice.
The Shell Doesn't Care
The shell loads apps via window.AppMount(). It doesn't care what framework you use:
// React app - this works for sure.
window.AppMount = (container) => {
const root = ReactDOM.createRoot(container);
root.render(<App />);
return { unmount: () => root.unmount() };
};
// Svelte app - mock example no actual Svelte code no idea if this works for real.
window.AppMount = (container) => {
const app = new App({ target: container });
return { unmount: () => app.$destroy() };
};
Framework agnostic by design.
At Scale: 80+ Modules
This is where the architecture really pays off.
Bundle Size Reality Check
Typical user session:
- Visits 5-10 apps
- Downloads: Shell (21 KB) + Shoelace (1.2 MB) + 5-10 apps (370-740 KB)
- Total: 1.6-2 MB
Comparable React SPA:
- Initial bundle: 2-6 MB
- Code splitting helps, but shared dependencies add up
Trailhead is competitive even with "wasteful" React duplication.
The Honest Truth
This was a rainy day experiment. A "what if we kept it simple?" exercise.
Trailhead isn't a replacement for Single-SPA or Module Federation. Those are mature, battle-tested frameworks with rich ecosystems.
But Trailhead demonstrates something important: micro-frontends don't have to be complex.
What I Learned
Simplicity wins: Native browser features (ESM, caching, page navigation) solve most problems.
Page reloads aren't slow: With proper caching, they're imperceptible. And they give you isolation for free.
Bundling React per app is fine: 67 KB per app is negligible compared to the simplicity gained.
Build-time i18n is underrated: Zero runtime overhead. No loading translation files. Just works.
Deployment simplicity matters: No URL rewrite rules means it works everywhere. S3, Netlify, Vercel, your own server.
The Tradeoffs
What you gain:
- Simplicity (no webpack hell)
- Deployment simplicity (no URL rewrites)
- Framework flexibility (different React versions)
- Automatic isolation (page reloads)
- Build-time i18n (zero runtime cost)
- Team autonomy (independent deployment)
What you give up:
- Fancy route transitions (fade, slide)
- Scroll position preservation
- 15ms faster navigation (imperceptible)
- Shared React runtime (saves ~67 KB per app)
- Mature ecosystem (tooling, plugins, community)
For a learning exercise? These tradeoffs are perfect.
For a startup with 5-20 apps? These tradeoffs are probably worth it.
For an enterprise with 80+ apps? You'll need more than a rainy day project, but the principles hold.
Try It Yourself
This was a fun experiment. Maybe it'll inspire you to question the complexity in your own architecture.
Sometimes the simplest solution is the best solution.
🍺 Happy Coding! 🤘
