Skip to content
SAGAR.
Go back

Building Faster Apps with Monorepos and Turborepo

You have a frontend app. A backend API. Maybe a shared design system.

And a component library you copy-paste between multiple projects.

Every time you change a shared utility, you update it in one repo, publish it to npm, bump the version in three other repos, and pray nothing breaks.

Sound familiar?

There’s a better way. It’s called a monorepo. No, it’s not complicated. You’ve just been told it is.

Table of contents

Open Table of contents

What Even Is a Monorepo?

A monorepo is one repository that holds multiple projects.

That’s it.

Instead of this:

github.com/sagar-shiroya/frontend-app
github.com/sagar-shiroya/backend-api
github.com/sagar-shiroya/design-system
github.com/sagar-shiroya/shared-utils

You have this:

github.com/sagar-shiroya/my-project
  ├── apps/
  │   ├── web/        ← your Next.js frontend
  │   └── api/        ← your Express backend
  └── packages/
      ├── ui/         ← your design system
      └── utils/      ← your shared utilities

One repo. Multiple projects. All living together.

No more copy-pasting. No more version bumping. No more “which repo was that file in?”


Does that mean putting everything in one folder?

Kind of. But there’s a difference.

A monorepo is not a monolith.

A monolith is one big app where all the code is together.

A monorepo is multiple independent deployable apps and shared packages.

All simply live in the same git repository.

Think of it like an apartment building.

Each apartment is independent. You cook your own food, pay your own rent, come and go as you like. But you share the building, the elevator, and the electricity bill.

That’s a monorepo. Shared infrastructure, independent units.


Why Should You Care?

Here’s a real scenario.

You’re building a SaaS product. You have:

In separate repos, changing formatDate means:

  1. Go to shared-utils repo
  2. Make the change
  3. Bump version (1.0.4 → 1.0.5)
  4. Publish to npm
  5. Go to web app repo, update dependency
  6. Go to mobile app repo, update dependency
  7. Go to API repo, update dependency
  8. Test everything separately
  9. Realize you made a typo in step 2 (oops…)

In a monorepo, changing formatDate means:

  1. Change it
  2. Done

Every app that uses it picks up the change instantly. No publishing. No version bumping. No separate deploys just to sync a utility.


The Problem With Naive Monorepos

Before tools like Turborepo existed, people did monorepos the hard way.

They’d run builds sequentially. App A builds, then App B builds, then App C builds.

Even if you only changed App A, you’d still wait for B and C to build.

This is why people said “monorepos are slow.”

Turborepo fixes this.


Turborepo

Turborepo is a build system made specifically for monorepos.

It does two things extremely well:

1. Only rebuilds what changed

If you change code in your web app, Turborepo knows your api hasn’t changed. It skips the api build entirely.

2. Caches everything

If you already built something and nothing changed, Turborepo returns the cached result instantly. No rebuild. Not even a second wasted.

These two features alone make a monorepo feel fast.

And if you opt into Turborepo’s remote cache, your teammates get your cache too. They pull your build result instead of rebuilding from scratch. Your CI pipeline does the same.


The Tools We’re Using

Here’s our stack for this guide:

ToolWhat it does
pnpmPackage manager with built-in workspace support
TurborepoBuild orchestration and caching
TypeScriptShared types across all packages
Node.js 20+Runtime

We’re using pnpm instead of npm or yarn because pnpm’s workspace support is first-class and it’s significantly faster. It also handles symlinks between your local packages way more cleanly.


Let’s Build One From Scratch

We’re building a real-world monorepo called my-saas.

It will have:


Step 1: Install pnpm

If you don’t have pnpm yet:

npm install -g pnpm

Check it works:

pnpm  --version
# 11.x.x

Step 2: Create the Project

mkdir my-saas
cd my-saas
git init

Create the root package.json:

{
  "name": "my-saas",
  "private": true,
  "scripts": {
    "build": "turbo build",
    "dev": "turbo dev",
    "lint": "turbo lint",
    "test": "turbo test"
  },
  "devDependencies": {
    "turbo": "^2.0.0"
  },
  "engines": {
    "node": ">=20",
    "pnpm": ">=9"
  }
}

A few things to notice:


Step 3: Set Up pnpm Workspaces

Create pnpm-workspace.yaml in the root:

packages:
  - "apps/*"
  - "packages/*"

This tells pnpm: “Anything inside apps/ or packages/ is a workspace package.”

Now create the folder structure:

mkdir -p apps/web apps/api packages/ui packages/utils packages/typescript-config

