DEV Community

Saswata Pal
Saswata Pal

Posted on

Why I Chose Monorepo Architecture: From Code Chaos to 2.8s Builds

Why I Chose Monorepo Architecture: From Code Chaos to 2.8s Builds

I broke production on a Friday night.

Changed a Button prop in the UI library. Committed. Deployed. Felt good.

Except I forgot the portfolio app had its own copy of Button.tsx. Different repo. Same component name. Different version.

Production broke. White screen. Users emailing "site down?"

That's when I knew: copy-pasting components across 3 repos had to end.

After moving to monorepo:

  • One Button.tsx. One source of truth.
  • Type errors caught before commit (TypeScript sees everything)
  • Builds in 2.8 seconds with cache
  • Deploy once, everything stays in sync

But here's what really changed: I stopped being a deployment coordinator and became a developer again.

No more context switching. No more "did I update all three repos?" paranoia. Just code.


TL;DR

Choose Monorepo if:

  • ✅ You have 2+ projects sharing code (components, utilities, types)
  • ✅ You value atomic commits across multiple packages
  • ✅ You want faster builds with intelligent caching
  • ✅ Your team (or future team) needs consistent tooling
  • ❌ Don't choose if: Single app with no shared code, or mega-scale (1000+ packages)

Key Stats from My Project:

  • Build time: 2.8s (vs 5+ min managing 3 separate repos)
  • Cache hit rate: 95% (rebuilds only what changed)
  • Deployment complexity: 3 pipelines → 1 pipeline
  • Code duplication: ~40% duplicated code → 0%

Investment:

  • Setup time: 30 minutes (first time)
  • Learning curve: Low (if you know npm, you know workspaces)
  • ROI: Saves ~2 hours/day in context switching + builds

Risk Level: Low (easy to migrate back if needed)

🎥 Video: [Coming soon - will add YouTube walkthrough]

👇 Keep reading for: Real monorepo structure from my production project, migration gotchas I hit, and why this decision pays for itself in the first week.


The Problem

My Context

I was building CodeCraft Labs - a full-stack portfolio and component showcase:

  • 3 applications: Portfolio site (Next.js), web app prototype, CLI tool
  • 2 shared packages: UI design system (@​ccl/ui with 25+ components), TypeScript configs
  • 1 developer: Just me (now), planning for 2-5 person team
  • Tech stack: React 19, TypeScript 5.6, Next.js 16, Tailwind v4, Turborepo
  • Deployment: Vercel (portfolio), future: Vercel (web), npm (CLI)
  • Project: github.com/saswatawork/codecraft-labs

The Challenge: Repository Hell

Managing 3 separate repos was slowly killing my productivity:

Problem 1: Code Duplication Nightmare

// 😱 The same Button component existed in 3 places:
// Repo 1: portfolio/components/Button.tsx (230 lines)
// Repo 2: web-app/components/Button.tsx (230 lines, copy-pasted)
// Repo 3: ui-library/src/Button.tsx (250 lines, "improved" version)

// Changed the API in one? Manual sync to other two.
// Forgot to sync? Production bugs.
// Fixed a bug in one? Copy-paste fix 3 times.
Enter fullscreen mode Exit fullscreen mode

The Reality:

  • 40% of my code was duplicated across repos
  • 15-20 minutes per "simple" component update
  • High risk of drift (Button in repo 1 ≠ Button in repo 2)

Problem 2: Deployment Complexity

# My daily workflow (the painful version):

# Update shared component
$ cd ~/ui-library
$ git pull
$ npm install
$ npm run build
$ npm version patch
$ npm publish
$ git push

# Update portfolio
$ cd ~/portfolio
$ npm install ui-library@​​latest  # Wait 45 seconds
$ npm run build                  # Wait 2 minutes
$ git add package.json package-lock.json
$ git commit -m "Update UI library"
$ git push                       # Vercel auto-deploys

# Update web app
$ cd ~/web-app
$ npm install ui-library@​​latest  # Another 45 seconds
$ npm run build                  # Another 2 minutes
$ git add package.json package-lock.json
$ git commit -m "Update UI library"
$ git push

# Total time: 8-10 minutes (if nothing breaks)
# Actual time: 15-20 minutes (because something always breaks)
Enter fullscreen mode Exit fullscreen mode

Problem 3: Type Safety Across Repos = Impossible

// In UI library (separate repo):
export interface ButtonProps {
  variant: 'primary' | 'secondary';
  size: 'sm' | 'md' | 'lg';
}

// In portfolio app (different repo):
<Button variant="primary" size="xl" /> 
// ❌ TypeScript can't catch this at dev time
// ✅ Only fails after: npm publish → npm install → npm build
// By then you've wasted 10 minutes
Enter fullscreen mode Exit fullscreen mode

Problem 4: Tooling Inconsistencies

Each repo had slightly different configs:

  • ESLint rules: 85% overlap, 15% chaos
  • TypeScript configs: Copy-pasted, slowly diverging
  • Prettier settings: "Did I use 2 spaces or 4 here?"
  • Git hooks: Some had pre-commit, some didn't
  • Node version: 18 in one, 20 in another

The Breaking Point:

One Friday evening, I updated Button's onClick signature to return a Promise. Updated portfolio app. Forgot about web app. Deployed.

Saturday morning: User reports "buttons don't work." The web app still expected synchronous onClick. TypeScript didn't catch it because they were in separate repos.

Fixed the bug in 5 minutes. Spent 2 hours questioning my architecture choices.

Why This Decision Mattered

Impact of staying with multi-repo:

  • ⏱️ Developer Productivity: 15-20 min per shared code update × 5-10 updates/day = 2+ hours daily waste
  • 💰 Cost Implications: 2 hrs/day × $50/hr (conservative) = $100/day = $2,000/month in lost productivity
  • 🔄 Migration Difficulty: The longer I waited, the harder migration would become
  • 📈 Scale Implications: Planning to grow from 3 apps to 8+ apps in next 6 months
  • 👥 Team Impact: When I hire 2-5 people, onboarding 3 repos × 3 configs = nightmare
  • 🐛 Bug Risk: Code drift between repos = production bugs (already happened twice)

The question wasn't "should I migrate?"

The question was "how much longer can I afford NOT to migrate?"


✅ What I Was Looking For

Must-Have Requirements

  1. Atomic Commits Across Packages - Change UI component + all consumers in one commit

    • Critical because: Prevents version drift and "forgot to update" bugs
    • Measures success: Can git log show UI lib + apps changed together
  2. Intelligent Build Caching - Don't rebuild unchanged packages

    • Critical because: 3 separate repos = 3 separate builds = 5+ min total
    • Measures success: Second build should be < 1 second
  3. Type Safety Across Boundaries - TypeScript understands all packages

    • Critical because: Caught 2 production bugs that multi-repo couldn't catch
    • Measures success: npm run typecheck validates entire monorepo
  4. Shared Tooling Configuration - One ESLint, one TypeScript config, one source of truth

    • Critical because: Spent 30+ min/week syncing configs across repos
    • Measures success: Change ESLint rule once, applies everywhere
  5. Simple Dependency Management - Easy to link local packages

    • Critical because: npm link is painful, pnpm workspace should be automatic
    • Measures success: Import from local package like any npm package

Nice-to-Have Features

  • Remote caching for team collaboration (Turborepo + Vercel)
  • Selective task execution (only test affected packages)
  • Parallel builds (utilize all CPU cores)
  • Simple CI/CD (one pipeline instead of three)
  • Easy onboarding (new devs clone one repo, run one command)

Deal Breakers

  • Requires major rewrites - Can't afford 1+ week of migration
  • Complex configuration (100+ lines of config)
  • Vendor lock-in - Must be able to migrate away if needed
  • Slow builds - If monorepo is slower than multi-repo, what's the point?

Evaluation Framework

I scored approaches on these dimensions (0-10 scale):

Criteria Weight Why It Matters for My Context
Developer Productivity 30% Solo dev - every minute counts
Type Safety 25% Already had 2 bugs from repo boundaries
Build Speed 20% 5+ min multi-repo builds killing flow
Migration Ease 15% Can't afford week-long rewrites
Future Team Scalability 10% Planning to hire 2-5 people in 6 months

Methodology: Each approach rated 1-10 per criterion, multiplied by weight, summed for final score. Minimum passing score: 7.0/10.


🥊 The Contenders

I evaluated 5 architectural approaches based on research, experimentation, and talking to developers managing 2-100+ packages:

Monorepo (Turborepo + pnpm workspaces) - Single Repo, Multiple Packages

  • Best For: 2-50 packages, small-to-medium teams, shared code across apps
  • Key Strength: Atomic commits, shared tooling, intelligent caching, type safety across boundaries
  • Key Weakness: Can become unwieldy at 100+ packages (though rare)
  • Example Projects: Vercel (turborepo.org), Next.js, Remix
  • Tooling: Turborepo, Nx, Lerna, Rush, pnpm/yarn/npm workspaces
  • Adoption: Used by Google, Meta, Microsoft for internal projects
  • Learning Curve: Low (if you know npm, you know workspaces)
  • Setup Time: 30 minutes

Quick Take: The modern standard for teams sharing code across multiple projects. Combines benefits of code reuse with independent deployability.

Multi-repo (Polyrepo) - Separate Repos per Project

  • Best For: Completely independent projects, different teams with no code sharing
  • Key Strength: Complete independence, clear ownership, simple CI/CD per repo
  • Key Weakness: Code duplication, manual version coordination, tooling inconsistency
  • Example Projects: Most traditional organizations, microservices with no shared libs
  • Tooling: Standard Git workflows, separate npm packages
  • Adoption: Default approach for most projects (until pain threshold hit)
  • Learning Curve: None (standard Git)
  • Setup Time: 0 (already how most people work)

Quick Take: Simple until you need to share code across repos. Then becomes expensive fast.

Mono-package (Single Repo, Single Package) - Everything in One npm Package

  • Best For: Single application with no plans to split
  • Key Strength: Simplest possible setup, no coordination needed
  • Key Weakness: Can't independently version/deploy parts, grows into unmaintainable monolith
  • Example Projects: Small apps, MVPs, solo side projects
  • Tooling: Standard npm/pnpm, no special tools needed
  • Adoption: Most small-to-medium single apps
  • Learning Curve: None
  • Setup Time: 0

Quick Take: Perfect for single apps. Doesn't scale to multiple deployable units.

Hybrid (Mix of Monorepo + Published Packages) - Internal Monorepo + External Multi-repo

  • Best For: Organizations with both internal apps and public open-source libraries
  • Key Strength: Internal speed (monorepo) + external flexibility (separate repos for OSS)
  • Key Weakness: Complexity of managing both patterns, sync overhead
  • Example Projects: Companies with internal apps + public npm packages
  • Tooling: Monorepo tools + traditional npm publishing
  • Adoption: Used by companies like Stripe, Shopify for some projects
  • Learning Curve: Medium (need to understand both patterns)
  • Setup Time: 1-2 hours

Quick Take: Best of both worlds for specific use cases. Overkill for most projects.

Meta-repo (Git Submodules/Subtrees) - Nested Repos

  • Best For: Legacy codebases, specific enterprise constraints
  • Key Strength: Maintains repo independence while nesting
  • Key Weakness: Git submodules are notoriously painful, poor DX
  • Example Projects: Some legacy enterprise codebases
  • Tooling: Git submodules, Git subtrees
  • Adoption: Declining (most teams migrating away)
  • Learning Curve: High (git submodules are confusing)
  • Setup Time: 2+ hours (then eternal debugging)

Quick Take: Don't. Just don't. Git submodules cause more problems than they solve.


📊 Head-to-Head Comparison

Quick Feature Matrix

Feature Monorepo Multi-repo Mono-package Hybrid Meta-repo
Code Reuse ⭐⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐
Type Safety ⭐⭐⭐⭐⭐ ⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐
Build Speed ⭐⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐
Atomic Commits ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐
Independence ⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐
CI/CD Simplicity ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐
Learning Curve Easy None None Medium Hard
Setup Time 30 min 0 min 0 min 1-2 hrs 2+ hrs
Tooling Required ✅ Turbo/Nx ❌ None ❌ None ✅ Multiple ⚠️ Git magic
Caching ✅ Excellent ⚠️ Per-repo ✅ Simple ✅ Varies ⚠️ Complex
Versioning ✅ Unified ⚠️ Manual sync ✅ Single ⚠️ Mixed ⚠️ Manual
Team Scale 1-100 devs Any 1-10 devs 10-500 devs Not recommended
Best Package Count 2-50 Any 1 5-20 2-10

Real-World Metrics from My Migration

Test Setup:

  • Machine: MacBook Pro M2, 16GB RAM
  • Project: 3 apps, 2 packages, ~50K lines of code
  • Test Date: November 2025
  • Methodology: Measured end-to-end from git pull to deployment

Scenario 1: Update Shared UI Component

Task: Change Button component API, update all consumers, deploy

Approach Steps Time Error Risk
Monorepo 1. Edit Button
2. Update consumers
3. git commit (atomic)
4. turbo build (2.8s)
5. git push
5 min Low (TS catches all)
Multi-repo 1. Edit Button in ui-lib
2. Publish to npm
3. Update portfolio
4. Update web app
5. Deploy both
20 min High (manual sync)
Difference - 15 min saved Fewer bugs

Scenario 2: Full Clean Build

Task: Clone repo, install deps, build everything

Approach Install Time Build Time Total Cache Benefit
Monorepo 45s (pnpm) 8.2s (cold) 53s 2.8s (cached)
Multi-repo 135s (3 × 45s) 180s (3 × 60s) 315s No cross-repo cache
Savings 90s 172s 262s (4.4x faster) Huge

Scenario 3: Type Check Across Projects

Task: Verify TypeScript types for entire codebase

Approach Coverage Errors Caught Time
Monorepo 100% (sees all) Button API mismatch 4.2s
Multi-repo Per-repo only ❌ Mismatches not caught 3 × 3s = 9s
Outcome Better safety Caught 2 real bugs 2x faster

Scenario 4: CI/CD Pipeline Execution

Task: Run tests, build, deploy on GitHub Actions

Approach Pipelines Total CI Time Monthly Cost
Monorepo 1 pipeline 2m 15s $8
Multi-repo 3 pipelines 3 × 2m = 6m $24
Savings 2 fewer 3m 45s saved $16/month

📊 Key Finding: Monorepo delivered 3-4x productivity improvement across all scenarios.


🔍 Deep Dive: Monorepo with Turborepo + pnpm Workspaces

What It Is

A monorepo is a single Git repository containing multiple related projects (apps, packages, libraries) that can be developed, versioned, and deployed independently while sharing code and tooling.

Modern monorepo = pnpm workspaces (package linking) + Turborepo (build orchestration)

How It Works

monorepo/
├── apps/                        # Deployable applications
│   ├── portfolio/              # Next.js app → Vercel
│   ├── web/                    # React app → Vercel
│   └── api/                    # Node.js API → Railway
│
├── packages/                    # Shared libraries
│   ├── ui/                     # Component library
│   │   ├── src/components/
│   │   ├── package.json        # name: "@​​ccl/ui"
│   │   └── tsconfig.json
│   └── typescript-config/      # Shared TS configs
│
├── pnpm-workspace.yaml         # Defines workspaces
├── turbo.json                  # Orchestrates builds
└── package.json                # Root package
Enter fullscreen mode Exit fullscreen mode

How pnpm Workspaces Link Packages:

// apps/portfolio/package.json
{
  "dependencies": {
    "@​​ccl/ui": "workspace:*"  // Links to local packages/ui/
  }
}
Enter fullscreen mode Exit fullscreen mode

When you run pnpm install, pnpm creates symlinks:

apps/portfolio/node_modules/@​​ccl/ui → ../../packages/ui/
Enter fullscreen mode Exit fullscreen mode

Result: Import from local package like any npm package:

// apps/portfolio/src/app/page.tsx
import { Button } from '@​​ccl/ui';  // ✅ Works instantly, no npm publish!

<Button onClick={handleClick}>Click me</Button>
Enter fullscreen mode Exit fullscreen mode

How Turborepo Optimizes Builds:

