Home / Articles / Scaffolding a pnpm and Turborepo Monorepo for Node.js Apps Step by Step

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.

1702 words

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.
  • outputs lists what to cache and restore, so unchanged packages are not rebuilt.
  • dev is uncached and persistent because a watcher never finishes.
  • lint runs 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 outputs or cache restores will miss it
  • keep base config at the root and extend it
  • add packageManager and 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.