Why This Matters
You're building a product that has both a web app and a mobile app. Either you're rebuilding the same business rules twice in two repos and praying they stay in sync, or you're sharing code intelligently and shipping both at half the cost.
The pattern below is what I use on every web + mobile client project. It's saved me an estimated 200+ hours across the last four projects and zero runtime bugs from drift between platforms.
The Honest Trade-off Upfront
Monorepos add tooling complexity. Your first day is harder. Your second year is dramatically easier. If you're shipping a one-off web app or a one-off mobile app, skip this entire post. If you're building both, read on.
The Structure
apps/
web/ # Next.js 16
mobile/ # Expo + React Native
packages/
api-client/ # typed HTTP/SDK wrappers
schemas/ # Zod schemas + inferred types
business-logic/ # pure functions, no React
config/ # shared eslint, tsconfig, prettier
package.json
pnpm-workspace.yaml
turbo.json
That's it. Four packages/, two apps/. The boundaries are deliberate.
What Goes in packages/
schemas/ - All Zod schemas live here. Types are inferred and re-exported. If your User shape changes, both apps get a TypeScript error in the same compile pass.
// packages/schemas/src/user.ts
export const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
plan: z.enum(['free', 'pro', 'enterprise']),
});
export type User = z.infer<typeof UserSchema>;api-client/ - Typed wrappers around your backend. No UI. Just functions that take input, hit an endpoint, validate the response with Zod, and return a typed result.
// packages/api-client/src/users.ts
import { UserSchema } from '@app/schemas';
export async function getUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
return UserSchema.parse(await res.json());
}business-logic/ - Pure TypeScript. No React, no React Native, no DOM. Pricing calculations, validation rules, date formatting, business state machines.
// packages/business-logic/src/billing.ts
export function calculateProration(...) { ... }config/ - One eslint config, one base tsconfig, one prettier config. Apps and packages extend.
What Stays in apps/
UI. Routing. Platform-specific anything.
A Button on web is different from a Button on mobile. They share zero code. They both consume the same User type and the same calculateProration function. That's the line.
The mistake I see most often: trying to share UI components across web and mobile via React Native Web. It can work for tiny use cases. For a real product, the UX needs to be different on each platform, and the shared component becomes a if (Platform.OS === 'web') spaghetti.
Tooling Choices
- Package manager: pnpm. npm and yarn workspaces work, but pnpm's strict resolution catches phantom dependencies.
- Task runner: Turborepo. Caches builds, runs tasks in parallel, dependency-aware.
- TypeScript: Project references. Each package has its own
tsconfig.jsonthat extends the base. - Linting: A single eslint config in
packages/config/eslint, extended by every app and package.
Path Aliases That Don't Break
Use TypeScript path aliases:
{
"compilerOptions": {
"paths": {
"@app/schemas": ["../../packages/schemas/src/index.ts"],
"@app/api-client": ["../../packages/api-client/src/index.ts"],
"@app/business-logic": ["../../packages/business-logic/src/index.ts"]
}
}
}For React Native (Metro bundler), you also need a metro.config.js watching packages/. Expo's monorepo template handles this - start from npx create-expo-app -t with-tabs and add packages/.
For Next.js, add transpilePackages: ['@app/schemas', '@app/api-client', '@app/business-logic'] to next.config.js.
The "API Client" Pattern That Pays Off
Your web app probably uses Next.js Server Actions. Your mobile app hits a REST or tRPC endpoint. Despite this, both go through packages/api-client/. On web, the client function is called from a Server Action. On mobile, from a TanStack Query hook. The contract is identical.
// shared
export async function createBooking(input: CreateBookingInput): Promise<Booking> { ... }
// web (in a Server Action)
'use server';
import { createBooking } from '@app/api-client';
export async function bookAction(formData: FormData) {
return createBooking({...parseFormData(formData)});
}
// mobile (in a query hook)
import { useMutation } from '@tanstack/react-query';
import { createBooking } from '@app/api-client';
export const useCreateBooking = () => useMutation({ mutationFn: createBooking });Same function. Same types. Same validation. Different runtime.
Things That Surprised Me
- Hot reload across packages works, but you need Turborepo's
--watchmode and a small Metro tweak. - CI is faster. Turborepo skips builds for packages that didn't change.
- Onboarding new developers is faster. They learn one set of types, one validation library, one logging convention.
- The mobile bundle stays small. Only the imports you actually use ship. The web
@app/api-clientdoesn't bloat the mobile binary.
What Goes Wrong If You Skip This
I migrated one client from two separate repos to a monorepo after they shipped both apps. Drift was already there: the web app validated phone numbers with one regex, mobile used a different one. Booking confirmations crashed on iPhone for Canadian phone numbers. The bug had been live for 3 months.
After the migration, that whole class of bug disappeared.
When NOT to Use This Pattern
- Your web and mobile apps are different products with different teams.
- One platform is a thin client (e.g. a marketing site) and shares almost nothing.
- You're using completely different stacks (e.g. Rails web + Flutter mobile).
Otherwise: shared types, shared business logic, shared API contracts. Per-platform UI. Always.
Related Reading
- Building an MVP mobile app with React Native + Expo - the mobile side of the monorepo
- React Native vs Flutter in 2026 - why this monorepo pattern only works with React Native
Want Help Setting This Up?
I architect monorepos for clients shipping web + mobile from day one. Book a call and I'll walk you through what your specific setup should look like.
Recevez des perspectives d'ingénierie pratiques
Agents vocaux IA, workflows d'automatisation et livraison rapide. Pas de spam, désabonnement à tout moment.