Step 4: Configure Turborepo

Create turbo.json in the root:

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

Let’s read what this actually says.

"dependsOn": ["^build"] — The ^ symbol means “build my dependencies first.”

So if web depends on ui, Turborepo will build ui before building web. Automatically. Every time.

"outputs" — Tell Turborepo what files a task produces. It uses this for caching. If these files exist and nothing has changed, it skips the task entirely.

"cache": false for dev — Dev servers run continuously. You never cache a running process.

"persistent": true for dev — This tells Turborepo the task will keep running (like a dev server) and not to wait for it to finish.


Step 5: Shared TypeScript Config

This one package saves you from copy-pasting tsconfig.json across every app.

cd packages/typescript-config

Create package.json:

{
  "name": "@my-saas/typescript-config",
  "version": "0.0.0",
  "private": true,
  "files": ["*.json"],
  "license": "MIT"
}

Create base.json:

{
  "$schema": "https://json.schemastore.org/tsconfig",
  "display": "Default",
  "compilerOptions": {
    "strict": true,
    "strictNullChecks": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "moduleDetection": "force",
    "isolatedModules": true
  }
}

Create nextjs.json for Next.js apps:

{
  "$schema": "https://json.schemastore.org/tsconfig",
  "display": "Next.js",
  "extends": "./base.json",
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["dom", "dom.iterable", "esnext"],
    "module": "esnext",
    "moduleResolution": "bundler",
    "allowJs": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [{ "name": "next" }]
  },
  "exclude": ["node_modules"]
}

Create node.json for Node.js apps (like your API):

{
  "$schema": "https://json.schemastore.org/tsconfig",
  "display": "Node.js",
  "extends": "./base.json",
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022"],
    "module": "NodeNext",
    "moduleResolution": "NodeNext"
  }
}

Now every app in your monorepo can use these configs. No duplication.


Step 6: Build the Shared Utils Package

cd packages/utils

Create package.json:

{
  "name": "@my-saas/utils",
  "version": "0.0.0",
  "private": true,
  "main": "./src/index.ts",
  "types": "./src/index.ts",
  "scripts": {
    "lint": "tsc --noEmit"
  },
  "devDependencies": {
    "@my-saas/typescript-config": "workspace:*",
    "typescript": "^5.4.0"
  }
}

Notice "workspace:*" — this is pnpm telling the package manager “find this dependency inside the workspace, not on npm.”

Create tsconfig.json:

{
  "extends": "@my-saas/typescript-config/base.json",
  "compilerOptions": {
    "outDir": "dist"
  },
  "include": ["src"]
}

Create src/index.ts:

export * from "./format";
export * from "./validation";

Create src/format.ts:

export function formatDate(date: Date | string): string {
  const d = typeof date === "string" ? new Date(date) : date;
  return new Intl.DateTimeFormat("en-US", {
    year: "numeric",
    month: "long",
    day: "numeric",
  }).format(d);
}

export function formatCurrency(
  amount: number,
  currency: string = "USD"
): string {
  return new Intl.NumberFormat("en-US", {
    style: "currency",
    currency,
  }).format(amount);
}

