Monorepos in JavaScript & TypeScript: A Practical Guide

How to structure, build, and maintain JavaScript/TypeScript monorepos

#JavaScript#TypeScript#Node.js

If we maintain more than two or three packages across separate repositories, we've probably hit the same wall: version mismatches, duplicated configs, copy-pasted CI pipelines. Monorepos solve most of that, but the tooling matters.

What a Monorepo Is

A monorepo is a single repository that contains multiple distinct projects or packages. It is not a monolith — packages can still be independently deployable, independently versioned, and have clear boundaries.

A typical structure:

my-monorepo/
├── packages/
│   ├── ui/            # shared component library
│   ├── utils/         # shared utilities
│   └── config/        # shared tsconfig, eslint, etc.
├── apps/
│   ├── web/           # Next.js frontend
│   ├── api/           # Express/Fastify backend
│   └── admin/         # admin dashboard
├── package.json
├── pnpm-workspace.yaml
└── turbo.json

The apps/ folder holds deployable applications. The packages/ folder holds shared libraries consumed by those apps. This convention is not enforced by any tool — it is what most teams settle on.

Benefits
  • Atomic changes across packages. Refactor a shared utility and update every consumer in one PR.
  • Single set of configs. One ESLint config, one TypeScript base config, one Prettier setup.
  • Simplified dependency management. One lockfile, one place to audit, one node_modules tree.
  • Easier code sharing. No publishing to npm just to share a helper between two internal projects.
Choosing a Package Manager

All three major package managers support workspaces, but they differ in important ways.

npm workspaces — built in since npm 7. Works for small setups. Symlinks packages and hoists dependencies. Slower and less strict than the alternatives.

// package.json (root)
{
  "name": "my-monorepo",
  "private": true,
  "workspaces": ["packages/*", "apps/*"]
}

Yarn (Berry/v4) — Plug'n'Play is interesting but introduces friction with tools that expect node_modules. Worth considering if our team is already on Yarn and comfortable with PnP.

pnpm — this is what I'd recommend for most new monorepos. It is fast, strict about dependencies (no phantom deps), and has excellent workspace support. The content-addressable store keeps disk usage reasonable.

# pnpm-workspace.yaml
packages:
  - 'packages/*'
  - 'apps/*'

Running commands with pnpm:

# install a dep in a specific package
pnpm add zod --filter @myorg/api

# run a script in a specific package
pnpm --filter @myorg/web dev

# run a script in all packages that have it
pnpm -r run build

The --filter flag supports package names, directory paths, and git-diff-based filtering (--filter ...[HEAD~1] to target only changed packages).

Shared TypeScript Config

Instead of copy-pasting tsconfig.json across packages, create a shared base:

// packages/config/tsconfig.base.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  }
}

Each package extends it:

// apps/web/tsconfig.json
{
  "extends": "@myorg/config/tsconfig.base.json",
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"]
}

The same pattern works for ESLint, Prettier, and any other config.

Internal Packages Without Publishing

Shared packages that are only consumed internally do not need a build step. We can point the package's exports directly at the TypeScript source:

// packages/utils/package.json
{
  "name": "@myorg/utils",
  "version": "0.0.0",
  "private": true,
  "exports": {
    ".": {
      "types": "./src/index.ts",
      "default": "./src/index.ts"
    }
  }
}

This works because our apps (Next.js, Vite, etc.) will transpile the imported source through their own bundler. No separate build step during development — changes to a shared util are picked up immediately.

If we need to publish a package to npm, we will need a proper build step with tsc or tsup. For internal packages, skip it.

Task Orchestration: Turborepo vs Nx

Once we have more than a handful of packages, running builds and tests in the right order and caching results becomes important.

Turborepo is the simpler option. Configuration is minimal:

// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "lint": {},
    "test": {
      "dependsOn": ["build"]
    }
  }
}

"dependsOn": ["^build"] means "build my dependencies before building me." Turborepo figures out the graph and parallelizes what it can. The cache is content-hash-based — if inputs have not changed, it replays the output.

# run build across all packages, respecting dependency order
turbo build

# run dev servers for web and api in parallel
turbo dev --filter=@myorg/web --filter=@myorg/api

