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.
Try the live demo | View source on GitHub
- The Problem
- What I Built
- Architecture Goals
- Design Decisions
- Performance: Page Reloads vs Client-Side Routing
- Production Readiness
- The Honest Truth
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 shell built on a simple premise: what if we just used the browser's native module system?
It's less a framework, more a way to orchestrate multiple apps inside a shared user interface. Think browser extensions or VSCode plugins - the shell provides the infrastructure, apps plug in and focus on business logic.
No webpack plugins. No runtime magic. No framework lock-in. Just:
- A vanilla TypeScript shell (21 KB) that provides services
- Independent plugin 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 shell 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 the shell doesn't care what you use - React, Vue, Svelte, or vanilla JavaScript.
Try it yourself:
- Live Demo - See how apps interact with the shell's services
- GitHub Repository - Full source code and documentation
The demo showcases the whole point: apps don't import libraries or manage infrastructure. They just call window.shell.* APIs. The shell handles navigation, HTTP calls, feedback, and UI components. Apps focus purely on business logic - like browser extensions or VSCode plugins.
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 is the whole point. It's your application's service layer - the infrastructure that apps plug into. Apps don't worry about navigation, HTTP calls, error handling, or UI feedback. They focus purely on business logic.
Think browser extensions. Think VSCode plugins. The shell provides the APIs, apps consume them.
The shell is responsible for:
- Navigation menu - Rendered once, shared across all apps, updated at runtime via JSON
- Feedback system - Toasts, loading overlays, confirmation dialogs
- Page layout - Consistent header, sidebar, content area
- HTTP client - Centralized API calls with automatic error handling, loading states, authentication
- Shoelace components - Loaded once, available everywhere
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. Change the JSON and it immediately reflects across all apps at runtime - no rebuild or redeployment needed. Add a new app? Just add a JSON entry. Reorder menu items? Edit the JSON. The menu updates everywhere instantly.
This is the shell's job: provide infrastructure so apps don't have to.
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. No imports. No state management. The shell does the work.
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. Apps just call simple APIs and the shell does the work.
Check out the demo and SaaS example - notice how apps use shell services without importing anything. No libraries. No state management. Just window.shell.* calls.
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. The shell does the heavy lifting.
21 KB shell. Loads once, cached forever. Every app benefits. That's the whole point.
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. The shell loads it, apps use it. Simple.
Key Features
Here's what makes the shell concept work:
Framework Agnostic: Use React, Vue, Svelte, or vanilla JavaScript - the shell doesn't care. Apps are plugins.
Independent Deployment: Deploy one app without touching the other 79. No coordination. No risk.
Zero Configuration Deployment: No URL rewrite rules (between apps). No CloudFront complexity. Upload files and it works.
Build-time i18n: Zero runtime overhead for translations. Compile once per language.
True Isolation: Page reloads provide automatic CSS and JavaScript isolation. No Shadow DOM needed.
Shared Infrastructure: Navigation menu (updated at runtime via JSON), HTTP client, feedback system - all handled by the shell. Apps just consume the APIs.
Real Page Navigation
No React Router in the shell. Navigation between apps = page reload.
But wait - can I still use React Router inside my app? Absolutely! Each app can use React Router (or any routing library) for its own internal routes. The page reload only happens when navigating between different apps. Within an app, use whatever routing you want. (You'll need to configure URL rewrites on your host as usual for client-side routing to work.)
"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 NOT production ready, and that's not the point.
This is an exploration of the shell concept - a way to orchestrate multiple apps inside a shared user interface. Think VSCode's relationship to extensions, or how browsers handle plugins. The shell provides stable APIs and infrastructure, apps plug in and focus on business logic.
The Shell Concept
The shell is a service layer that apps consume. It provides:
- Navigation infrastructure
- HTTP client with error handling
- User feedback system (toasts, dialogs, loading states)
- Shared component library (Shoelace)
- Page layout and routing
Apps are simple because the shell handles the infrastructure. They just call window.shell.* APIs.
Could you build this out for production? Absolutely. The shell is just TypeScript - you can add whatever you need:
- Cross-app communication (event bus, shared state)
- Advanced error handling and monitoring
- Authentication and authorization
- Feature flags
- Analytics
- Performance monitoring
- Whatever your apps need
The point is exploring whether this architecture pattern - orchestrating multiple apps through a shared shell - is viable. Spoiler: it is.
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 (between apps). 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?
No. This is a proof of concept to explore the shell architecture pattern.
For a learning exercise? Absolutely. It demonstrates that micro-frontends don't need to be complex.
For a real project? You'd need to build out the shell with proper error handling, monitoring, testing, and whatever features your apps need. But the core architecture is sound.
For an enterprise? The principles are solid and could inform your architecture decisions. The shell concept scales - you just need to invest in building it out.
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. A simple shell orchestrating independent apps can work. The shell does the infrastructure work, apps focus on business logic.
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.
Architectural Tradeoffs
What you gain:
- Simplicity (no webpack hell)
- Deployment simplicity (no URL rewrites between apps)
- Framework flexibility (different React versions)
- Automatic isolation (page reloads)
- Build-time i18n (zero runtime cost)
- Extensible shell (add features as needed)
- Shared services (navigation, HTTP, feedback)
What you give up (between apps):
- 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 exploring the shell concept. Your mileage may vary.
Try It Yourself
Live Demo - See the framework in action
GitHub Repository - Clone it, break it, make it your own
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! 🤘
