
JavaScript Monorepo Setup That Doesn't Make You Want to Quit
April 11, 2026
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.
- 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. - 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. - No build order. You run
npm run buildand 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.
{
"name": "my-monorepo",
"private": true,
"workspaces": [
"apps/*",
"packages/*"
]
}
The folder structure should look like this from day one. Do not improvise.
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.
{
"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.
{
"name": "@repo/web",
"dependencies": {
"@repo/shared": "*",
"next": "^16.0.0",
"react": "^19.0.0"
}
}
Tip: Always set"private": trueon internal packages. Without it,npm publishcan 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.
{
"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.
{
"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.
{
"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 inpackage.jsonalongside"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.
npm install turbo --save-dev --workspace-root
Then add turbo.json to your root. Three meaningful lines of config, not thirty.
{
"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
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.
- Create root
package.jsonwith"workspaces": ["apps/*", "packages/*"] - Add
tsconfig.base.jsonwithmoduleResolution: "bundler"andstrict: true - Create your first shared package with an
exportsfield pointing to TypeScript source - Reference the shared package from your app with
"@repo/shared": "*" - Run
npm installfrom root, verify the symlink exists innode_modules/@repo/shared - 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.