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:

code
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:

code
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)

typescript
// 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

vue
<!-- 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:

  1. Team maturity — all teams understood git and npm workspaces
  2. Shared design system — critical for consistent UX
  3. Single deployment — easier for our ops team
  4. Developer experience — IDE support better in monorepo
  5. Build optimization — turborepo handles incremental builds well

Implementation: Turborepo

We use Turborepo for build orchestration:

json
{
  "$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:

typescript
// 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

code
Bad: dashboard → api-client → shared-utils → dashboard

Solution: Enforce dependency graph with eslint:

javascript
// .eslintrc.js
{
  "rules": {
    "import/no-cycle": "error"
  }
}

2. Shared Dependency Versions

typescript
// 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:

code
# 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:

code
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:

  1. Clear package boundaries — dashboard team doesn't touch billing code
  2. Owned packages — each package has a code owner
  3. Version contracts@acme/api-client v2.0.0 is stable, don't break it
  4. 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):

MetricDirection
Build timeSharply down with caching
CI/CD timeDown, especially on unchanged packages
Git conflictsDown — one repo, one integration point
Deployment failuresDown — shared tooling, fewer bespoke pipelines
Code duplicationDown — importing beats re-implementing
Onboarding timeDown — one setup, one set of conventions

The architecture change pays off through clear ownership, faster development, and fewer bugs — if you enforce the boundaries.