TypeScript Monorepo With Turborepo: The Setup That Doesn't Slow Your Team Down

#Turborepo Next.js monorepo
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Founder & Lead Developer

Expert in software development and legacy code optimization

A Turborepo Next.js monorepo is the most pragmatic answer we know for a 3-10 engineer team that ships a Next.js frontend, a shared component library, and a Node.js backend from one repository. The promise is real: one git clone, shared types across the API boundary, one PR for a change that touches frontend and backend, and builds that only run for the packages that actually changed. The failure mode is just as real: dependency conflicts, module resolution errors that nobody can explain, and a CI pipeline that rebuilds everything on every commit because the cache silently stopped working three weeks ago.

The difference between the two outcomes is not the tool. It is a handful of setup decisions that have to be made deliberately, once, at the start. This post walks through the setup we use in client projects: the workspace structure, the tsconfig configuration that prevents import cycles, the pipeline definition, and the CI cache strategy that took one real codebase from 8-minute CI runs to 90 seconds.

Why monorepos slow teams down in the first place

Before the fix, the diagnosis. When a monorepo drags, it is almost always one of four problems:

  1. Everything builds on every commit. No task graph, or a task graph the tool cannot trust, means CI runs the full build, lint, and test suite for all packages even when a single README changed.
  2. Phantom dependencies. Package A imports from package B without declaring it in package.json. It works locally because of hoisting, then breaks in CI or after a lockfile change, producing module resolution errors that look random.
  3. Import cycles between packages. The component library imports a helper from the app, the app imports components from the library, and TypeScript project references or the bundler eventually choke on the loop.
  4. A silently broken cache. The team configured caching once, an output path changed, and every "cache hit" is either a miss or, worse, restores stale files.

Turborepo addresses the first problem directly with its task graph and content-addressed cache. The other three are configuration discipline, and that is what the rest of this setup is about.

The workspace structure: apps are deployable, packages are shared

Keep the top level boring and strict:

.
├── apps/
│   ├── web/          # Next.js frontend
│   └── api/          # Node.js backend (Fastify, NestJS, or plain HTTP)
├── packages/
│   ├── ui/           # shared React component library
│   ├── config/       # shared eslint, tsconfig, prettier presets
│   └── contracts/    # shared types: API request/response, zod schemas
├── package.json
├── pnpm-workspace.yaml
└── turbo.json

Two rules make this structure hold up:

  • Anything in apps/ is deployable and is never imported by anything else. Apps consume packages. Packages never import from apps. This single rule eliminates the most common source of import cycles.
  • Anything in packages/ has a real package.json with explicit dependencies. If ui uses clsx, it declares clsx. Relying on hoisting is how phantom dependencies are born. We use pnpm for exactly this reason: its strict node_modules layout turns undeclared imports into immediate, local errors instead of CI surprises.

The contracts package deserves a special mention. Putting your API request and response types (and, ideally, the zod schemas that validate them) into one shared package is the highest-leverage benefit of the whole monorepo: the frontend and backend can no longer drift apart without a type error telling you so.

tsconfig paths without import cycles

The most common TypeScript monorepo mistake is wiring paths aliases in the root tsconfig so every package can deep-import from every other package's src. It works at first, then produces two problems: the bundler and the type checker resolve modules differently, and nothing stops a cycle from forming.

Do this instead:

  • Each package gets its own tsconfig.json extending a shared base from packages/config.
  • Packages are imported by their package name (@acme/ui, @acme/contracts), never by relative path across package boundaries and never via a src deep import.
  • Each package defines an explicit entry point in package.json via exports. Consumers import from that entry point only.
// packages/ui/package.json
{
  "name": "@acme/ui",
  "exports": {
    ".": "./src/index.ts",
    "./styles.css": "./src/styles.css"
  }
}

Pointing exports at TypeScript source (the "internal packages" pattern) means the consuming app compiles the package with its own bundler, so there is no separate build step for ui or contracts at all in dev. Next.js needs to know about it via transpilePackages: ['@acme/ui', '@acme/contracts'] in next.config.js. Fewer build steps means fewer cache entries that can go stale.

The turbo.json pipeline that infers build order correctly