export function formatRelativeTime(date: Date | string): string {
  const d = typeof date === "string" ? new Date(date) : date;
  const now = new Date();
  const diffMs = now.getTime() - d.getTime();
  const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));

  if (diffDays === 0) return "Today";
  if (diffDays === 1) return "Yesterday";
  if (diffDays < 7) return `${diffDays} days ago`;
  if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`;

  return formatDate(d);
}

Create src/validation.ts:

export function isValidEmail(email: string): boolean {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}

export function isValidUrl(url: string): boolean {
  try {
    new URL(url);
    return true;
  } catch {
    return false;
  }
}

export function clamp(value: number, min: number, max: number): number {
  return Math.min(Math.max(value, min), max);
}

Clean, typed, shareable. Both your web app and api can use these without any publishing step.


Step 7: Build the Shared UI Package

This is where the real power shows up.

cd packages/ui

Create package.json:

{
  "name": "@my-saas/ui",
  "version": "0.0.0",
  "private": true,
  "exports": {
    ".": {
      "types": "./src/index.ts",
      "default": "./src/index.ts"
    }
  },
  "scripts": {
    "lint": "tsc --noEmit"
  },
  "devDependencies": {
    "@my-saas/typescript-config": "workspace:*",
    "@types/react": "^18.2.0",
    "typescript": "^5.4.0"
  },
  "peerDependencies": {
    "react": "^18.0.0"
  }
}

Create tsconfig.json:

{
  "extends": "@my-saas/typescript-config/nextjs.json",
  "compilerOptions": {
    "outDir": "dist"
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}

Create src/index.ts:

export * from "./button";
export * from "./card";
export * from "./badge";

Create src/button.tsx:

import * as React from "react";

type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
type ButtonSize = "sm" | "md" | "lg";

interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: ButtonVariant;
  size?: ButtonSize;
  isLoading?: boolean;
  leftIcon?: React.ReactNode;
}

const variantStyles: Record<ButtonVariant, string> = {
  primary: "bg-blue-600 text-white hover:bg-blue-700 border-transparent",
  secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200 border-transparent",
  ghost: "bg-transparent text-gray-700 hover:bg-gray-100 border-transparent",
  danger: "bg-red-600 text-white hover:bg-red-700 border-transparent",
};

const sizeStyles: Record<ButtonSize, string> = {
  sm: "px-3 py-1.5 text-sm",
  md: "px-4 py-2 text-sm",
  lg: "px-6 py-3 text-base",
};

export function Button({
  variant = "primary",
  size = "md",
  isLoading = false,
  leftIcon,
  children,
  className = "",
  disabled,
  ...props
}: ButtonProps) {
  return (
    <button
      className={`
        inline-flex items-center gap-2 font-medium rounded-lg border
        transition-colors duration-150 focus:outline-none focus:ring-2
        focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50
        disabled:cursor-not-allowed
        ${variantStyles[variant]}
        ${sizeStyles[size]}
        ${className}
      `}
      disabled={disabled || isLoading}
      {...props}
    >
      {isLoading ? (
        <span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
      ) : leftIcon ? (
        <span className="h-4 w-4">{leftIcon}</span>
      ) : null}
      {children}
    </button>
  );
}

Create src/card.tsx:

import * as React from "react";

interface CardProps {
  children: React.ReactNode;
  className?: string;
  padding?: "none" | "sm" | "md" | "lg";
}

interface CardHeaderProps {
  title: string;
  description?: string;
  action?: React.ReactNode;
}

const paddingStyles = {
  none: "",
  sm: "p-4",
  md: "p-6",
  lg: "p-8",
};

export function Card({ children, className = "", padding = "md" }: CardProps) {
  return (
    <div
      className={`bg-white rounded-xl border border-gray-200 shadow-sm ${paddingStyles[padding]} ${className}`}
    >
      {children}
    </div>
  );
}

export function CardHeader({ title, description, action }: CardHeaderProps) {
  return (
    <div className="flex items-start justify-between mb-4">
      <div>
        <h3 className="text-lg font-semibold text-gray-900">{title}</h3>
        {description && (
          <p className="text-sm text-gray-500 mt-0.5">{description}</p>
        )}
      </div>
      {action && <div className="ml-4 flex-shrink-0">{action}</div>}
    </div>
  );
}

Create src/badge.tsx:

import * as React from "react";

type BadgeVariant = "default" | "success" | "warning" | "danger" | "info";

interface BadgeProps {
  variant?: BadgeVariant;
  children: React.ReactNode;
}

const variantStyles: Record<BadgeVariant, string> = {
  default: "bg-gray-100 text-gray-700",
  success: "bg-green-100 text-green-700",
  warning: "bg-yellow-100 text-yellow-700",
  danger: "bg-red-100 text-red-700",
  info: "bg-blue-100 text-blue-700",
};

export function Badge({ variant = "default", children }: BadgeProps) {
  return (
    <span
      className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${variantStyles[variant]}`}
    >
      {children}
    </span>
  );
}

One component library. Used by every app. Changed in one place. Works everywhere.


Step 8: Set Up the Next.js Web App

cd apps/web

Create package.json:

{
  "name": "@my-saas/web",
  "version": "0.0.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "@my-saas/ui": "workspace:*",
    "@my-saas/utils": "workspace:*",
    "next": "^14.2.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@my-saas/typescript-config": "workspace:*",
    "@types/node": "^20.0.0",
    "@types/react": "^18.2.0",
    "typescript": "^5.4.0"
  }
}

See "@my-saas/ui": "workspace:*" and "@my-saas/utils": "workspace:*"?

That’s it. That’s the magic. You’re depending on your local packages the same way you’d depend on any npm package. pnpm resolves them from inside your workspace.

Create tsconfig.json:

{
  "extends": "@my-saas/typescript-config/nextjs.json",
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}

