Back to all posts
MonorepoArchitectureDevOps

Monorepo Best Practices

Nov 5, 2025
11 min read

Monorepo Best Practices

I learned to love monorepos the hard way — by suffering through the alternative.

At a large Canadian automotive company, we had a repository for basic UI components, a separate repository for complex components that depended on them, and then six separate repositories — one for each product line: cars, motorcycles, engines, marine, power equipment, and more. Plus utility repos for shared configs, linting rules, and deployment scripts.

When you released a patch to the base components, the chain reaction began. Update base components, then update complex components, then update all six product repos. Something always broke somewhere in that chain. A CI job would hang. A version mismatch would surface. A team using an older version would suddenly discover their build was broken. We outsourced CI/CD to external contractors, which added another layer of friction — every issue turned into a cross-team investigation.

We even started preparing a plan to consolidate everything into a single monorepo. I left the company before they finished.

That experience made me a firm believer: if your projects share code, put them together.

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. And for teams that have lived through multi-repo dependency hell — it's not even a question.

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 first
  • outputs - Cache these directories
  • cache: 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:

  1. Setup monorepo structure
  2. Move shared code to packages
  3. Migrate one app at a time
  4. Update CI/CD incrementally
  5. Train team on new workflows

Don't do big-bang migrations. Too risky.

Conclusion

Every time I set up a monorepo from scratch, I think about those six separate repos and the hours we spent chasing version mismatches across them. The initial monorepo setup takes effort — no question. But the alternative is worse: scattered code, broken dependency chains, and a CI pipeline that nobody fully understands.

Get the structure right. Invest in proper tooling. Cache aggressively. And keep your dependency graph clean. Future you will be grateful.


Questions about monorepos? Reach out.

Found this helpful?

Let's discuss your project needs.

Get in touch