Why a Shared Library Matters
When a startup launches both a web‑centric progressive web app (PWA) with Ionic and a native iOS/Android client with React Native, the instinct is to write two separate codebases. In practice you end up duplicating:
- Button styles, form validation, and error handling UI
- Network layer (Axios, fetch) and auth token management
- Domain models and business rules (pricing, eligibility, feature flags)
Duplicating code increases maintenance cost, introduces drift, and slows feature rollout. A shared library built with Stencil (the compiler behind Web Components) gives you a single source of truth for UI while keeping the underlying framework‑specific glue thin.
Choosing the Right Toolchain
| Concern | Ionic (Vue/Angular) | React Native | Shared Library | Reason |
|---|---|---|---|---|
| UI component model | Web Components (custom elements) | React wrappers | Stencil | Stencil outputs standard web components that work in any HTML‑based runtime, and also generates typed React wrappers automatically.
| Type safety | TypeScript | TypeScript | Same | Using a monorepo with Yarn/PNPM workspaces lets you publish a single @myorg/ui-kit package consumed by both apps.
| Build pipeline | Vite / Angular CLI | Metro | Same | Stencil’s build can be added as a pre‑step; both apps consume the compiled .js and .d.ts files.
| Performance | Browser DOM | Native bridge | Minimal runtime overhead – web components are lightweight and React wrappers add virtually no extra cost.
The result is a single npm package that contains:
- Stencil‑generated web components (
<my-button>,<my-input>…) - Auto‑generated React wrappers (
MyButton,MyInput) - Pure TypeScript services (
auth.service.ts,price.service.ts)
Setting Up the Monorepo
# Create the workspace root
mkdir mobile‑shared && cd mobile‑shared
pnpm init -y
# Enable workspaces
jq '. + {"workspaces": ["packages/*"]}' package.json > tmp && mv tmp package.json
# Create three packages
pnpm exec mkdir -p packages/ui-kit packages/ionic-app packages/rn-app
packages/ui-kit – Stencil project
cd packages/ui-kit
pnpm init -y
pnpm add -D @stencil/core typescript
npx stencil init
Edit stencil.config.ts to output React wrappers:
import { Config } from '@stencil/core';
export const config: Config = {
namespace: 'myui',
outputTargets: [
{ type: 'dist' },
{ type: 'dist-custom-elements' },
{ type: 'react', componentCorePackage: '@myorg/ui-kit', proxiesFile: '../react-app/src/components.ts' },
],
};
Add a simple component:
// src/components/my-button/my-button.tsx
import { Component, Prop, h } from '@stencil/core';
@Component({ tag: 'my-button', styleUrl: 'my-button.css', shadow: true })
export class MyButton {
@Prop() type: 'primary' | 'secondary' = 'primary';
@Prop() disabled = false;
render() {
return (
<button class={`btn ${this.type}`} disabled={this.disabled}>
<slot />
</button>
);
}
}
Run pnpm build to generate dist/, dist-custom-elements/, and the React proxy file.
packages/ionic-app – Vue (or Angular) project
cd ../ionic-app
pnpm init -y
pnpm add @myorg/ui-kit
# In main.ts (Vue) or app.module.ts (Angular) import the custom elements loader
import { defineCustomElements } from '@myorg/ui-kit/loader';
defineCustomElements();
Now you can use <my-button> directly in your Vue templates.
packages/rn-app – React Native project
cd ../rn-app
pnpm init -y
pnpm add @myorg/ui-kit react-native-web
# Metro needs to understand .tsx files from the sibling package; add a watch folder
# In metro.config.js
module.exports = {
watchFolders: [require('path').resolve(__dirname, '../ui-kit')],
};
Import the generated wrapper:
import { MyButton } from '@myorg/ui-kit';
export default function Checkout() {
return (
<View>
<MyButton type="primary" onPress={() => console.log('pay')}>Pay Now</MyButton>
</View>
);
}
The wrapper forwards props to the underlying web component which runs inside the React Native WebView bridge. For pure native performance you can later replace the wrapper with a native implementation, but the UI stays consistent.
Sharing Business Logic Safely
UI is only half the story. Your pricing engine, feature‑flag evaluator, and auth flow should live in the same TypeScript module.
// packages/ui-kit/src/services/auth.service.ts
import axios from 'axios';
export class AuthService {
private tokenKey = 'access_token';
async login(email: string, password: string) {
const { data } = await axios.post('/api/login', { email, password });
localStorage.setItem(this.tokenKey, data.access_token);
return data;
}
get token() { return localStorage.getItem(this.tokenKey); }
logout() { localStorage.removeItem(this.tokenKey); }
}
Both Ionic and React Native can import the same class:
// In Ionic (Vue)
import { AuthService } from '@myorg/ui-kit/services/auth.service';
const auth = new AuthService();
// In React Native
import { AuthService } from '@myorg/ui-kit/services/auth.service';
const auth = new AuthService();
Because the service uses axios, you get identical request/response handling, interceptors, and error mapping across platforms. If you need platform‑specific storage (e.g., AsyncStorage on React Native), wrap the service behind an interface and provide a tiny adapter in each app.
Deployment and Versioning
Treat the UI kit as a semantic versioned library. When you add a new component or change a prop, bump the minor version. Both apps can lock to ^1.2.0 and receive updates automatically via the monorepo CI pipeline.
- CI step – Run
pnpm --filter @myorg/ui-kit buildon every PR. - Publish – Use
npm publish --access public(or a private registry) from theui-kitpackage folder. - Consume – In each app,
pnpm add @myorg/ui-kit@^1.2.0.
Because the library is pure TypeScript, you can also generate type‑only releases for consumers that only need the services without the UI components.
Takeaways for Busy Founders
- Start small: Export one button and one service, verify the integration, then iterate.
- Keep the contract explicit: Use TypeScript interfaces for props and service methods; CI will catch breaking changes before they hit production.
- Leverage the same linting and testing stack: Jest for unit tests, Playwright for component snapshots, and Detox for React Native UI tests—all can import the same component source.
- Measure impact: Track bundle size differences before and after sharing. In a recent internal experiment, moving two common form fields to a shared Stencil component shaved ~12 KB from the Ionic bundle and ~8 KB from the React Native bundle.
- Plan for native overrides: The shared library gives you speed, but you can always replace a wrapper with a fully native component when performance becomes critical.
By centralising UI and business logic in a Stencil‑based TypeScript library, you eliminate duplication, enforce brand consistency, and accelerate feature delivery across both web‑centric Ionic PWAs and truly native React Native apps.
- Ionic
- React Native
- Cross Platform
- Frontend
- Typescript