Create next.config.ts:

import type { NextConfig } from "next";

const config: NextConfig = {
  transpilePackages: ["@my-saas/ui", "@my-saas/utils"],
};

export default config;

transpilePackages tells Next.js to compile your local workspace packages. This is necessary because your ui and utils packages export TypeScript source directly.

Now let’s use our shared packages. Create app/page.tsx:

import { Button, Card, CardHeader, Badge } from "@my-saas/ui";
import { formatDate, formatCurrency, isValidEmail } from "@my-saas/utils";

export default function HomePage() {
  const today = formatDate(new Date());
  const price = formatCurrency(49.99);
  const testEmail = "user@example.com";

  return (
    <main className="min-h-screen bg-gray-50 p-8">
      <div className="max-w-2xl mx-auto space-y-6">

        <div>
          <h1 className="text-3xl font-bold text-gray-900">My SaaS</h1>
          <p className="text-gray-500 mt-1">{today}</p>
        </div>

        <Card>
          <CardHeader
            title="Pro Plan"
            description="Everything you need to ship faster"
            action={<Badge variant="success">Popular</Badge>}
          />
          <p className="text-3xl font-bold text-gray-900">{price}<span className="text-sm font-normal text-gray-500">/month</span></p>
          <Button className="mt-4 w-full" variant="primary">
            Get Started
          </Button>
        </Card>

        <Card>
          <CardHeader title="Email Validation" />
          <p className="text-sm text-gray-600">
            Is <code>{testEmail}</code> valid?{" "}
            <Badge variant={isValidEmail(testEmail) ? "success" : "danger"}>
              {isValidEmail(testEmail) ? "Valid" : "Invalid"}
            </Badge>
          </p>
        </Card>

      </div>
    </main>
  );
}

This is the whole point. You imported Button, Card, Badge from your own local package. You imported formatDate, formatCurrency, isValidEmail from another local package. No npm publish. No version management. It just works.


Step 9: Set Up the Express API

cd apps/api

Create package.json:

{
  "name": "@my-saas/api",
  "version": "0.0.0",
  "private": true,
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js",
    "lint": "tsc --noEmit"
  },
  "dependencies": {
    "@my-saas/utils": "workspace:*",
    "express": "^4.19.0",
    "cors": "^2.8.5"
  },
  "devDependencies": {
    "@my-saas/typescript-config": "workspace:*",
    "@types/express": "^4.17.0",
    "@types/cors": "^2.8.0",
    "@types/node": "^20.0.0",
    "tsx": "^4.0.0",
    "typescript": "^5.4.0"
  }
}

Create tsconfig.json:

{
  "extends": "@my-saas/typescript-config/node.json",
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}

Create src/index.ts:

import express from "express";
import cors from "cors";
import { isValidEmail, formatDate, formatCurrency } from "@my-saas/utils";

const app = express();
const PORT = process.env.PORT || 3001;

app.use(cors());
app.use(express.json());

// Health check
app.get("/health", (_req, res) => {
  res.json({
    status: "ok",
    timestamp: formatDate(new Date()),
  });
});

// Validate email
app.post("/validate/email", (req, res) => {
  const { email } = req.body as { email: string };

  if (!email) {
    return res.status(400).json({ error: "Email is required" });
  }

  return res.json({
    email,
    isValid: isValidEmail(email),
    checkedAt: formatDate(new Date()),
  });
});

// Get pricing
app.get("/pricing", (_req, res) => {
  const plans = [
    { name: "Starter", price: 0, formatted: formatCurrency(0) },
    { name: "Pro", price: 49, formatted: formatCurrency(49) },
    { name: "Enterprise", price: 199, formatted: formatCurrency(199) },
  ];

  res.json({ plans });
});

app.listen(PORT, () => {
  console.log(`API running on http://localhost:${PORT}`);
});

Your API uses the exact same isValidEmail, formatDate, and formatCurrency as your frontend.

Same logic. One source of truth.

If you ever fix a bug in isValidEmail, it’s fixed in both the frontend and backend at the same time. No sync required.


Step 10: Install Everything

Go back to the root:

cd ../../  # back to my-saas root
pnpm install

pnpm reads your pnpm-workspace.yaml, finds all your workspace packages, and installs everything. It also creates symlinks between your local packages so they resolve correctly.

You’ll see node_modules at the root (shared dependencies) and inside each package (package-specific dependencies). pnpm is smart about deduplication. It won’t install the same package twice if it can avoid it.


Step 11: Run Everything

To run all dev servers at once:

