Build a luckier tech stack today
Build a luckier tech stack today
How to Set Up a TypeScript Monorepo with pnpm and Turborepo

How to Set Up a TypeScript Monorepo with pnpm and Turborepo

On September 23, 2026

A monorepo stops feeling clever the first time you change a shared button and the API service fails because of it. That is the trade: one repository, one install, one lockfile, and a dependency graph you have to keep honest.

The setup below suits a small team shipping a web app, a backend service, and a shared UI or validation library between them. pnpm runs the workspace. Turborepo runs your scripts across it. Neither one is doing the other's job, which is exactly why they pair well.

What each tool is responsible for

  • pnpm installs each dependency once into a global content-addressable store and links it into the packages that ask for it. Nothing else.
  • pnpm workspaces let packages refer to each other by name, so @acme/ui resolves to a folder on disk rather than a published tarball.
  • Turborepo reads the package graph, works out the order tasks should run in, and caches the output so a second run of the same task costs nothing.

The most useful side effect of pnpm's strict linking is that a package can only import what it has declared. If the API service has been quietly relying on a date library installed by the web app, that stops working immediately. It is annoying for about ten minutes, and then it is the reason your dependency graph stays readable.

Worth knowing before you start: pnpm keeps a single lockfile at the root and a symlinked node_modules per package. That means hoisting is not the default behaviour, and a tool that assumes a flat node_modules may need a line in .npmrc to cope. Add it consciously rather than adding it because something broke at 2am.

Laying out the workspace

A shape that holds up well: apps/ for anything deployable, packages/ for anything imported.

  • apps/web — the frontend
  • apps/api — the backend service
  • packages/ui — shared components
  • packages/tsconfig — the base TypeScript configs
  • packages/eslint-config — shared lint rules

Two files at the root get you started. The root package.json should set private to true so nobody publishes the workspace by accident, declare a packageManager field such as pnpm@9, and hold the scripts you actually run: build, test, lint, dev. Then pnpm-workspace.yaml lists the globs, apps/* and packages/*, and nothing else is needed.

Run pnpm install once at the root. Every package gets its own node_modules with links into the store, and a single lockfile at the root covers the whole repository. Commit that lockfile; it is the thing that makes CI and your laptop agree.

If you have an existing repo, migration is less scary than it sounds. Move folders into apps/ and packages/, add the workspace file, delete every nested lockfile and node_modules, and run pnpm install from the root exactly once. Whatever surfaces after that is a real dependency you were hiding behind hoisting.

Sharing a package without a build step

The simplest way to share TypeScript inside a monorepo is to export the source. packages/ui gets a package.json named @acme/ui with an exports field pointing at the .ts files rather than a compiled dist folder. Consumers list it as a dependency using the workspace protocol, so "@acme/ui": "workspace:*" means "whatever is on disk", and you never bump versions while iterating.

You then need the consumer's bundler to compile it. Next.js wants the package name listed in transpilePackages. Vite needs the workspace package excluded from pre-bundling or aliased to source. Either way it is a few lines, and it buys you no build step, no stale dist, and instant hot reload when you edit a component.

Reach for a compiled package only when something outside the repo consumes it: a published library, a CLI, or a Node service that runs the output directly. Mixing both approaches is fine, but be deliberate about which packages are which. A half-built internal package produces genuinely confusing bug reports.

A base TypeScript config worth sharing

Put a base.json in packages/tsconfig with the settings you want everywhere: strict set to true, skipLibCheck enabled, and a moduleResolution that matches your tooling. Each package then extends @acme/tsconfig/base.json and overrides only what it needs — jsx for React packages, node types for server ones. Keep TypeScript itself as a root devDependency so every package compiles with the same version. That alone prevents a class of errors that only shows up in CI.

The same trick works for ESLint. Ship one config package, extend it everywhere, and update the rules in one place. When a lint rule changes, you see the fallout across the whole repository in a single pull request instead of discovering it package by package over a month.

Adding Turborepo

Install it once at the root with pnpm add -D -w turbo, then describe your tasks in turbo.json. Two fields carry most of the weight:

  • dependsOn — using "^build" means "run the build of everything this package depends on first". Without it, Turborepo may start building your app before the library it imports has produced anything.
  • outputs — the paths each task writes, relative to that package. dist/**, .next/**, and a negation for the framework cache folder are typical. Get this wrong and you will see cache hits that restore nothing useful.

Mark long-running tasks as uncached and persistent. A dev server should never be a cache hit; it should start and stay running.

Rule of thumb: if a task writes a file that some later task reads, that path belongs in outputs. If a task only prints to a terminal, it probably belongs nowhere near the cache.

Running tasks, and only the work that changed

From the root, turbo run build runs build everywhere in dependency order and skips packages whose inputs have not changed since the last successful run. The first run is slow because nothing is cached; the tenth run after editing one file in packages/ui should rebuild only packages/ui and the apps downstream of it. That difference is the whole point of the exercise.

Use --filter to narrow the scope. turbo run test --filter=@acme/api runs the tests for one package, while --filter=@acme/api... adds everything it depends on. In CI, --filter=...[origin/main] limits the work to packages affected by the branch, which is often the difference between a three-minute check and a twelve-minute one.

When a task runs that you are sure should have been cached, do not start guessing. Run it with --dry=json and read the calculated hash inputs. Nine times out of ten you will find an environment variable or a root config file you forgot to declare.

Making caching trustworthy in CI

Local caching is a convenience. Remote caching is what makes a monorepo feel fast to a team, because one machine's build output becomes everybody's. Set TURBO_TOKEN and TURBO_TEAM in your CI provider and Turborepo will read and write a shared cache instead of a per-machine one.

Two things make that safe rather than confusing:

  • Declare every environment variable a task reads in env or globalEnv. A build that silently depends on an undeclared variable will cache the wrong result and hand it to someone with different settings.
  • List root-level inputs in globalDependencies — the lockfile, the base tsconfig, the workspace file. Change the lockfile and every cached task should be considered stale.

Keep deploys out of the cache. Anything that talks to a real environment is a side effect, not an artifact, and no amount of hashing makes it reproducible.

Mistakes that cost the most time

  • Installing TypeScript, ESLint, or a test runner in every package instead of once at the root, which guarantees version drift.
  • Forgetting dependsOn, then blaming Turborepo for a build that raced ahead of its own library.
  • Omitting outputs, so the cache reports a hit but restores an empty folder.
  • Publishing internal packages to a registry "just in case", which reintroduces the version-bump dance you moved to a monorepo to avoid.
  • Letting one package become half-built and half-source, which produces errors that read like nonsense.

None of this is complicated, but it is all visible. That is the actual cost of a monorepo: you trade a little hidden convenience for a graph you can read. pnpm enforces the edges, Turborepo schedules the work, and the rest is a habit of keeping both honest.

Photo: StockSnap / Pixabay

How to Write a Technical CV for UK Software Jobs

A practical guide to building a UK-style technical CV: the right sections, bullets that prove impact, correct British spelling, and the...

Common Mistakes in React State Management (and How to Fix Them)

A practical troubleshooting guide to the most common React state mistakes — prop drilling, needless re-renders, and context misuse — with fixes...