// turbo.json
{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],        // Build dependencies first
      "outputs": ["dist/**", ".next/**"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

When you run turbo build:

  1. Analyzes dependency graph: Portfolio depends on @​ccl/ui
  2. Builds in order: @​ccl/ui → portfolio
  3. Caches outputs: Hashes inputs, stores dist/ in .turbo/cache/
  4. Skips unchanged: If @​ccl/ui unchanged, reuses cached build
  5. Parallelizes: Builds independent packages simultaneously

Result: Second build takes 0.3s instead of 8.2s (95% time saved)

Pros ✅

  1. Atomic Commits Across Boundaries - Change shared code + consumers in one commit

    • Impact: Eliminated version drift bugs (had 2 in multi-repo setup)
    • Use case: Update Button API + all apps using it
    • Real example: See this commit - changed 5 files across 3 packages atomically
  2. Type Safety Across Packages - TypeScript understands entire codebase

    • Impact: Catches breaking changes before git commit
    • Use case: Rename prop, TS shows all usages
    • Real example: Caught Button onClick type change affecting 15 components
  3. Blazing Fast Builds with Caching - Only rebuild what changed

    • Impact: 2.8s cached builds vs 5+ min multi-repo
    • Use case: Daily development, CI/CD pipelines
    • Real example: 95% cache hit rate = instant builds
  4. Shared Tooling = Consistency - One ESLint, one TS config, one source of truth

    • Impact: Saved 30+ min/week syncing configs
    • Use case: Change linting rule once, applies everywhere
    • Real example: Enabled strict TypeScript modes for all packages in one commit
  5. Simplified Dependency Management - pnpm install links everything

    • Impact: No more npm link pain, no publishing to test
    • Use case: Develop library + consumer simultaneously
    • Real example: Edit Button, see changes in app instantly (HMR works)
  6. Better Refactoring - Find all usages across entire codebase

    • Impact: Safe large-scale refactors
    • Use case: Rename utility function used in 10 places
    • Real example: VS Code "Find All References" sees across packages

Cons ❌

  1. Initial Learning Curve - Need to understand workspaces + build tools

    • Impact: 2-3 hours learning pnpm workspaces + Turborepo
    • Workaround: Good docs exist, concepts simple once learned
    • Reality check: Easier than learning Docker, worth the investment
  2. Git History Can Get Large - All projects in one repo = more commits

    • Impact: Cloning repo takes longer (initially)
    • Workaround: Shallow clone, sparse checkout, or just accept it
    • Reality check: Modern Git handles large repos well, rarely an issue under 100K commits
  3. CI/CD Requires Smart Filtering - Need to detect what changed

    • Impact: Can't just "deploy everything" on every commit
    • Workaround: Turborepo's --filter or Nx affected commands
    • Reality check: Actually an advantage - only deploy what changed
  4. Tooling Uniformity Can Be Limiting - All packages must use same major versions

    • Impact: Can't have Next.js 14 in one app, Next.js 15 in another
    • Workaround: Usually not an issue (want consistency anyway)
    • Reality check: Forced consistency is often a feature, not a bug

Best For

  • 2-50 packages - Sweet spot for monorepo benefits
  • Shared code across apps - Component libraries, utilities, types
  • Teams under 100 people - Most companies (Google/Meta are outliers)
  • Full-stack projects - Frontend + backend + shared in one place
  • Rapid iteration - Change library + consumers without publishing

NOT For

  • Single app with no shared code - Overhead without benefit
  • Completely independent projects - If apps never share code, why monorepo?
  • 100+ packages - Possible but requires advanced tooling (Nx, Rush)
  • Different tech stacks - Hard to share tooling between Go, Python, Node.js (though possible)

🏗️ Architecture Impact

How monorepo architecture transformed my system design:

My Actual Project Structure

codecraft-labs/                           # Single Git repository
├── apps/                                # Deployable applications
│   ├── portfolio/                       # Next.js 16 portfolio site
│   │   ├── src/
│   │   │   └── app/
│   │   │       └── page.tsx            # Imports @​​ccl/ui components
│   │   ├── next.config.ts
│   │   ├── package.json                # depends on: "@​​ccl/ui": "workspace:*"
│   │   └── tsconfig.json               # extends: "@​​ccl/typescript-config/nextjs"
│   │
│   └── web/                            # Future web app
│       ├── src/
│       ├── package.json                # also uses "@​​ccl/ui": "workspace:*"
│       └── tsconfig.json
│
├── packages/                            # Shared libraries
│   ├── ui/                             # Component library (@​​ccl/ui)
│   │   ├── src/
│   │   │   ├── components/
│   │   │   │   ├── button/
│   │   │   │   │   ├── Button.tsx     # 170 lines, Radix + CVA + Slot
│   │   │   │   │   └── index.ts
│   │   │   │   ├── card/              # 25+ components total
│   │   │   │   └── ...
│   │   │   └── utils/
│   │   │       ├── cn.ts              # Tailwind merge utility
│   │   │       └── variants.ts        # CVA variant definitions
│   │   ├── package.json               # name: "@​​ccl/ui"
│   │   └── tsconfig.json
│   │
│   └── create-app/                     # CLI tool (@​​ccl/create-app)
│       ├── src/
│       ├── package.json                # published to npm
│       └── tsconfig.json
│
├── tools/
│   └── typescript-config/              # Shared TypeScript configs
│       ├── base.json                   # Base config for all packages
│       ├── nextjs.json                 # Next.js-specific config
│       └── package.json                # name: "@​​ccl/typescript-config"
│
├── pnpm-workspace.yaml                 # Defines: apps/*, packages/*, tools/*
├── turbo.json                          # Build orchestration (73 lines)
├── package.json                        # Root scripts: turbo build, turbo dev
└── biome.json                          # Shared linting/formatting config
Enter fullscreen mode Exit fullscreen mode

Why This Structure Works:

  1. Clear separation by purpose

    • apps/ = things you deploy independently
    • packages/ = things you share and publish
    • tools/ = configs and development utilities
  2. Dependency flow is unidirectional

   apps/portfolio → packages/ui → (no dependencies on apps)
   apps/web       → packages/ui
Enter fullscreen mode Exit fullscreen mode

Apps depend on packages, but packages never depend on apps. Prevents circular dependencies.

  1. Scoped package naming
    • @​ccl/ui not ui - prevents npm naming conflicts
    • Clear ownership: All packages under @​ccl scope
    • Easy to identify internal vs external packages

Design Patterns Enabled

Pattern 1: Shared Component Consumption with Type Safety

Problem it solves: Using same Button component across multiple apps without duplication or version drift

Implementation with Monorepo:

// packages/ui/src/components/button/Button.tsx
// Real production code - 170 lines, used across 2 apps

import { Slot } from '@​​radix-ui/react-slot';
import type { VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { cn } from '../../utils';
import { buttonVariants } from '../../utils/variants';

/**
 * Base Button props extending variant props
 * 
 * This component demonstrates monorepo benefits:
 * 1. Type-safe props across all consuming apps
 * 2. Single source of truth for Button behavior
 * 3. Changes propagate atomically to all consumers
 */
export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  /**
   * If true, renders as Slot (for composition with Next.js Link, etc.)
   */
  asChild?: boolean;
  /**
   * Loading state - shows spinner, disables interaction
   */
  loading?: boolean;
  /**
   * Icons before/after button text
   */
  leftIcon?: React.ReactNode;
  rightIcon?: React.ReactNode;
}

/**
 * Flexible, accessible button with 8 variants × 4 sizes × 7 tone colors
 * = 224 possible combinations, all type-safe
 */
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  (
    {
      className,
      variant = 'default',
      size = 'default',
      tone,
      asChild = false,
      loading = false,
      leftIcon,
      rightIcon,
      children,
      disabled,
      ...props
    },
    ref
  ) => {
    // Radix Slot pattern: Allows Button to merge props with child element
    // Key monorepo benefit: This pattern is consistent across all apps
    const Comp = asChild ? Slot : 'button';

    return (
      <Comp
        className={cn(buttonVariants({ variant, size, tone }), className)}
        ref={ref}
        disabled={disabled || loading}
        {...props}
      >
        {loading && <Spinner className="mr-2" />}
        {leftIcon && <span className="mr-2">{leftIcon}</span>}
        {children}
        {rightIcon && <span className="ml-2">{rightIcon}</span>}
      </Comp>
    );
  }
);

Button.displayName = 'Button';

// Key architectural decisions:
// 1. Radix Slot for composition - Enables <Button asChild><Link /></Button>
// 2. CVA for variants - Type-safe variant combinations
// 3. ForwardRef - Parent components can control button via ref
// 4. Loading state built-in - Consistent loading UX across apps
// 5. Icon support - Flexible icon placement without wrapper divs
Enter fullscreen mode Exit fullscreen mode

Without monorepo, this would be:

// ❌ Multi-repo nightmare:

// Repo 1: ui-library/src/Button.tsx (publish to npm)
export const Button = /* 170 lines */;

// Repo 2: portfolio/components/Button.tsx (copy-paste)
export const Button = /* 170 lines, diverging */;

// Repo 3: web-app/components/Button.tsx (copy-paste again)
export const Button = /* 170 lines, already different */;

// Update loading state? Change 3 files, publish npm, update deps, hope you didn't break anything
// TypeScript can't warn about API mismatches across repos
// Version drift is inevitable
Enter fullscreen mode Exit fullscreen mode

