Architecture

Monorepo Strategy: When to Use It and How to Scale

Austin H.•August 12, 2026•12 min read
#monorepo#nx#turbo#scaling#architecture

Monorepo vs. Polyrepo: The Trade-Off

Monorepo (Single Repository)

my-platform/
|--  apps/
||  |--  web/              (Next.js frontend)
||  |--  admin/            (Admin dashboard)
||  ||--  api/              (Express backend)
|--  packages/
||  |--  ui/               (React components)
||  |--  utils/            (Shared utilities)
||  |--  types/            (TypeScript interfaces)
||  |--  database/         (Prisma client)
||  ||--  api-client/       (SDK for consumers)
|--  tools/
||  |--  scripts/          (Build & deploy scripts)
||  ||--  ci/               (CI/CD configs)
||--  nx.json

Pros:

  • |-- a Shared code without npm publishing overhead
  • |-- a Atomic commits across packages (A + B updated together)
  • |-- a Easy refactoring across teams (rename type, IDE fixes all callers)
  • |-- a Unified tooling, linting, testing
  • |-- a Single CI/CD pipeline, consistent standards

Cons:

  • [OK] Large clones (git clone pulls everything)
  • [OK] Large installs (npm install slower)
  • [OK] Complex build graphs (build ordering matters)
  • [OK] All teams see all changes (transparency vs. privacy trade-off)
  • [OK] Harder to isolate failures (one bad commit affects everyone)
  • [OK] Requires discipline (clear boundaries between packages)

Polyrepo (Multiple Repositories)

my-platform-web/
my-platform-api/
my-platform-admin/
my-platform-ui/
my-platform-types/
my-platform-utils/
my-platform-database/

Pros:

  • |-- a Fast clones and installs (only what you need)
  • |-- a Team isolation and autonomy (independent schedules)
  • |-- a Independent versioning (web v1.0, api v2.5)
  • |-- a Simpler CI/CD per repo
  • |-- a Clear boundaries (easier to reason about)

Cons:

  • [OK] Shared code requires npm publishing (version coordination)
  • [OK] Version hell (web depends on types@1.0, admin depends on types@2.0)
  • [OK] Cross-package refactoring is painful (update npm package, bump versions everywhere)
  • [OK] Duplicate tooling (eslint config repeated, build scripts copied)
  • [OK] No atomic commits (update types, deploy new npm version, wait for consumers to upgrade)

Decision Matrix: When to Use What

| Scenario | Recommendation | Reasoning | | ---------- | --- | --- | | Startup (< 10 devs) | Monorepo | Move fast, share code, no ops overhead | | Micro-frontends (3+ UIs sharing design system) | Monorepo | Atomic design system updates, easy refactoring | | Full-stack app (web + api + shared types) | Monorepo | Types live in one place, zero version skew | | SDK + wrappers (JS + Python + Go client libs) | Monorepo | Refactor core once, all clients updated instantly | | Independent products (Slack + Dropbox inside same org) | Polyrepo | Different teams, schedules, deploy cadences | | Open source library ecosystem | Polyrepo | External contributors, per-package versions | | Large org (> 100 devs) | Depends, usually polyrepo clusters | One monorepo = git merge conflicts, slow CI. Better: monorepos per team + shared SDKs |

Setting Up a Monorepo with Nx

1. Create Workspace

npx create-nx-workspace@latest my-platform --preset=next,express
# Choose monorepo layout
# Nx will scaffold Next.js app + Express backend + shared packages

2. Project Structure

my-platform/
|--  apps/
||  |--  web/
||    ||  |--  src/
||    ||    ||  |--  app/        (Next.js App Router)
||    ||    ||  ||--  lib/
||    ||  |--  package.json
||    ||  ||--  project.json    (Nx config)
||  ||--  api/
||        |--  src/
||        |--  package.json
||        ||--  project.json
|--  packages/
||  |--  types/
||    ||  |--  src/
||    ||    ||  ||--  index.ts
||    ||  |--  package.json
||    ||  ||--  project.json
||  |--  utils/
||  |--  ui/
||  ||--  database/
|--  nx.json                 (Workspace config)
|--  package.json
|--  tsconfig.base.json      (Root TypeScript config)
||--  .eslintrc.json

3. Create Apps and Libraries

# Generate Next.js app
nx generate @nx/next:app web

# Generate Express app
nx generate @nx/express:app api

# Generate shared libraries
nx generate @nx/react:library ui --directory=packages
nx generate @nx/node:library utils --directory=packages
nx generate @nx/node:library types --directory=packages
nx generate @nx/node:library database --directory=packages

4. Configure TypeScript Paths

In tsconfig.base.json:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@my-platform/types": ["packages/types/src/index.ts"],
      "@my-platform/utils": ["packages/utils/src/index.ts"],
      "@my-platform/ui": ["packages/ui/src/index.ts"],
      "@my-platform/database": ["packages/database/src/index.ts"]
    }
  }
}

5. Shared Types

// packages/types/src/index.ts
export interface User {
  id: string;
  email: string;
  role: 'admin' | 'user';
  createdAt: Date;
}

export interface CreateUserRequest {
  email: string;
  password: string;
  role?: 'admin' | 'user';
}

export interface ApiResponse<T> {
  data: T;
  error: string | null;
  status: number;
}

6. Backend Uses Types

