Managing separate repositories for your blog, docs, and marketing sites creates friction when they share code. Each update to your authentication helper requires changes across three codebases, package version synchronization, and full CI rebuilds, even for unchanged components.
Turborepo consolidates related applications into one repository with content-aware caching. Update the authentication helper once, and only affected applications rebuild. Build times can drop significantly as more work is restored from cache.
This guide walks through Turborepo v2.x setup, task configuration, and deployment for multiple frontends sharing code, including a typed Strapi SDK consumed by every app in your monorepo.
In Brief:
tasks key in turbo.json, set up remote caching, and deploy only modified applications while maintaining type safety across your stack. Turborepo is a high-performance build system for JavaScript and TypeScript monorepos, maintained by Vercel. Currently at v2.x (with v2.5 updates), it optimizes build performance through content-aware caching and parallel task execution. The build system hashes source files, dependencies, and configuration to determine what changed. Unchanged tasks restore cached results instead of rebuilding.
Turborepo works best when multiple projects share code and deploy together. If your blog, docs site, and marketing page all pull from the same Strapi backend, a headless CMS, and share UI components, API clients, or TypeScript types, a monorepo lets you update shared code once and see changes propagate across all frontends in a single commit. If your projects evolve independently with minimal shared dependencies, separate repos are fine.
A monorepo keeps all your related projects, applications, shared libraries, and configuration, in one version-controlled repository. A static site, Gatsby docs site, and React marketing page can share UI kits and TypeScript types. Update a button's API, commit once, and all three apps receive the change immediately, with no package publishing and no version bumps.
1.
2├── apps
3│ ├── blog
4│ ├── docs
5│ └── marketing
6├── packages
7│ ├── ui
8│ ├── types
9│ └── api-client
10├── package.json
11└── turbo.jsonThe tradeoff is scale. Without tooling like Turborepo, large unified repos suffer slow builds and complex CI/CD. Turborepo eliminates the multi-repo overhead of publishing internal packages to npm, synchronizing versions, and rebuilding identical code in separate pipelines, while keeping builds fast through caching and parallelism.
Turborepo's speed comes from three mechanisms working together: content-aware hashing, dependency graph execution, and parallel processing.
When you execute a task, Turborepo creates a hash from your source files, package.json dependencies, environment variables, and build configuration. If that hash matches a cached entry, the tool restores previously generated output in milliseconds instead of rebuilding from scratch.
This content-based approach analyzes file contents rather than timestamps, so you never get stale builds. Change a single line in a shared package, and only the modified package plus its direct dependents rebuild. Everything else restores from cache. Configure what gets cached through the outputs field in your turbo.json:
1{
2 "tasks": {
3 "build": {
4 "outputs": ["dist/**", ".next/**", "!.next/cache/**"]
5 },
6 "test": {
7 "outputs": ["coverage/**"]
8 }
9 }
10}Without the outputs key, Turborepo caches task logs only. The glob negation !.next/cache/ excludes Next.js's internal build cache from your cached artifacts, a detail that matters when you're storing outputs in a remote cache.
The build system treats every task as a node in a dependency graph you define in turbo.json. Consider a Next.js blog importing components from a shared ui package. Before the blog compiles, the UI library must finish its own build. Encoding that relationship prevents "module not found" errors and unlocks true parallelism across unrelated workspaces.
1{
2 "tasks": {
3 "build": {
4 "dependsOn": ["^build"],
5 "outputs": ["dist/**"]
6 },
7 "test": {
8 "dependsOn": ["build"]
9 }
10 }
11}The caret ^build tells Turborepo to run the build task in every dependency workspace first. Without the caret, "build" means the build task in the same package must complete before tests run.
Turborepo runs independent tasks simultaneously based on your dependency graph, eliminating idle CPU time. This parallelism combines with remote caching to benefit your entire team.
Remote caching pushes build artifacts to a shared store. When you finish a build locally, the system uploads the hashed outputs. Your teammate pulls the branch, runs the build, and gets a near-instant cache hit instead of waiting minutes. CI skips unchanged workspaces entirely.
Setup requires authenticating and linking your repo:
1npx turbo login
2npx turbo linkBoth commands require a Vercel account. Remote caching on Vercel is free on all plans, even if you don't host there. For self-hosted options, community implementations like ducktors/turborepo-remote-cache support S3 and other storage backends, and turbo login --manual lets you point at a custom API endpoint.
Scaffold a new monorepo with create-turbo, installation docs:
1npx create-turbo@latest # npm
2pnpm dlx create-turbo@latest # pnpm
3yarn dlx create-turbo@latest # yarn
4bunx create-turbo@latest # bunTo add Turborepo to an existing repo, install it as a dev dependency:
1npm install turbo --save-devThe official docs recommend installing both globally and locally. Global turbo defers to the local version if one exists.
Your root package.json declares every workspace (for npm and Yarn), keeps the repository private, and centralizes shared tooling. For pnpm, workspaces are declared in pnpm-workspace.yaml instead. The packageManager field is required in Turborepo v2:
1{
2 "name": "company-monorepo",
3 "private": true,
4 "packageManager": "pnpm@8.15.0",
5 "workspaces": ["apps/*", "packages/*"],
6 "scripts": {
7 "dev": "turbo run dev",
8 "build": "turbo run build"
9 },
10 "devDependencies": {
11 "turbo": "^2",
12 "typescript": "^5.3.0"
13 }
14}1.
2├── apps
3│ ├── blog
4│ ├── docs
5│ └── marketing
6└── packages
7 ├── api-client
8 ├── types
9 ├── ui
10 └── strapi-clientApplications in apps/ consume code from packages/ through workspace dependencies. The build system caches outputs per package and rebuilds only changed code.
The turbo.json file defines how tasks execute across your repository. All v2.x configurations use the tasks key. The v1 pipeline key is deprecated. If you're migrating, run npx @turbo/codemod update to convert automatically.
1{
2 "$schema": "https://turbo.build/schema.json",
3 "tasks": {
4 "build": {
5 "dependsOn": ["^build"],
6 "outputs": ["dist/**", ".next/**", "!.next/cache/**"]
7 },
8 "lint": {
9 "dependsOn": ["^lint"]
10 },
11 "test": {
12 "dependsOn": ["build"],
13 "outputs": ["coverage/**"],
14 "inputs": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts"]
15 },
16 "dev": {
17 "cache": false,
18 "persistent": true
19 }
20 }
21}The inputs field narrows which files contribute to the cache hash. Editing a README won't trigger a test re-run when you scope inputs to source and test files. Set cache: false on development servers to preserve hot reloading, and persistent: true to mark them as long-running processes.
Turborepo v2 defaults to Strict Environment Mode, which filters the environment variables available to each task. This is a breaking change from v1's Loose Mode. Tasks that silently consumed CI-injected variables will fail until you explicitly declare them.
Four keys control environment variable handling:
1{
2 "globalEnv": ["NODE_ENV"],
3 "globalPassThroughEnv": ["CI", "GITHUB_TOKEN"],
4 "tasks": {
5 "build": {
6 "env": ["DATABASE_URL", "API_BASE_URL"],
7 "passThroughEnv": ["SENTRY_AUTH_TOKEN"]
8 }
9 }
10}Variables in env and globalEnv are included in the cache hash, so changing their values causes a cache miss. Variables in passThroughEnv and globalPassThroughEnv are available at runtime but don't affect caching. This distinction matters: your SENTRY_AUTH_TOKEN for uploading source maps shouldn't bust the build cache, but your DATABASE_URL should.
Turborepo also supports framework inference, so you don't need to list framework-prefixed variables explicitly.
Inside your monorepo, reusable code lives as internal packages. These packages skip the publish-to-npm workflow because workspace tooling handles linking. Your blog, docs, and admin apps all import from the same Strapi headless CMS client without version conflicts.
1packages/strapi-client/
2├─ src/
3│ ├─ client.ts
4│ └─ index.ts
5├─ package.json
6└─ tsconfig.jsonYour package manifest declares a name and explicit export map:
1{
2 "name": "@repo/strapi-client",
3 "version": "0.0.1",
4 "private": true,
5 "main": "./dist/index.js",
6 "types": "./dist/index.d.ts",
7 "exports": {
8 ".": {
9 "import": "./dist/index.js",
10 "types": "./dist/index.d.ts"
11 }
12 }
13}Add the package to any application workspace:
1npm add @repo/strapi-client@workspace:^Then import it directly:
1import { createClient } from '@repo/strapi-client';For TypeScript monorepos, configure path aliases in a shared tsconfig.base.json and extend it in each package. This keeps TypeScript CMS resolution consistent and avoids relative path gymnastics across workspaces.
Your package manager, npm, Yarn, pnpm, or Bun, treats every workspace as part of one dependency graph. Run install at the repository root so all apps and packages resolve together. One install command synchronizes all apps, libraries, and CI pipelines.
Turborepo stores local cache data under .turbo in the repository root. Add it to .gitignore:
1# .gitignore
2.turbo/Here's what cache hits and misses look like in your terminal:
1$ turbo run build
2• Packages in scope: @acme/blog, @acme/docs, @acme/ui
3• Running build in 3 packages
4@acme/ui:build: cache hit, replaying logs ██████████
5@acme/blog:build: cache miss, executing ██████████
6@acme/docs:build: cache hit, replaying logs ██████████
7
8 Tasks: 3 successful, 3 total
9 Cached: 2 cached, 3 total
10 Time: 4.2sAuthenticate and link your repo to share cache artifacts across your team and CI:
1npx turbo login # authenticate with Vercel
2npx turbo link # connect this repo to remote cacheIn CI, set two environment variables, TURBO_TOKEN and TURBO_TEAM, and Turborepo handles the rest. Use a repository variable (not a secret) for TURBO_TEAM to prevent GitHub Actions from censoring the team name in logs.
You can verify remote caching works by deleting your local .turbo/cache directory and running the build again. If artifacts replay from the remote store, you're set. Run turbo config to check your remote cache status at any time.
For teams that can't use Vercel, turbo login --manual accepts a custom API URL. Any HTTP server implementing Turborepo's Remote Caching API works as a backend.
Turborepo v2 introduced turbo watch, which re-runs tasks when files change. This is dependency-aware. Tasks re-run in the order defined in your turbo.json, not just when their own files change:
1turbo watch dev lint testFor tools with built-in watchers like next dev, mark the task as persistent: true and let the framework handle hot reloading. For tools without monorepo-aware watchers, mark the task as both persistent: true and interruptible: true so turbo watch can kill and restart the task when upstream dependencies change:
1{
2 "tasks": {
3 "dev": {
4 "cache": false,
5 "persistent": true,
6 "interruptible": true,
7 "dependsOn": ["^build"]
8 }
9 }
10}The Terminal UI (TUI) shipped as stable in 2.0 and is the default interface. Navigate between tasks with arrow keys, press Enter to attach to a task's interactive shell, and CTRL+Z to detach.
This enables workflows like running Jest or Vitest in watch mode directly from the TUI:
1{
2 "tasks": {
3 "test:watch": {
4 "interactive": true,
5 "persistent": true
6 }
7 }
8}Enable the TUI explicitly in turbo.json with "ui": "tui", though it's already the default in v2.
Integrate Turborepo into your CI pipeline after local setup works. A push triggers the pipeline, the runner pulls the remote cache, executes affected tasks, and pushes fresh artifacts back.
This v2-compatible workflow uses pnpm, based on the CI guide:
1name: CI
2
3on:
4 push:
5 branches: ["main"]
6 pull_request:
7 types: [opened, synchronize]
8
9jobs:
10 build:
11 name: Build and Test
12 timeout-minutes: 15
13 runs-on: ubuntu-latest
14 # To use Remote Caching, uncomment the next lines and follow the steps below.
15 # env:
16 # TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
17 # TURBO_TEAM: ${{ vars.TURBO_TEAM }}
18
19 steps:
20 - name: Check out code
21 uses: actions/checkout@v4
22 with:
23 fetch-depth: 2
24
25 - uses: pnpm/action-setup@v3
26 with:
27 version: 8
28
29 - name: Setup Node.js environment
30 uses: actions/setup-node@v4
31 with:
32 node-version: 20
33 cache: 'pnpm'
34
35 - name: Install dependencies
36 run: pnpm install
37
38 - name: Lint, type-check, and test
39 run: turbo run lint check-types test
40
41 - name: Build web app
42 run: turbo run build --filter=webThe fetch-depth: 2 gives Turborepo access to the previous commit for change detection. Content-based cache keys restore instantly on untouched code paths, reducing CI minutes and runner costs.
--filterThe --filter flag targets specific workspaces and their dependencies:
1turbo run build --filter=@apps/blog # by package name
2turbo run build --filter=web... # package + all its dependencies
3turbo run build --filter=[HEAD^1] # packages changed since last commitFor smarter change detection, Turborepo v2 replaces the deprecated turbo-ignore package with turbo query affected. Use the --affected flag for the simplest approach, or affected packages for conditional deployment steps:
1- name: Check if web is affected
2 id: web-affected
3 run: turbo query affected --packages web --exit-code
4 continue-on-error: true
5
6- name: Deploy web
7 if: steps.web-affected.outcome == 'failure'
8 run: turbo run deploy --filter=webThe exit code behavior is inverted from what you might expect: code 1 means the package IS affected (proceed with deploy), and code 0 means it isn't. With Strapi 5, content webhooks can trigger the same filtered build command for whichever frontend consumes that content.
Strapi delivers content via REST and GraphQL APIs, but coordinating multiple frontends that consume the same content creates overhead. Turborepo solves this with a shared SDK package. Create one workspace for your typed API client, update your Strapi content model, regenerate types, and Turborepo rebuilds only affected frontends.
The Strapi community repository, itself a Turborepo monorepo, demonstrates the canonical pattern: wrap @strapi/client in a shared workspace package that layers your content-type interfaces on top.
1├── apps/
2│ ├── web/ # Next.js
3│ └── marketing/ # Astro
4├── packages/
5│ └── strapi-client/
6│ ├── src/
7│ │ ├── client.ts
8│ │ ├── types/
9│ │ │ └── article.ts
10│ │ └── index.ts
11│ └── package.json
12├── cms/ # Strapi v5 backend
13└── turbo.jsonYour typed client factory lets each app inject its own environment configuration:
1// packages/strapi-client/src/client.ts
2import { strapi } from '@strapi/client';
3import type { Article } from './types/article';
4
5export function createStrapiClient(config: {
6 baseURL: string;
7 auth?: string;
8}) {
9 const client = strapi({
10 baseURL: config.baseURL,
11 auth: config.auth,
12 });
13
14 return {
15 articles: client.collection<Article>('articles'),
16 raw: client,
17 };
18}Strapi 5 uses a flattened response format. Attributes sit directly on each data object, and documentId replaces id as the stable identifier. Your type definitions reflect this:
1export interface Article {
2 id: number;
3 documentId: string;
4 title: string;
5 slug: string;
6 content: string;
7 createdAt: string;
8 updatedAt: string;
9}Both your Next.js and Astro frontends consume the same package:
1// apps/web/lib/strapi.ts
2import { createStrapiClient } from '@repo/strapi-client';
3
4export const cms = createStrapiClient({
5 baseURL: process.env.STRAPI_API_URL!,
6 auth: process.env.STRAPI_API_TOKEN,
7});The result is one Strapi backend, shared type safety via a single SDK package, and selective deployments. Strapi, as a headless CMS, handles multi-tenancy scenarios, while Turborepo coordinates builds and deploys only changed applications. This reduces coordination overhead across frontends.
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.