Benefits Realized:

  • Type Safety: Change ButtonProps → TypeScript shows all 15 usages across apps
  • Atomic Updates: One commit changes Button + all consumers
  • Zero Publishing: No npm publishnpm install cycle
  • Instant HMR: Edit Button, see changes in app immediately
  • Bundle Size: Tree-shaking works perfectly (same build process)

Package Configuration That Powers This:

// packages/ui/package.json - Makes sharing possible
{
  "name": "@​​ccl/ui",
  "version": "1.0.0",
  "main": "./src/index.ts",           // Points to source (not dist)
  "types": "./src/index.ts",          // TypeScript sees actual types
  "exports": {
    ".": "./src/index.ts",
    "./components/*": "./src/components/*/index.ts"
  },
  "peerDependencies": {
    "react": "^19.0.0",                // Apps provide React
    "react-dom": "^19.0.0"
  },
  "dependencies": {
    "@​​radix-ui/react-slot": "^1.1.0",
    "class-variance-authority": "^0.7.0",
    "tailwind-merge": "^2.5.0"
  }
}
Enter fullscreen mode Exit fullscreen mode
// apps/portfolio/package.json - Consumes shared UI
{
  "name": "@​​ccl/portfolio",
  "dependencies": {
    "@​​ccl/ui": "workspace:*",         // pnpm links to local package
    "next": "16.0.1",
    "react": "19.0.0",
    "react-dom": "19.0.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

Results & Impact:

  • Bundle Size: Button adds 2.3KB (vs 4.1KB when published npm package)
  • Dev Experience: HMR works across packages (< 100ms update time)
  • Reusability: 25+ components shared across 2 apps (soon 3+)
  • Type Safety: Caught 8 breaking changes during development before runtime
  • Maintenance: Update once, benefits everywhere

What I Learned:

  1. Radix Slot + Monorepo = Perfect Combo - Composition pattern works beautifully when all code is local
  2. CVA Variants Need Shared Config - Monorepo makes sharing Tailwind config trivial
  3. Source Imports > Compiled Builds - Pointing to .ts files (not dist/) enables better tree-shaking

Pattern 2: Build Orchestration with Turborepo

The Challenge: Apps depend on packages. Must build packages before apps, but want parallel builds when possible.

My Implementation:

// turbo.json - The brain of the monorepo (my actual production config)
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    // Core build task
    "build": {
      "dependsOn": ["^build"],              // ^ means "dependencies' build tasks first"
      "inputs": [
        "$TURBO_DEFAULT$",                   // All source files
        "!**/*.test.{js,jsx,ts,tsx}",       // Ignore test files
        "!**/*.spec.{js,jsx,ts,tsx}",
        "!**/*.stories.{js,jsx,ts,tsx}",    // Ignore Storybook stories
        "!**/tests/**/*"
      ],
      "outputs": [
        ".next/**",                          // Next.js build output
        "!.next/cache/**",                   // But ignore cache (changes every build)
        "dist/**",                           // Package build output
        "build/**"                           // Alternative build output
      ]
    },

    // Development mode - never cache, always run
    "dev": {
      "cache": false,                        // Dev changes constantly
      "persistent": true                     // Keep process running
    },

    // Type checking
    "typecheck": {
      "dependsOn": ["^build"],              // Need packages built first
      "inputs": [
        "$TURBO_DEFAULT$",
        "tsconfig.json",
        "tsconfig.*.json"
      ]
    },

    // Testing
    "test": {
      "dependsOn": ["^build"],
      "inputs": [
        "$TURBO_DEFAULT$",
        "jest.config.*",
        "vitest.config.*",
        "**/*.test.{js,jsx,ts,tsx}",
        "**/*.spec.{js,jsx,ts,tsx}"
      ],
      "outputs": ["coverage/**"]            // Cache coverage reports
    }
  },

  // Files that invalidate ALL caches when changed
  "globalDependencies": [
    "**/.env",
    "**/.env.local",
    "**/.env.production",
    "turbo.json",                            // This file!
    "package.json",                          // Root package changes affect all
    "pnpm-workspace.yaml"                    // Workspace structure changes
  ]
}
Enter fullscreen mode Exit fullscreen mode

How This Orchestrates Builds:

$ turbo build

# Turborepo analyzes dependency graph:
# 1. @​​ccl/typescript-config (no dependencies) → Build first
# 2. @​​ccl/ui (depends on typescript-config) → Build second
# 3. apps/portfolio (depends on @​​ccl/ui) → Build third
# 4. apps/web (depends on @​​ccl/ui) → Build in parallel with portfolio

# Execution:
[typescript-config] ✓ Cached (0.1s)
[ui] ✓ Built (2.3s)
[portfolio] ✓ Built (4.1s)  } These run in parallel
[web] ✓ Built (3.8s)        } Using all CPU cores

Total: 4.2s (vs 10.2s sequential)
Enter fullscreen mode Exit fullscreen mode

Cache Magic:

# First build (cold):
$ turbo build
[ui] Building... (2.3s)
[portfolio] Building... (4.1s)
Total: 4.2s

# Edit portfolio only, run again:
$ turbo build
[ui] ✓ Cached (0.1s)           # Unchanged, reuse cache
[portfolio] Building... (4.1s)  # Changed, rebuild
Total: 4.2s

# No changes, run again:
$ turbo build
[ui] ✓ Cached (0.1s)
[portfolio] ✓ Cached (0.1s)
Total: 0.3s  # 95% faster! 🚀
Enter fullscreen mode Exit fullscreen mode

Why This Architecture Wins:

  1. Dependency-aware builds - Never build out of order
  2. Intelligent caching - Hash inputs, reuse outputs
  3. Parallel execution - Utilize all CPU cores
  4. Selective invalidation - Only rebuild what changed

Scale Implications

Performance at different scales (based on monorepo research + my projections):

Package Count Behavior Build Time (Cold) Build Time (Cached) Recommendation
2-5 packages Optimal 2-5s 0.3s Perfect for monorepo
10-20 packages Still great 5-15s 0.5s Sweet spot
50 packages Good 20-40s 1-2s Consider Nx for graph UI
100+ packages Challenging 60s+ 3-5s Need advanced tooling (Nx, Rush)
500+ packages Specialized 5+ min 10s+ Google/Meta scale (rare)

My Project Stats:

  • Current: 6 packages (3 apps + 2 libs + 1 tool)
  • Cold build: 8.2s
  • Cached build: 2.8s (0.3s if nothing changed)
  • Plan: Grow to 15-20 packages over next year
  • Projection: Should stay under 20s cold, < 1s cached

⚡ Production Patterns from CodeCraft Labs

Real patterns powering my production monorepo:

Pattern 1: Workspace Protocol for Always-Fresh Dependencies

The Challenge: Ensuring apps always use latest local package code without manual version bumps

My Implementation:

// apps/portfolio/package.json
{
  "dependencies": {
    "@​​ccl/ui": "workspace:*",              //  "workspace:*" = link to latest local version
    "@​​ccl/typescript-config": "workspace:*"
  }
}
Enter fullscreen mode Exit fullscreen mode

How It Works:

# When you run pnpm install:
$ pnpm install

# pnpm creates symlinks:
node_modules/@​​ccl/ui → ../../packages/ui/

# Changes in packages/ui/ are INSTANTLY available in apps/portfolio/
# No npm publish, no version bump, no waiting
Enter fullscreen mode Exit fullscreen mode

Configuration:

# pnpm-workspace.yaml - Defines workspace boundaries
packages:
  - 'apps/*'        # All apps are workspaces
  - 'packages/*'    # All packages are workspaces
  - 'tools/*'       # Tools are workspaces too
Enter fullscreen mode Exit fullscreen mode

Results:

  • Iteration Speed: Edit component → See in app (< 100ms HMR)
  • Version Coordination: Always in sync, no drift possible
  • Type Safety: TypeScript sees actual source, not published .d.ts
  • Zero Overhead: No publishing step, no waiting for npm registry

Gotcha I Hit:

// ❌ Problem: Circular dependency
// packages/ui/src/hooks/useTheme.ts
import { ThemeProvider } from '@​​ccl/portfolio/providers';  // BAD!

// Apps should never be imported by packages
// Caused TypeScript "cannot find module" errors
Enter fullscreen mode Exit fullscreen mode

Solution:

// ✅ Fixed: Move ThemeProvider to @​​ccl/ui
// packages/ui/src/providers/ThemeProvider.tsx
export const ThemeProvider = /* ... */;

// apps/portfolio/src/app/layout.tsx
import { ThemeProvider } from '@​​ccl/ui/providers';  // GOOD!

