Repository Strategy

Table of Contents


Overview

Hybrid repository strategy: platform-core monorepo in the center with polyrepo MFEs radiating outward platform-core Monorepo (pnpm workspaces + Turborepo) design-system shared-types shared-utils eslint-config tsconfig event-contracts mfe-dashboard Polyrepo mfe-settings Polyrepo mfe-analytics Polyrepo mfe-onboarding Polyrepo Arrows = npm packages via GitHub Packages

This platform adopts a hybrid monorepo + polyrepo repository strategy to balance the competing needs of shared infrastructure co-location with autonomous team ownership of individual micro frontends.

Approach

ConcernRepository ModelRationale
Shared packages (design system, types, configs)Monorepo (platform-core)Co-location enables atomic cross-package changes, single PR for breaking changes, unified versioning via Changesets
Micro frontends (dashboard, settings, etc.)Polyrepo (one repo per MFE)Independent CI/CD pipelines, separate GitHub team permissions, autonomous deploy cadence, isolated blast radius

Rationale

Why not a full monorepo? At a scale of 3-5 teams and 5-10 micro frontends, a full monorepo introduces coupling that undermines the core benefit of the micro frontend architecture: team autonomy. Teams should be able to merge, version, and deploy their MFE without coordinating with every other team. A full monorepo also concentrates CI load---every PR triggers checks across the entire codebase unless sophisticated filtering is maintained.

Why not full polyrepo? Shared code (design system, TypeScript types, lint configs) must evolve atomically. If a breaking change in @org/shared-types requires coordinated updates in @org/design-system and @org/shared-utils, doing this across three separate repos means three PRs, three review cycles, and a fragile publish ordering problem. Co-locating shared packages in a single monorepo solves this.

Scale

  • Teams: 3-5 cross-functional squads, each owning 1-3 micro frontends
  • Micro frontends: 5-10 independently deployable applications
  • Shared packages: 7 packages in the monorepo, published to GitHub Packages under the @org scope
  • Toolchain: React 19 + TypeScript 5.x, Module Federation v2 (via Rsbuild), pnpm workspaces, Turborepo, Changesets, GitHub Actions

Monorepo Structure (platform-core)

The platform-core repository contains all shared infrastructure packages. It is managed with pnpm workspaces for dependency resolution and linking, Turborepo for task orchestration and caching, and Changesets for versioning and publishing.

pnpm Workspaces

pnpm-workspace.yaml:

packages:
  - "packages/*"

This single glob tells pnpm that every direct subdirectory under packages/ is a workspace package. pnpm will automatically link cross-references between them using symlinks in node_modules.

Full Directory Structure

platform-core/
├── packages/
│   ├── design-system/       # UI components, tokens, themes
│   ├── shared-types/        # TypeScript interfaces shared across platform
│   ├── shared-utils/        # Common utilities (date formatting, validation, etc.)
│   ├── eslint-config/       # Shared ESLint config (@org/eslint-config)
│   ├── tsconfig/            # Shared TypeScript base configs (@org/tsconfig)
│   ├── prettier-config/     # Shared Prettier config (@org/prettier-config)
│   └── event-contracts/     # CustomEvent type definitions for inter-MFE communication
├── turbo.json
├── pnpm-workspace.yaml
├── package.json             # Root scripts, devDependencies
├── .changeset/
│   └── config.json
├── .github/
│   └── workflows/
│       ├── ci.yml
│       └── release.yml
├── .npmrc
└── .gitignore

Root package.json:

{
  "name": "platform-core",
  "private": true,
  "scripts": {
    "build": "turbo run build",
    "dev": "turbo run dev",
    "test": "turbo run test",
    "lint": "turbo run lint",
    "typecheck": "turbo run typecheck",
    "format": "prettier --write \"packages/*/src/**/*.{ts,tsx}\"",
    "changeset": "changeset",
    "version-packages": "changeset version",
    "release": "turbo run build && changeset publish"
  },
  "devDependencies": {
    "@changesets/cli": "^2.27.0",
    "prettier": "^3.3.0",
    "turbo": "^2.8.10"
  },
  "packageManager": "pnpm@10.30.3",
  "engines": {
    "node": ">=20.0.0"
  }
}

.npmrc:

@org:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}
auto-install-peers=true
strict-peer-dependencies=false

Note (pnpm 10): Starting with pnpm 10, lifecycle scripts (e.g., postinstall) from dependencies are disabled by default for security. If a dependency relies on a postinstall script (e.g., for native compilation), you must explicitly allow it by adding onlyBuiltDependencies to package.json or setting enable-pre-post-scripts=true in .npmrc.

Package Details

@org/design-system

