Monorepo in 2026: Turborepo, pnpm Workspaces, and Shared Types Between Next.js and React Native
Last month, a client came to me with a problem that I've seen play out dozens of times across my six years of full-stack work. They had two separate codebases: a Next.js web app and a React Native mobile app. Every time the product team added a new feature, the developers had to duplicate TypeScript types, validation schemas, and API contracts across both repos. Bugs would appear in one place but not the other. A "user" object on web had slightly different fields than a "user" object on mobile. It was a maintenance nightmare.
The solution? A TypeScript monorepo that shares code, types, and even UI components between web and mobile. After six years of building these setups for clients on Fiverr (168+ projects and counting), I can tell you that 2026 is the best time ever to set one up. The tooling has matured, pnpm is blazing fast, and Turborepo has become the de facto orchestrator.
In this hands-on guide, I'll walk you through exactly how I build these monorepos in production: from initializing pnpm workspaces to sharing TypeScript types between Next.js and React Native, without the usual tooling headaches.
Why Monorepo in 2026?
The "monorepo vs. polyrepo" debate is effectively over. In 2026, if you're shipping to both web and mobile, a monorepo isn't a luxury; it's a necessity. Here's why:
1. Single source of truth for types. Your User, Order, and Product types live in one place. When the backend adds a field, both apps pick it up immediately.
2. Atomic refactors. Rename an API endpoint? One commit updates the web client, mobile client, and shared SDK. No more "we forgot to update iOS."
3. Shared business logic. Validation schemas (Zod), utility functions, constants, and even React components can live in shared packages.
4. Tooling efficiency. Turborepo's remote caching (yes, even the free tier via Vercel) means your CI builds drop from 12 minutes to under 2.
5. Better developer experience. One pnpm install, one git clone, one PR review covering both platforms.
I won't bore you with theory. Let's build something.
Prerequisites: What You Need Before Starting
Before we dive in, make sure you have:
- Node.js 20+ (LTS is fine)
- pnpm 9+ installed globally:
npm install -g pnpm - A terminal you're comfortable in
- Basic TypeScript knowledge
That's it. No paid tools, no cloud accounts (though Turborepo's remote cache is free on Vercel if you want it later).
Step 1: Initialize Your Monorepo Structure
Let's start by creating the directory structure. I use this layout for almost every client project:
mkdir my-app-monorepo && cd my-app-monorepo
pnpm init
Now, let's edit the root package.json to declare this as a workspace root:
{
"name": "my-app-monorepo",
"private": true,
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"type-check": "turbo run type-check"
},
"devDependencies": {
"turbo": "^2.3.0",
"typescript": "^5.6.0"
},
"packageManager": "pnpm@9.15.0",
"engines": {
"node": ">=20"
}
}
Next, create a pnpm-workspace.yaml file. This is where the magic happens:
packages:
- "apps/*"
- "packages/*"
Now let's create the actual directories:
mkdir -p apps packages
mkdir -p apps/web packages/types packages/ui packages/config
Your structure should now look like:
my-app-monorepo/
├── apps/
│ └── web/ (Next.js app)
│ └── mobile/ (React Native app - we'll add later)
├── packages/
│ ├── types/ (Shared TypeScript types)
│ ├── ui/ (Shared UI components)
│ └── config/ (Shared configs: tsconfig, eslint)
├── package.json
├── pnpm-workspace.yaml
└── turbo.json
Step 2: Configure Turborepo
Turborepo is what orchestrates builds, tests, and tasks across your workspace. Create a turbo.json at the root:
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local", "tsconfig.json"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^build"]
},
"type-check": {
"dependsOn": ["^build"]
}
}
}
The dependsOn: ["^build"] is critical. The caret (^) means "build my dependencies first." So when you build the web app, Turborepo automatically builds the types and ui packages first.
Step 3: Create the Shared Types Package
This is the heart of the monorepo. Create packages/types/package.json:
{
"name": "@my-app/types",
"version": "0.0.1",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"type-check": "tsc --noEmit",
"build": "tsc"
},
"devDependencies": {
"typescript": "^5.6.0"
},
"dependencies": {
"zod": "^3.23.0"
}
}
Pro tip: I'm pointing
mainandtypesdirectly to the.tssource file. This works thanks to modern bundlers (Next.js, Metro for React Native) and saves you from constantly rebuilding. For production libraries, you'd compile to.js, but for internal packages, source distribution is faster.
Now create packages/types/tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"declaration": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true
},
"include": ["src/**/*"]
}
Now the actual types. Create packages/types/src/user.ts:
import { z } from 'zod';
// Zod schema - runtime validation
export const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string().min(1),
role: z.enum(['admin', 'user', 'guest']),
createdAt: z.string().datetime(),
avatarUrl: z.string().url().optional(),
});
// TypeScript type - compile-time safety
export type User = z.infer<typeof UserSchema>;
export const CreateUserSchema = UserSchema.omit({
id: true,
createdAt: true,
});
export type CreateUserDto = z.infer<typeof CreateUserSchema>;
And packages/types/src/index.ts to export everything:
export * from './user';
export * from './api';
Let me add one more file, packages/types/src/api.ts, for API contracts:
import { z } from 'zod';
import { UserSchema } from './user';
export const ApiResponseSchema = <T extends z.ZodTypeAny>(data: T) =>
z.object({
success: z.boolean(),
data,
error: z.string().optional(),
timestamp: z.string().datetime(),
});
export const GetUserResponseSchema = ApiResponseSchema(UserSchema);
export type GetUserResponse = z.infer<typeof GetUserResponseSchema>;
export const PaginatedSchema = <T extends z.ZodTypeAny>(item: T) =>
z.object({
items: z.array(item),
total: z.number().int().nonnegative(),
page: z.number().int().positive(),
pageSize: z.number().int().positive(),
});
export type Paginated<T> = {
items: T[];
total: number;
page: number;
pageSize: number;
};
Notice how I'm using Zod schemas that double as runtime validators AND type generators. One source of truth. This pattern has saved me dozens of hours across client projects.
Step 4: Set Up the Next.js App
Now let's consume these types in Next.js. From the root, run:
cd apps
pnpm create next-app@latest web --typescript --eslint --app --src-dir --import-alias "@/*"
cd web
Edit apps/web/package.json to add the workspace dependency:
{
"name": "web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@my-app/types": "workspace:*",
"next": "15.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}
The workspace:* protocol is pnpm's way of saying "use the local package, whatever version it is."
Now use it in a page. Create apps/web/src/app/users/page.tsx:
import { UserSchema, type User } from '@my-app/types';
async function getUsers(): Promise<User[]> {
const res = await fetch('https://api.example.com/users');
const json = await res.json();
// Runtime validation - catches API contract changes immediately
const users = z.array(UserSchema).parse(json);
return users;
}
export default async function UsersPage() {
const users = await getUsers();
return (
<main className="p-8">
<h1 className="text-3xl font-bold mb-6">Users</h1>
<ul className="space-y-2">
{users.map((user) => (
<li key={user.id} className="p-4 border rounded">
<p className="font-semibold">{user.name}</p>
<p className="text-sm text-gray-600">{user.email}</p>
<span className="text-xs bg-blue-100 px-2 py-1 rounded">
{user.role}
</span>
</li>
))}
</ul>
</main>
);
}
You also need to add the Zod import. Update the file:
import { z } from 'zod';
import { UserSchema, type User } from '@my-app/types';
And make sure Zod is installed in the web app or shared. I usually keep Zod in the types package since it's primarily a validation library.
Step 5: Add the React Native App
For React Native, the setup is slightly trickier because Metro bundler needs special configuration to resolve workspace packages. Let's add the mobile app:
cd apps
pnpm create expo-app mobile --template blank-typescript
cd mobile
Edit apps/mobile/package.json:
{
"name": "mobile",
"version": "1.0.0",
"private": true,
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@my-app/types": "workspace:*",
"expo": "~52.0.0",
"expo-status-bar": "~2.0.0",
"react": "19.0.0",
"react-native": "0.76.0"
}
}
Metro Configuration for Workspaces
Create or edit apps/mobile/metro.config.js:
const { getDefaultConfig } = require('expo/metro-config');
const path = require('path');
const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, '../..');
const config = getDefaultConfig(projectRoot);
// 1. Watch all files within the monorepo
config.watchFolders = [workspaceRoot];
// 2. Resolve symlinked packages from the workspace
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, 'node_modules'),
path.resolve(workspaceRoot, 'node_modules'),
];
// 3. Force Metro to resolve (sub)dependencies only from the `nodeModulesPaths`
config.resolver.disableHierarchicalLookup = true;
module.exports = config;
Now use the same types in your mobile app. Edit apps/mobile/App.tsx:
import { useEffect, useState } from 'react';
import { View, Text, FlatList, StyleSheet } from 'react-native';
import { z } from 'zod';
import { UserSchema, type User } from '@my-app/types';
export default function App() {
const [users, setUsers] = useState<User[]>([]);
useEffect(() => {
fetch('https://api.example.com/users')
.then((res) => res.json())
.then((json) => {
const validated = z.array(UserSchema).parse(json);
setUsers(validated);
})
.catch(console.error);
}, []);
return (
<View style={styles.container}>
<Text style={styles.title}>Users</Text>
<FlatList
data={users}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View style={styles.userCard}>
<Text style={styles.name}>{item.name}</Text>
<Text style={styles.email}>{item.email}</Text>
<Text style={styles.role}>{item.role}</Text>
</View>
)}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 20, paddingTop: 60, backgroundColor: '#fff' },
title: { fontSize: 28, fontWeight: 'bold', marginBottom: 20 },
userCard: { padding: 16, borderWidth: 1, borderColor: '#ddd', borderRadius: 8, marginBottom: 8 },
name: { fontSize: 16, fontWeight: '600' },
email: { fontSize: 14, color: '#666', marginTop: 4 },
role: { fontSize: 12, color: '#007AFF', marginTop: 4 },
});
Same User type, same Zod validation, same data shape. Web and mobile are now in sync.
Step 6: Install Everything and Run
From the monorepo root:
pnpm install
This single command installs dependencies for ALL apps and packages. pnpm's content-addressable store makes this incredibly fast and disk-efficient.
Run the web app:
pnpm --filter web dev
Run the mobile app:
pnpm --filter mobile start
Or run both at once via Turbo:
pnpm dev
Sharing More Than Just Types
Once you've got types working, you'll want to share more. Here are patterns I use regularly:
Shared ESLint Config
Create packages/config/eslint/index.js:
module.exports = {
extends: ['next/core-web-vitals', 'turbo'],
rules: {
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
},
};
Then in apps/web/.eslintrc.json:
{
"extends": ["@my-app/eslint-config"]
}
Shared UI Components
Want to share a Button component between web and mobile? You can, but use react-native-web for primitives, or create platform-specific implementations. I'll cover this in a future post because it deserves its own deep-dive.
Shared API Client
Create packages/api/ with a fetch wrapper that uses your shared types:
import { z } from 'zod';
export async function apiFetch<T extends z.ZodTypeAny>(
url: string,
schema: T
): Promise<z.infer<T>> {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
return schema.parse(json);
}
Common Pitfalls and How I Avoid Them
After building dozens of these, here are the mistakes I see most often:
1. Forgetting to add the package to your workspace's package.json. If pnpm install doesn't pick up your changes, check that pnpm-workspace.yaml includes the path.
2. Using ^ versions on workspace packages. Use workspace:* to always point to the local version.
3. Mixing ESM and CJS. Pick one module system. I use ESM everywhere with "type": "module" in root package.json.
4. Not configuring Metro for monorepos. The metro.config.js block I showed above is non-negotiable for React Native.
5. Rebuilding on every change. Source distribution (main: "./src/index.ts") avoids constant rebuilds.
Performance Numbers From Real Projects
On a recent client project with 4 apps and 12 shared packages:
- Cold install: 47 seconds (pnpm) vs 3 minutes 12 seconds (npm/yarn)
- CI build with Turbo cache: 1 minute 48 seconds vs 14 minutes cold
- TypeScript check across all packages: 6.3 seconds
These aren't theoretical numbers. They're from my last Fiverr client's monorepo migration.
Wrapping Up
A well-configured monorepo in 2026 isn't just a developer convenience; it's a competitive advantage. You ship faster, break less, and onboard new developers in hours instead of days. The combination of pnpm workspaces, Turborepo orchestration, and shared Zod-validated TypeScript types gives you a foundation that scales from a 2-person startup to a 50-person engineering team without restructuring.
I've deployed this exact setup for clients shipping everything from fintech apps to e-commerce platforms, and it works. If you need help setting up a monorepo for your team, or want to migrate an existing polyrepo setup, I'm available for consulting work. Reach out through my portfolio at raselhossain.dev and let's talk about your architecture.
Happy coding, and may your types stay in sync.
Frequently Asked Questions
What is a monorepo and why use one in 2026?
A monorepo is a single Git repository containing multiple distinct projects (apps and packages). In 2026, monorepos have become the standard for teams shipping to multiple platforms because they enable code sharing, atomic refactors, and unified CI/CD. Modern tooling like pnpm and Turborepo has eliminated the historical pain points around install times and build complexity.
Should I use Turborepo or Nx in 2026?
For most teams, Turborepo is the better choice. It's simpler, has zero-config support for most frameworks, integrates seamlessly with Vercel deployments, and has a gentler learning curve. Nx is more powerful for very large enterprises with complex plugin needs, but Turborepo covers 90% of use cases with less cognitive overhead.
Can I share React components between Next.js and React Native?
Yes, but with caveats. You can share pure logic components, hooks, and types freely. For UI components that render different things on web vs. mobile, you have three options: use react-native-web for cross-platform primitives, create platform-specific implementations behind a unified API, or use a library like Tamagui that handles the platform differences automatically.
How does pnpm compare to npm and yarn workspaces?
pnpm is significantly faster and more disk-efficient than both npm and yarn. It uses a content-addressable store (similar to git) that hard-links packages instead of duplicating them. pnpm also has stricter dependency resolution by default, which catches phantom dependency bugs that plague npm and yarn workspaces.
Do I need to publish my shared packages to npm?
No. With pnpm workspaces and workspace:* protocol, your shared packages stay internal to your monorepo. pnpm creates symlinks in node_modules pointing to your local package directories. This is faster than publishing to a private registry and keeps your internal code truly internal.
How do I handle CI/CD for a monorepo?
Turborepo handles this elegantly with its --filter flag and remote caching. In your CI pipeline, run turbo run build --filter=...[origin/main] to only build packages that changed. Combined with Turborepo's free remote cache on Vercel, you can cut CI times by 70-90%.
