Skip to content

Monorepos with Turborepo: share code between your website, dashboard and API

When a project grows into a website, an admin dashboard and an API, keeping them in separate repositories gets painful. Here’s how a Turborepo monorepo works, how to set one up, and the caching that makes it fast.

Nasir Mollah

5 min read

On this page10 sections

Many real products aren’t one app. A typical business platform might have a public website, an admin dashboard for the team and an API that both use — plus shared things like TypeScript types, validation schemas, UI components and configuration.

Put each app in its own repository and you’ll soon be copying types between projects, publishing internal packages just to share a function, and coordinating releases across three pull requests. A monorepo keeps everything in one repository, and Turborepo makes working in it fast.

What a monorepo is (and isn’t)

A monorepo is a single repository containing multiple projects that can depend on each other. It is not the same as a monolith: each app can still be built and deployed independently.

Benefits:

  • Share code directly — types, schemas, utilities, UI components — without publishing packages.
  • Atomic changes — change the API and the website that uses it in one commit and one pull request.
  • Consistent tooling — one TypeScript, ESLint and Prettier setup for everything.
  • Easier refactoring — your editor and compiler see all usages across apps.

Costs:

  • More moving parts in the build setup.
  • CI can get slow if every change rebuilds everything — which is the problem Turborepo solves.

A typical structure

my-platform/
├── apps/
│   ├── web/          # public Next.js website
│   ├── admin/        # Next.js admin dashboard
│   └── api/          # Express or Fastify API
├── packages/
│   ├── types/        # shared TypeScript types
│   ├── validation/   # shared Zod schemas
│   ├── ui/           # shared React components
│   └── config/       # shared tsconfig / eslint config
├── package.json
├── pnpm-workspace.yaml   # or "workspaces" in package.json for npm/yarn
└── turbo.json

Apps are things you deploy. Packages are code the apps share.

Workspaces: the foundation

Package managers (npm, pnpm, Yarn, Bun) support workspaces: multiple packages in one repository, installed together and linked to each other. With pnpm:

# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"

A shared package has its own package.json:

{
  "name": "@platform/validation",
  "version": "0.0.0",
  "private": true,
  "main": "./src/index.ts",
  "types": "./src/index.ts",
  "dependencies": { "zod": "^3.23.0" }
}

And an app depends on it like any other package:

{
  "name": "web",
  "dependencies": {
    "@platform/validation": "workspace:*"
  }
}

Now the website, the dashboard and the API can all import the same schema:

import { bookingSchema } from "@platform/validation";

Change the schema once and every app — front end and back end — validates the same way. TypeScript flags every place that needs updating.

Where Turborepo comes in

Workspaces handle linking. Turborepo handles running tasks — build, lint, test, dev — across all those packages, in the right order, in parallel, and without repeating work it has already done.

The configuration lives in turbo.json:

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

Key ideas:

  • dependsOn: ["^build"] — the ^ means “first run build in the packages this one depends on”. Turborepo works out the order from your dependency graph.
  • outputs — which files a task produces, so Turborepo can cache and restore them.
  • cache: false / persistent: true — for long-running dev servers that shouldn’t be cached.

Run tasks from the root:

npx turbo run build          # build everything, in dependency order, in parallel
npx turbo run dev            # start all dev servers
npx turbo run lint typecheck # run several tasks

Caching: the reason it’s fast

Before running a task, Turborepo computes a hash of everything that affects it: the package’s source files, its dependencies, relevant environment variables and the task configuration. If it has seen that exact hash before, it replays the cached output and logs instead of running the task again.

In practice:

  • Change only the dashboard? The website and API builds come from cache in milliseconds.
  • Re-run CI on the same commit? Almost everything is a cache hit.

With remote caching, the cache is shared between teammates and CI machines, so work done once on one machine is reused everywhere.

Environment variables and caching

If a build depends on environment variables, declare them so they’re part of the hash — otherwise you could get a cached build made with different values:

{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "env": ["NEXT_PUBLIC_SITE_URL", "DATABASE_URL"],
      "outputs": [".next/**", "!.next/cache/**"]
    }
  }
}

Filtering: work on just what you need

npx turbo run dev --filter=web            # only the website (and what it needs)
npx turbo run build --filter=admin...     # admin and its dependencies
npx turbo run test --filter=...[origin/main]   # only packages changed since main

Filtering keeps local development focused and CI fast.

Internal packages: two approaches

  1. Just-in-time (source) packages — the package exports TypeScript source directly (like the example above), and each app’s bundler compiles it. Simple, no build step, great for apps built with Next.js or Vite. (Next.js may need the package listed in transpilePackages.)
  2. Compiled packages — the package has its own build step producing JavaScript and type declarations in dist/. More setup, but works with any consumer, including plain Node.js services.

Start with source packages where your tooling supports them; switch to compiled packages when a consumer needs plain JavaScript.

Monorepos in CI and deployment

  • Run turbo run lint typecheck test build in CI, with remote caching enabled.
  • Deploy each app independently. Many hosting platforms detect monorepos and let you set the app’s root directory; for your own servers, build the specific app with --filter.
  • Use Turborepo’s prune feature (turbo prune <app> --docker) to create a minimal subset of the monorepo for an app’s Docker image.

When a monorepo is worth it

A monorepo pays off when you have several apps that share code and change together — a website, dashboard and API for one product is the classic case. For a single small app, a normal repository is simpler. For unrelated projects with different teams and release cycles, separate repositories may be the better fit.

The takeaway

A monorepo lets your website, dashboard and API share types, validation and components directly, and ship coordinated changes in one pull request. Workspaces link the packages; Turborepo runs tasks in the right order and caches results so you never build the same thing twice. Start small — a shared types and validation package is often enough to feel the difference.

  • Turborepo
  • Monorepo
  • DevOps
  • TypeScript

Building something? Let’s talk.

If you’re planning a web app, a website or a new feature, a free call is a good place to start — you’ll get honest options, a fixed price and a timeline.