AttributeValue
PurposeShared UI component library: buttons, inputs, modals, layout primitives, design tokens, and theme provider
ExportsReact components, CSS custom property tokens, ThemeProvider, utility class helpers
Consumed asdependencies in MFE repos (components are runtime code)
BuildTypeScript + Rsbuild library mode, outputs ESM to dist/
Key depsreact (peerDependency), @org/shared-types (workspace dependency)
// packages/design-system/package.json
{
  "name": "@org/design-system",
  "version": "1.4.0",
  "type": "module",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    },
    "./tokens": {
      "types": "./dist/tokens/index.d.ts",
      "import": "./dist/tokens/index.js"
    }
  },
  "files": ["dist"],
  "scripts": {
    "build": "rsbuild build",
    "dev": "rsbuild dev",
    "test": "vitest run",
    "lint": "eslint src/",
    "typecheck": "tsc --noEmit"
  },
  "peerDependencies": {
    "react": "^19.2.4",
    "react-dom": "^19.2.4"
  },
  "dependencies": {
    "@org/shared-types": "workspace:*"
  },
  "devDependencies": {
    "@org/eslint-config": "workspace:*",
    "@org/tsconfig": "workspace:*",
    "@org/prettier-config": "workspace:*",
    "react": "^19.2.4",
    "react-dom": "^19.2.4",
    "typescript": "^5.9.3",
    "vitest": "^4.0.18",
    "@rsbuild/core": "^1.7.3"
  }
}

@org/shared-types

AttributeValue
PurposePlatform-wide TypeScript type definitions: user models, API response shapes, route definitions, shared enums, MFE lifecycle interfaces
ExportsTypeScript interfaces, type aliases, enums (no runtime code)
Consumed asdevDependencies in MFE repos (types are erased at build time)
Buildtsc only---emits .d.ts files to dist/
Key depsNone (zero runtime dependencies)
// packages/shared-types/package.json
{
  "name": "@org/shared-types",
  "version": "2.1.0",
  "type": "module",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    },
    "./events": {
      "types": "./dist/events/index.d.ts",
      "import": "./dist/events/index.js"
    }
  },
  "files": ["dist"],
  "scripts": {
    "build": "tsc --project tsconfig.build.json",
    "lint": "eslint src/",
    "typecheck": "tsc --noEmit"
  },
  "devDependencies": {
    "@org/eslint-config": "workspace:*",
    "@org/tsconfig": "workspace:*",
    "typescript": "^5.9.3"
  }
}

@org/shared-utils

AttributeValue
PurposeCommon utility functions: date formatting helpers, validation schemas, URL builders, retry logic, feature flag helpers
ExportsPure functions, Zod schemas, constants
Consumed asdependencies in MFE repos (utilities are runtime code)
BuildTypeScript compiled to ESM via tsc
Key deps@org/shared-types (workspace), zod (peer)
// packages/shared-utils/package.json
{
  "name": "@org/shared-utils",
  "version": "1.2.0",
  "type": "module",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    },
    "./validation": {
      "types": "./dist/validation/index.d.ts",
      "import": "./dist/validation/index.js"
    },
    "./date": {
      "types": "./dist/date/index.d.ts",
      "import": "./dist/date/index.js"
    }
  },
  "files": ["dist"],
  "scripts": {
    "build": "tsc --project tsconfig.build.json",
    "test": "vitest run",
    "lint": "eslint src/",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "@org/shared-types": "workspace:*"
  },
  "peerDependencies": {
    "zod": "^3.23.0"
  },
  "devDependencies": {
    "@org/eslint-config": "workspace:*",
    "@org/tsconfig": "workspace:*",
    "typescript": "^5.9.3",
    "vitest": "^4.0.18",
    "zod": "^3.23.0"
  }
}

@org/eslint-config

AttributeValue
PurposeShared ESLint flat config presets enforcing consistent code quality across the platform
ExportsFlat config arrays: base, react, worker
Consumed asdevDependencies everywhere
BuildNone (config files are consumed directly)

@org/tsconfig

AttributeValue
PurposeShared TypeScript base configurations for consistent compiler options
ExportsJSON config files: base.json, react.json, worker.json
Consumed asdevDependencies everywhere, referenced via extends in tsconfig.json
BuildNone (JSON files consumed directly)

@org/prettier-config

AttributeValue
PurposeSingle source of truth for code formatting rules
ExportsPrettier config object
Consumed asdevDependencies everywhere, referenced from .prettierrc.js
BuildNone

@org/event-contracts

AttributeValue
PurposeTypeScript type definitions for CustomEvent payloads used in inter-MFE communication via the browser event bus
ExportsEvent name constants, payload type interfaces, typed helper functions (createEvent, listenForEvent)
Consumed asdevDependencies in MFE repos
Buildtsc emitting .d.ts and minimal JS runtime helpers
Key deps@org/shared-types (workspace)
// packages/event-contracts/package.json
{
  "name": "@org/event-contracts",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    }
  },
  "files": ["dist"],
  "scripts": {
    "build": "tsc --project tsconfig.build.json",
    "test": "vitest run",
    "lint": "eslint src/",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "@org/shared-types": "workspace:*"
  },
  "devDependencies": {
    "@org/eslint-config": "workspace:*",
    "@org/tsconfig": "workspace:*",
    "typescript": "^5.9.3",
    "vitest": "^4.0.18"
  }
}

