Back to all posts
MonorepoArchitectureDevOps

Monorepo Best Practices

Nov 5, 2025
11 min read

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 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

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.

Found this helpful?

Let's discuss your project needs.

Get in touch