The Developer's Guide to Building Production-Ready Monorepos

John O. EmmanuelJohn O. Emmanuel
10 min read

Picture this: You're managing five different JavaScript projects, each with its own repository, package.json, and deployment pipeline. Sound familiar? Every time you need to update a shared utility function, you're copy-pasting code across repositories, dealing with version mismatches, and watching your CI/CD costs skyrocket. There's a better way.

Welcome to the world of monorepos, where Google manages over 2 billion lines of code in a single repository, and companies like Facebook, Microsoft, and Netflix have revolutionized their development workflows. Today, we'll build a production-ready monorepo from scratch that will transform how you think about project organization.

What Makes Monorepos Special?

Before we dive into the technical details, let's understand why monorepos have become the secret weapon of successful development teams. Unlike traditional multi-repository setups where each project lives in isolation, a monorepo houses multiple related projects under one roof while maintaining clear boundaries between them.

Think of it as moving from individual apartments to a well-designed co-working space. Everyone has their own office (project), but they share common facilities (utilities, configurations, and tools). This approach eliminates the coordination nightmare of managing dependencies across separate repositories while preserving the modularity that keeps code organized.

Setting Up Your Development Environment

Let's start by preparing your machine for monorepo development. We'll use modern tools that make the process smooth and enjoyable.

Prerequisites Check

First, ensure your system meets these requirements:

# Check Node.js version (needs v18.0.0 or higher)
node --version

# Check Git version (needs v2.30.0 or higher)
git --version

# Install Bun (our preferred package manager)
curl -fsSL https://bun.sh/install | bash

If you prefer Yarn or npm, that works too, but Bun offers significantly faster installation and execution times.

Step 1: Creating Your Monorepo Foundation

Let's start by using the battle-tested template that powers many production applications:

# Clone the proven template
git clone https://github.com/John-pels/monorepo-template.git awesome-monorepo
cd awesome-monorepo

# Clean slate - remove existing git history
rm -rf .git
git init
git add .
git commit -m "feat: initialize monorepo from template"

Alternatively, you can use the template directly on GitHub by clicking "Use this template" – perfect if you want to start with a clean repository right away.

Understanding the Architecture

Your new monorepo follows a carefully designed structure:

awesome-monorepo/
├── apps/                    # Your applications live here
│   ├── web/                # Next.js frontend (port 8000)
│   ├── dashboard/          # Vite + TanStack Router admin (port 8001)
│   └── mobile/             # React Native app
├── packages/               # Shared libraries and utilities
│   ├── ui/                 # Reusable React components
│   ├── utils/              # Business logic and helpers
│   ├── eslint-config/      # Consistent linting rules
│   ├── jest-config/        # Testing configurations
│   └── types/              # TypeScript definitions
├── docs/                   # Project documentation
└── tools/                  # Build and development utilities

This structure follows the principle of separation of concerns – applications are consumer-facing products, while packages contain reusable code that applications can depend on.

Step 2: Installing Essential Dependencies

Now let's equip your monorepo with the tools that ensure code quality and smooth development:

# Install code quality tools
bun add -D @biomejs/biome

# Add git hooks for automated checks
bun add -D husky lint-staged

# Set up commit message validation
bun add -D @commitlint/cli @commitlint/config-conventional

Why These Tools Matter

  • Biome: A lightning-fast alternative to ESLint and Prettier that handles both linting and formatting

  • Husky: Prevents bad commits from entering your repository

  • lint-staged: Only runs checks on files you've changed

  • Commitlint: Enforces consistent commit messages that work with automated changelogs

Step 3: Configuring Code Quality Tools

Setting Up Biome

Create a biome.json file in your root directory with this configuration:

{
  "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json",
  "vcs": {
    "enabled": true,
    "clientKind": "git",
    "useIgnoreFile": true
  },
  "organizeImports": {
    "enabled": true
  },
  "linter": {
    "enabled": true,
    "ignore": ["./apps/dashboard/src/routeTree.gen.ts"],
    "rules": {
      "recommended": true,
      "correctness": {
        "noUnusedVariables": "error",
        "noUnusedImports": "error"
      },
      "security": {
        "noDangerouslySetInnerHtml": "off"
      },
      "suspicious": {
        "noConsoleLog": "warn"
      }
    }
  },
  "formatter": {
    "enabled": true,
    "lineWidth": 80,
    "indentStyle": "space",
    "indentWidth": 2
  },
  "javascript": {
    "formatter": {
      "semicolons": "asNeeded",
      "trailingCommas": "es5",
      "quoteStyle": "single"
    }
  }
}

This configuration provides sensible defaults while being strict about code quality. The ignore array excludes auto-generated files from checks.

Configuring Git Hooks

Initialize Husky and set up your git hooks:

# Initialize Husky
npx husky init

# Create pre-commit hook
echo "npx lint-staged" > .husky/pre-commit

# Create commit message validation hook
echo "npx commitlint --edit \$1" > .husky/commit-msg

# Make hooks executable
chmod +x .husky/pre-commit
chmod +x .husky/commit-msg

Setting Up Commit Message Standards

Create a commitlint.config.js file:

module.exports = {
 extends: ['@commitlint/config-conventional'],
  rules: {
    'body-leading-blank': [1, 'always'],
    'body-max-line-length': [2, 'always', 100],
    'footer-leading-blank': [1, 'always'],
    'footer-max-line-length': [2, 'always', 100],
    'header-max-length': [2, 'always', 100],
    'scope-case': [2, 'always', 'lower-case'],
    'subject-case': [
      2,
      'never',
      ['sentence-case', 'start-case', 'pascal-case', 'upper-case'],
    ],
    'subject-empty': [2, 'never'],
    'subject-full-stop': [2, 'never', '.'],
    'type-case': [2, 'always', 'lower-case'],
    'type-empty': [2, 'never'],
    'type-enum': [
      2,
      'always',
      [
        'build',
        'chore',
        'ci',
        'docs',
        'feat',
        'fix',
        'perf',
        'refactor',
        'revert',
        'style',
        'test',
        'translation',
        'security',
        'changeset',
      ],
    ],
  },
}

Step 4: Configuring Package Management

Update your root package.json with essential scripts and workspace configuration:

{
  "name": "awesome-monorepo",
  "private": true,
  "packageManager": "bun@1.2.19",
  "workspaces": ["apps/*", "packages/*"],
  "scripts": {
    "build": "turbo run build",
    "dev": "dotenv -- turbo run dev",
    "lint": "biome check --write --unsafe",
    "format": "biome format --write .",
    "format-and-lint:fix": "biome check --write --unsafe",
    "test": "turbo run test",
    "check-types": "turbo run check-types",
    "prepare": "husky"
  },
  "lint-staged": {
    "*.{js,jsx,ts,tsx,json}": [
      "biome check --apply --no-errors-on-unmatched"
    ]
  }
}

The workspaces field tells your package manager where to find sub-projects, while the scripts provide convenient commands for common operations.

Step 5: Setting Up Turborepo

Turborepo is the engine that makes your monorepo fast and efficient. Create a turbo.json configuration:

{
  "$schema": "https://turborepo.com/schema.json",
  "ui": "tui",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["$TURBO_DEFAULT$", ".env*"],
      "outputs": [".next/**", "!.next/cache/**"]
    },
    "lint": {
      "dependsOn": ["^lint"]
    },
    "check-types": {
      "dependsOn": ["^check-types"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}

This configuration defines task relationships and caching strategies. The ^build syntax means "run build on dependencies first."

Step 6: Creating Your First Application

Let's add a Next.js application to demonstrate the workflow:

cd apps
npx create-next-app@latest web --typescript --tailwind --eslint --app --src-dir
cd web

Connecting to Shared Packages

Edit the apps/web/package.json to include references to your shared packages:

{
  "dependencies": {
    "@repo/ui": "*",
    "@repo/utils": "*",
    "next": "latest",
    "react": "latest",
    "react-dom": "latest"
  },
  "devDependencies": {
    "@repo/eslint-config": "*",
    "@repo/typescript-config": "*"
  }
}

The "*" version tells the package manager to use the local workspace version.

Creating a Shared UI Package

Build a reusable component library:

mkdir -p packages/ui/src
cd packages/ui

# Initialize package
bun init -y

Create packages/ui/package.json:

{
  "name": "@repo/ui",
  "version": "0.0.0",
  "main": "./src/index.ts",
  "types": "./src/index.ts",
  "exports": {
    ".": "./src/index.ts"
  },
  "peerDependencies": {
    "react": "^18.0.0"
  },
  "devDependencies": {
    "@repo/eslint-config": "*",
    "@repo/typescript-config": "*"
  }
}

Add a simple button component in packages/ui/src/button.tsx:

import { ReactNode } from 'react'

interface ButtonProps {
  children: ReactNode
  onClick?: () => void
  variant?: 'primary' | 'secondary'
}

export function Button({ 
  children, 
  onClick, 
  variant = 'primary' 
}: ButtonProps) {
  const baseClasses = 'px-4 py-2 rounded font-medium transition-colors'
  const variantClasses = {
    primary: 'bg-blue-600 text-white hover:bg-blue-700',
    secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300'
  }

  return (
    <button 
      className={`${baseClasses} ${variantClasses[variant]}`}
      onClick={onClick}
    >
      {children}
    </button>
  )
}

Export it in packages/ui/src/index.ts:

export { Button } from './button'

Step 7: Using Shared Packages

Now use your shared button in the Next.js app. Edit apps/web/src/app/page.tsx:

import { Button } from '@repo/ui'

export default function Home() {
  return (
    <main className="flex min-h-screen flex-col items-center justify-center p-24">
      <h1 className="text-4xl font-bold mb-8">Welcome to Our Monorepo!</h1>
      <Button 
        onClick={() => alert('Hello from shared UI!')}
        variant="primary"
      >
        Click me!
      </Button>
    </main>
  )
}

Step 8: Development Workflow

Install all dependencies and start development:

# Install all dependencies across workspaces
bun install

# Start all applications in development mode
bun dev

# Or start a specific app
bun dev --filter=web

The beauty of this setup is hot reloading across packages. Change your button component, and see it update instantly in your Next.js app!

Step 9: Building and Testing

Running Builds

# Build everything
bun run build

# Build specific app and its dependencies
bun run build --filter=web

# Check for TypeScript errors
bun run check-types

Setting Up Tests

Create a shared Jest configuration in packages/jest-config/index.js:

module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  moduleNameMapping: {
    '^@repo/(.*)$': '<rootDir>/../../packages/$1/src'
  },
  collectCoverageFrom: [
    'src/**/*.{ts,tsx}',
    '!src/**/*.d.ts'
  ]
}

Add tests for your button component in packages/ui/src/__tests__/button.test.tsx:

import { render, screen, fireEvent } from '@testing-library/react'
import { Button } from '../button'

describe('Button', () => {
  it('renders with correct text', () => {
    render(<Button>Click me</Button>)
    expect(screen.getByText('Click me')).toBeInTheDocument()
  })

  it('calls onClick when clicked', () => {
    const handleClick = jest.fn()
    render(<Button onClick={handleClick}>Click me</Button>)

    fireEvent.click(screen.getByText('Click me'))
    expect(handleClick).toHaveBeenCalledTimes(1)
  })
})

Step 10: Advanced Features

Dependency Management

Add dependencies strategically:

# Add to specific workspace
bun workspace @repo/ui add lucide-react

# Add to root (affects all workspaces)
bun add -D typescript

# Add to multiple workspaces
bun workspaces foreach add axios

Environment Variables

Create environment-specific configurations:

# Root .env
NODE_ENV=development
DATABASE_URL=postgresql://localhost:5432/dev