Internal Dependencies

Monorepo internal dependency graph showing packages and their dependencies eslint-config tsconfig prettier-config Leaf packages (no internal deps) @org/shared-types @org/shared-utils @org/event-contracts @org/design-system dependency devDependency

Packages within the monorepo reference each other using the pnpm workspace protocol. This tells pnpm to resolve the dependency from the local workspace rather than the registry.

// In packages/design-system/package.json
{
  "dependencies": {
    "@org/shared-types": "workspace:*"
  },
  "devDependencies": {
    "@org/eslint-config": "workspace:*",
    "@org/tsconfig": "workspace:*",
    "@org/prettier-config": "workspace:*"
  }
}

When publishing, workspace:* is automatically replaced by Changesets with the actual version number (e.g., "@org/shared-types": "^2.1.0"), so consumers outside the monorepo receive correct semver ranges. More specifically, workspace:* resolves to the exact current version with a caret range (^x.y.z), workspace:~ resolves to a tilde range (~x.y.z), and workspace:^ also resolves to a caret range. The replacement happens at publish time only---pnpm-lock.yaml and local node_modules continue to use the workspace symlinks during development.

Dependency graph within the monorepo:

@org/eslint-config      (leaf - no internal deps)
@org/tsconfig           (leaf - no internal deps)
@org/prettier-config    (leaf - no internal deps)
@org/shared-types       (leaf - no internal deps, uses tsconfig/eslint-config as devDeps)
    ↑
@org/shared-utils       (depends on shared-types)
@org/event-contracts    (depends on shared-types)
    ↑
@org/design-system      (depends on shared-types)

TypeScript Project References

The monorepo root tsconfig.json defines project references so TypeScript understands the build order and can perform incremental compilation.

Root tsconfig.json:

{
  "files": [],
  "references": [
    { "path": "packages/shared-types" },
    { "path": "packages/shared-utils" },
    { "path": "packages/event-contracts" },
    { "path": "packages/design-system" }
  ]
}

Each package's tsconfig.build.json declares its own references to upstream packages:

// packages/shared-utils/tsconfig.build.json
{
  "extends": "@org/tsconfig/base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "composite": true
  },
  "include": ["src/**/*.ts"],
  "references": [
    { "path": "../shared-types" }
  ]
}
// packages/design-system/tsconfig.build.json
{
  "extends": "@org/tsconfig/react.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "composite": true
  },
  "include": ["src/**/*.ts", "src/**/*.tsx"],
  "references": [
    { "path": "../shared-types" }
  ]
}

Running tsc --build at the root compiles packages in the correct topological order: shared-types first (no internal dependencies), then shared-utils, event-contracts, and design-system in parallel (all depend only on shared-types).


Turborepo Configuration

turbo.json

Turborepo orchestrates task execution across all workspace packages, respecting dependency order and maximizing parallelism. It also provides local and remote caching to skip redundant work.

// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": [
    "**/.env.*local"
  ],
  "globalEnv": [
    "NODE_ENV"
  ],
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"],
      "env": ["NODE_ENV"]
    },
    "dev": {
      "dependsOn": ["^build"],
      "persistent": true,
      "cache": false
    },
    "test": {
      "dependsOn": ["^build"],
      "outputs": ["coverage/**"],
      "env": ["CI"]
    },
    "lint": {
      "outputs": [],
      "env": ["CI"]
    },
    "typecheck": {
      "dependsOn": ["^build"],
      "outputs": []
    }
  }
}

Key configuration decisions:

TaskdependsOnoutputscacheRationale
build["^build"]["dist/**"]yes (default)Must build upstream packages first. Cache the dist/ output.
dev["^build"]---falseNeeds upstream built, but dev servers are long-running and must not be cached. persistent: true keeps the process alive.
test["^build"]["coverage/**"]yesTests may import built artifacts from upstream packages. Cache coverage reports.
lint(none)[]yesLinting is self-contained---no need to wait for upstream builds. No file output, but exit code is cached.
typecheck["^build"][]yesType checking requires upstream .d.ts files to exist. No file output, but result is cached.

The ^ prefix in dependsOn means "run this task in all dependency packages first" (topological dependency). Without the caret, it would mean "run this task in the same package first."

Remote Caching

Turborepo's remote cache allows CI runs and developer machines to share build artifacts, dramatically reducing build times for unchanged packages.

Setup with Vercel:

# Authenticate (one-time per machine or CI secret)
npx turbo login

# Link the repo to a Vercel team for remote caching
npx turbo link

CI environment variables:

# .github/workflows/ci.yml (excerpt)
env:
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
  TURBO_TEAM: ${{ vars.TURBO_TEAM }}

With these set, every turbo run command automatically reads from and writes to the remote cache.

Cache hit impact:

ScenarioWithout Remote CacheWith Remote Cache
PR touching only design-system~90s (all packages build)~25s (only design-system rebuilds; shared-types, shared-utils are cache hits)
PR touching only eslint-config~90s~10s (config-only change, all builds are cache hits, only lint re-runs)
Full rebuild (cache cold)~90s~90s (same, no cache to hit)

Self-hosted alternative: For teams that cannot use Vercel, Turborepo supports self-hosted remote cache servers implementing the Turborepo Remote Cache API. Options include turbo-remote-cache-cloudflare (Cloudflare Workers + R2) or ducktape (Docker-based).

Task Pipeline

Turborepo task pipeline: lint, typecheck, build, and test with remote caching layer lint no deps needed typecheck ^build required build outputs: dist/** test ^build required Turborepo Remote Cache (Vercel / self-hosted) turbo run build cache hit = skip

Turborepo constructs a directed acyclic graph (DAG) of tasks based on the dependsOn declarations in turbo.json and the dependency relationships declared in each package's package.json.

Example: turbo run build

Build Dependency Tree — shared-types builds first, then dependents in parallel @org/shared-types build (runs first) in parallel @org/shared-utils build @org/event-contracts build @org/design-system build

Execution order:

  1. @org/shared-types#build runs first (no upstream dependencies).
  2. @org/shared-utils#build, @org/event-contracts#build, and @org/design-system#build run in parallel (all depend only on shared-types).
  3. @org/eslint-config, @org/tsconfig, and @org/prettier-config have no build script, so Turborepo skips them.

Filtering with --filter:

# Build only design-system and its dependencies
turbo run build --filter=@org/design-system...

# Build only packages that changed since main
turbo run build --filter=...[origin/main]

# Run tests in a specific package without building dependencies
turbo run test --filter=@org/shared-utils

# Build everything that depends on shared-types (downstream)
turbo run build --filter=...@org/shared-types

The ... syntax controls direction:

  • @org/design-system... = the package and all its dependencies (upstream)
  • ...@org/shared-types = the package and everything that depends on it (downstream)
  • ...[origin/main] = all packages with changes since the given git ref

Polyrepo Structure (MFEs)

Each micro frontend lives in its own GitHub repository, owned by a single team. All MFE repos follow a standardized layout established by a shared template repository (org/mfe-template).

Standard MFE Repository Layout

mfe-dashboard/
├── src/
│   ├── App.tsx              # Root component (exposed via Module Federation)
│   ├── bootstrap.tsx        # Async bootstrap for Module Federation
│   ├── index.ts             # Entry point (dynamic import of bootstrap)
│   ├── pages/
│   │   ├── DashboardPage.tsx
│   │   └── AnalyticsPage.tsx
│   ├── components/
│   │   ├── DashboardCard.tsx
│   │   └── MetricsChart.tsx
│   ├── hooks/
│   │   ├── useDashboardData.ts
│   │   └── usePolling.ts
│   ├── services/            # API calls to BFF
│   │   └── dashboardApi.ts
│   └── types/
│       └── dashboard.ts     # MFE-specific types
├── rsbuild.config.ts        # Module Federation remote configuration
├── package.json
├── tsconfig.json
├── eslint.config.js         # Extends @org/eslint-config (flat config)
├── .prettierrc.js           # Extends @org/prettier-config
├── .npmrc                   # GitHub Packages registry config
├── vitest.config.ts
└── .github/
    └── workflows/
        ├── ci.yml           # Lint, typecheck, test on every PR
        └── deploy.yml       # Build + deploy to Cloudflare Pages on merge to main

Entry point pattern (critical for Module Federation):

// src/index.ts
// Dynamic import ensures shared dependencies are initialized before bootstrap
import("./bootstrap");
// src/bootstrap.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";

const mount = (el: HTMLElement) => {
  const root = ReactDOM.createRoot(el);
  root.render(
    <React.StrictMode>
      <App />
    </React.StrictMode>
  );
  return () => root.unmount();
};

// Standalone mode: mount directly if not consumed as a remote
const rootEl = document.getElementById("root");
if (rootEl) {
  mount(rootEl);
}

export { mount };

package.json (typical MFE):

{
  "name": "@org/mfe-dashboard",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "rsbuild dev",
    "build": "rsbuild build",
    "preview": "rsbuild preview",
    "test": "vitest run",
    "test:watch": "vitest",
    "lint": "eslint src/",
    "typecheck": "tsc --noEmit",
    "format": "prettier --write \"src/**/*.{ts,tsx}\""
  },
  "dependencies": {
    "@org/design-system": "^1.4.0",
    "@org/shared-utils": "^1.2.0",
    "@org/event-contracts": "^1.0.0",
    "react": "^19.2.4",
    "react-dom": "^19.2.4",
    "react-router-dom": "^7.13.1"
  },
  "devDependencies": {
    "@org/eslint-config": "^1.0.0",
    "@org/prettier-config": "^1.0.0",
    "@org/shared-types": "^2.1.0",
    "@org/tsconfig": "^1.0.0",
    "@module-federation/enhanced": "^2.0.1",
    "@rsbuild/core": "^1.7.3",
    "@rsbuild/plugin-react": "^1.4.1",
    "@testing-library/react": "^16.0.0",
    "typescript": "^5.9.3",
    "vitest": "^4.0.18"
  }
}

