Building a React Monorepo with Turborepo
AI generated
</>
{ }
React · Monorepo · Turborepo · CI/CD
Building a React Monorepo with Turborepo
from workspace structure to remote cache

A React monorepo with Turborepo bundles multiple apps and shared packages into one repository, without every build having to recompile everything. This article shows the workspace structure, the turbo.json configuration, remote caching and a CI pipeline that only builds the packages actually affected.

18 min read Turborepo · npm Workspaces · Changesets Remote Caching · Selective Builds

1. Why a monorepo makes sense for multiple React apps

As soon as a company operates more than one React application, say a shop frontend, an admin dashboard and a marketing site, the question of shared code inevitably comes up. A React monorepo bundles all these applications together with shared packages such as a UI component library or an API client into a single repository, instead of spreading them across multiple separate git repositories and published npm packages.

The most obvious advantage of a React monorepo is atomic commits across boundaries: a change to a shared component and the adjustment of every consumer of that component land in the same commit, instead of needing to be synchronized across multiple repositories and version updates. The downside without suitable tooling, however, is that naive build scripts rebuild the entire repository on every change, even if only a single app is affected.

Turborepo solves exactly this problem: it understands the dependencies between the packages of a React monorepo, aggressively caches build results, and only builds what actually changed. The following sections show the complete setup, from the base structure to a production ready CI pipeline.

2. Turborepo base structure: apps, packages and workspaces

A React monorepo with Turborepo usually follows a two folder convention: apps/ holds the independently deployable applications, packages/ holds shared code imported by multiple apps but not deployed directly itself. This separation makes it clear at a glance what is an end product and what is pure infrastructure for other packages.


my-monorepo/
├── apps/
│   ├── shop-frontend/        # Vite + React, deployed to Vercel
│   │   ├── package.json
│   │   └── src/
│   └── admin-dashboard/      # Next.js, deployed separately
│       ├── package.json
│       └── src/
├── packages/
│   ├── ui/                   # Shared React component library
│   │   ├── package.json
│   │   └── src/
│   ├── api-client/           # Typed fetch wrapper, shared across apps
│   │   ├── package.json
│   │   └── src/
│   └── tsconfig/             # Shared tsconfig.json base files
│       └── base.json
├── turbo.json
├── package.json               # root workspace manifest
└── package-lock.json

The link between the packages runs through npm, Yarn or pnpm workspaces, declared in the root package.json. A package such as @repo/ui gets added to apps/shop-frontend/package.json as a completely normal dependency, but points to the local folder via a workspace link instead of the npm registry. Changes to packages/ui are immediately available in every app this way, without a publish step for a React monorepo in local development.

3. turbo.json: pipeline, caching and task dependencies

The centerpiece of any Turborepo configuration for a React monorepo is the turbo.json file. It defines which tasks such as build, test or lint exist, in what order they must run across package boundaries, and which outputs may be cached.


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

The ^ prefix before build in dependsOn means that every package the current package depends on must be built first, before it gets built itself. For a React monorepo this ensures that apps/shop-frontend never builds against a stale version of packages/ui. The outputs key tells Turborepo which directories to cache as build results, while inputs determines which files trigger a cache miss when they change.

4. Remote caching: drastically cutting build times for the team

Local caching in Turborepo already stores build results on your own disk, so an unchanged task gets answered instantly from the cache on the second call. The real value for a team only emerges with remote caching, which shares the same cache via Vercel Remote Cache or a self hosted alternative with the entire team and the CI pipeline.


# Authenticate once against the Turborepo Remote Cache
npx turbo login
npx turbo link

# From now on, a build already run by a teammate or CI
# is fetched from the remote cache instead of rebuilt locally
npx turbo run build
# Cache hit, replaying output for packages/ui, apps/shop-frontend

The practical effect for a React monorepo in a team: if a colleague already built and tested the same commit, every other developer and every CI runner downloads the result from the remote cache in seconds instead of minutes. For larger monorepos with many packages, this effect adds up to a noticeable reduction in overall CI runtime, often by 60 to 80 percent compared to an uncached build.

5. Shared UI component packages across multiple apps

A common use case for a React monorepo is a shared UI component library used by every app in the repository, without being published to the public npm registry. The packages/ui package exports its components via a package.json with a correct exports field, and gets imported by consuming apps as a normal dependency.

What matters here is that packages/ui itself doesn't necessarily need its own build pipeline, if the consuming framework, such as Vite or Next.js, can transpile TypeScript source code directly. For stricter setups with a standalone build, tsup is worth using, a fast bundler specifically for TypeScript libraries that produces ESM and CommonJS output simultaneously. In both cases, the decisive advantage of the React monorepo remains: a change to a button component is immediately visible in every app, without waiting for a version bump and publish cycle.

6. Selective builds with --filter: building only affected packages

In a large React monorepo with a dozen apps, it would be wasteful to actually build and test every package on every pull request check, when only a single app has changed. Turborepo's --filter flag allows targeting just the affected packages and their dependents.


# Build only shop-frontend and everything it depends on
npx turbo run build --filter=shop-frontend...

# Build only packages that changed since the main branch (used in CI)
npx turbo run build --filter="...[origin/main]"

# Combine: only affected packages, but include their dependents too
npx turbo run test --filter="...[origin/main]" --filter="...^shop-frontend"

