How to Build a Turborepo Monorepo with npm Workspaces
Published September 25, 2026 · Editorial policy

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'sbuildscript cannot run until all of its internal workspace dependencies (e.g.,packages/ui) have successfully completed theirbuildscript.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: falseandpersistent: true: Essential for long-running processes likenpm 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.
- Verify that
packages/uihas a"build"script that outputs to the exact path specified in its"main"and"types"fields. - Verify that your root
turbo.jsoncontains"dependsOn": ["^build"]under thebuildtask. This ensures Turborepo compilespackages/uibefore compilingapps/web. - 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.
- 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.usePollingor ensure the package is included in optimizeDeps. - Verify your root
turbo.jsonhaspersistent: trueandcache: falseset for thedevtask 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.