Note that @org/shared-types is a devDependency because its exports are pure TypeScript types erased at compile time. @org/design-system and @org/shared-utils are dependencies because they ship runtime code.

Consuming Monorepo Packages

MFE repos install shared packages from GitHub Packages, the private npm registry hosted alongside the GitHub repositories.

.npmrc in every MFE repo:

@org:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}

Installing shared packages:

# Runtime dependencies
pnpm add @org/design-system @org/shared-utils @org/event-contracts

# Dev dependencies (types and configs)
pnpm add -D @org/shared-types @org/eslint-config @org/tsconfig @org/prettier-config

Automated dependency updates with Renovate:

Renovate is configured in every MFE repo to automatically open PRs when new versions of @org/* packages are published. Patches are auto-merged; minor and major versions require manual review.

// renovate.json in each MFE repo
{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "extends": [
    "github>org/platform-core//renovate-presets"
  ]
}

This extends the shared Renovate preset from the monorepo (covered in Renovate Shared Presets).


TypeScript Strategy

Shared Base Configs

The @org/tsconfig package provides base TypeScript configurations that every package and MFE extends. This ensures consistent compiler behavior across the entire platform.

Package structure:

packages/tsconfig/
├── base.json
├── react.json
├── worker.json
└── package.json

base.json:

{
  "$schema": "https://json.schemastore.org/tsconfig",
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "lib": ["ES2022"],

    // Strict type checking
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "noPropertyAccessFromIndexSignature": true,
    "forceConsistentCasingInFileNames": true,
    "exactOptionalPropertyTypes": false,

    // Interop
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "isolatedModules": true,
    "verbatimModuleSyntax": true,

    // Output
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "skipLibCheck": true
  },
  "exclude": [
    "node_modules",
    "dist",
    "coverage"
  ]
}

react.json:

{
  "$schema": "https://json.schemastore.org/tsconfig",
  "extends": "./base.json",
  "compilerOptions": {
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "jsx": "react-jsx",
    "types": ["vite/client"]
  }
}

worker.json:

{
  "$schema": "https://json.schemastore.org/tsconfig",
  "extends": "./base.json",
  "compilerOptions": {
    "lib": ["ES2022"],
    "types": ["@cloudflare/workers-types"]
  }
}

package.json:

{
  "name": "@org/tsconfig",
  "version": "1.0.0",
  "files": ["base.json", "react.json", "worker.json"],
  "exports": {
    "./base.json": "./base.json",
    "./react.json": "./react.json",
    "./worker.json": "./worker.json"
  }
}

Project References in Monorepo

Within the monorepo, TypeScript project references establish the compilation dependency graph. This enables:

  1. Correct build order: tsc --build compiles packages in topological order.
  2. Incremental builds: Only recompile packages whose sources (or upstream .d.ts) changed.
  3. Editor performance: The TypeScript language server resolves cross-package types via .d.ts files rather than loading all source files.

Reference chain:

tsconfig.json (root)
├── references → packages/shared-types/tsconfig.build.json
│                  extends: @org/tsconfig/base.json
│                  composite: true
│                  references: (none)
│
├── references → packages/shared-utils/tsconfig.build.json
│                  extends: @org/tsconfig/base.json
│                  composite: true
│                  references: [shared-types]
│
├── references → packages/event-contracts/tsconfig.build.json
│                  extends: @org/tsconfig/base.json
│                  composite: true
│                  references: [shared-types]
│
└── references → packages/design-system/tsconfig.build.json
                   extends: @org/tsconfig/react.json
                   composite: true
                   references: [shared-types]

The composite: true flag is required for any package that is a project reference target. It forces TypeScript to emit .d.ts files and a .tsbuildinfo manifest used for incremental compilation.

Cross-Repo Type Sharing

Types flow from the monorepo to MFE repos through two complementary mechanisms:

1. @org/shared-types as an npm package:

Platform-wide contracts---user models, API response shapes, route definitions, feature flags---are defined in @org/shared-types and published to GitHub Packages. MFEs install it as a devDependency.

// In @org/shared-types/src/index.ts
export interface User {
  id: string;
  email: string;
  displayName: string;
  role: "admin" | "editor" | "viewer";
  preferences: UserPreferences;
}

export interface UserPreferences {
  theme: "light" | "dark" | "system";
  locale: string;
  timezone: string;
}

export interface ApiResponse<T> {
  data: T;
  meta: {
    timestamp: number;
    requestId: string;
  };
}
// In mfe-dashboard/src/services/dashboardApi.ts
import type { ApiResponse, User } from "@org/shared-types";

export async function fetchCurrentUser(): Promise<ApiResponse<User>> {
  const res = await fetch("/api/user");
  return res.json();
}

2. Module Federation type generation for remote-specific types:

Types for Module Federation exposed modules (the App component's props, route metadata, etc.) are generated alongside the remote's build using @module-federation/enhanced's type generation feature. These are consumed by the host application at development time.

// Generated by Module Federation (mfe-dashboard)
declare module "mfe_dashboard/App" {
  import { ComponentType } from "react";

  interface AppProps {
    basePath: string;
    onNavigate?: (path: string) => void;
  }

  const App: ComponentType<AppProps>;
  export default App;
}

Pattern summary:

Type CategoryMechanismExample
Platform contracts (models, API shapes)@org/shared-types npm packageUser, ApiResponse<T>
Inter-MFE event payloads@org/event-contracts npm packageNavigationEvent, NotificationEvent
MFE-specific exposed module typesModule Federation type generationmfe_dashboard/App props
MFE-internal typesLocal src/types/ directoryDashboardMetric, ChartConfig

Shared Configurations

ESLint Config (@org/eslint-config)

The @org/eslint-config package provides pre-built ESLint flat config arrays for ESLint 10+ (eslint@^10.0.2). ESLint 10 drops support for the legacy .eslintrc.* configuration format entirely---only the flat config file (eslint.config.js) is supported. Teams do not write their own rules---they select a preset and optionally add project-specific overrides.

Package structure:

packages/eslint-config/
├── src/
│   ├── index.ts       # Re-exports all presets
│   ├── base.ts        # Base config (TypeScript, import sorting, no-unused-vars)
│   ├── react.ts       # Extends base + React-specific rules
│   └── worker.ts      # Extends base + Cloudflare Workers rules
├── package.json
└── tsconfig.json

base.ts:

import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
import importPlugin from "eslint-plugin-import-x";

export const base = tseslint.config(
  eslint.configs.recommended,
  ...tseslint.configs.strictTypeChecked,
  ...tseslint.configs.stylisticTypeChecked,
  {
    plugins: {
      "import-x": importPlugin,
    },
    rules: {
      // Enforce consistent imports
      "import-x/order": [
        "error",
        {
          "groups": [
            "builtin",
            "external",
            "internal",
            "parent",
            "sibling",
            "index"
          ],
          "newlines-between": "always",
          "alphabetize": { "order": "asc" }
        }
      ],
      "import-x/no-duplicates": "error",

      // TypeScript-specific
      "@typescript-eslint/no-unused-vars": [
        "error",
        { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }
      ],
      "@typescript-eslint/consistent-type-imports": [
        "error",
        { "prefer": "type-imports" }
      ],
      "@typescript-eslint/no-floating-promises": "error",
      "@typescript-eslint/no-misused-promises": "error",
    },
  }
);

react.ts:

import reactPlugin from "eslint-plugin-react";
import hooksPlugin from "eslint-plugin-react-hooks";
import a11yPlugin from "eslint-plugin-jsx-a11y";

import { base } from "./base.js";

export const react = [
  ...base,
  reactPlugin.configs.flat.recommended,
  reactPlugin.configs.flat["jsx-runtime"],
  {
    plugins: {
      "react-hooks": hooksPlugin,
      "jsx-a11y": a11yPlugin,
    },
    rules: {
      ...hooksPlugin.configs.recommended.rules,
      ...a11yPlugin.configs.recommended.rules,
      "react/prop-types": "off", // TypeScript handles prop validation
    },
    settings: {
      react: { version: "detect" },
    },
  },
];

Consumption in an MFE repo:

// eslint.config.js (in mfe-dashboard)
import { react } from "@org/eslint-config";

export default [
  ...react,
  {
    languageOptions: {
      parserOptions: {
        project: "./tsconfig.json",
      },
    },
  },
  // Project-specific overrides (rare)
  {
    files: ["src/legacy/**/*.ts"],
    rules: {
      "@typescript-eslint/no-explicit-any": "off",
    },
  },
];