Turborepo builds its task graph from workspace dependencies plus the pipeline definition. The key symbol is ^, which means "this task in my dependencies first":

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**"]
    },
    "lint": {
      "dependsOn": ["^build"]
    },
    "test": {
      "dependsOn": ["^build"],
      "outputs": ["coverage/**"]
    },
    "typecheck": {
      "dependsOn": ["^build"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}

Three details here are load-bearing:

  • "dependsOn": ["^build"] makes Turborepo build contracts and ui before web and api, in dependency order, in parallel where possible. You never hand-maintain build order again.
  • outputs must list everything the task writes, and exclude what it should not restore. For Next.js that means including .next/** but excluding .next/cache/**. Getting outputs wrong is the single most common cause of broken caching.
  • dev is uncacheable and persistent. Caching a dev server makes no sense, and marking it persistent tells Turborepo it never exits.

With this in place, turbo run build lint test typecheck on a PR only executes tasks whose inputs changed. A docs-only commit runs almost nothing.

The CI cache strategy for a Turborepo Next.js monorepo: 8 minutes to 90 seconds

On a real client codebase (Next.js app, component library, Node API, roughly 90k lines of TypeScript), CI ran 8 minutes on every PR before this setup and about 90 seconds after, with the median PR touching one package. Two layers made that happen.

Layer 1: remote cache. Turborepo's remote cache shares task results across machines, which is exactly what stateless CI runners need. Vercel's hosted Remote Cache is the zero-config option (npx turbo login && npx turbo link, then set TURBO_TOKEN and TURBO_TEAM in CI). Self-hosted open-source implementations exist if your compliance requirements rule out a third-party cache; we cover that kind of decision in our tech stack strategy work.

Layer 2: GitHub Actions cache for dependencies. The remote cache covers task outputs; you still want the pnpm store cached so installs are fast:

- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
  with:
    node-version: 22
    cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run build lint test typecheck
  env:
    TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
    TURBO_TEAM: ${{ vars.TURBO_TEAM }}

Do not use actions/cache to cache node_modules directly or to cache .turbo as a substitute for a real remote cache. Restoring node_modules across lockfile changes causes exactly the class of module resolution errors monorepos are blamed for, and a branch-scoped .turbo cache gives you cache hits on the branch that authored the change and misses everywhere else.

The mistakes that silently break the cache

Every one of these has bitten a team we have worked with:

  • Not cleaning output directories before builds. If dist/ accumulates files from previous builds, the cache stores them, and a restored cache resurrects deleted modules. Make build scripts clean first (tsup and Next.js handle this; hand-rolled tsc builds often do not).
  • Importing from the wrong entry point. A deep import like @acme/ui/src/button bypasses the exports map. It may compile, but Turborepo's hash inputs and the package boundary no longer agree on what changed, and consumers rebuild when they should not (or worse, do not when they should). Recent TypeScript moduleResolution: "bundler" settings will flag these imports; turn that on.
  • Environment variables missing from the hash. If a build reads NEXT_PUBLIC_API_URL and that variable is not declared in turbo.json under env, two builds with different values share one cache entry. Declare every variable a task reads. Turborepo's strict env mode makes undeclared variables fail loudly; enable it.
  • Untracked inputs. A build script that reads a file outside the package directory (a root-level VERSION file, generated code) needs that file listed in the task's inputs, or changes to it will not invalidate the cache.

The pattern behind all four: Turborepo's cache is only as correct as your declaration of what goes in and what comes out. It cannot see side effects you did not declare.

When this setup is overkill

Honesty matters here. If you have one Next.js app and no second deployable, a monorepo adds ceremony without payoff; a well-structured single app is simpler. If you have 40 engineers and hundreds of packages, you will eventually want the stricter constraint enforcement of Nx or Bazel-style tooling. The setup in this post is tuned for the middle: two to five deployables, a handful of shared packages, and a team small enough that nobody wants to own build infrastructure as a full-time job.

If your monorepo is already in the painful state (slow CI, random resolution errors, a cache nobody trusts), the fix is usually a focused cleanup of the four problem areas above rather than a migration to a new tool. That is a code-level review exercise, and it is the kind of work we do in code quality consulting engagements. If you are starting a new product and want the workspace, pipeline, and CI wired correctly from the first commit, that fits our custom software development work.

FAQ

Do I need Turborepo if I already use pnpm workspaces? pnpm workspaces solve linking and dependency isolation; they do not give you a task graph or a cache. Without Turborepo (or an equivalent), pnpm -r build runs every package's build every time, in an order you maintain by hand. The two tools are complementary, not alternatives.

Should shared packages be pre-built or consumed as source? For a 3-10 engineer team, consume them as source (the internal packages pattern with transpilePackages). Pre-building earns its keep only when a package is published externally or consumed by tooling that cannot transpile TypeScript.

Does the remote cache leak source code? The remote cache stores task outputs (compiled artifacts, logs), not your repository. Artifacts are content-addressed and access is scoped by token. If policy still forbids third-party storage, self-host the cache; the protocol is open.

How do I stop apps importing from each other? Enforce it mechanically: an ESLint rule (import/no-restricted-paths or Turborepo's own boundaries feature) that forbids any import from apps/* outside the app itself. Conventions decay; lint rules do not.

If you want a second pair of eyes on your monorepo setup, or you are deciding whether a monorepo fits your team at all, write to hello@wolf-tech.io or find us at wolf-tech.io. We are happy to look at a concrete repo rather than argue in the abstract.