Monorepo Best Practices
Monorepo Best Practices
Monorepos aren't just for Google and Facebook. They're the right choice for many teams.
Why Monorepo?
Benefits:
- Shared code - One source of truth for components, utils
- Atomic changes - Update library and consumers in one PR
- Better refactoring - See all usages across projects
- Unified tooling - One config for linting, testing, building
- Easier onboarding - Everything in one repo
Drawbacks:
- Slower CI if not optimized
- More complex tooling needed
- Harder access control (everything visible)
For most teams, benefits outweigh drawbacks.
Tools
I use Turborepo. It's fast, simple, and works great with npm/pnpm.
Alternatives:
- Nx - More features, steeper learning curve
- Lerna - Older, less maintained
- Rush - Microsoft's tool, great for large teams
Structure
Clean structure is critical:
my-monorepo/
├── apps/
│ ├── web/ # Main web app
│ ├── admin/ # Admin dashboard
│ └── mobile/ # React Native app
├── packages/
│ ├── ui/ # Shared component library
│ ├── utils/ # Shared utilities
│ ├── config/ # Shared configs (eslint, ts)
│ └── types/ # Shared TypeScript types
├── tooling/
│ ├── eslint-config/
│ └── typescript-config/
├── package.json
├── turbo.json
└── pnpm-workspace.yaml
Apps = Deployable applications Packages = Shared libraries Tooling = Dev tools and configs
Package Setup
Each package has its own package.json:
{ "name": "@repo/ui", "version": "0.0.0", "private": true, "main": "./src/index.ts", "types": "./src/index.ts", "exports": { ".": "./src/index.ts", "./button": "./src/button.tsx" } }
Apps import packages:
import { Button } from "@repo/ui"; import { formatDate } from "@repo/utils";
TypeScript resolves these via workspace protocol.
Turborepo Configuration
turbo.json:
{ "tasks": { "build": { "dependsOn": ["^build"], "outputs": ["dist/**", ".next/**"] }, "test": { "dependsOn": ["^build"] }, "lint": {}, "dev": { "cache": false, "persistent": true } } }
Key features:
dependsOn- Build dependencies firstoutputs- Cache these directoriescache: false- Don't cache dev server
Workspace Protocol
Use pnpm for best monorepo support:
pnpm-workspace.yaml:
packages: - "apps/*" - "packages/*" - "tooling/*"
Install workspace dependencies:
{ "dependencies": { "@repo/ui": "workspace:*", "@repo/utils": "workspace:*" } }
Shared Configuration
Share ESLint config:
// tooling/eslint-config/index.js module.exports = { extends: [ "eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:react/recommended", ], // ... rules };
Use in packages:
{ "eslintConfig": { "extends": ["@repo/eslint-config"] } }
Same pattern for TypeScript, Prettier, Jest configs.
CI/CD Optimization
Problem: CI runs all tests on every commit, even if code didn't change.
Solution: Turborepo's remote caching.
# Only rebuild what changed turbo run build --filter=[HEAD^1] # Use remote cache turbo run build --cache-dir=.turbo/cache
GitHub Actions example:
name: CI on: [pull_request] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: pnpm/action-setup@v2 with: version: 8 - name: Setup Node uses: actions/setup-node@v3 with: node-version: 18 cache: 'pnpm' - run: pnpm install - name: Build run: pnpm turbo run build --filter=[HEAD^1] - name: Test run: pnpm turbo run test --filter=[HEAD^1]
Huge time savings on large PRs.
Versioning Strategy
For internal packages (not published to npm):
{ "version": "0.0.0", "private": true }
For published packages, use Changesets:
pnpm add -DW @changesets/cli pnpm changeset init
Create changeset:
pnpm changeset
It asks:
- Which packages changed?
- Semver bump (major/minor/patch)?
- Changelog entry?
Then in CI:
pnpm changeset version pnpm changeset publish
Automatic versioning and publishing.
Common Pitfalls
1. Not Using Task Dependencies
{ "tasks": { "build": { "dependsOn": ["^build"] // Build deps first! } } }
Without this, apps might build before their dependencies.
2. Circular Dependencies
Never create circular dependencies:
❌ @repo/ui -> @repo/utils -> @repo/ui
Keep dependency graph acyclic.
3. Too Many Small Packages
Don't create a package for every util function. Group related code:
✅ @repo/utils (contains formatDate, parseUrl, etc.)
❌ @repo/format-date, @repo/parse-url, ...
4. Ignoring Cache Configuration
Configure outputs properly:
{ "tasks": { "build": { "outputs": ["dist/**", "build/**", ".next/**"] } } }
Missing this = no caching benefits.
Testing in Monorepo
Shared Jest config:
// packages/jest-config/jest.config.js module.exports = { preset: "ts-jest", testEnvironment: "jsdom", moduleNameMapper: { "^@repo/(.*)$": "<rootDir>/../$1/src", }, };
Run all tests:
turbo run test
Run tests for specific package:
turbo run test --filter=@repo/ui
Development Workflow
Start all apps in dev mode:
turbo run dev --parallel
Work on specific app:
turbo run dev --filter=web
Watch mode for package:
turbo run dev --filter=@repo/ui
Migration Strategy
Moving to monorepo? Do it gradually:
- Setup monorepo structure
- Move shared code to packages
- Migrate one app at a time
- Update CI/CD incrementally
- Train team on new workflows
Don't do big-bang migrations. Too risky.
Conclusion
Monorepos are powerful when done right:
- Clear structure
- Proper tooling (Turborepo/Nx)
- Optimized CI/CD
- Shared configs
- Good documentation
The initial setup takes effort, but the long-term benefits are massive.
Questions about monorepos? Reach out.