Prettier + ESLint import ordering conflict: The shared Prettier config uses prettier-plugin-organize-imports, which may reorder imports in a way that conflicts with the import-x/order rule in @org/eslint-config. To avoid a fix/format loop, install eslint-config-prettier and add it as the last entry in your flat config array. This disables all ESLint rules that overlap with Prettier formatting. Alternatively, remove prettier-plugin-organize-imports from the Prettier config and rely solely on ESLint's import-x/order rule for import sorting.

Prettier Config (@org/prettier-config)

A single package ensures every file across the platform is formatted identically.

packages/prettier-config/index.js:

/** @type {import("prettier").Config} */
const config = {
  semi: true,
  singleQuote: false,
  tabWidth: 2,
  trailingComma: "all",
  printWidth: 100,
  bracketSpacing: true,
  arrowParens: "always",
  endOfLine: "lf",
  plugins: ["prettier-plugin-organize-imports"],
};

export default config;

package.json:

{
  "name": "@org/prettier-config",
  "version": "1.0.0",
  "type": "module",
  "main": "./index.js",
  "exports": {
    ".": "./index.js"
  },
  "files": ["index.js"],
  "peerDependencies": {
    "prettier": "^3.3.0"
  },
  "dependencies": {
    "prettier-plugin-organize-imports": "^4.0.0"
  }
}

