Skip to content
Mehedi Hasan Sagor

// blog

Cross‑Platform Mobile Development with Ionic & React Native: A Monorepo Strategy for Shared Code

Mehedi Hasan Sagor Oct 15, 2026 6 min read
Cross‑Platform Mobile Development with Ionic & React Native: A Monorepo Strategy for Shared Code

Why a Monorepo Makes Sense for Ionic + React Native

When a startup needs both a web‑first progressive app (Ionic) and a truly native experience (React Native), the temptation is to create two separate codebases. That approach doubles the maintenance burden: every bug fix, feature flag, or API contract change must be duplicated. A monorepo—one repository that houses both front‑ends and any shared libraries—solves this problem by giving you a single source of truth for business logic, type definitions, and tests.

Key benefits

  • Consistency: A single CI pipeline validates all platforms on every commit.
  • Speed: Shared code lives in one place, so developers can ship fixes without switching contexts.
  • Scalability: Adding a third platform (e.g., a desktop Electron wrapper) only requires a new UI package, not a whole new repo.

Repository Layout

A pragmatic layout looks like this:

my-app/
├─ packages/
│  ├─ core/               # Shared TypeScript utilities, API clients, models
│  ├─ ui-ionic/            # Ionic/Angular or Ionic/React UI layer
│  └─ ui-react-native/     # React Native UI layer
├─ tools/
│  └─ scripts/            # Lint, format, build helpers
├─ .gitignore
├─ package.json           # Workspace definitions (Yarn or npm workspaces)
└─ README.md

Both UI packages list ../core as a dependency, so any change in core is instantly available to the apps.

Sharing Business Logic with TypeScript

1. Define a clean domain model

Create plain TypeScript interfaces that describe the data your app works with. Keep them framework‑agnostic.

// packages/core/src/models.ts
export interface Product {
  id: string;
  name: string;
  price: number; // stored in cents to avoid floating point issues
  currency: string;
}

export interface CartItem extends Product {
  quantity: number;
}

2. Centralize API calls

A thin wrapper around fetch (or axios) lives in core. It handles base URLs, auth headers, and error mapping.

// packages/core/src/api.ts
import { Product, CartItem } from './models';

const API_BASE = process.env.API_BASE_URL ?? 'https://api.myapp.com';

function authHeader() {
  const token = localStorage.getItem('jwt');
  return token ? { Authorization: `Bearer ${token}` } : {};
}

export async function fetchProducts(): Promise<Product[]> {
  const res = await fetch(`${API_BASE}/products`, {
    headers: { ...authHeader() },
  });
  if (!res.ok) throw new Error('Failed to load products');
  return res.json();
}

export async function addToCart(item: CartItem): Promise<void> {
  await fetch(`${API_BASE}/cart`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      ...authHeader(),
    },
    body: JSON.stringify(item),
  });
}

Both UI layers import fetchProducts and addToCart directly, ensuring identical network behaviour.

UI Layer Differences

Ionic (React) Example

// packages/ui-ionic/src/pages/ProductList.tsx
import { useEffect, useState } from 'react';
import { fetchProducts, addToCart } from '../../core/src/api';
import { Product } from '../../core/src/models';
import { IonButton, IonCard, IonContent, IonHeader, IonTitle, IonToolbar } from '@ionic/react';

export const ProductList = () => {
  const [products, setProducts] = useState<Product[]>([]);
  useEffect(() => {
    fetchProducts().then(setProducts).catch(console.error);
  }, []);

  const handleAdd = async (p: Product) => {
    await addToCart({ ...p, quantity: 1 });
    // optional: show toast
  };

  return (
    <IonContent>
      {products.map(p => (
        <IonCard key={p.id}>
          <h2>{p.name}</h2>
          <p>{(p.price / 100).toFixed(2)} {p.currency}</p>
          <IonButton onClick={() => handleAdd(p)}>Add to Cart</IonButton>
        </IonCard>
      ))}
    </IonContent>
  );
};

React Native Example

// packages/ui-react-native/src/screens/ProductList.tsx
import { useEffect, useState } from 'react';
import { View, Text, Button, FlatList, StyleSheet } from 'react-native';
import { fetchProducts, addToCart } from '../../core/src/api';
import { Product } from '../../core/src/models';