pnpm dev

Turborepo spins up all dev scripts in parallel. Your Next.js app, your Express API will be running simultaneously. One command.

To build everything:

pnpm build

Turborepo figures out the order automatically. It builds utils and ui first (because web and api depend on them), then builds web and api in parallel.

To build only the web app:

pnpm turbo build --filter=@my-saas/web

The --filter flag is incredibly useful. You can run tasks on specific packages.

Perfect for CI.


Monorepo Flow

Monorepo Flow

Understanding the Dependency Graph

Here’s how Turborepo thinks about your packages:

        @my-saas/typescript-config       <--- Build First

        ├─────────────│────────────────┐
        │                              │
@my-saas/utils                    @my-saas/ui    <--- Build parallel
        │                              │
        └──────────────┬───────────────┘

            ┌──────────┴──────────┐
            │                     │
      @my-saas/web             @my-saas/api     <--- Build parallel at the end

When you run turbo build, it walks this graph bottom-up.

  1. typescript-config has no dependencies → build it first.

  2. utils and ui depend on typescript-config → build them next (in parallel).

  3. web and api depend on utils and ui → build them last (in parallel).

You never have to think about this order.

Turborepo handles it based on your package.json dependencies.


The Caching System Explained

This is where Turborepo earns its reputation.

When you run turbo build, Turborepo computes a hash for each task. The hash is based on:

If the hash matches something it’s seen before, it restores the cached output instantly.

Watch this in action:

# First build — builds everything fresh
pnpm build

# Run it again without changing anything
pnpm build

Second run output:

@my-saas/utils:build: cache hit, replaying output
@my-saas/ui:build: cache hit, replaying output
@my-saas/web:build: cache hit, replaying output
@my-saas/api:build: cache hit, replaying output

Tasks:    4 successful, 4 total
Cached:   4 cached, 4 total
Time:     312ms

312 milliseconds. For a complete build. Because everything was cached.

Now change one line in apps/web:

pnpm build
@my-saas/utils:build: cache hit, replaying output
@my-saas/ui:build: cache hit, replaying output
@my-saas/api:build: cache hit, replaying output
@my-saas/web:build: cache miss, executing...

Tasks:    4 successful, 4 total
Cached:   3 cached, 4 total
Time:     8.2s

Only web rebuilt. The rest hit cache. This scales beautifully as your monorepo grows.


Environment Variables in Turborepo

Turborepo doesn’t automatically include environment variables in its cache hash.

You have to declare them.

This is a feature, not a bug. You control exactly which env vars affect which tasks.

{
  "$schema": "https://turbo.build/schema.json",
  "globalEnv": ["NODE_ENV"],
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**"],
      "env": ["DATABASE_URL", "NEXT_PUBLIC_API_URL"]
    },
    "dev": {
      "cache": false,
      "persistent": true,
      "env": ["DATABASE_URL", "NEXT_PUBLIC_API_URL", "PORT"]
    }
  }
}

globalEnv — affects every task. env inside a task — specific to that task.

If DATABASE_URL changes, Turborepo invalidates the cache for tasks that declared it. If it didn’t change, cache is still valid.

Create a .env file at the root:

# .env
NODE_ENV=development
DATABASE_URL=postgresql://localhost:5432/mydb
NEXT_PUBLIC_API_URL=http://localhost:3001
PORT=3001

And add .env to .gitignore.


Adding a New Package (The Real Workflow)

Let’s say you want to add a @my-saas/email package for sending emails.

mkdir packages/email
cd packages/email

Create package.json:

{
  "name": "@my-saas/email",
  "version": "0.0.0",
  "private": true,
  "main": "./src/index.ts",
  "types": "./src/index.ts",
  "scripts": {
    "lint": "tsc --noEmit"
  },
  "dependencies": {
    "@my-saas/utils": "workspace:*"
  },
  "devDependencies": {
    "@my-saas/typescript-config": "workspace:*",
    "typescript": "^5.4.0"
  }
}

Create src/index.ts:

import { isValidEmail } from "@my-saas/utils";

export interface EmailOptions {
  to: string;
  subject: string;
  body: string;
}

export async function sendEmail(options: EmailOptions): Promise<void> {
  if (!isValidEmail(options.to)) {
    throw new Error(`Invalid email address: ${options.to}`);
  }

  // Your email sending logic here
  console.log(`Sending email to ${options.to}: ${options.subject}`);
}

