← Back to Blog
Monorepo Management for Multi-App Projects

When we built WellOS—a platform with 6 integrated apps (3 React Native mobile apps, 2 Next.js web apps, and 1 Node.js backend)—we needed a way to share code, manage dependencies, and build efficiently. A monorepo was the answer.

After two years managing a monorepo across multiple apps in a single repository, here's everything I learned about monorepo management.

Why Monorepo?

WellOS consists of:

  • Field Worker Mobile App (React Native)
  • Manager Mobile App (React Native)
  • Admin Mobile App (React Native)
  • Customer Portal (Next.js)
  • Internal Dashboard (Next.js)
  • Backend API (NestJS)

These apps share:

  • Business logic (domain models, validation)
  • UI components (design system)
  • Type definitions (TypeScript interfaces)
  • Utilities (date formatting, calculations)
  • Configuration (ESLint, TypeScript, environment)

Options:

  1. Separate repos: Duplicate code, version hell, hard to coordinate changes
  2. NPM packages: Publish shared code as packages (slow, version management)
  3. Monorepo: Single repo, shared code, atomic changes

We chose monorepo, and it transformed our development workflow.

Monorepo Structure

wellos/
├── apps/
│   ├── mobile-field/          # React Native - field workers
│   ├── mobile-manager/        # React Native - managers
│   ├── mobile-admin/          # React Native - admins
│   ├── web-customer/          # Next.js - customer portal
│   ├── web-dashboard/         # Next.js - internal dashboard
│   └── api/                   # NestJS - backend
├── packages/
│   ├── domain/                # Domain models, business logic
│   ├── ui/                    # Shared React components
│   ├── ui-native/             # React Native specific components
│   ├── config/                # Shared config (ESLint, TS, etc.)
│   ├── utils/                 # Utility functions
│   └── types/                 # TypeScript type definitions
├── package.json
├── turbo.json
└── tsconfig.json

Setting Up with Turborepo

We use Turborepo for fast, cached builds:

// package.json (root)
{
  "name": "wellos-monorepo",
  "private": true,
  "workspaces": [
    "apps/*",
    "packages/*"
  ],
  "scripts": {
    "dev": "turbo run dev",
    "build": "turbo run build",
    "test": "turbo run test",
    "lint": "turbo run lint",
    "type-check": "turbo run type-check"
  },
  "devDependencies": {
    "turbo": "^1.10.0",
    "typescript": "^5.0.0"
  }
}

Turborepo configuration:

// turbo.json
{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**", "build/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "test": {
      "dependsOn": ["^build"],
      "outputs": ["coverage/**"]
    },
    "lint": {
      "outputs": []
    },
    "type-check": {
      "dependsOn": ["^build"],
      "outputs": []
    }
  }
}

The dependsOn: ["^build"] means "build dependencies first." Turborepo handles the ordering automatically.

Shared Packages

Domain Package

Business logic shared across all apps:

// packages/domain/src/entities/well-inspection.ts
export class WellInspection {
  constructor(
    public readonly id: string,
    public wellId: string,
    public inspectorId: string,
    public inspectionDate: Date,
    public status: InspectionStatus,
    public findings: InspectionFinding[]
  ) {}
 
  addFinding(finding: InspectionFinding): void {
    if (this.status === InspectionStatus.COMPLETED) {
      throw new Error('Cannot add finding to completed inspection');
    }
    this.findings.push(finding);
  }
 
  // Same business logic in mobile, web, and API
}
// packages/domain/package.json
{
  "name": "@wellos/domain",
  "version": "1.0.0",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch"
  },
  "dependencies": {
    "date-fns": "^2.30.0"
  },
  "devDependencies": {
    "@wellos/tsconfig": "*",
    "typescript": "^5.0.0"
  }
}

UI Components Package

Shared React components:

// packages/ui/src/Button/Button.tsx
export interface ButtonProps {
  variant?: 'primary' | 'secondary' | 'danger';
  size?: 'sm' | 'md' | 'lg';
  children: React.ReactNode;
  onClick?: () => void;
  disabled?: boolean;
}
 
export const Button: React.FC<ButtonProps> = ({
  variant = 'primary',
  size = 'md',
  children,
  onClick,
  disabled,
}) => {
  return (
    <button
      className={`btn btn-${variant} btn-${size}`}
      onClick={onClick}
      disabled={disabled}
    >
      {children}
    </button>
  );
};
// packages/ui/package.json
{
  "name": "@wellos/ui",
  "version": "1.0.0",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "scripts": {
    "build": "tsup src/index.tsx --format esm,cjs --dts",
    "dev": "tsup src/index.tsx --format esm,cjs --dts --watch"
  },
  "dependencies": {
    "react": "^18.2.0"
  },
  "devDependencies": {
    "@wellos/tsconfig": "*",
    "tsup": "^7.0.0",
    "typescript": "^5.0.0"
  }
}

