Programming

How to Build a Turborepo Monorepo with npm Workspaces

Tech Setup4 min read
TS

Tech Setup

Published September 25, 2026 · Editorial policy

How to Build a Turborepo Monorepo with npm Workspaces

Managing a growing codebase across multiple repositories leads to dependency hell, fragmented CI/CD pipelines, and inconsistent tooling. If you are a full-stack developer maintaining a React frontend, an Express backend, and a shared TypeScript utility library, keeping versions synchronized across separate Git repos wastes hours every week. By consolidating your code into a monorepo using npm workspaces and Turborepo, you get atomic commits, shared internal packages, and intelligent task caching that cuts build times in half without migrating to heavy package managers like pnpm or Yarn.

The Layout

A clean monorepo starts with a flat, predictable directory structure. You want your workspace packages separated by domain so Turborepo can map their dependency graph accurately.

Create a new directory for your project and initialize the workspace root:

mkdir turbo-workspace
cd turbo-workspace
git init
npm init -y

Open your root package.json and configure it to recognize your workspace paths. The workspaces field tells npm where to look for your internal packages and applications.

{
  "name": "turbo-workspace",
  "version": "1.0.0",
  "private": true,
  "workspaces": [
    "apps/*",
    "packages/*"
  ],
  "devDependencies": {
    "turbo": "^1.11.3"
  }
}

Now, scaffold the physical folder structure to match those workspace globs:

mkdir -p apps/web apps/api packages/ui packages/tsconfig

Your directory tree should look like this:

turbo-workspace/
├── apps/
│   ├── api/
│   └── web/
├── packages/
│   ├── tsconfig/
│   └── ui/
├── package.json
└── turbo.json

Configuring the Pipeline

Turborepo relies on a root-level configuration file named turbo.json to understand how tasks depend on one another. Without this file, running npm run build across workspaces executes everything concurrently without respecting build order, causing builds to fail when apps try to import unpublished packages.

Create turbo.json in the root of your project:

{
  "$schema": "https://turborepo.org/schema.json",
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**"]
    },
    "lint": {
      "dependsOn": []
    },
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}

Understanding the Pipeline Properties

  • dependsOn: ["^build"]: The caret (^) tells Turborepo that a package's build script cannot run until all of its internal workspace dependencies (e.g., packages/ui) have successfully completed their build script.
  • outputs: Specifies which directories Turborepo should cache. When you run a build a second time with no code changes, Turborepo restores these directories instantly from the local cache.
  • cache: false and persistent: true: Essential for long-running processes like npm run dev. You do not want Turborepo caching a development server.

Building Internal Packages

To see how workspaces and Turborepo work together, build a shared UI component library and consume it inside a frontend application.

The Shared TypeScript Config

First, set up a shared TypeScript configuration in packages/tsconfig/base.json so every package enforces identical compilation rules.

{
  "$schema": "https://json.schemastore.org/tsconfig",
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "declaration": true,
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  }
}

Initialize packages/tsconfig/package.json:

{
  "name": "@repo/tsconfig",
  "version": "1.0.0",
  "private": true,
  "files": [
    "base.json"
  ]
}

The UI Package

Initialize packages/ui to act as your internal component library.

Create packages/ui/package.json:

{
  "name": "@repo/ui",
  "version": "1.0.0",
  "private": true,
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "scripts": {
    "build": "tsc",
    "lint": "eslint ."
  },
  "dependencies": {
    "@repo/tsconfig": "*"
  },
  "devDependencies": {
    "typescript": "^5.3.3"
  }
}

Create packages/ui/tsconfig.json to extend the shared config:

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

Create a dummy component in packages/ui/src/index.ts:

export const Button = () => {
  return "Button Component";
};

The Web Application

Now, set up apps/web to consume @repo/ui.

Create apps/web/package.json:

{
  "name": "web",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch",
    "lint": "eslint ."
  },
  "dependencies": {
    "@repo/ui": "*"
  },
  "devDependencies": {
    "@repo/tsconfig": "^1.0.0",
    "typescript": "^5.3.3"
  }
}

Notice the dependency "@repo/ui": "*". Because npm workspaces recognize packages within the packages/* glob, installing dependencies links this automatically without needing to publish to the public npm registry.

Create apps/web/src/index.ts to use the button:

import { Button } from "@repo/ui";

console.log(`Rendering: ${Button()}`);

Linking and Running Tasks

With your packages configured, link the dependencies and test the pipeline.

Run the install command from the root of your repository:

npm install

Expected output:

added 42 packages, and audited 43 packages in 3s

found 0 vulnerabilities

npm will create a root node_modules directory and symlink @repo/ui and @repo/tsconfig directly into the respective consuming apps.

Now, execute the build pipeline using Turborepo by adding root scripts to your main package.json.

Update your root package.json scripts:

  "scripts": {
    "build": "turbo run build",
    "lint": "turbo run lint",
    "dev": "turbo run dev"
  },

Run the build command:

npm run build

Expected output:

@repo/tsconfig:build: cache bypass, force executing
@repo/ui:build: cache bypass, force executing
web:build: cache bypass, force executing

 Tasks:    3 successful, 3 total
Cached:    0 cached, 3 total
  Time:    1.245s

Run npm run build a second time immediately after:

npm run build

Expected output:

@repo/tsconfig:build: cache hit, suppressing output
@repo/ui:build: cache hit, suppressing output
web:build: cache hit, suppressing output

 Tasks:    3 successful, 3 total
Cached:    3 cached, 3 total
--------------------------------------------------------------------------------
  Time:    98ms

Turborepo detected that no source files changed, pulling artifacts instantly from its local cache.

Troubleshooting Common Failures

Monorepos introduce unique failure modes related to hoisting and symlinks. When commands fail, check these friction points first.

Cannot find module '@repo/ui' or its corresponding type declarations

This happens when npm workspaces failed to link the internal package, or the target package was not built before the consuming app tried to compile.

  1. Verify that packages/ui has a "build" script that outputs to the exact path specified in its "main" and "types" fields.
  2. Verify that your root turbo.json contains "dependsOn": ["^build"] under the build task. This ensures Turborepo compiles packages/ui before compiling apps/web.
  3. Wipe your lockfile and node modules to force a clean relink:
    rm -rf node_modules apps/*/node_modules packages/*/node_modules package-lock.json
    npm install
    

Changes in shared packages do not trigger hot-reloading in dev mode

If you are running npm run dev and editing files in packages/ui does not update apps/web, your workspace is likely missing a file watcher or your app's bundler is not configured to resolve symlinked workspace packages.

  1. Ensure your bundler (Vite, Next.js, Webpack) is configured to transpile external workspace packages. For example, in Vite, you may need to adjust server.watch.usePolling or ensure the package is included in optimizeDeps.
  2. Verify your root turbo.json has persistent: true and cache: false set for the dev task so Turborepo keeps processes open concurrently.

Maintaining Performance at Scale

As your team adds more apps and packages, keep your monorepo fast by adhering to strict boundaries. Never allow circular dependencies between packages (e.g., Package A imports Package B, which imports Package A). Turborepo will throw an explicit cycle detection error if this occurs.

Keep your root package.json clean by placing shared development dependencies—like Prettier, ESLint, or TypeScript—at the root level, while keeping runtime dependencies scoped strictly to the applications and packages that require them. For teams scaling past a single developer, consider enabling remote caching via Vercel or a custom HTTP cache server so your CI pipeline shares the exact same build cache as your local machine.