The ...[origin/main] syntax is especially valuable for a React monorepo in CI, because it instructs Turborepo to figure out via a git diff which packages have changed since the main branch, and automatically include every package that depends on them too. A change to packages/ui correctly triggers a rebuild of every app using that component library, while an isolated change to a single app doesn't trigger unnecessary builds of other, unrelated apps.

7. Versioning and publishing with Changesets

As soon as a React monorepo contains packages that actually need to be published as npm packages, say an internal design system library for multiple teams, versioning becomes its own challenge. Changesets solves this by having every pull request that changes a publishable package add a small markdown file in the .changeset/ folder describing how that package should be versioned.

A GitHub Actions workflow collects these changeset files when merging into the main branch, automatically updates package.json version numbers according to semantic versioning, and generates a changelog entry. For a React monorepo with several independently versioned packages, this process avoids manual version bumps and ensures that every change is documented traceably before it actually gets published.

8. CI pipeline for a Turborepo monorepo

A production ready CI pipeline for a React monorepo combines remote caching with selective filters to ensure both correctness and speed. The decisive trick: fetch-depth: 0 at checkout, so Turborepo has the full git history available for comparison against the main branch.


# .github/workflows/ci.yml
name: Monorepo CI

on:
  pull_request:
    branches: [main]

jobs:
  build-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Turborepo needs full history for --filter=[origin/main]

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "npm"

      - run: npm ci

      - name: Lint, test and build only affected packages
        env:
          TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
          TURBO_TEAM: ${{ vars.TURBO_TEAM }}
        run: |
          npx turbo run lint test build --filter="...[origin/main]"

The TURBO_TOKEN and TURBO_TEAM environment variables connect the CI pipeline to the remote cache, so a commit already tested locally doesn't have to be fully rebuilt in CI again. For a React monorepo with many packages, this combination of selective filtering and remote caching often reduces the average CI runtime from ten minutes to under two, especially for small, focused pull requests.

9. Monorepo versus multi-repo compared

The choice between a React monorepo and several separate repositories depends on team size, the number of shared packages, and release cadence. The table below compares the key differences.

Criterion React monorepo (Turborepo) Multi-repo
Shared code Directly via workspaces, no publish needed Must be published and versioned
Atomic changes One commit across package boundaries Multiple PRs and version updates needed
CI runtime without caching Can get long with a naive setup Naturally scoped to one repo
CI runtime with Turborepo Selective, often faster than multi-repo No comparison needed, isolated per repo
Per team access control Harder to fine tune Repo boundaries are natural team boundaries

For teams with several related React apps and a lot of shared code, a React monorepo with Turborepo is almost always the more efficient choice, because it enables shared code without publish overhead and stays performant even as the codebase grows, thanks to remote caching. Multi-repo remains sensible when teams work completely independently, share little code, or when strict access separation between teams is a hard requirement.

Mironsoft

Monorepo architecture and CI/CD optimization for React teams

Want to bundle multiple React apps into an efficient monorepo?

We migrate existing multi-repo setups into a Turborepo monorepo, set up remote caching, and build a selective CI pipeline that builds only the packages actually affected.

Monorepo migration

Turning an existing multi-repo structure into a Turborepo setup

Remote caching

Setting up team and CI wide caching for drastically shorter build times

CI pipeline

Selective builds with --filter and Changesets for clean version management

10. Summary

A React monorepo with Turborepo solves the basic problem of shared code between multiple applications, without accepting the downsides of naive full rebuild scripts. The combination of an apps/ and packages/ structure, a clearly defined turbo.json pipeline, and remote caching makes build times predictable and short for the entire team and the CI pipeline.

Selective builds with --filter ensure that a small change to a single app doesn't trigger an unnecessary rebuild of the entire React monorepo, while Changesets document the versioning of published packages in a structured way. For teams with several related React apps, this approach has by now become the industry standard, because it enables shared code without publish overhead and stays performant as the codebase grows.

React Monorepo with Turborepo at a Glance

Workspace structure

apps/ for deployable applications, packages/ for shared code, linked via npm, Yarn or pnpm workspaces.

turbo.json pipeline

dependsOn with a ^ prefix enforces correct build order, outputs defines cacheable results.

Remote caching

Team and CI share the same cache, often reducing build times by 60 to 80 percent.

Selective builds

--filter="...[origin/main]" builds only actually affected packages and their dependents.

11. FAQ: React Monorepo with Turborepo

1When does a React monorepo pay off?
With several apps sharing a lot of code, a monorepo considerably simplifies atomic changes.
2What does the ^ prefix in dependsOn do?
Forces dependent packages to build first, so nothing ever builds against a stale version.
3What is remote caching?
Shares build results between team and CI, so already built commits aren't recompiled.
4How does --filter work?
Determines changed packages via git diff and automatically builds every dependent too.
5Does a shared package need publishing?
No, within the same monorepo a workspace link is enough, no npm publish needed.
6What are Changesets for?
Document version changes per pull request and automate version bumps on merge.
7Why fetch-depth: 0 in CI?
Turborepo needs full git history to detect changes against the main branch.
8How much does CI runtime drop?
Often 60 to 80 percent, especially for small, focused pull requests.
9Is Turborepo compatible with pnpm?
Yes, it works equally well with npm, Yarn and pnpm workspaces.
10When is multi-repo preferable?
With completely independent teams sharing little code, or strict access separation requirements.