export function createWelcomeEmail(name: string): EmailOptions {
  return {
    to: "",
    subject: `Welcome to My SaaS, ${name}!`,
    body: `Hi ${name}, thanks for joining us.`,
  };
}

Run pnpm install from the root to link the new package.

Now add it to your API:

// apps/api/package.json
{
  "dependencies": {
    "@my-saas/utils": "workspace:*",
    "@my-saas/email": "workspace:*",
    ...
  }
}

Import and use:

// apps/api/src/index.ts
import { sendEmail, createWelcomeEmail } from "@my-saas/email";

app.post("/users", async (req, res) => {
  const { name, email } = req.body;

  // ... create user in database

  const welcomeEmail = createWelcomeEmail(name);
  await sendEmail({ ...welcomeEmail, to: email });

  res.status(201).json({ success: true });
});

You added a new package and connected it to an existing app in about 5 minutes. No npm publish. No version management. No waiting.


Managing Scripts at Scale

As your monorepo grows, you’ll want to run tasks only for specific apps or filter by certain criteria.

Some useful turbo command patterns:

# Run dev for only the web app
pnpm turbo dev  --filter=@my-saas/web

# Build all packages that web depends on
pnpm turbo build  --filter=@my-saas/web...

# Run lint for everything that changed since main branch
pnpm turbo lint  --filter=...[origin/main]

# Run tests for packages that depend on utils (after changing utils)
pnpm turbo test  --filter=...@my-saas/utils

# Run build for all apps (not packages)
pnpm turbo build  --filter=./apps/*

The filter syntax is powerful. The ... means “and all dependents/dependencies.”


Your Final Folder Structure

After everything is set up, your monorepo looks like this:

my-saas/
├── apps/
│   ├── web/
│   │   ├── app/
│   │   │   └── page.tsx
│   │   ├── next.config.ts
│   │   ├── package.json
│   │   └── tsconfig.json
│   └── api/
│       ├── src/
│       │   └── index.ts
│       ├── package.json
│       └── tsconfig.json
├── packages/
│   ├── ui/
│   │   ├── src/
│   │   │   ├── index.ts
│   │   │   ├── button.tsx
│   │   │   ├── card.tsx
│   │   │   └── badge.tsx
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── utils/
│   │   ├── src/
│   │   │   ├── index.ts
│   │   │   ├── format.ts
│   │   │   └── validation.ts
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── email/
│   │   ├── src/
│   │   │   └── index.ts
│   │   ├── package.json
│   │   └── tsconfig.json
│   └── typescript-config/
│       ├── base.json
│       ├── nextjs.json
│       ├── node.json
│       └── package.json
├── .env
├── .gitignore
├── package.json
├── pnpm-workspace.yaml
└── turbo.json

Clean. Organised. Scalable.


Common Mistakes to Avoid

Mistake 1: Putting everything in one package

Your packages/utils should not have 400 functions. Split by domain. packages/utils for generic helpers, packages/email for email, packages/auth for auth helpers. Smaller packages = better caching granularity.

Mistake 2: Forgetting to declare env vars in turbo.json

If your build behaves differently on different machines despite having the same code, you probably have an undeclared env var that Turborepo isn’t accounting for in the cache hash.

Mistake 3: Using * versions for workspace dependencies

Use workspace:* (pnpm syntax), not just *. They look similar but workspace:* is explicit about using the local version. Regular * might fall back to npm.

Mistake 4: Adding node_modules to outputs

// Don't do this
"outputs": ["node_modules/**", "dist/**"]

// Do this
"outputs": ["dist/**"]

Caching node_modules bloats your cache and causes weird bugs.

Mistake 5: Running npm install in a pnpm workspace

Always use pnpm install. Running npm install in a pnpm workspace will create a separate package-lock.json and mess up your symlinks. Add a root .npmrc:

engine-strict=true

And in your root package.json:

{
  "engines": {
    "npm": "please-use-pnpm",
    "pnpm": ">=9"
  }
}

This will throw an error if someone tries to use npm.


When Should You NOT Use a Monorepo?

Monorepos aren’t for everyone. Be honest with yourself.

Skip it if:

Use it if:


A Quick Recap

Let’s zoom out.

You started with scattered repos and a manual sync nightmare.

Now you have:

You went from 5 terminals and 4 GitHub repos to one clean, fast, maintainable codebase.

That’s the monorepo promise. And with Turborepo and pnpm, it actually delivers.


Thank you for reading it. :)

Connect with me on X or LinkedIn, if you have any feedback or suggestion.


Share this post on:

Next Post
Database Connections & Connection Pooling