Native Components Package

React Native specific components:

// packages/ui-native/src/Button/Button.tsx
import { TouchableOpacity, Text, StyleSheet } from 'react-native';
 
export interface ButtonProps {
  variant?: 'primary' | 'secondary' | 'danger';
  size?: 'sm' | 'md' | 'lg';
  children: string;
  onPress?: () => void;
  disabled?: boolean;
}
 
export const Button: React.FC<ButtonProps> = ({
  variant = 'primary',
  size = 'md',
  children,
  onPress,
  disabled,
}) => {
  return (
    <TouchableOpacity
      style={[styles.button, styles[variant], styles[size]]}
      onPress={onPress}
      disabled={disabled}
    >
      <Text style={styles.text}>{children}</Text>
    </TouchableOpacity>
  );
};
 
const styles = StyleSheet.create({
  button: { borderRadius: 8, padding: 12 },
  primary: { backgroundColor: '#007AFF' },
  secondary: { backgroundColor: '#6B7280' },
  danger: { backgroundColor: '#EF4444' },
  sm: { padding: 8 },
  md: { padding: 12 },
  lg: { padding: 16 },
  text: { color: '#fff', fontWeight: '600' },
});

Config Packages

Shared configuration:

// packages/tsconfig/base.json
{
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["ES2020"],
    "module": "commonjs",
    "moduleResolution": "node",
    "esModuleInterop": true,
    "strict": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  }
}
 
// packages/tsconfig/react.json
{
  "extends": "./base.json",
  "compilerOptions": {
    "jsx": "react-jsx",
    "lib": ["ES2020", "DOM", "DOM.Iterable"]
  }
}
 
// packages/tsconfig/nextjs.json
{
  "extends": "./react.json",
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["ES2020", "DOM"],
    "allowJs": true,
    "noEmit": true,
    "incremental": true,
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules"]
}

Apps extend these configs:

// apps/web-dashboard/tsconfig.json
{
  "extends": "@wellos/tsconfig/nextjs.json"
}

Using Shared Packages in Apps

In Next.js Apps

// apps/web-dashboard/package.json
{
  "name": "@wellos/web-dashboard",
  "dependencies": {
    "@wellos/domain": "*",
    "@wellos/ui": "*",
    "@wellos/types": "*",
    "@wellos/utils": "*",
    "next": "14.0.0",
    "react": "^18.2.0"
  }
}
// apps/web-dashboard/components/InspectionList.tsx
import { Button } from '@wellos/ui';
import { WellInspection } from '@wellos/domain';
import { formatDate } from '@wellos/utils';
 
export const InspectionList = ({ inspections }: { inspections: WellInspection[] }) => {
  return (
    <div>
      {inspections.map((inspection) => (
        <div key={inspection.id}>
          <h3>{inspection.wellId}</h3>
          <p>{formatDate(inspection.inspectionDate)}</p>
          <Button onClick={() => viewInspection(inspection.id)}>
            View Details
          </Button>
        </div>
      ))}
    </div>
  );
};

In React Native Apps

// apps/mobile-field/package.json
{
  "name": "@wellos/mobile-field",
  "dependencies": {
    "@wellos/domain": "*",
    "@wellos/ui-native": "*",
    "@wellos/types": "*",
    "@wellos/utils": "*",
    "react-native": "0.72.0"
  }
}
// apps/mobile-field/screens/InspectionListScreen.tsx
import { Button } from '@wellos/ui-native';
import { WellInspection } from '@wellos/domain';
import { formatDate } from '@wellos/utils';
 
export const InspectionListScreen = () => {
  const inspections = useInspections();
 
  return (
    <View>
      {inspections.map((inspection) => (
        <View key={inspection.id}>
          <Text>{inspection.wellId}</Text>
          <Text>{formatDate(inspection.inspectionDate)}</Text>
          <Button onPress={() => navigate('InspectionDetail', { id: inspection.id })}>
            View Details
          </Button>
        </View>
      ))}
    </View>
  );
};

Build Pipeline with Turborepo

Turborepo caches build outputs. If nothing changed, builds are instant:

$ turbo run build
 
# First build
packages/domain:build: cache miss, executing
packages/ui:build: cache miss, executing
apps/web-dashboard:build: cache miss, executing
# ... takes 2 minutes
 
$ turbo run build
 
# Second build (nothing changed)
packages/domain:build: cache hit, replaying output
packages/ui:build: cache hit, replaying output
apps/web-dashboard:build: cache hit, replaying output
# ... takes 2 seconds!

This works locally and in CI. Turborepo uses content-based hashing to detect changes.

Selective Builds

Build only what changed:

# Build everything
turbo run build
 
# Build only affected apps
turbo run build --filter=...@wellos/mobile-field
 
# Build a single app and its dependencies
turbo run build --filter=@wellos/web-dashboard

In CI, we use Git to build only changed apps:

# .github/workflows/build.yml
name: Build
on: [push]
 
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
 
      - name: Install dependencies
        run: npm install
 
      - name: Build affected apps
        run: npx turbo run build --filter=...[origin/main]

--filter=...[origin/main] builds only apps that changed since main.

Development Workflow

Running Multiple Apps

# Run all apps in dev mode
turbo run dev
 
# Run specific apps
turbo run dev --filter=@wellos/mobile-field
turbo run dev --filter=@wellos/web-dashboard --filter=@wellos/api

Turborepo runs them in parallel with streaming output.

Watch Mode for Packages

When developing shared packages, run in watch mode:

# Terminal 1: Watch shared packages
cd packages/domain
npm run dev
 
# Terminal 2: Run the app
cd apps/mobile-field
npm run dev

Changes to packages/domain automatically rebuild and hot reload in the app.

Version Management

Internal Packages

Use workspace protocol:

{
  "dependencies": {
    "@wellos/domain": "*"  // Always use latest local version
  }
}

External Dependencies

Keep versions synchronized with root package.json:

// package.json (root)
{
  "devDependencies": {
    "typescript": "^5.0.0",
    "eslint": "^8.45.0"
  }
}

Apps inherit these versions:

// apps/web-dashboard/package.json
{
  "devDependencies": {
    "@wellos/tsconfig": "*",
    // typescript inherited from root
  }
}

Testing in Monorepo

Run tests across all packages:

turbo run test
 
# With coverage
turbo run test -- --coverage
 
# Watch mode
turbo run test -- --watch

Test shared packages independently:

// packages/domain/src/entities/__tests__/well-inspection.test.ts
import { WellInspection } from '../well-inspection';
 
describe('WellInspection', () => {
  it('should not allow adding findings after completion', () => {
    const inspection = new WellInspection(/*...*/);
    inspection.complete();
 
    expect(() => {
      inspection.addFinding(/*...*/);
    }).toThrow('Cannot add finding to completed inspection');
  });
});

Tests run in CI for affected packages only, saving time.

Challenges and Solutions

Challenge 1: React Native Metro Bundler

Metro doesn't support monorepos out of the box. We needed custom configuration:

// apps/mobile-field/metro.config.js
const path = require('path');
 
module.exports = {
  projectRoot: __dirname,
  watchFolders: [
    path.resolve(__dirname, '../../node_modules'),
    path.resolve(__dirname, '../../packages'),
  ],
  resolver: {
    nodeModulesPaths: [
      path.resolve(__dirname, 'node_modules'),
      path.resolve(__dirname, '../../node_modules'),
    ],
  },
};

Challenge 2: Build Order

Apps must build after packages. Turborepo handles this with dependsOn:

{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"]  // ^ means dependencies
    }
  }
}

Challenge 3: Large node_modules

Hoisting dependencies to the root reduces duplication:

# Before (without hoisting)
apps/mobile-field/node_modules/  # 500MB
apps/web-dashboard/node_modules/ # 500MB
# Total: 1GB+

# After (with hoisting)
node_modules/  # 600MB
apps/*/node_modules/  # ~50MB each
# Total: ~800MB

Lessons Learned

  1. Start with packages, not apps: Define shared packages first, then build apps on top
  2. Keep packages small: Many small packages > few large packages
  3. Use Turborepo caching: Saves massive CI time
  4. Watch mode is essential: Make package changes feel instant
  5. Document internal APIs: Shared packages need docs just like external ones

When NOT to Use Monorepo

Monorepos aren't always the answer:

  • Independent release cycles: If apps deploy independently, separate repos might be better
  • Different teams: Cross-team monorepos require coordination
  • Massive scale: Google's monorepo works for Google, but 100K+ developers requires special tooling

For WellOS (6 apps, 10 developers), monorepo was perfect.

Conclusion

Managing WellOS as a monorepo with Turborepo transformed our development experience. Code sharing is trivial, atomic changes across apps are possible, and builds are blazingly fast with caching.

The key is structure: organize into apps and packages, use Turborepo for builds, and leverage workspace dependencies.

After 27 years of managing codebases, monorepos are my preferred approach for multi-app projects with shared code. The initial setup investment pays dividends in developer productivity.

Share this article

Help others discover this content


Jason Cochran

Jason Cochran

Sofware Engineer | Cloud Consultant | Founder at Strataga

27 years of experience building enterprise software for oil & gas operators and startups. Specializing in SCADA systems, field data solutions, and AI-powered rapid development. Based in Midland, TX serving the Permian Basin.