--- title: "Monorepo Best Practices" author: "Maksym Kuzmitskyi (MaximusFT)" published: "2026-07-30T19:59:51.228Z" updated: "2026-08-31T15:10:00.107Z" url: "https://zyvop.com/monorepo-best-practices-5chto" canonical: "https://ma-x.im/blog/monorepo-best-practices" tags: ["Monorepo","Architecture","DevOps"] --- # Monorepo Best Practices ![Cover image]() ![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: ```python 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`: ```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: ```typescript import { Button } from "@repo/ui"; import { formatDate } from "@repo/utils"; ``` TypeScript resolves these via workspace protocol. ## Turborepo Configuration `turbo.json`: ```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`: ```yaml packages: - "apps/*" - "packages/*" - "tooling/*" ``` Install workspace dependencies: ```json { "dependencies": { "@repo/ui": "workspace:*", "@repo/utils": "workspace:*" } } ``` ## Shared Configuration Share ESLint config: ```js // tooling/eslint-config/index.js module.exports = { extends: [ "eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:react/recommended", ], // ... rules }; ``` Use in packages: ```json { "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. ```bash # Only rebuild what changed turbo run build --filter=[HEAD^1] # Use remote cache turbo run build --cache-dir=.turbo/cache ``` GitHub Actions example: ```yaml 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): ```json { "version": "0.0.0", "private": true } ``` For published packages, use **Changesets**: ```bash pnpm add -DW @changesets/cli pnpm changeset init ``` Create changeset: ```bash pnpm changeset ``` It asks: - Which packages changed? - Semver bump (major/minor/patch)? - Changelog entry? Then in CI: ```bash pnpm changeset version pnpm changeset publish ``` Automatic versioning and publishing. ## Common Pitfalls ### 1\. Not Using Task Dependencies ```json { "tasks": { "build": { "dependsOn": ["^build"] // Build deps first! } } } ``` Without this, apps might build before their dependencies. ### 2\. Circular Dependencies **Never** create circular dependencies: ```typescript ❌ @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: ```typescript ✅ @repo/utils (contains formatDate, parseUrl, etc.) ❌ @repo/format-date, @repo/parse-url, ... ``` ### 4\. Ignoring Cache Configuration Configure `outputs` properly: ```json { "tasks": { "build": { "outputs": ["dist/**", "build/**", ".next/**"] } } } ``` Missing this = no caching benefits. ## Testing in Monorepo Shared Jest config: ```js // packages/jest-config/jest.config.js module.exports = { preset: "ts-jest", testEnvironment: "jsdom", moduleNameMapper: { "^@repo/(.*)$": "/../$1/src", }, }; ``` Run all tests: ```bash turbo run test ``` Run tests for specific package: ```bash turbo run test --filter=@repo/ui ``` ## Development Workflow Start all apps in dev mode: ```bash turbo run dev --parallel ``` Work on specific app: ```bash turbo run dev --filter=web ``` Watch mode for package: ```bash 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_]()_._