Consumption in any repo:

// .prettierrc.js
export { default } from "@org/prettier-config";

Alternatively, in package.json:

{
  "prettier": "@org/prettier-config"
}

Renovate Shared Presets

The monorepo hosts a shared Renovate preset that all MFE repos extend. This ensures consistent dependency update behavior across the platform.

platform-core/renovate-presets.json:

{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "description": "Shared Renovate presets for the micro frontends platform",
  "extends": [
    "config:recommended",
    ":semanticCommits",
    ":automergePatch",
    "schedule:earlyMondays"
  ],
  "labels": ["dependencies"],
  "packageRules": [
    {
      "description": "Group all @org packages into a single PR",
      "matchPackagePatterns": ["^@org/"],
      "groupName": "platform packages",
      "groupSlug": "platform-packages",
      "automerge": false,
      "schedule": ["at any time"]
    },
    {
      "description": "Auto-merge patch updates for @org packages",
      "matchPackagePatterns": ["^@org/"],
      "matchUpdateTypes": ["patch"],
      "automerge": true,
      "automergeType": "pr",
      "schedule": ["at any time"]
    },
    {
      "description": "Group React-related packages",
      "matchPackageNames": ["react", "react-dom", "@types/react", "@types/react-dom"],
      "groupName": "react",
      "groupSlug": "react"
    },
    {
      "description": "Group test infrastructure",
      "matchPackageNames": ["vitest", "@testing-library/react", "@testing-library/jest-dom"],
      "groupName": "testing",
      "groupSlug": "testing",
      "automerge": true,
      "matchUpdateTypes": ["minor", "patch"]
    },
    {
      "description": "Group TypeScript and build tooling",
      "matchPackageNames": ["typescript", "@rsbuild/core", "@rsbuild/plugin-react"],
      "groupName": "build tooling",
      "groupSlug": "build-tooling"
    },
    {
      "description": "Pin major versions of critical shared deps to prevent Module Federation mismatches",
      "matchPackageNames": ["react", "react-dom"],
      "matchUpdateTypes": ["major"],
      "enabled": false
    }
  ],
  "vulnerabilityAlerts": {
    "labels": ["security"],
    "automerge": true,
    "schedule": ["at any time"]
  }
}

Consumption in each MFE repo:

// renovate.json
{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "extends": [
    "github>org/platform-core//renovate-presets"
  ]
}

The github>org/platform-core//renovate-presets syntax tells Renovate to fetch the preset file renovate-presets.json from the root of the platform-core repo in the org GitHub organization.


Cross-Repo Dependency Flow

Cross-repo dependency flow: Changesets version, publish to npm, Renovate opens PR, MFE updates Changesets version bump Publish changeset publish GitHub Packages npm registry Renovate detects update PR CI + review MFE Update deploy Monorepo (platform-core) Polyrepos (MFE repos) Patch updates auto-merge after CI passes | Minor/major require team review

Change Propagation Pipeline

When a developer makes a change to a shared package in the monorepo, the following sequence propagates that change to all consuming MFE repos:

Change Propagation Pipeline — Monorepo release to MFE polyrepo consumption MONOREPO (platform-core) 1 Developer opens PR modifying @org/design-system 2 CI runs: build, test, lint, typecheck (Turborepo) 3 Developer adds changeset: package + bump type + description 4 PR merged to main 5 Changesets opens "Version Packages" PR 6 "Version Packages" PR merged 7 Release workflow: a. changeset version (bump + CHANGELOG) b. changeset publish (GitHub Packages) c. repository-dispatch to MFE repos npm publish + repository_dispatch POLYREPOS (MFE repos) 8 Renovate detects new version (or repository-dispatch triggers run) 9 Renovate opens PR: "Update platform packages" Groups all @org/* updates + changelog excerpts 10 CI runs on Renovate PR: build, test, lint, typecheck 11 Patch: auto-merge | Minor/Major: team review 12 Merge triggers deploy: MFE rebuilt + deployed

Changeset Configuration

.changeset/config.json:

{
  "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
  "changelog": [
    "@changesets/changelog-github",
    { "repo": "org/platform-core" }
  ],
  "commit": false,
  "fixed": [],
  "linked": [],
  "access": "restricted",
  "baseBranch": "main",
  "updateInternalDependencies": "patch",
  "ignore": []
}

Key settings:

  • access: "restricted": Packages are published as private to the GitHub organization.
  • updateInternalDependencies: "patch": When @org/shared-types is bumped, packages that depend on it (like @org/design-system) automatically get a patch bump.
  • changelog: "@changesets/changelog-github": Generates changelogs with GitHub PR and contributor links.

Release Workflow

.github/workflows/release.yml:

name: Release

on:
  push:
    branches:
      - main

concurrency: ${{ github.workflow }}-${{ github.ref }}

permissions:
  contents: write
  packages: write
  pull-requests: write

jobs:
  release:
    name: Release
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v6

      - name: Setup pnpm
        uses: pnpm/action-setup@v4

      - name: Setup Node.js
        uses: actions/setup-node@v6
        with:
          node-version: 22
          cache: "pnpm"
          registry-url: "https://npm.pkg.github.com"

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Build
        run: pnpm run build
        env:
          TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
          TURBO_TEAM: ${{ vars.TURBO_TEAM }}

      - name: Create Release Pull Request or Publish
        id: changesets
        uses: changesets/action@v1
        with:
          publish: pnpm run release
          version: pnpm run version-packages
          title: "chore: version packages"
          commit: "chore: version packages"
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      - name: Notify MFE repos
        if: steps.changesets.outputs.published == 'true'
        uses: actions/github-script@v7
        with:
          github-token: ${{ secrets.MFE_DISPATCH_TOKEN }}
          script: |
            const repos = [
              'mfe-dashboard',
              'mfe-settings',
              'mfe-admin',
              'mfe-analytics',
              'mfe-onboarding',
            ];

            const published = JSON.parse('${{ steps.changesets.outputs.publishedPackages }}');

            for (const repo of repos) {
              await github.rest.repos.createDispatchEvent({
                owner: context.repo.owner,
                repo,
                event_type: 'platform-packages-released',
                client_payload: {
                  packages: published.map(p => ({
                    name: p.name,
                    version: p.version,
                  })),
                },
              });
            }

CI Workflow

.github/workflows/ci.yml:

name: CI

on:
  pull_request:
    branches: [main]

permissions:
  contents: read

jobs:
  ci:
    name: Build, Test & Lint
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          fetch-depth: 0

      - name: Setup pnpm
        uses: pnpm/action-setup@v4

      - name: Setup Node.js
        uses: actions/setup-node@v6
        with:
          node-version: 22
          cache: "pnpm"

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Build
        run: pnpm run build
        env:
          TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
          TURBO_TEAM: ${{ vars.TURBO_TEAM }}

      - name: Lint
        run: pnpm run lint

      - name: Type Check
        run: pnpm run typecheck

      - name: Test
        run: pnpm run test

Notification Mechanism

The repository-dispatch event sent in the release workflow serves two purposes:

  1. Immediate Renovate trigger: MFE repos can have a workflow that listens for platform-packages-released events and triggers a Renovate run, bypassing the normal Renovate schedule.
  2. Visibility: The event payload includes the list of published packages and versions, which can be logged or posted to Slack.

MFE-side workflow listening for dispatches:

# .github/workflows/platform-update.yml (in each MFE repo)
name: Platform Update

on:
  repository_dispatch:
    types: [platform-packages-released]

jobs:
  trigger-renovate:
    name: Trigger Renovate
    runs-on: ubuntu-latest
    steps:
      - name: Log published packages
        run: |
          echo "Platform packages released:"
          echo '${{ toJSON(github.event.client_payload.packages) }}'

      - name: Trigger Renovate
        uses: actions/github-script@v7
        with:
          github-token: ${{ secrets.RENOVATE_TOKEN }}
          script: |
            await github.rest.actions.createWorkflowDispatch({
              owner: context.repo.owner,
              repo: context.repo.repo,
              workflow_id: 'renovate.yml',
              ref: 'main',
            });

Troubleshooting: pnpm Workspace Resolution

Common issues encountered when working with pnpm workspaces in this repository:

Peer dependency conflicts during pnpm install:

When a workspace package declares a peerDependency (e.g., react) and another workspace package depends on a different version, pnpm will report a peer dependency conflict. To diagnose, run pnpm why <package> to inspect the resolution tree. Setting auto-install-peers=true and strict-peer-dependencies=false in .npmrc (already configured above) relaxes enforcement, but conflicts should still be resolved by aligning versions across the workspace.

workspace:* protocol not resolving:

If pnpm install fails with an error like No matching version found for @org/shared-types@workspace:*, ensure that (a) the package name in the dependency exactly matches the name field in the target package's package.json, and (b) the target package directory is included in the packages glob in pnpm-workspace.yaml. Running pnpm ls --depth 0 at the root can help verify which packages pnpm recognizes as workspace members.

Phantom dependencies or hoisting issues:

By default pnpm uses a strict node_modules layout that prevents phantom dependency access. If a package imports a module it does not declare in its own package.json, the import will fail. This is intentional. Add the missing dependency explicitly rather than relying on hoisting. If a tool requires hoisting (rare), you can configure public-hoist-pattern in .npmrc, but prefer fixing the declaration first.


References