// Rule: Dependencies flow one direction (apps → packages, never packages → apps)
Enter fullscreen mode Exit fullscreen mode

Pattern 2: Shared TypeScript Configuration

The Challenge: 6 packages each need TypeScript config, don't want to duplicate 200+ lines

My Implementation:

// tools/typescript-config/base.json - Base config for all packages
{
  "$schema": "https://json.schemastore.org/tsconfig",
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2023"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,                         // Strict mode for all packages
    "esModuleInterop": true,
    "skipLibCheck": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",                      // Let framework handle JSX
    "incremental": true,                    // Faster rebuilds
    "noEmit": true,                         // Build tools handle emit
    "paths": {
      "@​​ccl/*": ["../../packages/*/src"]    // Monorepo path mapping
    }
  },
  "exclude": ["node_modules", "dist", "build", ".next"]
}
Enter fullscreen mode Exit fullscreen mode
// tools/typescript-config/nextjs.json - Extends base, adds Next.js specifics
{
  "extends": "./base.json",
  "compilerOptions": {
    "lib": ["DOM", "DOM.Iterable", "ES2023"],
    "plugins": [{ "name": "next" }],
    "jsx": "preserve"
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"]
}
Enter fullscreen mode Exit fullscreen mode

Consuming in apps:

// apps/portfolio/tsconfig.json - 9 lines instead of 200+
{
  "extends": "@​​ccl/typescript-config/nextjs",
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@​​/*": ["./src/*"]                    // App-specific path alias
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"]
}
Enter fullscreen mode Exit fullscreen mode

Results:

  • Consistency: Change one config → affects all packages
  • Maintainability: Update TypeScript settings in one place
  • Onboarding: New package? Copy 9 lines, done.
  • Type Safety: Shared strict mode catches more bugs

Real Win:

# Enabled strictNullChecks across entire monorepo in one commit:

$ git diff tools/typescript-config/base.json
+  "strictNullChecks": true,

# Fixed 47 type errors revealed across all packages
# In multi-repo: Would need to enable in 6 different tsconfig files
Enter fullscreen mode Exit fullscreen mode

Pattern 3: Unified Linting with Biome

The Challenge: ESLint + Prettier = slow, complex config, two tools

My Implementation:

// biome.json - Single config for linting + formatting (root of monorepo)
{
  "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
  "formatter": {
    "enabled": true,
    "indentStyle": "space",
    "indentWidth": 2,
    "lineWidth": 100
  },
  "linter": {
    "enabled": true,
    "rules": {
      "recommended": true,
      "suspicious": {
        "noExplicitAny": "warn",              // Warn on any type
        "noConsoleLog": "warn"                // Warn on console.log
      },
      "style": {
        "useConst": "error",                  // Enforce const
        "useTemplate": "warn"                 // Prefer template literals
      }
    }
  },
  "javascript": {
    "formatter": {
      "quoteStyle": "single",
      "trailingCommas": "es5",
      "semicolons": "always"
    }
  },
  "organizeImports": {
    "enabled": true                           // Auto-sort imports
  }
}
Enter fullscreen mode Exit fullscreen mode

Package scripts:

// package.json (root)
{
  "scripts": {
    "format": "biome format --write .",       // Format entire monorepo
    "lint": "turbo lint",                     // Lint per-package (cached)
    "lint:fix": "biome check --write ."       // Fix all issues
  }
}
Enter fullscreen mode Exit fullscreen mode

Why This Works:

  • One Config: Applies to all packages automatically
  • Fast: Biome is 25x faster than ESLint + Prettier
  • Cached: Turborepo caches lint results per package
  • Consistent: Impossible for packages to have different styles

Results:

  • Lint time: 0.8s for entire monorepo (vs 12s with ESLint)
  • Format time: 0.3s (vs 3.2s with Prettier)
  • Config size: 50 lines (vs 300+ with ESLint + plugins)

🔄 Migration Path: Multi-repo → Monorepo

My actual migration story:

  • Timeline: 2 days (weekend project)
  • Project state: 3 repos, ~50K lines of code, 150+ npm dependencies
  • Team: Solo developer (just me)
  • Risk level: Medium (had to coordinate 3 deployments)

Pre-Migration Assessment

What I analyzed before starting:

# Checked repo sizes and dependencies
$ cd ~/ui-library && cloc src/
     150 files
   5,243 lines of code

$ cd ~/portfolio && cloc src/
     89 files
  12,458 lines of code

$ cd ~/web-app && cloc src/
     67 files
   8,934 lines of code

Total: ~27,000 lines of code (plus node_modules, configs, etc.)

# Checked for circular dependencies (would break monorepo)
$ npm ls --all | grep '@​​ccl'
# Found: ui-library has no deps on apps ✅ Safe to merge
Enter fullscreen mode Exit fullscreen mode

Risk Assessment:

  • Low risk: No circular dependencies between repos
  • ⚠️ Medium risk: 3 active deployments need coordination
  • 🚨 High risk: Different Node versions (18 in one, 20 in others)

Decision: Proceed with migration, standardize on Node 20

Step 1: Create Monorepo Structure (Time: 30 min)

Goal: Set up empty monorepo with workspaces configured

# Create new repo
$ mkdir codecraft-labs-monorepo
$ cd codecraft-labs-monorepo
$ git init

# Initialize with pnpm
$ pnpm init

# Create workspace structure
$ mkdir -p apps packages tools

# Configure pnpm workspaces
$ cat > pnpm-workspace.yaml << EOF
packages:
  - 'apps/*'
  - 'packages/*'
  - 'tools/*'
EOF

# Install Turborepo
$ pnpm add -D turbo

# Create basic turbo.json
$ cat > turbo.json << EOF
{
  "\$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "dist/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}
EOF
Enter fullscreen mode Exit fullscreen mode

Verification:

$ pnpm install
# Should see: Workspace created successfully

$ tree -L 2
.
├── apps/
├── packages/
├── tools/
├── node_modules/
├── package.json
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
└── turbo.json
Enter fullscreen mode Exit fullscreen mode

Step 2: Migrate UI Library First (Time: 45 min)

Goal: Move shared library first (it has no dependencies on apps)

# Copy ui-library into monorepo
$ cd ~/codecraft-labs-monorepo
$ cp -r ~/ui-library packages/ui

# Update package.json name to scoped
$ cd packages/ui
$ cat > package.json << EOF
{
  "name": "@​​ccl/ui",
  "version": "1.0.0",
  "main": "./src/index.ts",
  "types": "./src/index.ts",
  "exports": {
    ".": "./src/index.ts",
    "./components/*": "./src/components/*/index.ts"
  },
  ...existing dependencies...
}
EOF

# Install dependencies
$ cd ../..
$ pnpm install
Enter fullscreen mode Exit fullscreen mode

🐛 Gotcha #1: Package Name Conflicts

Symptom:

$ pnpm install
ERR_PNPM_PEER_DEP_ISSUES  Unmet peer dependencies

@​​ccl/ui requires react@​​19.0.0 but found react@​​18.2.0
Enter fullscreen mode Exit fullscreen mode

Root Cause:
Old ui-library used React 18. New monorepo targets React 19. pnpm enforces strict peer deps.

Solution:

# Update all React deps to v19
$ cd packages/ui
$ pnpm add -D react@​​19.0.0 react-dom@​​19.0.0

# Update package.json peerDependencies
{
  "peerDependencies": {
    "react": "^19.0.0",           # Was ^18.0.0
    "react-dom": "^19.0.0"
  }
}

$ cd ../..
$ pnpm install
# ✅ Fixed!
Enter fullscreen mode Exit fullscreen mode

How to avoid:

  • Audit all package.json files for version mismatches BEFORE migration
  • Run pnpm outdated to catch version drift

Step 3: Migrate Portfolio App (Time: 30 min)

Goal: Move first app, link to local @​ccl/ui package

# Copy portfolio into monorepo
$ cp -r ~/portfolio apps/portfolio

# Remove old ui-library dependency
$ cd apps/portfolio
$ pnpm remove ui-library

# Add workspace dependency
$ pnpm add @​​ccl/ui@​​workspace:*

# Verify package.json
{
  "dependencies": {
    "@​​ccl/ui": "workspace:*",     # ⭐ Links to local package
    "next": "16.0.1",
    "react": "19.0.0"
  }
}

# Install and test
$ cd ../..
$ pnpm install
$ turbo build --filter=portfolio
Enter fullscreen mode Exit fullscreen mode

🐛 Gotcha #2: Import Paths Broke

Symptom:

// apps/portfolio/src/app/page.tsx
import { Button } from 'ui-library';  // ❌ Module not found

