Repository Strategy
Table of Contents
- Overview
- Monorepo Structure (platform-core)
- Turborepo Configuration
- Polyrepo Structure (MFEs)
- TypeScript Strategy
- Shared Configurations
- Cross-Repo Dependency Flow
- Troubleshooting: pnpm Workspace Resolution
- References
Overview
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
| Concern | Repository Model | Rationale |
|---|---|---|
| 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
@orgscope - 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 apostinstallscript (e.g., for native compilation), you must explicitly allow it by addingonlyBuiltDependenciestopackage.jsonor settingenable-pre-post-scripts=truein.npmrc.
Package Details
@org/design-system
| Attribute | Value |
|---|---|
| Purpose | Shared UI component library: buttons, inputs, modals, layout primitives, design tokens, and theme provider |
| Exports | React components, CSS custom property tokens, ThemeProvider, utility class helpers |
| Consumed as | dependencies in MFE repos (components are runtime code) |
| Build | TypeScript + Rsbuild library mode, outputs ESM to dist/ |
| Key deps | react (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
| Attribute | Value |
|---|---|
| Purpose | Platform-wide TypeScript type definitions: user models, API response shapes, route definitions, shared enums, MFE lifecycle interfaces |
| Exports | TypeScript interfaces, type aliases, enums (no runtime code) |
| Consumed as | devDependencies in MFE repos (types are erased at build time) |
| Build | tsc only---emits .d.ts files to dist/ |
| Key deps | None (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
| Attribute | Value |
|---|---|
| Purpose | Common utility functions: date formatting helpers, validation schemas, URL builders, retry logic, feature flag helpers |
| Exports | Pure functions, Zod schemas, constants |
| Consumed as | dependencies in MFE repos (utilities are runtime code) |
| Build | TypeScript 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
| Attribute | Value |
|---|---|
| Purpose | Shared ESLint flat config presets enforcing consistent code quality across the platform |
| Exports | Flat config arrays: base, react, worker |
| Consumed as | devDependencies everywhere |
| Build | None (config files are consumed directly) |
@org/tsconfig
| Attribute | Value |
|---|---|
| Purpose | Shared TypeScript base configurations for consistent compiler options |
| Exports | JSON config files: base.json, react.json, worker.json |
| Consumed as | devDependencies everywhere, referenced via extends in tsconfig.json |
| Build | None (JSON files consumed directly) |
@org/prettier-config
| Attribute | Value |
|---|---|
| Purpose | Single source of truth for code formatting rules |
| Exports | Prettier config object |
| Consumed as | devDependencies everywhere, referenced from .prettierrc.js |
| Build | None |
@org/event-contracts
| Attribute | Value |
|---|---|
| Purpose | TypeScript type definitions for CustomEvent payloads used in inter-MFE communication via the browser event bus |
| Exports | Event name constants, payload type interfaces, typed helper functions (createEvent, listenForEvent) |
| Consumed as | devDependencies in MFE repos |
| Build | tsc 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
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:
| Task | dependsOn | outputs | cache | Rationale |
|---|---|---|---|---|
build | ["^build"] | ["dist/**"] | yes (default) | Must build upstream packages first. Cache the dist/ output. |
dev | ["^build"] | --- | false | Needs upstream built, but dev servers are long-running and must not be cached. persistent: true keeps the process alive. |
test | ["^build"] | ["coverage/**"] | yes | Tests may import built artifacts from upstream packages. Cache coverage reports. |
lint | (none) | [] | yes | Linting is self-contained---no need to wait for upstream builds. No file output, but exit code is cached. |
typecheck | ["^build"] | [] | yes | Type 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:
| Scenario | Without Remote Cache | With 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 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
Execution order:
@org/shared-types#buildruns first (no upstream dependencies).@org/shared-utils#build,@org/event-contracts#build, and@org/design-system#buildrun in parallel (all depend only onshared-types).@org/eslint-config,@org/tsconfig, and@org/prettier-confighave nobuildscript, 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:
- Correct build order:
tsc --buildcompiles packages in topological order. - Incremental builds: Only recompile packages whose sources (or upstream
.d.ts) changed. - Editor performance: The TypeScript language server resolves cross-package types via
.d.tsfiles 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 Category | Mechanism | Example |
|---|---|---|
| Platform contracts (models, API shapes) | @org/shared-types npm package | User, ApiResponse<T> |
| Inter-MFE event payloads | @org/event-contracts npm package | NavigationEvent, NotificationEvent |
| MFE-specific exposed module types | Module Federation type generation | mfe_dashboard/App props |
| MFE-internal types | Local src/types/ directory | DashboardMetric, 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 theimport-x/orderrule in@org/eslint-config. To avoid a fix/format loop, installeslint-config-prettierand add it as the last entry in your flat config array. This disables all ESLint rules that overlap with Prettier formatting. Alternatively, removeprettier-plugin-organize-importsfrom the Prettier config and rely solely on ESLint'simport-x/orderrule 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
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:
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-typesis 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:
- Immediate Renovate trigger: MFE repos can have a workflow that listens for
platform-packages-releasedevents and triggers a Renovate run, bypassing the normal Renovate schedule. - 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
- pnpm Workspaces: https://pnpm.io/workspaces
- Turborepo Documentation: https://turbo.build/repo/docs
- Turborepo Remote Caching: https://turbo.build/repo/docs/core-concepts/remote-caching
- Changesets: https://github.com/changesets/changesets
- Changesets GitHub Action: https://github.com/changesets/action
- TypeScript Project References: https://www.typescriptlang.org/docs/handbook/project-references.html
- GitHub Packages npm Registry: https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-npm-registry
- Renovate Shareable Config Presets: https://docs.renovatebot.com/config-presets/
- Module Federation: https://module-federation.io/
- ESLint Flat Config: https://eslint.org/docs/latest/use/configure/configuration-files-new