Frontend Architecture for Large SaaS: Monorepos, Micro-Frontends & Module Federation
Learn how to architect frontend systems for enterprise SaaS. Covers monorepo strategies, micro-frontend patterns, Module Federation, state management at scale, and team autonomy.
The Scaling Problem
Our SaaS application started as a single Vue.js codebase. Three years later, 40 engineers across 8 teams were stepping on each other's toes:
- Git conflicts — 20+ per day from different teams modifying the same files
- Deployment bottlenecks — one bug in team A's feature blocked team B's release
- Code ownership unclear — who owns the form validation layer? Three people claim they do
- Bundle bloat — features for team A's customers were loading for team B's customers
- Development friction — new team members took 4 weeks to understand the codebase
Something had to change.
Option 1: Monorepo (What We Chose)
We chose a monorepo structure with pnpm workspaces:
apps/
├── dashboard/ # Main SaaS dashboard
├── admin-panel/ # Admin-only features
├── billing/ # Standalone billing app
└── docs/ # Internal & external docs
packages/
├── design-system/ # Shared components
├── api-client/ # Shared API layer
├── shared-utils/ # Common utilities
├── shared-state/ # Pinia stores
└── icons/ # SVG icons library
Monorepo Benefits
- Single source of truth for shared code
- Atomic commits — can update multiple packages together
- Shared CI/CD — one build pipeline for all apps
- Consistent tooling — eslint, prettier, typescript config
- Easy refactoring — move code between apps without npm publish
Monorepo Drawbacks
- Build complexity — must rebuild only changed packages
- Dependency management — circular dependencies possible
- Harder to open-source — internal code in same repo
- Steeper learning curve — new engineers need to understand workspace structure
For us, benefits outweighed drawbacks.
Option 2: Micro-Frontends (The Alternative)
For teams that want complete independence, micro-frontends are powerful:
main-app/
├── shell/ # Shell/host
├── packages/
│ ├── dashboard-mfe/ # Micro-frontend (team A)
│ ├── billing-mfe/ # Micro-frontend (team B)
│ └── admin-mfe/ # Micro-frontend (team C)
Each micro-frontend:
- Has its own git repo
- Deploys independently
- Ships its own bundle
- Uses shared design tokens + api client
Micro-Frontend Patterns
1. Module Federation (Webpack 5 / Vite)
// host/vite.config.ts
import federation from '@originjs/vite-plugin-federation'
export default defineConfig({
plugins: [
federation({
name: 'host',
remotes: {
dashboard: 'http://localhost:5001/assets/remoteEntry.js',
billing: 'http://localhost:5002/assets/remoteEntry.js',
admin: 'http://localhost:5003/assets/remoteEntry.js',
},
shared: ['vue', 'pinia', '@acme/api-client']
})
]
})
// remote/vite.config.ts
export default defineConfig({
plugins: [
federation({
name: 'dashboard',
filename: 'remoteEntry.js',
exposes: {
'./App': './src/App.vue',
'./store': './src/store/index.ts'
},
shared: ['vue', 'pinia', '@acme/api-client']
})
]
})
2. URL-based Routing
<!-- host/App.vue -->
<script setup>
const microFrontends = {
dashboard: { url: 'http://dashboard.micro.local', path: '/dashboard' },
billing: { url: 'http://billing.micro.local', path: '/billing' },
admin: { url: 'http://admin.micro.local', path: '/admin' }
}
const loadMicroFrontend = async (name) => {
const mfe = microFrontends[name]
const container = await import(mfe.url)
return container.App
}
</script>
<template>
<router-view />
</template>
Each micro-frontend is a complete app that can:
- Deploy independently
- Use different versions of dependencies (if needed)
- Be owned by a separate team
- Scale independently
Our Monorepo Decision
We chose monorepo because:
- Team maturity — all teams understood git and npm workspaces
- Shared design system — critical for consistent UX
- Single deployment — easier for our ops team
- Developer experience — IDE support better in monorepo
- Build optimization — turborepo handles incremental builds well
Implementation: Turborepo
We use Turborepo for build orchestration:
{
"$schema": "https://turbo.build/json-schema.json",
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"],
"cache": true
},
"test": {
"dependsOn": ["build"],
"outputs": [],
"cache": true
},
"lint": {
"outputs": []
},
"dev": {
"cache": false,
"persistent": true
}
},
"globalEnv": ["NODE_ENV", "VERCEL_ENV"],
"globalDependencies": ["tsconfig.json", ".eslintrc.js"]
}
Benefits:
- Parallel execution — builds run in parallel when possible
- Task pipelines — test only runs after build
- Caching — skips tasks that haven't changed
- CI/CD integration — auto-detects changed packages
State Management at Scale
Pinia (Vue's official state management) handles monorepo scales well:
// packages/shared-state/src/stores/auth.ts
import { defineStore } from 'pinia'
export const useAuthStore = defineStore('auth', () => {
const user = ref(null)
const isAuthenticated = computed(() => !!user.value)
const login = async (credentials) => {
const response = await api.login(credentials)
user.value = response.user
localStorage.setItem('token', response.token)
}
const logout = () => {
user.value = null
localStorage.removeItem('token')
}
return { user, isAuthenticated, login, logout }
})
// Usage in any app
import { useAuthStore } from '@acme/shared-state'
const authStore = useAuthStore()
authStore.login(credentials)
Preventing Common Pitfalls
1. Circular Dependencies
Bad: dashboard → api-client → shared-utils → dashboard
Solution: Enforce dependency graph with eslint:
// .eslintrc.js
{
"rules": {
"import/no-cycle": "error"
}
}
2. Shared Dependency Versions
// Root tsconfig.json
{
"compilerOptions": {
"moduleResolution": "bundler",
"resolvePackageJsonExports": true
},
"extends": "./tsconfig.base.json"
}
// pnpm-workspace.yaml
packages:
- 'packages/*'
- 'apps/*'
# Lockfile ensures all packages use same versions
3. Code Ownership
Create a CODEOWNERS file:
# CODEOWNERS
packages/design-system/ @design-team
packages/api-client/ @backend-team, @frontend-team
apps/dashboard/ @dashboard-team
apps/admin/ @admin-team
Performance Impact
Monorepo with proper caching:
Before: npm run build (all apps)
Real: 45 seconds
User: 78 seconds (with npm overhead)
After: turbo build (only changed apps)
Real: 12 seconds (cached)
User: 3 seconds (with caching)
Cached builds like this reclaim real hours of CI time every week — measure your own before/after; the delta is usually the easiest infrastructure win of the quarter.
Team Autonomy in Monorepo
To prevent chaos:
- Clear package boundaries — dashboard team doesn't touch billing code
- Owned packages — each package has a code owner
- Version contracts —
@acme/api-clientv2.0.0 is stable, don't break it - Shared conventions — naming, folder structure, testing patterns
When Monorepo Isn't Enough
For teams that need complete independence, consider micro-frontends:
Monorepo → Single deployment, shared everything Micro-Frontends → Independent deployments, isolated teams Hybrid → Monorepo for shared packages, micro-frontends for apps
We started with monorepo. If we grew to 200 engineers across 30 teams, we'd likely move to micro-frontends with Module Federation.
Final Metrics
The movement a monorepo migration typically produces (illustrative example — not measured claims from one project):
| Metric | Direction |
|---|---|
| Build time | Sharply down with caching |
| CI/CD time | Down, especially on unchanged packages |
| Git conflicts | Down — one repo, one integration point |
| Deployment failures | Down — shared tooling, fewer bespoke pipelines |
| Code duplication | Down — importing beats re-implementing |
| Onboarding time | Down — one setup, one set of conventions |
The architecture change pays off through clear ownership, faster development, and fewer bugs — if you enforce the boundaries.