Error: Cannot find module 'ui-library'
Enter fullscreen mode Exit fullscreen mode

Root Cause:
Old package name was ui-library, new name is @​ccl/ui. All imports need updating.

Solution:

# Find all old imports
$ cd apps/portfolio
$ grep -r "from 'ui-library'" src/

# Replace with new scoped name
$ find src/ -type f -name "*.tsx" -o -name "*.ts" | xargs sed -i '' "s/from 'ui-library'/from '@​​ccl\/ui'/g"

# Verify
$ grep -r "from '@​​ccl/ui'" src/
src/app/page.tsx:import { Button } from '@​​ccl/ui';
src/components/Hero.tsx:import { Card } from '@​​ccl/ui';
# ✅ 23 imports updated
Enter fullscreen mode Exit fullscreen mode

How to avoid:

  • Use consistent scoped names from the start (@company/package)
  • Use IDE refactoring tools (VS Code "Find and Replace" across workspace)

Step 4: Migrate Web App (Time: 30 min)

Goal: Move second app, same process as portfolio

# Copy, update deps, fix imports (same as Step 3)
$ cp -r ~/web-app apps/web
$ cd apps/web
$ pnpm remove ui-library
$ pnpm add @​​ccl/ui@​​workspace:*

# Fix imports
$ find src/ -type f \( -name "*.tsx" -o -name "*.ts" \) -exec sed -i '' "s/from 'ui-library'/from '@​​ccl\/ui'/g" {} +

$ cd ../..
$ pnpm install
$ turbo build --filter=web
# ✅ Builds successfully
Enter fullscreen mode Exit fullscreen mode

🐛 Gotcha #3: TypeScript Path Mapping

Symptom:

$ turbo build --filter=web

Error: Cannot find module '@​​ccl/ui' or its corresponding type declarations
Enter fullscreen mode Exit fullscreen mode

Root Cause:
TypeScript doesn't know where @​ccl/ui is. Needs path mapping.

Solution:

// apps/web/tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@​​ccl/*": ["../../packages/*/src"]   //  Map @​​ccl/* to packages
    }
  }
}

// Even better: Share via @​​ccl/typescript-config
// tools/typescript-config/base.json
{
  "compilerOptions": {
    "paths": {
      "@​​ccl/*": ["../../packages/*/src"]
    }
  }
}

// apps/web/tsconfig.json
{
  "extends": "@​​ccl/typescript-config/base"
}
Enter fullscreen mode Exit fullscreen mode

How to avoid:

  • Set up shared TypeScript config FIRST (Step 0.5)
  • All apps inherit path mappings automatically

Step 5: Configure Turborepo for Multi-app (Time: 20 min)

Goal: Optimize build order and caching

// turbo.json - Final production config
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],              // Build deps first
      "inputs": [
        "$TURBO_DEFAULT$",
        "!**/*.test.{js,jsx,ts,tsx}",       // Ignore tests
        "!**/*.stories.{js,jsx,ts,tsx}"     // Ignore stories
      ],
      "outputs": [
        ".next/**",                          // Next.js output
        "!.next/cache/**",                   // Ignore cache
        "dist/**"                            // Package output
      ]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "lint": {
      "dependsOn": ["^build"]
    },
    "test": {
      "dependsOn": ["^build"],
      "outputs": ["coverage/**"]
    }
  },
  "globalDependencies": [
    "**/.env",
    "turbo.json",
    "package.json",
    "pnpm-workspace.yaml"
  ]
}
Enter fullscreen mode Exit fullscreen mode

Test the pipeline:

# Build everything
$ turbo build
[ui] ✓ Built (2.3s)
[portfolio] ✓ Built (4.1s)
[web] ✓ Built (3.8s)
Total: 4.2s (parallel execution)

# Build again (should be cached)
$ turbo build
[ui] ✓ Cached (0.1s)
[portfolio] ✓ Cached (0.1s)
[web] ✓ Cached (0.1s)
Total: 0.3s  # 🎉 14x faster!
Enter fullscreen mode Exit fullscreen mode

Step 6: Update CI/CD (Time: 30 min)

Goal: Replace 3 GitHub Actions workflows with 1 monorepo workflow

Before (multi-repo):

# .github/workflows/portfolio.yml (in portfolio repo)
# .github/workflows/web.yml (in web repo)
# .github/workflows/ui.yml (in ui-library repo)
# Total: 3 separate workflows
Enter fullscreen mode Exit fullscreen mode

After (monorepo):

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

on:
  push:
    branches: [main]
  pull_request:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@​​v4

      - uses: pnpm/action-setup@​​v2
        with:
          version: 9.1.0

      - uses: actions/setup-node@​​v4
        with:
          node-version: 20
          cache: 'pnpm'

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Build
        run: turbo build

      - name: Test
        run: turbo test

      - name: Lint
        run: turbo lint
Enter fullscreen mode Exit fullscreen mode

Deploy configuration (Vercel):

// vercel.json (in apps/portfolio/)
{
  "buildCommand": "cd ../.. && turbo build --filter=portfolio",
  "installCommand": "pnpm install",
  "framework": "nextjs"
}
Enter fullscreen mode Exit fullscreen mode

Migration Checklist

Completed during migration:

## Pre-Migration (30 min) ✅
- [x] Backed up all repos to GitHub
- [x] Created migration branch: `git checkout -b migrate-to-monorepo`
- [x] Documented current build times (baseline)
- [x] Checked for circular dependencies (none found)
- [x] Standardized Node version (20.0.0)

## Structure Setup (30 min) ✅
- [x] Created monorepo folder structure
- [x] Configured pnpm-workspace.yaml
- [x] Installed Turborepo
- [x] Created basic turbo.json

## Package Migration (45 min) ✅
- [x] Migrated @​​ccl/ui package
- [x] Updated package name to scoped
- [x] Fixed React version conflicts
- [x] Verified package builds

## App Migration (60 min) ✅
- [x] Migrated portfolio app
- [x] Migrated web app
- [x] Updated all import paths
- [x] Fixed TypeScript path mappings
- [x] Both apps build successfully

## Configuration (50 min) ✅
- [x] Configured turbo.json for multi-app
- [x] Set up shared TypeScript config
- [x] Migrated biome.json (linting)
- [x] Updated CI/CD pipeline
- [x] Configured Vercel deployment

## Verification (30 min) ✅
- [x] Full build works: `turbo build`
- [x] Cached builds work (< 1s)
- [x] Dev mode works: `turbo dev`
- [x] Type checking passes: `turbo typecheck`
- [x] Tests pass: `turbo test`
- [x] CI/CD pipeline runs successfully
- [x] Portfolio deploys to Vercel
- [x] Web app deploys to Vercel

## Cleanup (20 min) ✅
- [x] Archived old repos (marked as deprecated)
- [x] Updated README with monorepo instructions
- [x] Updated documentation
- [x] Committed: `git commit -m "Migrate to monorepo"`
- [x] Pushed and merged to main
- [x] Celebrated! 🎉
Enter fullscreen mode Exit fullscreen mode

Total Migration Time

My actual experience:

  • Day 1 (Saturday): 4 hours (Steps 1-4)
  • Day 2 (Sunday): 2 hours (Steps 5-6 + verification)
  • Total: 6 hours over weekend

Your mileage:

  • Smaller project (1-2 apps): 2-3 hours
  • Similar project (3 apps + libs): 4-6 hours
  • Larger project (5+ apps): 8-12 hours
  • Complex project (10+ apps): 2-3 days

Troubleshooting Common Issues

Issue 1: "Workspace dependency not found"

  • Symptom: ERR_PNPM_NO_MATCHING_VERSION
  • Cause: Package name mismatch in pnpm-workspace.yaml
  • Fix: Verify package.json name matches workspace pattern

Issue 2: "Module not found" in imports

  • Symptom: TypeScript can't find @​ccl/ui
  • Cause: Missing path mapping in tsconfig.json
  • Fix: Add "paths": { "@​ccl/*": ["../../packages/*/src"] }

Issue 3: "Cached build outdated"

  • Symptom: Changes not reflected in build output
  • Cause: Turborepo cache invalidation missed
  • Fix: turbo build --force or delete .turbo/cache/

Issue 4: "CI/CD runs too long"

  • Symptom: GitHub Actions using 10+ minutes
  • Cause: Not using Turborepo remote cache
  • Fix: Configure Vercel remote cache or GitHub Actions cache

Rollback Plan

If migration fails catastrophically:

# Immediate rollback (2 minutes)
$ cd ~/old-repos/
$ git push --force origin main  # Restore old repos