// apps/api/src/routes/users.ts
import { Router } from 'express';
import type { User, CreateUserRequest, ApiResponse } from '@my-platform/types';
import { hashPassword } from '@my-platform/utils';
import { db } from '@my-platform/database';

const router = Router();

router.post('/users', async (req, res) => {
  const body: CreateUserRequest = req.body;
  
  try {
    const hashedPassword = await hashPassword(body.password);
    const user = await db.user.create({
      data: { email: body.email, password: hashedPassword, role: body.role || 'user' },
    });
    
    const response: ApiResponse<User> = {
      data: user,
      error: null,
      status: 201,
    };
    res.status(201).json(response);
  } catch (error) {
    const response: ApiResponse<null> = {
      data: null,
      error: error.message,
      status: 400,
    };
    res.status(400).json(response);
  }
});

export default router;

7. Frontend Uses Both

// apps/web/src/app/page.tsx
'use client';
import { useState } from 'react';
import type { User, CreateUserRequest, ApiResponse } from '@my-platform/types';
import { Button } from '@my-platform/ui';
import { emailValidator } from '@my-platform/utils';

export default function Page() {
  const [users, setUsers] = useState<User[]>([]);
  const [email, setEmail] = useState('');

  const createUser = async () => {
    if (!emailValidator(email)) {
      alert('Invalid email');
      return;
    }

    const req: CreateUserRequest = { email, password: 'temp', role: 'user' };
    const res = await fetch('/api/users', {
      method: 'POST',
      body: JSON.stringify(req),
    });

    const data: ApiResponse<User> = await res.json();
    if (data.error) {
      alert(data.error);
    } else {
      setUsers([...users, data.data]);
    }
  };

  return (
    <div>
      <input value={email} onChange={(e) => setEmail(e.target.value)} />
      <Button onClick={createUser}>Create User</Button>
      {users.map((u) => <div key={u.id}>{u.email}</div>)}
    </div>
  );
}

Build Optimization: Only What Changed

View Dependency Graph

nx graph
# Shows which packages depend on which. Visually:
# web |    ui, types, utils, database
# api |    types, utils, database
# ui |    types

Build Only Affected Apps

# Build only apps that depend on changed files
nx affected --targets=build

# Test only affected apps
nx affected --targets=test

# Lint only affected files
nx affected --targets=lint

Run Specific App

nx run web:dev          # Start Next.js dev server
nx run api:serve        # Start Express server
nx run ui:storybook     # Start Storybook for UI lib

Common Gotchas

1. Circular Dependencies

// [OK]  WRONG: ui imports from web
// packages/ui/src/Button.tsx
import { useAppContext } from '@my-platform/web'; // Creates cycle!

// |-- a RIGHT: web imports from ui, never the other way
// packages/ui/src/Button.tsx
export function Button() { ... }  // Pure component

// apps/web/src/components/MyButton.tsx
import { Button } from '@my-platform/ui';

2. Package Version Skew

{
  "dependencies": {
    "react": "^18.0.0"
  },
  "devDependencies": {
    "@types/react": "^18.0.0"  // [OK]  version mismatch
  }
}

Keep all packages in one package.json (root), not per-app.

3. Slow CI

If CI runs 30 min for every commit, use caching:

# .github/workflows/ci.yml
- uses: nrwl/nx-set-shas@v3
  with:
    main-branch-base: origin/main
- run: npx nx affected --targets=lint,test,build --parallel=4 --cacheInputs

Deployment

Each app deploys independently:

# Deploy web to Vercel
vercel deploy apps/web

# Deploy API to Render
render deploy apps/api

# UI lib doesn't deploy (used as dependency)

Migration Path: Polyrepo | Monorepo

If you already have separate repos, merge carefully:

  1. Create monorepo with Nx
  2. Copy my-platform-web | apps/web
  3. Copy my-platform-api | apps/api
  4. Copy my-platform-types | packages/types
  5. Update imports from import { X } from 'my-platform-types' | import { X } from '@my-platform/types'
  6. Delete old repos (keep as git tags for history)
  7. Update CI/CD to use nx affected

When to Stay Polyrepo

  • Independent products with different governance
  • External contributors (open source)
  • Teams with completely different tech stacks
  • Scaling to 200+ developers (split into smaller monorepos per team)

Example: nitsuah Platform

github.com/Nitsuah-Labs/nitsuah-io uses Nx for:

apps/
|--  nitsuah-io/           (Main portfolio site)
||--  admin/                (Content management)

packages/
|--  ui/                   (Design components)
|--  utils/                (Shared functions)
|--  types/                (TypeScript interfaces)
||--  database/             (Prisma schema & client)

One repo, multiple apps, zero code duplication, atomic commits.

Takeaway

Monorepos scale when teams share code frequently and move together. Use Nx or Turbo to manage complexity, enforce clear package boundaries, and leverage caching to keep CI fast. The investment pays off when your tenth refactor would have been a nightmare in polyrepo land.

Key Takeaways

  • Monorepos enable atomic cross-package changes and shared tooling
  • Nx/TurboRepo provide intelligent build caching and affected detection
  • Clear package boundaries (apps vs packages) prevent spaghetti dependencies
  • Shared TypeScript config ensures consistent types across packages
  • Single CI pipeline with affected-only runs keeps CI fast at scale

Nx Configuration

{
  "npmScope": "myorg",
  "implicitDependencies": {
    "package.json": "*"
  }
}

Thanks for reading!

Read More Articles