Nx is more powerful but more opinionated. It has its own plugin system, generators, affected-based testing, and a cloud cache. Worth considering for large setups (50+ packages). For most teams, I'd start with Turborepo — it hits the sweet spot. Migrating to Nx later is straightforward; going the other direction is harder.

Dependency Management Between Packages

Reference internal packages through the dependencies field with the workspace protocol:

// apps/web/package.json
{
  "name": "@myorg/web",
  "dependencies": {
    "@myorg/utils": "workspace:*",
    "@myorg/ui": "workspace:*",
    "react": "^19.0.0"
  }
}

workspace:* (pnpm/Yarn) tells the package manager to resolve from the local workspace. npm workspaces use "*" — the symlink happens automatically.

Watch out for version conflicts on shared externals. If @myorg/web uses React 19 but @myorg/ui specifies React 18 as a peer dependency, we will get runtime bugs. Keep peer dependency ranges aligned, or use overrides/resolutions to force a single version.

A Real-World Folder Layout
monorepo/
├── apps/
│   ├── web/
│   │   ├── src/
│   │   ├── package.json      # depends on @myorg/ui, @myorg/utils
│   │   └── tsconfig.json     # extends shared config
│   └── api/
│       ├── src/
│       │   ├── routes/
│       │   └── index.ts
│       ├── package.json      # depends on @myorg/utils, @myorg/db
│       └── tsconfig.json
├── packages/
│   ├── ui/
│   │   ├── src/
│   │   │   ├── Button.tsx
│   │   │   └── index.ts
│   │   └── package.json
│   ├── utils/
│   │   ├── src/
│   │   │   ├── format.ts
│   │   │   └── index.ts
│   │   └── package.json
│   ├── db/
│   │   ├── src/
│   │   │   ├── client.ts
│   │   │   └── schema.ts
│   │   └── package.json
│   └── config/
│       ├── tsconfig.base.json
│       ├── eslint.config.mjs
│       └── package.json
├── pnpm-workspace.yaml
├── turbo.json
├── package.json
└── .github/
    └── workflows/
        └── ci.yml
CI: Only Build What Changed

Instead of running every test suite on every commit, filter to affected packages:

# .github/workflows/ci.yml
name: CI
on:
  pull_request:
    branches: [main]

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # needed for change detection

      - uses: pnpm/action-setup@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile

      - run: pnpm turbo build lint test --filter=...[origin/main]

--filter=...[origin/main] tells Turborepo to only run tasks for packages that changed since origin/main. Combined with caching, this keeps CI fast as the repo grows.

Things to Keep in Mind
  • Remote caching (Turborepo, Nx) — in my experience, the cache hit rate tends to be lower than expected. Local caching provides most of the benefit.
  • Code generators — Nx has scaffolding generators, but the generated code still needs maintenance. A template directory and a shell script often work just as well.
  • Strict module boundary enforcement — tools like Nx's enforce-module-boundaries can prevent bad imports, but teams often end up fighting the rules before the repo is large enough to need them. Add it when boundary violations actually become a problem.
Common Pitfalls

Circular dependencies. Package A depends on B, which depends on A. TypeScript may let this slide, but builds will break in confusing ways. Extract the shared code into a third package.

The catch-all utils package. It is tempting to put all shared code into one @myorg/utils package. Over time it becomes a dumping ground. Split by domain early: @myorg/date-utils, @myorg/validation, etc.

Incorrect dependency graph. If build tasks do not declare their dependencies correctly in turbo.json, we get nondeterministic failures. Set up dependsOn properly.

When a Monorepo Is Not Worth It
  • A single project with no shared code — a monorepo adds overhead for zero benefit.
  • Projects with completely different toolchains (e.g. a Go backend and a React frontend with nothing shared) — separate repos are fine.
  • A team without bandwidth to maintain the tooling — a poorly maintained monorepo is worse than well-maintained polyrepos.
Getting Started

The simplest path:

mkdir my-monorepo && cd my-monorepo
pnpm init
mkdir -p packages/shared apps/web

Set up the workspace, add a shared package, wire it into an app with workspace:*, and see how it feels. We can layer on Turborepo, shared configs, and CI integration as needs arise.

Start simple, add complexity only when the pain justifies it.