# Or if mid-migration:
$ git checkout main             # Return to before migration
$ rm -rf codecraft-labs-monorepo
Enter fullscreen mode Exit fullscreen mode

When to rollback:

  • ❌ Cannot resolve dependency conflicts within 2 hours
  • ❌ Build time worse than multi-repo
  • ❌ Production deployments broken for > 1 hour
  • ❌ Team completely blocked (if applicable)

Reality: Didn't need to rollback. Migration smooth, issues were minor.


📊 Performance Analysis

Real measurements from my production monorepo:

Build Performance

Test Setup:

  • Machine: MacBook Pro M2, 16GB RAM
  • Project: 6 packages (3 apps, 2 libs, 1 tool)
  • Date: November 2025
  • Methodology: Averaged 10 runs, cleared cache between cold runs

Cold Build (No Cache)

$ rm -rf .turbo/cache node_modules/.cache
$ time turbo build

[@​​ccl/typescript-config] Building... (0.2s)
[@​​ccl/ui] Building... (2.3s)
[portfolio] Building... (4.1s)
[web] Building... (3.8s)

Total: 8.2s (real time: 4.2s due to parallelization)
Enter fullscreen mode Exit fullscreen mode

Breakdown by package:

Package Build Time Dependencies Output Size
typescript-config 0.2s None Configs only
@​ccl/ui 2.3s typescript-config 143KB
portfolio 4.1s @​ccl/ui 2.1MB (.next/)
web 3.8s @​ccl/ui 1.8MB (dist/)

Parallel execution: ui, portfolio, and web built simultaneously once dependencies satisfied.

Cached Build (No Changes)

$ turbo build

[@​​ccl/typescript-config] ✓ Cached (0.08s)
[@​​ccl/ui] ✓ Cached (0.09s)
[portfolio] ✓ Cached (0.12s)
[web] ✓ Cached (0.11s)

Total: 0.3s  # 95% faster than cold build! 🚀
Enter fullscreen mode Exit fullscreen mode

Cache hit rate: 100% (all packages unchanged)

Incremental Build (1 Component Changed)

# Modified: packages/ui/src/components/Button/Button.tsx
$ turbo build

[@​​ccl/typescript-config] ✓ Cached (0.08s)
[@​​ccl/ui] Building... (2.3s)        # Rebuilt (file changed)
[portfolio] Building... (4.1s)       # Rebuilt (depends on @​​ccl/ui)
[web] Building... (3.8s)             # Rebuilt (depends on @​​ccl/ui)

Total: 4.2s  # Only rebuilt what needed
Enter fullscreen mode Exit fullscreen mode

Smart invalidation: Changed @​ccl/ui → Rebuilt ui + both apps. TypeScript config unchanged → Cached.

HMR (Hot Module Replacement) Speed

# Dev mode running: turbo dev
# Edit packages/ui/src/components/Button/Button.tsx

[packages/ui] Change detected... (52ms)
[portfolio] Hot reloaded (87ms)
[web] Hot reloaded (94ms)

Average: 78ms from file save to browser update
Enter fullscreen mode Exit fullscreen mode

Developer experience: Changes feel instant. Can iterate 10+ times per minute.

Comparison: Monorepo vs Multi-repo

Scenario: Update shared Button component

Stage Monorepo Multi-repo Δ
Edit component 0s 0s -
Build library 2.3s 2.3s -
Publish library 0s 45s (npm publish) -45s
Update app deps 0s 90s (2 apps × npm install) -90s
Build apps 4.2s 8.2s (sequential) -4s
Deploy 1 push 3 pushes -2 pushes
Total 6.5s 146s 22x faster

Bundle Size Analysis

Production build output:

$ turbo build --filter=portfolio
$ du -sh apps/portfolio/.next/

File Sizes:
├── Static pages: 234KB (12 pages)
├── Shared chunks: 189KB
│   ├── framework.js: 89KB (Next.js + React)
│   ├── main.js: 67KB (App code)
│   └── vendors.js: 33KB (@​​ccl/ui + deps)
├── CSS: 28KB (Tailwind)
└── Total: 451KB

Compared to previous setup:
- Before (published npm package): 489KB (+38KB overhead)
- After (monorepo): 451KB
- Savings: 8% smaller bundle (better tree-shaking)
Enter fullscreen mode Exit fullscreen mode

Why smaller?

  • Monorepo imports from source (.ts files)
  • Webpack sees full code → Better tree-shaking
  • Published package had pre-compiled code → Less optimization

CI/CD Performance

GitHub Actions workflow:

# .github/workflows/ci.yml runs on every push
Enter fullscreen mode Exit fullscreen mode

Multi-repo era (3 separate workflows):

Workflow Install Build Test Total
ui-library 45s 35s 28s 108s
portfolio 52s 85s 41s 178s
web 48s 72s 35s 155s
Total 145s 192s 104s 441s

Cost: ~7.5 min × 30 runs/day × $0.008/min = $18/month

Monorepo (single workflow with caching):

Stage Time Cached Time
Checkout 8s 8s
Setup Node + pnpm 12s 4s (cached)
Install deps 45s 8s (cached)
Turbo build 95s 12s (cached)
Turbo test 38s 6s (cached)
Total 198s 38s

Cost: ~3.3 min × 30 runs/day × $0.008/min = $8/month

Savings:

  • Time: 441s → 198s = 55% faster (243s saved)
  • Cost: $18 → $8 = $10/month saved
  • Complexity: 3 workflows → 1 workflow

Runtime Performance (Client-Side)

Lighthouse scores for portfolio app:

Metric Before After Monorepo Δ
Performance 87/100 92/100 +5
First Contentful Paint 1.2s 1.1s -0.1s
Largest Contentful Paint 2.8s 2.4s -0.4s
Time to Interactive 3.1s 2.7s -0.4s
Total Blocking Time 180ms 140ms -40ms
Cumulative Layout Shift 0.02 0.02 -

Why faster?

  • Smaller bundle (better tree-shaking)
  • Shared React instance (no duplicate framework code)
  • Optimized shared chunk splitting

Real-World Impact

Before Monorepo (Multi-repo workflow):

Task Frequency Time/Task Daily Total
Update shared component 3×/day 12 min 36 min
Full rebuild 10×/day 3 min 30 min
Context switch repos 20×/day 2 min 40 min
Fix version conflicts 1×/day 15 min 15 min
Total - - 121 min/day

After Monorepo:

Task Frequency Time/Task Daily Total
Update shared component 3×/day 0.5 min 1.5 min
Full rebuild (cached) 10×/day 0.3 min 3 min
Context switch (none!) 0×/day 0 min 0 min
Fix version conflicts 0×/day 0 min 0 min
Total - - 4.5 min/day

Time saved: 121 - 4.5 = 116.5 minutes per day (almost 2 hours!)

Monthly value: 116.5 min/day × 20 work days × $50/hr ÷ 60 = $1,942/month


Was It Worth It?

Time saved (rough estimate):

  • Component updates: ~35 min/day
  • Build waiting: ~25 min/day
  • Context switching: ~40 min/day
  • Version conflicts: ~15 min/day
  • Total: ~2 hours daily (ballpark)

Setup cost: 6 hours over a weekend

Payback: About 3 days of work

If you value your time at $50/hr, that's roughly $20K/year saved. But honestly, the biggest win isn't the math—it's not worrying about version drift breaking production at 11pm on Friday.

Infrastructure:

  • CI/CD: $18/month → $8/month (GitHub Actions minutes)
  • Pretty minor savings, not the main point

Would I do it again? 100% yes. Still using it 6 months later, zero regrets.


When to Choose Differently

While monorepo transformed my workflow, here's when you should consider alternatives:

Choose Multi-repo If:

  • Projects are truly independent - Zero shared code, different languages/frameworks

    • Example: E-commerce frontend (React) + analytics service (Python) + billing system (Go)
    • Reason: No benefit to monorepo if there's no code sharing
  • Different teams with no collaboration - Separate companies, acquisitions, isolated teams

    • Example: Parent company with 5 acquired startups maintaining their own products
    • Reason: Political/organizational boundaries matter more than technical efficiency
  • Different deployment cadences required - One app ships hourly, another quarterly

    • Example: Public website (frequent) + enterprise SaaS (controlled releases)
    • Reason: Monorepo encourages aligned release cycles, which may conflict
  • Security/compliance separation needed - PCI-compliant payment vs non-compliant marketing

    • Example: Bank's public website vs core banking system
    • Reason: Audit boundaries, access control, compliance scope

Real-world scenario:

"If you're managing 5 completely different products (iOS app in Swift, Android in Kotlin, Backend in Go, Admin panel in Vue, Marketing site in WordPress), multi-repo makes sense. No shared code = no monorepo benefit."

