Hybrid Monorepo/Polyrepo Setup for Micro Frontends with React + TypeScript
Comprehensive Tooling and Patterns Research
Table of Contents
- Monorepo Tools Comparison for Core Packages
- Local Cross-Repo Development
- Design System Package Distribution
- TypeScript Project References
- CI/CD Patterns
- Recommended Package Manager
- Overall Recommended Architecture
1. Monorepo Tools Comparison for Core Packages
Architecture Context
Your monorepo contains: design system, shared utilities, shared types, and common configurations. This is a "library-focused" monorepo (not an "app monorepo"), which affects tool choice significantly.
Turborepo
What it is: A high-performance build system for JavaScript/TypeScript monorepos by Vercel. Written in Rust (migrated from Go starting late 2023).
Strengths for your use case:
- Minimal setup: add to an existing monorepo in under 10 minutes
- Reads your package manager's workspace configuration (pnpm, npm, or yarn) and infers the dependency graph from
package.jsonfiles - Remote caching via Vercel (or self-hosted) -- a cache hit takes ~0.2s vs ~30s for a cold build
- Composable configuration (introduced in Turborepo 2.7, December 2025) allows reusable config snippets
- Lightweight: does not impose opinions on project structure
Limitations:
- Pure task runner -- no code generation, no dependency graph visualization, no project constraints enforcement
- No built-in library publishing workflow
- Less suited for enforcing architectural boundaries
turbo.json example for your setup:
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^build"]
},
"check-types": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["^build"]
}
}
}
Nx
What it is: A comprehensive build system and "Build Intelligence Platform" by Nrwl. Evolved significantly in 2025 with AI-driven CI/CD features.
Strengths for your use case:
- Built-in dependency graph visualization (
nx graph) - Powerful affected command: only builds/tests what changed
- Built-in code generators for React libraries, components
- Enforces module boundaries and architectural constraints via lint rules
- Native Module Federation support with
@nx/react/module-federation - First-class support for publishable libraries
- Up to 7x faster than Turborepo in large monorepos (benchmark-dependent)
Limitations:
- Steeper learning curve: Nx wants to manage your workspace its way
- Heavier: more configuration, more concepts (executors, generators, targets)
- Migration of an existing project is more complex than Turborepo
- Can feel like overkill for a library-only monorepo with 4 packages
nx.json example:
{
"targetDefaults": {
"build": {
"dependsOn": ["^build"],
"outputs": ["{projectRoot}/dist"],
"cache": true
},
"lint": {
"cache": true
},
"test": {
"cache": true
}
},
"namedInputs": {
"default": ["{projectRoot}/**/*"],
"production": ["default", "!{projectRoot}/**/*.spec.ts"]
}
}
pnpm Workspaces Alone
What it is: pnpm's built-in workspace feature for managing multiple packages in a single repository.
Strengths for your use case:
- Zero additional tooling beyond pnpm itself
workspace:*protocol for local dependencies -- best-in-class local development ergonomics- Strict dependency resolution prevents phantom dependencies
- Content-addressable store saves significant disk space
Limitations:
- No task orchestration (builds run sequentially or you script parallelism yourself)
- No caching (every build is a cold build)
- No affected detection
- No dependency graph visualization
pnpm-workspace.yaml:
packages:
- "packages/*"
Recommendation for Your Setup
Use Turborepo + pnpm workspaces. Here is the reasoning:
- Your monorepo is library-focused (design system + shared libs), not an app monorepo. Turborepo's lightweight approach is ideal.
- Turborepo adds caching and task orchestration on top of pnpm workspaces without imposing structural opinions.
- The pnpm
workspace:*protocol provides the best local development experience for inter-package references. - Turborepo's remote caching dramatically speeds up CI.
- Nx would be the right choice if you had the micro frontends inside the monorepo (its Module Federation integration is excellent), but since your MFEs are in separate repos, Nx's advantages are diminished for the core packages monorepo.
Monorepo structure:
core-packages/
├── packages/
│ ├── design-system/ # React component library
│ │ ├── src/
│ │ ├── package.json # @myorg/design-system
│ │ └── tsconfig.json
│ ├── shared-utils/ # Utility functions
│ │ ├── src/
│ │ ├── package.json # @myorg/shared-utils
│ │ └── tsconfig.json
│ ├── shared-types/ # TypeScript type definitions
│ │ ├── src/
│ │ ├── package.json # @myorg/shared-types
│ │ └── tsconfig.json
│ └── config/ # Shared configs (ESLint, TypeScript, Prettier)
│ ├── eslint/
│ ├── typescript/
│ └── prettier/
├── package.json
├── pnpm-workspace.yaml
├── turbo.json
└── tsconfig.json # Root tsconfig with project references
Root package.json:
{
"name": "@myorg/core-packages",
"private": true,
"packageManager": "pnpm@9.15.0",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"check-types": "turbo run check-types",
"test": "turbo run test",
"changeset": "changeset",
"version-packages": "changeset version",
"publish-packages": "turbo run build lint test && changeset publish"
},
"devDependencies": {
"@changesets/cli": "^2.27.0",
"turbo": "^2.3.0"
}
}
2. Local Cross-Repo Development
This is the most critical challenge in your hybrid setup: how do developers work on a micro frontend while simultaneously iterating on the design system or shared utils?
Approach 1: pnpm link / npm link
How it works:
pnpm link <path-to-local-package>creates a symlink from the consuming project'snode_modulesto the local package directory- Changes in the linked package are immediately visible (no rebuild of the link required, but you do need to rebuild the package itself)
Configuration:
# In a micro frontend repo:
pnpm link /path/to/core-packages/packages/design-system
pnpm link /path/to/core-packages/packages/shared-utils
Limitations:
- Symlinks can cause "dual package" issues: the linked package resolves its own
node_modulesfrom its original location, leading to duplicate React instances and the infamous "Invalid Hook Call" error - pnpm's strict
node_modulesstructure makes linking across different pnpm workspaces particularly tricky - Does not simulate a real
npm publish-- you may miss packaging issues - Breaks when the consuming project uses a different package manager than the source
Verdict: Not recommended as the primary approach for cross-repo development with React due to the duplicate dependency issue.
Approach 2: yalc
How it works:
yalc publishin the library project copies the built package files to a global store (~/.yalc)yalc add @myorg/design-systemin the consuming project copies files from the global store into the project and adds afile:.yalc/@myorg/design-systemdependency topackage.jsonyalc pushpublishes AND updates all consuming projects in one step
Setup:
# Install globally
npm install -g yalc
# In the design system package (after building):
cd core-packages/packages/design-system
pnpm build
yalc publish
# In a micro frontend repo:
cd mfe-checkout
yalc add @myorg/design-system
yalc add @myorg/shared-utils
# When you make changes to the design system:
cd core-packages/packages/design-system
pnpm build && yalc push # Automatically updates all consumers
Pros:
- Simulates a real npm publish -- catches packaging issues early
- No duplicate dependency problems (files are copied, not symlinked)
- Works across different package managers
yalc push --watchcan automate the push on changes
Cons:
- Requires building the package before publishing to yalc (not instant)
- Adds
.yalcdirectory and modifiespackage.json/ lockfile (must be gitignored) - Not maintained as actively as other tools (last significant update varies)
- pnpm compatibility has had issues historically (though most are resolved)
Required .gitignore additions:
.yalc
yalc.lock
Verdict: Good middle-ground. Best for quick cross-repo iteration when you need realistic package resolution.
Approach 3: Verdaccio (Local npm Registry)
How it works:
- Runs a full local npm registry on
http://localhost:4873 - You publish packages to it exactly as you would to npmjs.com
- Consuming projects install from it using a scoped
.npmrcconfiguration - Falls through to the public npm registry for packages not found locally
Setup:
# Install and start
npm install -g verdaccio
verdaccio # Starts on http://localhost:4873
# Create a user (one-time)
npm adduser --registry http://localhost:4873
Project .npmrc (in each micro frontend repo):
@myorg:registry=http://localhost:4873
Publishing workflow:
# In design system package:
cd core-packages/packages/design-system
pnpm build
npm publish --registry http://localhost:4873
# In micro frontend:
pnpm install @myorg/design-system@latest
Docker setup for team-wide use:
# docker-compose.yml
services:
verdaccio:
image: verdaccio/verdaccio
ports:
- "4873:4873"
volumes:
- verdaccio-storage:/verdaccio/storage
- ./verdaccio-config.yaml:/verdaccio/conf/config.yaml
volumes:
verdaccio-storage:
Pros:
- Most realistic simulation of the production workflow
- Works with any package manager
- Acts as a caching proxy for public npm (speeds up installs)
- Teams can share a single Verdaccio instance
- Can be used in CI for integration testing
Cons:
- Requires running a separate service
- More ceremony per publish cycle (version bump required each time)
- Slower iteration loop compared to symlinks or yalc
Verdict: Best for team environments and CI integration testing. Overkill for individual developer iteration.
Approach 4: pnpm overrides / npm overrides
How it works:
- Override any dependency resolution to point to a local file path
- In pnpm, add
overridesto the rootpackage.jsonorpnpm-workspace.yaml
Configuration (package.json in micro frontend):
{
"pnpm": {
"overrides": {
"@myorg/design-system": "file:../core-packages/packages/design-system",
"@myorg/shared-utils": "file:../core-packages/packages/shared-utils"
}
}
}
Important caveat: This bakes absolute paths into pnpm-lock.yaml, which causes problems:
- Lock files differ between developers' machines
- Cannot be committed to version control reliably
- There is an open feature request (May 2025) for "local-only overrides" in pnpm, but it is not yet implemented
Workaround -- use a local-only overrides script:
#!/bin/bash
# scripts/link-local.sh -- DO NOT COMMIT the modified package.json
CORE_PATH="${CORE_PACKAGES_PATH:-../core-packages}"
# Temporarily add overrides
node -e "
const pkg = require('./package.json');
pkg.pnpm = pkg.pnpm || {};
pkg.pnpm.overrides = {
'@myorg/design-system': 'file:${CORE_PATH}/packages/design-system',
'@myorg/shared-utils': 'file:${CORE_PATH}/packages/shared-utils',
'@myorg/shared-types': 'file:${CORE_PATH}/packages/shared-types'
};
require('fs').writeFileSync('./package.json', JSON.stringify(pkg, null, 2));
"
pnpm install
echo "Local overrides applied. Run 'git checkout package.json pnpm-lock.yaml' to revert."
Verdict: Useful as a quick hack but not suitable as a team-wide workflow due to lock file contamination.
Approach 5: Module Federation for Local Development
How it works:
- The design system is exposed as a Module Federation remote
- Micro frontends consume it at runtime, not at build time
- For local development, you point the remote URL to
localhost
Configuration in the design system (remote):
// packages/design-system/rsbuild.config.ts (or webpack.config.js)
import { ModuleFederationPlugin } from '@module-federation/enhanced/rspack';
export default {
plugins: [
new ModuleFederationPlugin({
name: 'design_system',
filename: 'remoteEntry.js',
exposes: {
'./Button': './src/components/Button',
'./Input': './src/components/Input',
'./Card': './src/components/Card',
'./theme': './src/theme',
},
shared: {
react: { singleton: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
},
}),
],
};
Configuration in a micro frontend (host/consumer):
// mfe-checkout/rsbuild.config.ts
import { ModuleFederationPlugin } from '@module-federation/enhanced/rspack';
export default {
plugins: [
new ModuleFederationPlugin({
name: 'mfe_checkout',
remotes: {
design_system: process.env.NODE_ENV === 'development'
? 'design_system@http://localhost:3001/remoteEntry.js'
: 'design_system@https://cdn.myorg.com/design-system/remoteEntry.js',
},
shared: {
react: { singleton: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
},
}),
],
};
Usage in micro frontend code:
// Dynamic import from the federated module
const Button = React.lazy(() => import('design_system/Button'));
// Or with typed wrapper (recommended):
import type { ButtonProps } from '@myorg/design-system'; // types from npm
const Button = React.lazy(() => import('design_system/Button'));
Verdict: This is the most powerful approach for your architecture. It eliminates the need for linking/publishing during development entirely. See Section 3 for the full Module Federation vs npm package distribution analysis.
Recommended Combined Workflow
Use a layered approach:
| Scenario | Tool | Why |
|---|---|---|
| Daily development (design system + MFE) | Module Federation (dev server) | Instant feedback, no build/link step |
| Testing packaging/exports | yalc | Catches real packaging issues |
| Team-wide integration testing | Verdaccio (Docker) | Realistic registry simulation |
| CI integration tests | Verdaccio or GitHub Packages | Full production-like flow |
| Production | GitHub Packages (npm) + Module Federation (runtime) | See Section 3 |
3. Design System Package Distribution
Package Structure
packages/design-system/
├── src/
│ ├── components/
│ │ ├── Button/
│ │ │ ├── Button.tsx
│ │ │ ├── Button.styles.ts # or Button.module.css
│ │ │ ├── Button.test.tsx
│ │ │ └── index.ts
│ │ ├── Input/
│ │ ├── Card/
│ │ └── ...
│ ├── hooks/
│ │ ├── useTheme.ts
│ │ └── index.ts
│ ├── theme/
│ │ ├── tokens.ts
│ │ ├── ThemeProvider.tsx
│ │ └── index.ts
│ ├── utils/
│ │ └── index.ts
│ └── index.ts # Main barrel export
├── tsup.config.ts
├── package.json
├── tsconfig.json
└── CHANGELOG.md
Build Configuration with tsup
tsup (built on esbuild) is the recommended build tool for React component libraries due to its speed, simplicity, and tree-shaking support.
tsup.config.ts:
import { defineConfig } from 'tsup';
export default defineConfig({
entry: {
index: 'src/index.ts',
// Granular entry points for better tree-shaking:
'components/Button': 'src/components/Button/index.ts',
'components/Input': 'src/components/Input/index.ts',
'components/Card': 'src/components/Card/index.ts',
'hooks/index': 'src/hooks/index.ts',
'theme/index': 'src/theme/index.ts',
},
format: ['esm', 'cjs'],
dts: true,
splitting: true,
clean: true,
treeshake: true,
sourcemap: true,
external: ['react', 'react-dom'], // Peer dependencies
minify: false, // Let consumers handle minification
target: 'es2020',
});
package.json for Distribution
{
"name": "@myorg/design-system",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./components/*": {
"import": {
"types": "./dist/components/*/index.d.ts",
"default": "./dist/components/*/index.js"
},
"require": {
"types": "./dist/components/*/index.d.cts",
"default": "./dist/components/*/index.cjs"
}
},
"./hooks": {
"import": {
"types": "./dist/hooks/index.d.ts",
"default": "./dist/hooks/index.js"
}
},
"./theme": {
"import": {
"types": "./dist/theme/index.d.ts",
"default": "./dist/theme/index.js"
}
}
},
"sideEffects": false,
"files": ["dist"],
"peerDependencies": {
"react": "^18.2.0 || ^19.0.0",
"react-dom": "^18.2.0 || ^19.0.0"
},
"publishConfig": {
"registry": "https://npm.pkg.github.com",
"access": "restricted"
},
"repository": {
"type": "git",
"url": "https://github.com/myorg/core-packages.git",
"directory": "packages/design-system"
},
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"lint": "eslint src/",
"check-types": "tsc --noEmit",
"test": "vitest run"
}
}
Tree-Shaking Considerations
"sideEffects": false-- This tells bundlers that any unused exports can be safely eliminated. If you have CSS imports or global styles, list them explicitly:
{
"sideEffects": ["**/*.css", "**/*.scss"]
}
-
ESM output is mandatory -- Tree-shaking only works reliably with ES modules. The
"type": "module"and"module"field ensure bundlers use the ESM version. -
Multiple entry points -- The
exportsmap with granular paths allows consumers to import only what they need:
// Imports only Button code, not the entire design system:
import { Button } from '@myorg/design-system/components/Button';
-
External peer dependencies -- React and ReactDOM must be
externalin tsup config to avoid bundling them into the library. -
Avoid barrel file re-exports of entire modules -- Each component should have its own entry point. A single
index.tsthat re-exports everything defeats tree-shaking in some bundlers.
Versioning Strategy
Recommendation: Use Changesets with independent versioning.
.changeset/config.json:
{
"$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "restricted",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}
Workflow:
# Developer makes changes, then:
pnpm changeset
# Interactive prompt: select packages, bump type, write summary
# CI or release manager runs:
pnpm changeset version # Updates versions + changelogs
pnpm changeset publish # Publishes to registry
# Or combine with Turborepo:
pnpm publish-packages # build + lint + test + publish
Why independent versioning (not fixed):
@myorg/shared-typeschanges far more frequently than@myorg/design-system- Fixed versioning forces ALL packages to bump on every change, causing unnecessary updates in consuming repos
- Independent versioning lets micro frontends update only the packages they actually depend on
Publishing to a Private npm Registry
Recommendation: GitHub Packages.
Why GitHub Packages over alternatives:
- Native integration with GitHub Actions (authentication via
GITHUB_TOKEN) - Access control mirrors repository permissions
- Scoped to your GitHub organization
- No additional service to manage
- Free for private packages within GitHub plans
Setup:
- Root
.npmrcin the monorepo:
@myorg:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}
- Each package's
package.jsonmust include:
{
"name": "@myorg/design-system",
"publishConfig": {
"registry": "https://npm.pkg.github.com"
},
"repository": {
"type": "git",
"url": "https://github.com/myorg/core-packages.git"
}
}
- In each micro frontend's
.npmrc:
@myorg:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}
Cloudflare alternative: Cloudflare does not offer a native npm registry service. If you want a self-hosted alternative to GitHub Packages, Verdaccio on Cloudflare Workers or a similar edge deployment could work, but it is significantly more effort for little benefit over GitHub Packages.
npm Package vs Module Federation: When to Use Each
Dual distribution strategy (recommended):
| Aspect | npm Package | Module Federation Remote |
|---|---|---|
| When consumed | Build time | Runtime |
| TypeScript types | Full type safety via .d.ts | Requires separate type package or @module-federation/typescript |
| Versioning | Explicit (semver in package.json) | Implicit (whatever is deployed) |
| Offline/CI builds | Works without network | Requires remote to be available |
| Tree-shaking | Excellent (with ESM) | Limited (loads exposed modules) |
| Independent deployment | No (requires rebuild) | Yes (update remote, consumers get changes) |
| Best for | Shared types, utilities, theme tokens | UI components, feature modules |
Recommended approach:
- Publish
@myorg/shared-typesand@myorg/shared-utilsas npm packages only (they are consumed at build time and tree-shake well) - Publish
@myorg/design-systemas both npm package AND Module Federation remote:- npm package: for TypeScript types, IDE autocompletion, and as a fallback
- Module Federation remote: for runtime consumption with independent deployment
- Use
@myorg/configpackages as npm packages only (dev dependencies)
Module Federation shared configuration for the design system:
// Design system federation config
{
name: 'design_system',
filename: 'remoteEntry.js',
exposes: {
'./Button': './src/components/Button',
'./Input': './src/components/Input',
'./Card': './src/components/Card',
'./ThemeProvider': './src/theme/ThemeProvider',
},
shared: {
react: {
singleton: true,
requiredVersion: '^18.2.0',
eager: false, // Do NOT set eager:true -- it bloats the entry
},
'react-dom': {
singleton: true,
requiredVersion: '^18.2.0',
eager: false,
},
// Share the design system's theme tokens as a singleton:
'@myorg/shared-utils': {
singleton: true,
requiredVersion: '^1.0.0',
},
},
}
4. TypeScript Project References
How TypeScript Project References Work
TypeScript project references allow the compiler to understand your monorepo as a series of connected projects ("islands") rather than one monolithic unit. Each package is compiled independently, with explicit dependency declarations.
Key compiler options required:
| Option | Purpose |
|---|---|
composite: true | Enables project reference mode; implies declaration and incremental |
declaration: true | Generates .d.ts files (required for cross-project type resolution) |
declarationMap: true | Generates sourcemaps for declarations (enables "Go to Definition" across packages) |
incremental: true | Caches compilation results in tsconfig.tsbuildinfo |
Configuration for the Monorepo
Shared base config -- packages/config/typescript/base.json:
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"forceConsistentCasingInFileNames": true,
"noUncheckedIndexedAccess": true
}
}
Shared React library config -- packages/config/typescript/react-library.json:
{
"extends": "./base.json",
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"composite": true
}
}
Root tsconfig.json (project references):
{
"files": [],
"references": [
{ "path": "packages/shared-types" },
{ "path": "packages/shared-utils" },
{ "path": "packages/design-system" }
]
}
packages/shared-types/tsconfig.json:
{
"extends": "../config/typescript/base.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}
packages/shared-utils/tsconfig.json:
{
"extends": "../config/typescript/base.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"],
"references": [
{ "path": "../shared-types" }
]
}
packages/design-system/tsconfig.json:
{
"extends": "../config/typescript/react-library.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"],
"references": [
{ "path": "../shared-types" },
{ "path": "../shared-utils" }
]
}
Building with Project References
# Build all projects in dependency order:
tsc --build
# Build a specific project and its dependencies:
tsc --build packages/design-system
# Clean build artifacts:
tsc --build --clean
In turbo.json, the check-types task already handles this via the dependsOn: ["^build"] configuration, ensuring packages are built before their dependents are type-checked.
tsconfig paths for Local Development (within the monorepo)
Path aliases in the root tsconfig.json enable IDE features (autocompletion, go-to-definition) without requiring a build step:
{
"compilerOptions": {
"paths": {
"@myorg/shared-types": ["./packages/shared-types/src"],
"@myorg/shared-types/*": ["./packages/shared-types/src/*"],
"@myorg/shared-utils": ["./packages/shared-utils/src"],
"@myorg/shared-utils/*": ["./packages/shared-utils/src/*"],
"@myorg/design-system": ["./packages/design-system/src"],
"@myorg/design-system/*": ["./packages/design-system/src/*"]
}
}
}
Important: These paths are for development only. The actual module resolution for consumers uses the exports field in package.json. The pnpm workspace:* protocol resolves the dependency, and the exports map points to the built output.
Type Safety for External Repos (Micro Frontends)
When micro frontends consume the packages from the npm registry, type safety comes from the .d.ts files published alongside the JavaScript:
- tsup generates
.d.tsfiles from the TypeScript source - The
"types"and"exports"fields inpackage.jsonpoint to these declarations - Consumers get full type checking, autocompletion, and go-to-definition (via
declarationMap)
For Module Federation types, use @module-federation/typescript:
pnpm add -D @module-federation/typescript
This plugin generates type declarations for federated modules and makes them available to consuming hosts. Alternatively, maintain a @myorg/design-system-types package that exports only the TypeScript interfaces and types, consumed as an npm dependency even when the components themselves come via Module Federation.
A pragmatic approach is to have micro frontends depend on @myorg/design-system as a devDependency (for types only) while consuming components at runtime via Module Federation:
{
"devDependencies": {
"@myorg/design-system": "^2.0.0"
}
}
// Types come from the npm package (build time):
import type { ButtonProps } from '@myorg/design-system';
// Components come from Module Federation (runtime):
const Button = React.lazy(() => import('design_system/Button'));
5. CI/CD Patterns
Monorepo CI/CD (Core Packages)
GitHub Actions workflow for the monorepo:
.github/workflows/ci.yml:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm build
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
- run: pnpm lint
- run: pnpm check-types
- run: pnpm test
.github/workflows/release.yml:
name: Release
on:
push:
branches: [main]
permissions:
contents: write
packages: write
pull-requests: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
registry-url: 'https://npm.pkg.github.com'
scope: '@myorg'
- run: pnpm install --frozen-lockfile
- run: pnpm 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 publish-packages
version: pnpm changeset version
title: 'chore: version packages'
commit: 'chore: version packages'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.GITHUB_TOKEN }}
How this workflow operates:
- On every push to
main, the Changesets action checks if there are pending changesets. - If there are changesets, it creates a "Version Packages" PR that bumps versions and updates changelogs.
- When that PR is merged, the action detects the version bumps and runs
pnpm publish-packages, which builds, tests, and publishes to GitHub Packages.
Deploy Design System Module Federation Remote
If you serve the design system as a Module Federation remote, add a deployment step:
- name: Deploy Module Federation Remote
if: steps.changesets.outputs.published == 'true'
run: |
# Build the MF remote bundle
cd packages/design-system
pnpm build:federation
# Deploy to CDN (example: Cloudflare R2 or AWS S3)
aws s3 sync dist/federation/ s3://my-cdn-bucket/design-system/ \
--cache-control "public, max-age=31536000, immutable"
# Invalidate CDN cache
aws cloudfront create-invalidation \
--distribution-id ${{ secrets.CF_DISTRIBUTION_ID }} \
--paths "/design-system/remoteEntry.js"
Micro Frontend Repos: Consuming New Versions
Automated dependency updates with Renovate (recommended over Dependabot):
Why Renovate:
- Supports 90+ package managers (vs Dependabot's 30+)
- Groups monorepo package updates into a single PR (
group:monorepospreset) - Shared config presets standardize dependency policies across all repos
- Built-in automerge with configurable rules
- Regex managers for updating versions in any file format
- Works across GitHub, GitLab, Bitbucket, Azure DevOps
Shared Renovate config (in a separate repo or gist):
renovate-config/default.json:
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended",
"group:monorepos",
":automergeMinor",
":automergeDigest"
],
"packageRules": [
{
"description": "Auto-merge patch updates from our design system",
"matchPackagePatterns": ["@myorg/*"],
"matchUpdateTypes": ["patch"],
"automerge": true,
"automergeType": "pr",
"platformAutomerge": true
},
{
"description": "Group all @myorg packages together",
"matchPackagePatterns": ["@myorg/*"],
"groupName": "myorg core packages"
},
{
"description": "Require manual review for major updates",
"matchPackagePatterns": ["@myorg/*"],
"matchUpdateTypes": ["major"],
"automerge": false,
"reviewers": ["team:frontend-platform"]
}
],
"registryAliases": {
"npm": "https://npm.pkg.github.com"
},
"npmrc": "@myorg:registry=https://npm.pkg.github.com\n//npm.pkg.github.com/:_authToken=${RENOVATE_TOKEN}"
}
In each micro frontend repo, renovate.json:
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["github>myorg/renovate-config"]
}
Alternative: GitHub Actions webhook-triggered updates.
When the monorepo publishes new versions, trigger updates across micro frontend repos:
.github/workflows/notify-consumers.yml (in monorepo):
name: Notify Consumers
on:
workflow_run:
workflows: [Release]
types: [completed]
jobs:
notify:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
strategy:
matrix:
repo:
- myorg/mfe-checkout
- myorg/mfe-catalog
- myorg/mfe-account
- myorg/mfe-search
- myorg/mfe-admin
steps:
- name: Trigger dependency update
uses: peter-evans/repository-dispatch@v3
with:
token: ${{ secrets.CROSS_REPO_PAT }}
repository: ${{ matrix.repo }}
event-type: core-packages-updated
client-payload: '{"ref": "${{ github.ref }}"}'
In each micro frontend repo, .github/workflows/update-deps.yml:
name: Update Core Packages
on:
repository_dispatch:
types: [core-packages-updated]
permissions:
contents: write
pull-requests: write
jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: 'https://npm.pkg.github.com'
scope: '@myorg'
- run: pnpm update "@myorg/*" --latest
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create Pull Request
uses: peter-evans/create-pull-request@v6
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: 'chore: update @myorg core packages'
title: 'chore: update @myorg core packages'
body: |
Automated update triggered by new release of core packages.
Please verify:
- [ ] Design system components render correctly
- [ ] No TypeScript errors
- [ ] Tests pass
branch: chore/update-core-packages
delete-branch: true
Micro Frontend CI Pipeline
.github/workflows/ci.yml (in each MFE repo):
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
registry-url: 'https://npm.pkg.github.com'
scope: '@myorg'
- run: pnpm install --frozen-lockfile
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: pnpm lint
- run: pnpm check-types
- run: pnpm test
- run: pnpm build
deploy:
needs: ci
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# ... deploy micro frontend to CDN/hosting
6. Recommended Package Manager
Comparison Matrix
| Feature | pnpm | yarn (v4 Berry) | npm (v10) |
|---|---|---|---|
| Disk efficiency | Content-addressable store (best) | PnP or node_modules | Standard node_modules |
| Install speed | Fastest | Fast with PnP | Slowest |
| Strict deps | Enforced by default | Optional (PnP) | Not enforced |
| Workspace protocol | workspace:* (best) | workspace:* | Supported (basic) |
| Cross-repo linking | pnpm link (strict) | yarn link | npm link |
| Module Federation compat | Excellent | PnP has issues | Good |
| Turborepo integration | First-class | First-class | First-class |
| Monorepo support | Native workspaces | Native workspaces | Native workspaces |
| overrides / resolutions | pnpm.overrides | resolutions | overrides |
Recommendation: pnpm
pnpm is the clear choice for your setup. Here is why:
-
Content-addressable store: Across 5-10 micro frontend repos + the monorepo, pnpm's global store prevents duplicate downloads. A single copy of
react@18.2.0is shared across all projects. -
Strict dependency resolution: Prevents phantom dependencies (importing packages not declared in
package.json), which is critical when your packages will be consumed by other repos that may have different dependency trees. -
workspace:*protocol: The best local development experience within the monorepo. When you publish,workspace:*is automatically replaced with the actual version number. -
Module Federation compatibility: pnpm's node_modules structure (symlinks into
.pnpmstore) works well with webpack and rspack module resolution. Unlike Yarn PnP, there are no known compatibility issues with Module Federation. -
Turborepo integration: Turborepo reads pnpm workspace configuration natively and understands the dependency graph.
-
Cross-repo linking: While
pnpm linkhas quirks, the combination with yalc or Module Federation dev servers covers all cross-repo scenarios. -
Performance at scale: With 5-10 micro frontend repos, pnpm's install speed advantage compounds significantly.
pnpm version pin in the monorepo:
{
"packageManager": "pnpm@9.15.0",
"engines": {
"node": ">=20.0.0",
"pnpm": ">=9.0.0"
}
}
Use corepack to ensure consistent pnpm versions:
corepack enable
corepack prepare pnpm@9.15.0 --activate
7. Overall Recommended Architecture
Summary of Tool Choices
| Concern | Tool | Alternatives Considered |
|---|---|---|
| Package manager | pnpm | yarn, npm |
| Monorepo orchestration | Turborepo | Nx, pnpm alone |
| Package building | tsup | Rollup, Vite library mode |
| Versioning/publishing | Changesets | Lerna, manual |
| Private registry | GitHub Packages | Verdaccio, npm Enterprise |
| Cross-repo local dev | Module Federation dev + yalc | pnpm link, Verdaccio |
| Module Federation runtime | @module-federation/enhanced (Rspack/webpack) | Vite plugin |
| Automated dep updates | Renovate | Dependabot |
| TypeScript config | Project references + shared base configs | paths-only |
| CI/CD | GitHub Actions + Turborepo remote cache | CircleCI, Jenkins |
Developer Workflow (Day-to-Day)
1. Clone both repos:
git clone myorg/core-packages
git clone myorg/mfe-checkout
2. Start the design system dev server (Module Federation remote):
cd core-packages && pnpm dev
# Design system serves remoteEntry.js at localhost:3001
3. Start the micro frontend (Module Federation host):
cd mfe-checkout && pnpm dev
# MFE consumes design system from localhost:3001
4. Make changes to the design system:
# Edit packages/design-system/src/components/Button/Button.tsx
# Hot reload propagates through Module Federation -- no linking needed
5. When ready to share with the team:
pnpm changeset # Declare the change
git commit && git push # PR and review
# On merge: CI builds, versions, and publishes automatically
# Renovate creates update PRs in all micro frontend repos
Production Architecture
Key Principles
-
Types at build time, components at runtime: Use npm packages for TypeScript types and utilities. Use Module Federation for UI components that benefit from independent deployment.
-
Automate everything across repos: Changesets for versioning, Renovate for dependency propagation, GitHub Actions for CI/CD. Minimize manual coordination.
-
Strict boundaries: pnpm's strict resolution + TypeScript project references + clear package boundaries prevent dependency chaos.
-
Cache aggressively: Turborepo remote cache for the monorepo, pnpm's content-addressable store for installations, CDN caching for Module Federation remotes.
-
Dual development modes: Module Federation for daily cross-repo development (instant feedback), yalc for testing real packaging behavior before releases.
Sources
- Nx vs. Turborepo: Integrated Ecosystem or High-Speed Task Runner?
- Turborepo, Nx, and Lerna: The Truth about Monorepo Tooling in 2026
- Why I Chose Turborepo Over Nx
- How we configured pnpm and Turborepo for our monorepo (Nhost)
- Setting Up a Scalable Monorepo With Turborepo and PNPM
- Structuring a repository (Turborepo docs)
- Turborepo TypeScript Guide
- Different approaches to testing packages locally: yalc
- Different approaches to testing packages locally: Verdaccio
- yalc GitHub repository
- Verdaccio - lightweight private npm registry
- pnpm vs verdaccio vs yalc comparison
- How to bundle a tree-shakable TypeScript library with tsup
- Creating a tree-shakable library with tsup
- Tree Shaking in React: How to write a tree-shakable component library
- How to create a lean, tree-shakeable React Design System library
- Module Federation Shared Configuration
- Solving micro-frontend challenges with Module Federation
- Micro Frontends with Module Federation in Monorepo
- Module Federation - Rspack
- Everything You Need to Know About TypeScript Project References (Nx)
- Managing TypeScript Packages in Monorepos (Nx)
- Live types in a TypeScript monorepo
- TypeScript project references (moonrepo)
- TypeScript Documentation: Project References
- Working with the npm registry - GitHub Packages
- Publishing Node.js packages - GitHub Actions
- Changesets GitHub repository
- Complete Monorepo Guide: pnpm + Workspace + Changesets (2025)
- Renovate vs Dependabot comparison
- Renovate Bot GitHub
- From Monorepo to Polyrepo: Scaling Micro-Frontends Across Teams
- Creating separate monorepo CI/CD pipelines with GitHub Actions
- GitHub Actions in 2026: Complete Guide to Monorepo CI/CD
- pnpm overrides for dependency management
- pnpm local-only overrides discussion
- Choosing the Right JavaScript Package Manager in 2025