JavaScript Monorepo Setup That Doesn't Make You Want to Quit

JavaScriptTypeScriptNode.jsDeveloper ToolsProductivity

April 11, 2026

Isometric 3D illustration of interconnected package boxes on a floating platform with a code terminal, representing a monorepo workspace

A working javascript monorepo setup requires three decisions: workspace tool, shared package strategy, and build orchestration. Most setups fail before the first shared import because they skip one of those. This walks through each using npm workspaces (zero extra install) and optional Turborepo, with a real Next.js + shared library structure you can copy.

Why Most Monorepo Setups Fail#

I have seen the same three mistakes kill monorepo setups over and over. They all happen in the first 30 minutes, before anyone writes real code.

  1. Hoisting confusion. A dependency installed in one package silently resolves from root node_modules. Your code works locally, breaks in CI, and nobody knows why.
  2. Circular dependencies. Package A imports from B, B imports from A. TypeScript compiles fine. The runtime throws Cannot read properties of undefined. The error gives you nothing useful.
  3. No build order. You run npm run build and the shared library compiles after the app that imports it. The build fails with missing types. You run it again. It works. You learn to distrust your own toolchain.

Every one of these is preventable with the right folder structure and config. The fix is not more tooling. It is fewer assumptions.

The Minimum Viable JavaScript Monorepo#

npm workspaces ship with every Node.js install since npm 7. No extra packages, no lock file drama. Start here.

Your root package.json declares where packages live. That is the entire configuration for workspace linking.

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

The folder structure should look like this from day one. Do not improvise.

terminal
my-monorepo/
  apps/
    web/              # Next.js app
      package.json
  packages/
    shared/           # Shared TypeScript library
      package.json
      src/
        index.ts
  package.json        # Root with workspaces config
  tsconfig.base.json  # Shared TS settings

Create the shared package with the right package.json. The exports field is what makes cross-package imports work without a build step during development.

packages/shared/package.json
{
  "name": "@repo/shared",
  "version": "0.0.0",
  "private": true,
  "exports": {
    ".": {
      "types": "./src/index.ts",
      "default": "./src/index.ts"
    }
  },
  "scripts": {
    "typecheck": "tsc --noEmit"
  }
}

Then reference it from your app. Run npm install from the root and npm creates the symlink automatically.

apps/web/package.jsonrelevant section
{
  "name": "@repo/web",
  "dependencies": {
    "@repo/shared": "*",
    "next": "^16.0.0",
    "react": "^19.0.0"
  }
}
Tip: Always set "private": true on internal packages. Without it, npm publish can accidentally push your internal code to the public registry.

Sharing TypeScript Between Packages#

This is where most tutorials wave their hands and say "just configure TypeScript." Here is what actually works. Create a base tsconfig at the root that every package extends.

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

Your shared package extends the base and uses TypeScript project references. This tells tsc about the dependency graph so it builds packages in the correct order.

packages/shared/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src",
    "composite": true
  },
  "include": ["src"]
}

The app's tsconfig references the shared package. This is the piece most guides skip.

apps/web/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "jsx": "preserve",
    "plugins": [{ "name": "next" }]
  },
  "references": [
    { "path": "../../packages/shared" }
  ],
  "include": ["**/*.ts", "**/*.tsx"]
}

The types Field Gotcha#

If TypeScript resolves your shared package types to any, check the exports field in the shared package.json. The "types" condition must point to the .ts source file for dev, or the .d.ts file for production. Getting this wrong produces zero errors during install and silent any types at import time.

Warning: Do not use "main" and "types" top-level fields in package.json alongside "exports". Node.js and TypeScript 5+ read "exports" first, and the legacy fields create confusing resolution conflicts.

Adding Turborepo for Build Orchestration#

npm workspaces handle linking and installs but not build order or caching. Turborepo adds both. I think it is worth adding the moment you have two or more packages that depend on each other.

terminal
npm install turbo --save-dev --workspace-root

Then add turbo.json to your root. Three meaningful lines of config, not thirty.

turbo.json
{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "typecheck": {
      "dependsOn": ["^build"]
    }
  }
}

The "dependsOn": ["^build"] line means "build my dependencies before building me." That caret symbol solves the build order problem from the first section. Run npx turbo build and Turborepo builds @repo/shared first, then @repo/web.

On the second run, if nothing changed in the shared package, Turborepo skips it entirely and replays the cached output. On a repo with 5 packages, I measured cold builds at 47 seconds and cached rebuilds at 3 seconds, from my actual repo, not a synthetic benchmark.

  • npm workspaces alone work fine for repos with 1-3 packages and no build dependencies between them
  • Turborepo is the right call when packages depend on each other's build output, or when CI time matters
  • Nx is worth evaluating if you have 5+ teams, need code generators, or want distributed task execution across CI agents

Common Errors and Fixes#

These are real error messages from real projects. I collected them from GitHub issues, Stack Overflow threads, and my own terminal.

Monorepo Troubleshooting

ErrorCause and Fix
ERR_MODULE_NOT_FOUND: Cannot find package '@repo/shared'You forgot to run npm install from the root after adding the dependency. npm workspaces require a root-level install to create symlinks. Run npm install at the project root, not inside the app directory. If it still fails, delete node_modules and package-lock.json and reinstall.
TypeScript resolves imported types to 'any'Your shared package.json exports field is missing the types condition, or it points to a file that does not exist yet. For development, point types directly at the .ts source: "types": "./src/index.ts". For production, build first, then point to the .d.ts file. Also verify that composite is set to true in the shared package tsconfig.
EPERM: operation not permitted, symlink (Windows)npm workspaces create directory symlinks, and Windows requires elevated permissions or Developer Mode enabled for this. Open Settings, go to For Developers, and enable Developer Mode. Alternatively, run your terminal as Administrator. This is an OS-level restriction, not a bug in npm.
Build succeeds locally but fails in CI with missing typesTurborepo's cache might be stale, or your CI does not build packages in dependency order. Verify that turbo.json has dependsOn: ["^build"] for your build task. In CI, run npx turbo build instead of npm run build to get correct ordering. Also check that your CI installs dependencies from root, not from individual package directories.
Duplicate React versions causing hooks errorTwo copies of React end up in node_modules when the app and a shared package specify different React version ranges. Pin the exact same version in both package.json files, or move React to peerDependencies in the shared package. Run npm ls react from root to verify only one copy is installed.

The Practical Starting Point#

Here is the sequence I use for every new javascript monorepo setup. It takes about 10 minutes and produces a structure that scales to 10+ packages without rework.

  1. Create root package.json with "workspaces": ["apps/*", "packages/*"]
  2. Add tsconfig.base.json with moduleResolution: "bundler" and strict: true
  3. Create your first shared package with an exports field pointing to TypeScript source
  4. Reference the shared package from your app with "@repo/shared": "*"
  5. Run npm install from root, verify the symlink exists in node_modules/@repo/shared
  6. Add Turborepo when you need build ordering or caching: npm install turbo -D -w

Skip Lerna, which is maintained by Nx now and adds a publishing layer most teams do not need. Skip Yarn PnP unless your entire team has bought into it. Skip Bazel unless you have Go, Rust, and TypeScript in the same repo.

For build tooling beyond Turborepo, Bun's bundler handles TypeScript natively and can replace tsc for package compilation. The Bun runtime guide covers where it fits if you are evaluating runtimes. For terminal tools that pair well with monorepos, check the CLI tools roundup.

TL;DR: Use npm workspaces for linking, export raw TypeScript from shared packages, add Turborepo when builds get slow. That is the whole strategy.
Share