export const ProductList = () => {
  const [products, setProducts] = useState<Product[]>([]);
  useEffect(() => {
    fetchProducts().then(setProducts).catch(console.error);
  }, []);

  const handleAdd = async (p: Product) => {
    await addToCart({ ...p, quantity: 1 });
    // optional: show a Snackbar
  };

  return (
    <FlatList
      data={products}
      keyExtractor={item => item.id}
      renderItem={({ item }) => (
        <View style={styles.card}>
          <Text style={styles.title}>{item.name}</Text>
          <Text>{(item.price / 100).toFixed(2)} {item.currency}</Text>
          <Button title="Add to Cart" onPress={() => handleAdd(item)} />
        </View>
      )}
    />
  );
};

const styles = StyleSheet.create({
  card: { padding: 16, marginBottom: 12, backgroundColor: '#fff', borderRadius: 8 },
  title: { fontSize: 18, fontWeight: 'bold' },
});

Both components look different, but the underlying data flow is identical because they rely on the same core services.

Testing Shared Logic Once

Write unit tests in core using Jest (or Vitest). Since the UI packages import the same functions, you get confidence across platforms without duplicating test suites.

// packages/core/__tests__/api.test.ts
import { fetchProducts } from '../src/api';
import fetchMock from 'jest-fetch-mock';

fetchMock.enableMocks();

describe('API layer', () => {
  beforeEach(() => fetchMock.resetMocks());

  it('returns products', async () => {
    fetchMock.mockResponseOnce(JSON.stringify([{ id: '1', name: 'Demo', price: 1999, currency: 'USD' }]));
    const products = await fetchProducts();
    expect(products).toHaveLength(1);
    expect(products[0].name).toBe('Demo');
  });
});

Run the same test suite from the root npm test command; CI will execute it once for the whole monorepo.

Build & CI Considerations

  1. Workspace aware scripts – Use Yarn workspaces (or npm workspaces) so npm run build in the root builds all packages in the correct order.
  2. Platform‑specific bundlers – Ionic uses Vite/Webpack, React Native uses Metro. Keep their configs isolated; the shared code is pure TypeScript and compiles to plain JavaScript, so both bundlers can consume it without extra configuration.
  3. Cache artifacts – Cache node_modules per workspace in CI to avoid rebuilding the shared library on every run.
  4. Linting – A single ESLint config at the repo root enforces the same coding standards across UI and core.

Trade‑offs to Keep in Mind

| Aspect | Advantage | Caveat | |--------|-----------|--------| | Shared code | Single source of truth; less duplication. | Core must stay framework‑agnostic; avoid UI‑specific APIs (e.g., window, document). | | Monorepo size | Easier to reason about dependencies. | Larger repo can increase checkout time; mitigate with shallow clones in CI. | | Tooling | Unified lint, format, test runner. | Need to configure each platform's bundler to resolve workspace packages (often a metro.config.js alias). | | Developer onboarding | New hires learn one repo layout. | New developers must understand both Ionic and React Native ecosystems; documentation is essential. |

Actionable Checklist for Your First Sync Build

  1. Initialize a workspace – yarn init -w then add packages/* as workspaces in the root package.json.
  2. Create core – Add TypeScript, Jest, and a tsconfig.json that outputs to dist/.
  3. Bootstrap UI packages – Scaffold Ionic React (ionic start) and React Native (npx react-native init) inside their respective folders, then point their package.json dependencies to ../core.
  4. Add path aliases – In metro.config.js and vite.config.ts, map @core/* to ../core/src.
  5. Write one API call – Implement a simple fetch in core; consume it from both UI layers.
  6. Run tests – yarn test should execute core tests and report success.
  7. CI pipeline – Add a job that runs yarn install --frozen-lockfile && yarn test && yarn build.

By following these steps you get a maintainable, production‑ready codebase that serves both web‑centric Ionic users and native‑centric React Native users without the overhead of two completely separate projects.

Bottom Line

A monorepo that isolates core business logic from UI implementations lets you reap the benefits of code reuse while preserving the native feel each platform demands. The upfront effort of setting up the workspace pays off quickly: fewer bugs, faster feature rollout, and a single source of truth for API contracts. For startups aiming to ship both a progressive web app and a native mobile experience, this approach is a pragmatic path to scale.

  • Ionic
  • React Native
  • Cross Platform
  • Monorepo
  • Javascript