Xortrix
← Back to Blog

Mobile + Web from One Codebase: A Pragmatic Guide

9 min read

The Situation

We've shipped products that run as both a Next.js web app and a React Native mobile app. The question everyone asks: "How much code do you share?"

The honest answer: about 60-70% of the business logic, close to 0% of the UI code. And that's fine. In fact, that's the correct ratio. The moment you try to push UI code sharing above 10-15%, you start fighting the platform instead of building your product.

This post covers what we actually share, what we don't, and the project structure that makes it work without any monorepo tooling complexity.

What Actually Shares Well

Not everything can cross the platform boundary, but a surprising amount of your codebase is pure TypeScript logic that has no opinion about where it runs. Here's what we put in our shared package:

API client code. GraphQL queries, mutations, API response types, and request helpers. The actual HTTP layer differs (fetch on web, a configured client on mobile), but the query definitions, variable types, and response parsing are identical.

Business logic.Domain calculations, validation rules, data transformations, date/time formatting. This is pure logic that doesn't touch any UI or platform API.

Type definitions. This is the biggest win. Shared TypeScript interfaces mean both platforms are always working with the same data shapes. When the API changes, you update one file and both apps get the type errors immediately.

Constants and configuration. API endpoints, feature flags, validation rules. Anything that both apps need to agree on.

The pattern that works best is dependency injection. Your shared API utility accepts an interface for the HTTP client. The web app injects a fetch-based client. The mobile app injects one with token refresh, certificate pinning, and retry logic. Same API surface, different plumbing underneath.

What Doesn't Share (And Shouldn't)

UI components. React Native uses View, Text, ScrollView. Web uses div, p, main. Every attempt to abstract this with a universal component library ends with a leaky abstraction that makes both platforms worse. Write your components twice. It genuinely takes less time than maintaining a cross-platform UI layer.

Navigation.Next.js App Router and React Navigation are fundamentally different paradigms. One is file-system based with server-side rendering. The other is a stack-based navigator with gesture handling. Don't abstract this. Embrace the difference.

State management.On the web, you can lean heavily on server components and URL state. The mobile app needs more client-side state — offline caching, background sync queues, and local-first patterns. These are different problems requiring different solutions.

Storage.localStorage vs AsyncStorage vs SecureStore. The APIs are different, the capabilities are different, and the security considerations are different. But the interface can be shared — more on that below.

The Project Structure That Works

We use a monorepo-ish structure. "Ish" because we don't use Lerna, Nx, or Turborepo. For a two-app setup, those tools add more ceremony than value.

/packages
  /shared          # TypeScript types, API clients, business logic
    /types
    /api
    /utils
    /validation
    package.json   # name: "@myapp/shared"

  /web             # Next.js app
    /src
    next.config.ts
    package.json   # depends on "@myapp/shared": "file:../shared"

  /mobile          # React Native (Expo) app
    /src
    app.json
    package.json   # depends on "@myapp/shared": "file:../shared"

The shared package is referenced via a relative file path in each app's package.json. That's it. No workspace protocol, no hoisted dependencies, no build step for the shared package. TypeScript's project references handle the compilation, and both apps import from the shared package as if it were a published npm package.

Practical Patterns

Platform-specific wrappers with a shared interface. This is the single most useful pattern in cross-platform development. Define the contract once, implement it per platform:

// packages/shared/storage.ts
export interface StorageAdapter {
  get(key: string): Promise<string | null>;
  set(key: string, value: string): Promise<void>;
  remove(key: string): Promise<void>;
}

// packages/web/src/lib/storage.ts
import type { StorageAdapter } from "@myapp/shared/storage";

export const webStorage: StorageAdapter = {
  async get(key) {
    return localStorage.getItem(key);
  },
  async set(key, value) {
    localStorage.setItem(key, value);
  },
  async remove(key) {
    localStorage.removeItem(key);
  },
};

// packages/mobile/src/lib/storage.ts
import type { StorageAdapter } from "@myapp/shared/storage";
import * as SecureStore from "expo-secure-store";

export const mobileStorage: StorageAdapter = {
  async get(key) {
    return SecureStore.getItemAsync(key);
  },
  async set(key, value) {
    await SecureStore.setItemAsync(key, value);
  },
  async remove(key) {
    await SecureStore.deleteItemAsync(key);
  },
};

Any shared business logic that needs storage accepts a StorageAdapter parameter. The calling code in each app passes in the right implementation. Dependency injection at its simplest.

Shared validation with Zod.Zod schemas work identically in Node, the browser, and React Native. Define validation rules once and use them everywhere — form validation on the client, request validation on the server, and type inference across the codebase.

Date handling.Use date-fns instead of Moment.js. It's tree-shakeable, works in every JavaScript environment, and doesn't require polyfills on React Native. Wrap it in a thin utility layer in the shared package so formatting is consistent across both apps.

Common Mistakes to Avoid

Don't use react-native-web unless you're building a single app.If your web app is a full Next.js site with SSR, SEO requirements, and server components, react-native-web adds a layer of complexity for marginal benefit. It makes sense for apps where the web version is essentially the mobile app in a browser. It doesn't make sense when your web and mobile experiences are meaningfully different.

Don't share UI components.Teams spend weeks building a cross-platform Button component that renders a Pressable on mobile and a button on web, with shared props for variant, size, and loading state. Then they discover that the mobile button needs haptic feedback, the web button needs keyboard focus styles, and the abstraction starts accumulating platform checks until it's harder to maintain than two separate components would have been.

Don't abstract navigation.Building a "universal router" that maps Next.js routes to React Navigation screens always breaks. Deep linking works differently. Transition animations work differently. Back behavior works differently. Write your navigation code twice and keep the route names consistent by convention.

Don't over-abstract too early. Start with copy-paste between the two apps. When you find yourself changing the same logic in both places for the third time, extract it to the shared package. Premature abstraction costs more than a little duplication.

The Honest Trade-off

Code sharing between web and mobile saves maybe 20-30% of total development time. That's meaningful but it's not the 2x productivity gain that cross-platform frameworks promise.

The real value isn't the shared code — it's the shared understanding. One team, one data model, one API contract. When you fix a bug in the business logic, it's fixed on both platforms. When you add a field to the API response type, both apps see the change at compile time. When a new developer joins, they learn one domain model, not two.

The monorepo structure also forces good architectural habits. When you have to decide "does this belong in shared or in the platform package?" you end up with cleaner separation of concerns than you would in a single-platform app. Business logic stays pure. Platform code stays focused on platform-specific concerns.

Share the boring stuff — types, validation, API clients, business logic. Write the interesting stuff — UI, navigation, animations, platform integrations — twice. The result is two apps that feel native to their platforms, backed by a shared core that keeps them consistent.

That's not a compromise. That's the right architecture.

We help teams ship production software — from serverless architectures and AI features to cross-platform mobile apps. If you're building something and need engineering help, let's talk.