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:
- Separate repos: Duplicate code, version hell, hard to coordinate changes
- NPM packages: Publish shared code as packages (slow, version management)
- 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-dashboardIn 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/apiTurborepo 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 devChanges 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 -- --watchTest 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
- Start with packages, not apps: Define shared packages first, then build apps on top
- Keep packages small: Many small packages > few large packages
- Use Turborepo caching: Saves massive CI time
- Watch mode is essential: Make package changes feel instant
- 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.

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.