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 switchinβ¦
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.
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)
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
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
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
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
Type Safety Across Boundaries - TypeScript understands all packages
- Critical because: Caught 2 production bugs that multi-repo couldnβt catch
- Measures success:
npm run typecheckvalidates entire monorepo
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
Simple Dependency Management - Easy to link local packages
- Critical because:
npm linkis painful,pnpm workspaceshould 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 pullto 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
How pnpm Workspaces Link Packages:
// apps/portfolio/package.json
{
"dependencies": {
"@ββccl/ui": "workspace:*" // Links to local packages/ui/
}
}
When you run pnpm install, pnpm creates symlinks:
apps/portfolio/node_modules/@ββccl/ui β ../../packages/ui/
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>
How Turborepo Optimizes Builds:
// turbo.json
{
"tasks": {
"build": {
"dependsOn": ["^build"], // Build dependencies first
"outputs": ["dist/**", ".next/**"]
}
}
}
When you run turbo build:
- Analyzes dependency graph: Portfolio depends on @βccl/ui
- Builds in order: @βccl/ui β portfolio
- Caches outputs: Hashes inputs, stores dist/ in
.turbo/cache/ - Skips unchanged: If @βccl/ui unchanged, reuses cached build
- Parallelizes: Builds independent packages simultaneously
Result: Second build takes 0.3s instead of 8.2s (95% time saved)
Pros β
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
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
onClicktype change affecting 15 components
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
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
Simplified Dependency Management - pnpm install links everything
- Impact: No more
npm linkpain, no publishing to test - Use case: Develop library + consumer simultaneously
- Real example: Edit Button, see changes in app instantly (HMR works)
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 β
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
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
CI/CD Requires Smart Filtering - Need to detect what changed
- Impact: Canβt just "deploy everything" on every commit
- Workaround: Turborepoβs
--filteror Nx affected commands - Reality check: Actually an advantage - only deploy what changed
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
Why This Structure Works:
Clear separation by purpose
apps/= things you deploy independentlypackages/= things you share and publishtools/= configs and development utilities
Dependency flow is unidirectional
apps/portfolio β packages/ui β (no dependencies on apps)
apps/web β packages/ui
Apps depend on packages, but packages never depend on apps. Prevents circular dependencies.
- Scoped package naming
@βccl/uinotui- 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
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
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 publishβnpm installcycle - 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"
}
}
// 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"
}
}
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:
- Radix Slot + Monorepo = Perfect Combo - Composition pattern works beautifully when all code is local
- CVA Variants Need Shared Config - Monorepo makes sharing Tailwind config trivial
- 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
]
}
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)
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! π
Why This Architecture Wins:
- Dependency-aware builds - Never build out of order
- Intelligent caching - Hash inputs, reuse outputs
- Parallel execution - Utilize all CPU cores
- 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:*"
}
}
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
Configuration:
# pnpm-workspace.yaml - Defines workspace boundaries
packages:
- 'apps/*' # All apps are workspaces
- 'packages/*' # All packages are workspaces
- 'tools/*' # Tools are workspaces too
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
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)
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"]
}
// 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"]
}
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"]
}
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
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
}
}
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
}
}
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
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
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
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
π 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
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!
How to avoid:
- Audit all package.json files for version mismatches BEFORE migration
- Run
pnpm outdatedto 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
π 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'
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
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
π Gotcha #3: TypeScript Path Mapping
Symptom:
$ turbo build --filter=web
Error: Cannot find module '@ββccl/ui' or its corresponding type declarations
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"
}
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"
]
}
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!
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
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
Deploy configuration (Vercel):
// vercel.json (in apps/portfolio/)
{
"buildCommand": "cd ../.. && turbo build --filter=portfolio",
"installCommand": "pnpm install",
"framework": "nextjs"
}
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! π
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 pr