This article is published in English.
Scaffolding a pnpm and Turborepo Monorepo for Node.js Apps Step by Step
Set up a TypeScript monorepo from an empty folder with pnpm workspaces and Turborepo, then run, build and filter tasks across a web app, an API and shared packages.
Once a product has a frontend, a backend and code both of them need, separate repositories start to hurt: shared types drift, configuration is copied by hand and one change spans several pull requests. pnpm workspaces plus Turborepo fix this without merging the projects into one. This walkthrough goes from an empty directory to two TypeScript apps and a shared package that you develop, build and filter from one root.
The finished layout:
my-monorepo/
├── apps/
│ ├── web/
│ └── api/
│
├── packages/
│ ├── types/
│ └── eslint-config/
│
├── package.json
├── pnpm-workspace.yaml
├── turbo.json
├── tsconfig.json
└── pnpm-lock.yaml
What a monorepo gives you
A monorepo is one Git repository holding several apps and packages. The alternative is one repository per concern:
frontend-repository
backend-repository
shared-types-repository
ui-library-repository
In a monorepo those become folders, with deployable code under apps and reusable code under packages:
my-monorepo/
├── apps/
│ ├── web/
│ └── api/
│
└── packages/
├── types/
└── ui/
Code is then shared directly instead of being published first. A types package consumed by both frontend and backend means a payload change reaches both sides in one commit:
apps/web
↓
packages/types
↑
apps/api
Where Turborepo fits
pnpm workspaces link packages; Turborepo decides how tasks run across them. It offers task orchestration, dependency-aware ordering, parallel execution, local and remote caching, incremental builds and workspace support.
Take a repository with three workspaces:
apps/web
apps/api
packages/types
Each may define the same scripts:
build
lint
test
dev
Rather than entering each directory in the right order, you run them from the root and Turborepo parallelizes where it can. For when this layer is worth it, see where Turborepo fits in a NestJS monorepo and when to skip it.
Prerequisites
You need Node.js, pnpm, Git and an editor. Check Node.js:
node -v
And pnpm:
pnpm -v
If pnpm is missing, Corepack, bundled with Node.js, can supply it. Enable it:
corepack enable
Activate the latest pnpm:
corepack prepare pnpm@latest --activate
Confirm:
pnpm -v
Setting up the root workspace
Create the directory:
mkdir my-monorepo
cd my-monorepo
Initialize Git:
git init
Generate the root manifest:
pnpm init
Which leaves:
my-monorepo/
└── package.json
Install Turborepo at the root
Turborepo serves the whole repository, so --workspace-root installs it in the root rather than in a package:
pnpm add turbo --save-dev --workspace-root
The root manifest ends up with scripts that delegate to turbo run; private keeps the root from being published:
{
"name": "my-monorepo",
"private": true,
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"test": "turbo run test"
},
"devDependencies": {
"turbo": "..."
}
}
Your turbo version depends on install time. Recent releases also expect a packageManager field in the root package.json; if turbo cannot detect your package manager, check the docs.
Declare the workspaces
pnpm finds packages through a root file:
pnpm-workspace.yaml
List the globs to treat as packages:
packages:
- "apps/*"
- "packages/*"
Every directory directly under these becomes a workspace:
apps/*
packages/*
Create the folders for both apps and the types package:
mkdir -p apps/web
mkdir -p apps/api
mkdir -p packages/types
The tree so far:
my-monorepo/
├── apps/
│ ├── web/
│ └── api/
│
├── packages/
│ └── types/
│
├── package.json
└── pnpm-workspace.yaml
Adding the two applications
The web app
To keep attention on the monorepo, the web app is plain Node.js for now. Enter it:
cd apps/web
Give it a manifest:
pnpm init
The target shape:
apps/web/
├── package.json
└── src/
└── index.ts
Create the entry file:
mkdir src
touch src/index.ts
Add a placeholder:
console.log("Hello from Web application");
Every workspace needs TypeScript, so go back to the root:
cd ../..
And install it once:
pnpm add typescript --save-dev --workspace-root
The API
Same pattern: enter and initialize:
cd apps/api
pnpm init
Create the entry file:
mkdir src
touch src/index.ts
Add its placeholder:
console.log("Hello from API application");
Both apps now match:
apps/
├── web/
│ ├── src/
│ │ └── index.ts
│ └── package.json
│
└── api/
├── src/
│ └── index.ts
└── package.json
Sharing TypeScript configuration
Return to the root:
cd ../..
Create a base config:
tsconfig.json
It holds options every workspace shares: a modern target, NodeNext resolution and strict checking:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}
Each app extends it and adds only local settings. For the web app, create:
apps/web/tsconfig.json
It points to the root file, sets an output folder and compiles only src:
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src"]
}
The API gets the same file:
apps/api/tsconfig.json
With identical content:
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src"]
}
Strictness or target changes now happen in one place.
Giving each workspace build scripts
Turborepo runs scripts that workspaces define. Open the web manifest:
apps/web/package.json
Set a scoped name and three scripts: build via tsc, dev in watch mode, lint via ESLint:
{
"name": "@repo/web",
"private": true,
"scripts": {
"build": "tsc",
"dev": "tsx watch src/index.ts",
"lint": "eslint ."
}
}
Then the API manifest:
apps/api/package.json
With its own name:
{
"name": "@repo/api",
"private": true,
"scripts": {
"build": "tsc",
"dev": "tsx watch src/index.ts",
"lint": "eslint ."
}
}
Filters and workspace dependencies refer to these @repo/... names. The dev script needs tsx:
pnpm add tsx --save-dev --workspace-root
The lint script also assumes ESLint is installed and configured; add it or drop the script, or pnpm lint will fail.
Configuring the task pipeline
turbo.json describes how each task behaves. Create it at the root:
turbo.json
Define the tasks:
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^lint"]
}
}
}
What to notice:
"dependsOn": ["^build"]makes a package wait for the builds of the workspace packages it depends on; the caret means dependencies.outputslists what to cache and restore, so unchanged packages are not rebuilt.devis uncached andpersistentbecause a watcher never finishes.lintruns dependencies first too.
tasks is the current key; older versions used pipeline, so older examples may need adjusting.
Running and building from the root
Start all dev processes:
pnpm dev
This works because the root dev script is:
"dev": "turbo run dev"
Turborepo finds dev in each workspace and starts them together, replacing one terminal for the web app:
cd apps/web
pnpm dev
And another for the API:
cd apps/api
pnpm dev
With a single root command:
pnpm dev
Build everything the same way:
pnpm build
Turborepo orders builds by package dependencies, shared packages first:
pnpm build
│
▼
turbo run build
│
├── packages/types
│
├── apps/api
│
└── apps/web
That order comes from declared dependencies: until packages/types has a package.json and the apps depend on it, Turborepo will not build it first.
Targeting a single workspace
pnpm's --filter runs a script in one workspace, such as the API dev server:
pnpm --filter @repo/api dev
Or a web build:
pnpm --filter @repo/web build
Turborepo's own filter keeps caching and ordering:
pnpm turbo run build --filter=@repo/api
Commands you will use daily
Install everything:
pnpm install
Start development:
pnpm dev
Build all:
pnpm build
Lint all:
pnpm lint
Build one package:
pnpm --filter @repo/api build
Run one app:
pnpm --filter @repo/web dev
Add a dependency to one workspace:
pnpm --filter @repo/api add express
Depend on a local package; workspace:* links the repo copy instead of fetching from the registry:
pnpm --filter @repo/api add @repo/types@workspace:*
Why not stop at pnpm workspaces
You could rely on workspaces alone:
apps/
packages/
As the repo grows, though, you coordinate more by hand:
build
test
lint
typecheck
dev
dependencies
task ordering
caching
Turborepo adds orchestration: one command understands package relationships, skips unchanged work and runs independent tasks in parallel:
pnpm turbo run build
For one app and one package, plain workspaces may suffice. Still picking a package manager? See our npm and pnpm comparison.
Wrapping up
A good monorepo is a shared environment where apps and packages evolve under one toolset. The stack here is small:
pnpm
+
Turborepo
+
TypeScript
Start with two apps:
apps/
├── web/
└── api/
Grow into more services:
apps/
├── web/
├── admin/
├── api/
└── worker/
Backed by shared packages:
packages/
├── ui/
├── types/
├── database/
├── auth/
└── utils/
The benefit is sharing code, types, config and workflows while each app stays independently organized. As you extend it:
- declare local dependencies with
workspace:*so builds are ordered correctly - list every artifact in
outputsor cache restores will miss it - keep base config at the root and extend it
- add
packageManagerand tools like ESLint before relying on root scripts in CI
References: the Turborepo documentation, the Turborepo repository, the pnpm documentation and the Node.js documentation.