# App-specific .env.local
NEXT_PUBLIC_API_URL=http://localhost:3001

CI/CD Integration

Create .github/workflows/ci.yml:

name: CI
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v1
        with:
          bun-version: latest

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

      - name: Lint code
        run: bun run lint

      - name: Type check
        run: bun run check-types

      - name: Run tests
        run: bun run test

      - name: Build applications
        run: bun run build

Performance Optimizations

Caching Strategy

Turborepo's intelligent caching saves significant time:

# First build (slow)
bun run build

# Second build (lightning fast - uses cache)
bun run build

# Force rebuild (ignores cache)
bun run build --force

Selective Operations

Run commands only on changed packages:

# Only lint changed files
bun run lint --filter=[HEAD^1]

# Build only affected packages
bun run build --filter=...[origin/main]

Common Patterns and Best Practices

Package Naming

Use consistent naming conventions:

@your-company/ui          # UI components
@your-company/utils       # Utilities
@your-company/config      # Configurations
@your-company/types       # TypeScript types

Dependency Management

  • Keep shared dependencies in the root package.json

  • Use exact versions for critical dependencies

  • Regular dependency audits with bun audit

Code Organization

packages/ui/
├── src/
│   ├── components/       # Reusable components
│   ├── hooks/           # Custom React hooks
│   ├── utils/           # Utility functions
│   └── index.ts         # Main export file
├── package.json
└── tsconfig.json

Troubleshooting Common Issues

Installation Problems

# Clear all caches and reinstall
bun pm cache rm
rm -rf node_modules
rm bun.lockb
bun install

Build Failures

# Check TypeScript errors
bun run check-types

# Clear Turborepo cache
bun run build --force

# Verbose output for debugging
bun run build --verbose

Development Server Issues

# Check port conflicts
lsof -i :3000

# Clear Next.js cache
rm -rf apps/web/.next

# Restart with clean cache
bun dev --filter=web

Taking Your Monorepo to Production

Deployment Strategies

Each application can deploy independently:

# Deploy web app to Vercel
cd apps/web
vercel deploy

# Deploy API to Railway
cd apps/api
railway deploy

Monitoring and Analytics

Consider adding tools like:

Real-World Examples

This template powers several production applications. You can:

Beyond the Basics

Micro-frontends

Your monorepo can easily evolve into a micro-frontend architecture:

# Add module federation support
bun add @module-federation/nextjs-mf

# Create shell application
mkdir apps/shell

Mobile Integration

Add React Native applications seamlessly:

cd apps
npx create-expo-app mobile --template blank-typescript

Backend Services

Include Node.js APIs and databases:

mkdir apps/api
cd apps/api
bun init -y
bun add express cors helmet

The Road Ahead

Monorepos represent the future of scalable application development. Companies using this approach report Faster development cycle, Reduction in dependency management overhead, Fewer integration issues, improved code reuse, and consistency. Your monorepo journey doesn't end here. As your projects grow, you'll discover new patterns, tools, and optimizations. The foundation you've built today will adapt and scale with your needs.

The monorepo template provides a battle-tested starting point, but the real magic happens when you adapt it to your specific needs. Whether you're building the next unicorn startup or managing enterprise applications, monorepos offer the flexibility and power to scale your development efforts effectively.

References and Further Reading

Start your monorepo journey today with the proven template and join thousands of developers who've streamlined their development workflows. Your future self will thank you for making the switch!


Ready to transform your development workflow? Get started with the monorepo template and experience the power of unified development today.

20
Subscribe to my newsletter

Read articles from John O. Emmanuel directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

John O. Emmanuel
John O. Emmanuel

A Versatile Senior Software Engineer with over 5 years of expertise in full-stack development, focusing on React, React-Native, Node.js, Golang, and TypeScript ecosystems. Demonstrated success in spearheading remote teams and architecting high-performance, scalable web and mobile applications. Exceptional communicator skilled in fostering collaboration across diverse time zones and cultural backgrounds. Committed to driving innovation and operational excellence in distributed work environments.