Choose Mono-package If:

  • Single application, no plans to split - One deployable unit, simple structure

    • Example: Small SaaS with unified frontend + backend
    • Reason: Overhead of monorepo without benefits
  • Tiny project (< 10K lines) - Doesn't justify workspace complexity

    • Example: Weekend project, portfolio site, landing page
    • Reason: Keep it simple until you need complexity

Real-world scenario:

"Building a simple blog? Just use Next.js in one folder. Don't add Turborepo + workspaces until you actually have multiple packages to manage."

Choose Hybrid (Monorepo + Published Packages) If:

  • Internal apps + public open-source libraries - Need both speed and external visibility

    • Example: Company uses internal monorepo, publishes select packages to npm
    • Reason: Fast internal iteration, stable external API
  • Need independent versioning for public consumers - External users expect semver

    • Example: Design system used internally + by external customers
    • Reason: Internal apps use workspace:*, external customers use npm versions

Real-world scenario:

"You maintain a UI library used by your 5 internal apps AND 1,000 external customers. Keep internal apps in monorepo (fast iteration), publish stable versions to npm for external use (controlled releases)."

Stay with Multi-repo If:

  • Migration cost > benefit - Existing setup works, team is productive

    • Example: 3-year-old project with 10 repos, team knows it well
    • Reason: "If it ain't broke, don't fix it" (unless pain threshold reached)
  • Team lacks buy-in - Developers resistant to change, no monorepo experience

    • Example: Team of 20, no one has worked with monorepos before
    • Reason: Social/political cost may outweigh technical benefit

Reality check:

"If your team is shipping fast and nobody's complaining about multi-repo pain, you probably don't need monorepo. Wait until the pain is real before migrating."

🌳 Decision Tree

Use this flowchart to decide which architecture fits YOUR project:

START: What's your project context?
    ↓
[Question 1: Code Sharing]
Do you have 2+ projects sharing code?
├─ NO → [Question 1a: Future Plans]
│       ↓
│       Plan to build multiple apps in next 6 months?
│       ├─ NO → ✅ Use Mono-package (simple!)
│       └─ YES → ⚠️ Consider Monorepo (future-proof)
│
└─ YES → [Question 2: Scale]
        ↓
        How many packages total?
        ├─ 2-50 packages → [Question 3: Team]
        │                  ↓
        │                  Solo or small team (< 10)?
        │                  ├─ YES → ✅ Use Monorepo with Turborepo
        │                  │         (Simpler than Nx, good docs)
        │                  └─ NO (10-100 people)
        │                            ↓
        │                            Need advanced features (generators, graph UI)?
        │                            ├─ YES → ✅ Use Nx
        │                            └─ NO → ✅ Use Turborepo
        │
        ├─ 50-100 packages → ✅ Use Nx or Rush
        │                     (Need advanced dependency graph)
        │
        └─ 100+ packages → ✅ Use Nx with Computation Caching
                           (Or consider splitting monorepo)

[Question 4: Independence]
Are projects completely independent? (different languages, no shared code)
├─ YES → ✅ Use Multi-repo
│         (Monorepo overhead without benefit)
└─ NO → Continue to Question 5

[Question 5: Public Packages]
Do you publish packages to npm for external consumers?
├─ YES → [Question 5a]
│        ↓
│        Also have internal apps?
│        ├─ YES → ✅ Use Hybrid (monorepo + published packages)
│        └─ NO → ✅ Use Multi-repo (simpler for pure libraries)
│
└─ NO → ✅ Use Monorepo
Enter fullscreen mode Exit fullscreen mode

Quick decision shortcuts:

Your Situation Recommended Approach Reason
Solo dev, 2-3 apps sharing code Monorepo (Turborepo) Fastest iteration, simple setup
Team of 20, 50+ packages Monorepo (Nx) Advanced features, scales well
5 independent microservices Multi-repo No code sharing = no benefit
Single Next.js app Mono-package Don't add complexity
UI library + 3 internal apps Monorepo Perfect use case
Open-source library only Multi-repo Independent versioning easier
Startup scaling fast Monorepo Flexibility for rapid growth
Enterprise with compliance Multi-repo or Hybrid Security boundaries matter

Cheat Sheet

Most-Used Commands

# Development
turbo dev                    # Start all apps in dev mode
turbo dev --filter=portfolio # Start specific app
turbo dev --filter=@​​ccl/ui   # Start specific package

# Building
turbo build                  # Build all packages
turbo build --force          # Force rebuild (ignore cache)
turbo build --filter=portfolio --filter=web  # Build specific apps

# Testing
turbo test                   # Run all tests
turbo test --filter=@​​ccl/ui  # Test specific package

# Linting
turbo lint                   # Lint everything
turbo lint --filter=portfolio # Lint specific app

# Package Management
pnpm add <package> -w        # Add to root workspace
pnpm add <package> --filter=portfolio  # Add to specific app
pnpm add @​​ccl/ui --filter=portfolio    # Link workspace package

# Cache Management
turbo run build --force      # Bypass cache
rm -rf .turbo/cache          # Clear local cache
rm -rf node_modules/.cache   # Clear node cache
Enter fullscreen mode Exit fullscreen mode

Key Config Snippets

turbo.json (essential patterns):

{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "dist/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

pnpm-workspace.yaml:

packages:
  - 'apps/*'
  - 'packages/*'
Enter fullscreen mode Exit fullscreen mode

package.json (workspace linking):

{
  "dependencies": {
    "@​​ccl/ui": "workspace:*"
  }
}
Enter fullscreen mode Exit fullscreen mode

Common Errors & Fixes

Error: "Workspace dependency not found"

# Symptom: ERR_PNPM_NO_MATCHING_VERSION
# Fix: Check package name matches workspace pattern
# Verify: pnpm-workspace.yaml includes the package path
pnpm install  # Reinstall to rebuild links
Enter fullscreen mode Exit fullscreen mode

Error: "Module not found @​ccl/ui"

# Symptom: TypeScript can't find workspace package
# Fix: Add path mapping in tsconfig.json
{
  "compilerOptions": {
    "paths": {
      "@​​ccl/*": ["../../packages/*/src"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Error: "Cached build outdated"

# Symptom: Changes not reflected in build
# Fix: Force rebuild
turbo build --force
# Or clear cache
rm -rf .turbo/cache
Enter fullscreen mode Exit fullscreen mode

Error: "Peer dependency conflict"

# Symptom: pnpm strict peer dependency error
# Fix: Align all React versions
pnpm add react@​​19.0.0 react-dom@​​19.0.0 -w
# Update all packages to same version
Enter fullscreen mode Exit fullscreen mode

Quick Troubleshooting

Problem Quick Fix
Build stuck rm -rf .turbo && turbo build --force
Import not found Check pnpm-workspace.yaml includes package
Cache issues rm -rf .turbo/cache node_modules/.cache
Type errors Verify tsconfig.json paths mapping
Slow builds Check turbo.json outputs are correct

Final Verdict

Bottom line: Monorepo transformed how I work. Period.

6 months later update:

Still using it. Zero regrets. The time savings are real—I don't track exact minutes anymore, but I know I ship faster.

What worked better than expected:

  • Refactoring is fearless now (TypeScript catches everything)
  • Adding new apps takes minutes (copy structure, link packages, done)
  • Cache hit rate stayed high (95%+ even with active development)

What I underestimated:

  • Occasional cache invalidation quirks (need --force once a month)
  • Team onboarding would be trivial (if I had a team yet)

Would I do it again?

100% yes. If you're managing 2+ projects that share code, monorepo is a no-brainer in 2025.

When I'd reconsider: If scaling to 100+ packages or building completely independent products (e-commerce + SaaS backend in different languages).

For solo devs and small teams building related projects? This is the way.


Resources

Official Docs:

My Actual Code:

Tools:

Community:


Your Turn

Questions I'd love to answer:

  1. What's your current multi-repo pain point? (Deployment? Versioning? Something else?)
  2. Considering monorepo but hesitant? What's holding you back?
  3. Already using monorepo? What's your biggest challenge?

Drop a comment or DM me—happy to help debug your setup or answer questions.


Let's Connect

Building in public and sharing what I learn. Follow along:

Twitter: saswatapal14

LinkedIn: saswata-pal

GitHub: saswatawork

Dev.to: saswatapal

More tech decision breakdowns coming—React 19, Tailwind v4, Vitest, and the rest of the stack.

Building something cool with monorepo? I'd love to see it. Tag me or send a link.


Published: December 2025

Part of the Tech Stack Decisions series

Top comments (0)