Skip to main content
mobile

Sharing Code Between Web and Mobile: The Monorepo Pattern That Saved Me 200 Hours

How to structure a monorepo so your Next.js web app and React Native mobile app share business logic, types, and API clients - without coupling that bites you later.

May 12, 2026·10 min read·LLoic Bachellerie

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.json that 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 --watch mode 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-client doesn'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.

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.


Share:
Newsletter

Get practical engineering insights

AI voice agents, automation workflows, and shipping fast. No spam, unsubscribe anytime.

Related Posts