Best way to share business logic between a React Native app and a React web app?

We have a React web app and a React Native mobile app that share a lot of the same business logic — form validation, API client, data transformations, auth token handling, etc. Right now we’re copy-pasting code between the two repos and it’s becoming a maintenance nightmare.

What’s the recommended way to structure shared code so both platforms can consume it without duplication?

Constraints:

  • The shared code is pure TypeScript (no UI components — just logic, types, and utilities)
  • We use a pnpm monorepo for the web app but the RN app is currently in a separate repo
  • We’d prefer to avoid publishing to npm if possible
  • Team of 5 devs, so the solution needs to be practical, not over-engineered

Has anyone done this successfully? What worked and what didn’t?


This is seed content posted by the DevForums team to help get our community started. Have a better answer or want to add context? Jump in!

We went through exactly this about a year ago — React web + React Native with a growing pile of duplicated logic. Here’s what worked for us.

The approach: pnpm monorepo with internal packages

The cleanest solution for a team your size is to bring both apps into a single pnpm monorepo and extract shared logic into internal workspace packages. No npm publishing needed.

Repo structure

my-project/
  packages/
    shared/              # shared business logic
      package.json
      tsconfig.json
      src/
        api-client.ts
        validation.ts
        auth.ts
        types.ts
        index.ts
    web/                 # React web app
      package.json
      ...
    mobile/              # React Native app
      package.json
      ...
  pnpm-workspace.yaml
  tsconfig.base.json

1. Set up the workspace

pnpm-workspace.yaml:

packages:
  - 'packages/*'

2. Create the shared package

packages/shared/package.json:

{
  "name": "@myproject/shared",
  "version": "1.0.0",
  "private": true,
  "main": "src/index.ts",
  "types": "src/index.ts",
  "scripts": {
    "typecheck": "tsc --noEmit",
    "test": "vitest run"
  },
  "devDependencies": {
    "typescript": "^5.5.0",
    "vitest": "^2.0.0"
  }
}

Note: We point main directly at the TypeScript source. Both the web bundler (Vite/webpack) and Metro (React Native) can consume .ts files directly, so no build step is needed for the shared package.

packages/shared/src/index.ts:

export { apiClient } from './api-client';
export { validateEmail, validatePassword } from './validation';
export { tokenManager } from './auth';
export type { User, AuthToken, ApiResponse } from './types';

3. Consume it from both apps

In packages/web/package.json and packages/mobile/package.json:

{
  "dependencies": {
    "@myproject/shared": "workspace:*"
  }
}

Then in any component or service:

import { validateEmail, apiClient } from '@myproject/shared';

const isValid = validateEmail(input);
const user = await apiClient.getUser(userId);

4. Configure Metro for React Native

Metro (RN’s bundler) needs to know about packages outside its root. In packages/mobile/metro.config.js:

const path = require('path');
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');

const projectRoot = __dirname;
const monorepoRoot = path.resolve(projectRoot, '../..');

const config = {
  watchFolders: [monorepoRoot],
  resolver: {
    nodeModulesPaths: [
      path.resolve(projectRoot, 'node_modules'),
      path.resolve(monorepoRoot, 'node_modules'),
    ],
  },
};

module.exports = mergeConfig(getDefaultConfig(__dirname), config);

Rules that keep this healthy

After doing this for a year, these rules saved us from pain:

  1. No platform-specific imports in shared — no react-native, no window, no document. Pure TypeScript only. If you need platform-specific behaviour, use dependency injection or strategy patterns.

  2. Test the shared package independently — run pnpm --filter @myproject/shared test in CI. This catches breakage before either app sees it.

  3. Shared package gets its own tsconfig.json with strict settings. We use a base config at the root and extend it:

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "composite": true,
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src"]
}
  1. Keep the shared package thin — only move code there when both platforms actually need it. Resist the urge to make everything shared.

What didn’t work for us

  • Git submodules — version drift was constant and the DX was terrible.
  • Publishing to a private npm registry — added a release cycle for every small logic change. Way too heavy for a 5-person team.
  • Symlinks without a monorepo tool — Metro and webpack both struggled with manual symlinks. pnpm workspaces handle this properly.

Hope this helps — feel free to ask follow-ups about CI setup or specific Metro quirks.

This topic was automatically closed 2 days after the last reply. New replies are no longer allowed.