@iop362/framework
v1.0.1
Published
White-label React Native framework — shared modules, theming, and feature flags
Maintainers
Readme
@org/framework
White-label React Native framework — shared modules, theming, feature flags, and navigation for multiple branded apps from a single codebase.
Table of Contents
- Overview
- Monorepo Structure
- Architecture
- Quick Start
- FrameworkConfig Reference
- Theme Tokens
- String Tokens
- Callbacks Reference
- Feature Flags
- Module System
- Provider Hooks
- Peer Dependencies
- Development Setup
- Client App Examples
- Scripts
- Tech Stack
Overview
@org/framework is an internal React Native package that powers multiple white-label consumer apps. Each app is a thin shell — it provides a single FrameworkConfig object and the framework handles everything else: theming, navigation, feature-flagged modules, localised strings, and injectable business logic.
How it works:
// apps/client-a/app/index.tsx
import { FrameworkApp } from '@org/framework';
import { appConfig } from '../config/appConfig';
export default function App() {
return <FrameworkApp config={appConfig} />;
}That one line boots the entire app. All customisation — brand colours, copy, analytics callbacks, feature access — is expressed through appConfig.
What you get out of the box:
- Bottom-tab navigation with remotely toggled modules
- Offline-first feature flag caching (AsyncStorage)
- Deep-merged theme tokens (override only what you need)
- Localised/branded string resources
- Injectable callbacks for analytics, formatting, and auth events
- TypeScript-strict public API
Monorepo Structure
ReactNative/
├── framework/ ← @org/framework (this repo)
│ ├── src/
│ │ ├── index.ts ← Public API surface
│ │ ├── FrameworkApp.tsx
│ │ ├── config/ ← FrameworkConfig types
│ │ ├── theme/ ← ThemeProvider + defaults
│ │ ├── strings/ ← StringProvider + defaults
│ │ ├── callbacks/ ← CallbackProvider + defaults
│ │ ├── flags/ ← FeatureFlagProvider + types
│ │ ├── modules/ ← registry + home/profile/settings/payments
│ │ ├── navigation/ ← RootNavigator + LoadingScreen
│ │ └── utils/ ← deepMerge
│ └── package.json
│
└── apps/
├── client-a/ ← Red brand (com.clienta.app)
│ ├── app/ ← expo-router routes
│ └── config/ ← appConfig.ts (theme, strings, callbacks)
│
└── client-b/ ← Green brand (com.clientb.app)
├── app/
└── config/Each client app links to @org/framework via a local file: path during development and will point to the published npm package in CI/production.
Architecture
Provider Stack
Every screen in a client app is wrapped in this provider hierarchy (outermost → innermost):
<ThemeProvider> — Design tokens (colors, typography, spacing, radius)
<StringProvider> — Localised/branded string resources
<CallbackProvider> — Injectable business-logic hooks (analytics, auth, formatting)
<FeatureFlagProvider>— Remote flags + AsyncStorage cache
<NavigationIndependentTree> — Isolates nav tree from expo-router's container
<NavigationContainer>
<RootNavigator> — Renders flag-active tab modules
NavigationIndependentTreeis required when embedding the framework inside anexpo-routershell, which already has its ownNavigationContainer.
Data Flow
App boots
→ FeatureFlagProvider fetches GET /flags?appId=com.clienta.app
→ Flags cached in AsyncStorage (offline fallback)
→ RootNavigator reads flags, renders only active tab screens
→ Each tab module lazy-loaded on first visitQuick Start
1. Install the framework
# In your client app directory
npm install @org/framework
# or, for local development (monorepo):
# package.json: "@org/framework": "file:../../framework"2. Install peer dependencies
npm install expo react react-native \
@react-navigation/native @react-navigation/bottom-tabs @react-navigation/stack \
@react-native-async-storage/async-storage \
expo-router expo-linking expo-constants expo-font expo-splash-screen expo-status-bar \
react-native-screens react-native-safe-area-context \
react-native-gesture-handler react-native-reanimated3. Create your app config
// config/appConfig.ts
import type { FrameworkConfig } from '@org/framework';
export const appConfig: FrameworkConfig = {
appId: 'com.myapp.brand',
apiBaseUrl: 'https://api.mybrand.com',
theme: {
colors: {
primary: '#FF5722',
primaryDark: '#E64A19',
},
},
strings: {
appName: 'My Brand',
welcomeTitle: 'Welcome to My Brand',
},
callbacks: {
trackEvent: (event, properties) => {
MyAnalytics.track(event, properties);
},
formatCurrency: (amount, currency) =>
new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount),
formatDate: (date) =>
new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(date),
},
};4. Mount FrameworkApp
// app/index.tsx (expo-router entry)
import { FrameworkApp } from '@org/framework';
import { appConfig } from '../config/appConfig';
export default function App() {
return <FrameworkApp config={appConfig} />;
}5. Configure Metro for monorepo (local dev only)
// metro.config.js
const { getDefaultConfig } = require('expo/metro-config');
const path = require('path');
const config = getDefaultConfig(__dirname);
const frameworkPath = path.resolve(__dirname, '../../framework');
config.watchFolders = [frameworkPath];
// Force singleton packages to always resolve from the app's node_modules
// (prevents duplicate React / React Navigation instances from framework/node_modules)
config.resolver.resolveRequest = (context, moduleName, platform) => {
const singletons = ['react', 'react-native', '@react-navigation/native', /* ... */];
if (singletons.includes(moduleName)) {
return { type: 'sourceFile', filePath: require.resolve(
path.resolve(__dirname, 'node_modules', moduleName)
)};
}
return context.resolveRequest(context, moduleName, platform);
};
module.exports = config;FrameworkConfig Reference
interface FrameworkConfig {
appId: string; // Required. Bundle ID / package name
apiBaseUrl: string; // Required. Base URL for the flags API
theme?: DeepPartial<ThemeTokens>; // Optional. Deep-merged over DEFAULT_THEME
strings?: Partial<StringTokens>; // Optional. Shallow-merged over DEFAULT_STRINGS
assets?: Partial<AssetTokens>; // Optional. Logo, splash, avatar images
callbacks?: Partial<FrameworkCallbacks>; // Optional. Analytics, auth, formatting
}| Key | Type | Required | Description |
|-----|------|----------|-------------|
| appId | string | ✅ | Unique app identifier sent to the flags API |
| apiBaseUrl | string | ✅ | Base URL — framework calls GET {apiBaseUrl}/flags?appId={appId} |
| theme | DeepPartial<ThemeTokens> | — | Override any design token; unspecified keys fall back to defaults |
| strings | Partial<StringTokens> | — | Override any string; unspecified keys fall back to English defaults |
| assets | Partial<AssetTokens> | — | logo, logoLight, splashImage (use require() references) |
| callbacks | Partial<FrameworkCallbacks> | — | Override analytics, auth events, currency/date formatting |
Theme Tokens
Accessed via the useTheme() hook. All tokens have defaults — override only the subset you need.
Colors (ThemeTokens.colors)
| Token | Default | Description |
|-------|---------|-------------|
| primary | #007AFF | Main brand color (buttons, active states) |
| primaryDark | #0056CC | Dark variant (pressed states, gradients) |
| primaryLight | #4DA3FF | Light variant (highlights, tinted backgrounds) |
| secondary | #5856D6 | Accent color |
| secondaryDark | #3634A3 | Dark accent |
| secondaryLight | #8E8CF0 | Light accent |
| background | #FFFFFF | Screen background |
| surface | #F2F2F7 | Card / elevated surface |
| error | #FF3B30 | Destructive actions, error states |
| success | #34C759 | Confirmations, success states |
| warning | #FF9500 | Warnings, cautions |
| info | #5AC8FA | Informational |
| text | #000000 | Primary text |
| textSecondary | #6C6C70 | Secondary / descriptive text |
| textDisabled | #AEAEB2 | Disabled / placeholder text |
| border | #C6C6C8 | Input borders, dividers |
| divider | #E5E5EA | List separators |
| overlay | rgba(0,0,0,0.4) | Modal overlays |
Typography (ThemeTokens.typography)
| Token | Default | Description |
|-------|---------|-------------|
| fontFamily | System default | Regular weight font |
| fontFamilyMedium | System default | Medium weight font |
| fontFamilyBold | System default | Bold weight font |
| fontSize.xs | 11 | Extra-small (captions) |
| fontSize.sm | 13 | Small (secondary labels) |
| fontSize.base | 15 | Base body text |
| fontSize.md | 17 | Medium (subheadings) |
| fontSize.lg | 20 | Large (section titles) |
| fontSize.xl | 24 | Extra-large |
| fontSize.2xl | 28 | 2× extra-large |
| fontSize.3xl | 34 | Display / hero text |
| fontWeight.regular | '400' | — |
| fontWeight.medium | '500' | — |
| fontWeight.semibold | '600' | — |
| fontWeight.bold | '700' | — |
| lineHeightMultiplier | 1.4 | Multiplied by fontSize for lineHeight |
Spacing (ThemeTokens.spacing)
| Token | Default (px) |
|-------|-------------|
| xs | 4 |
| sm | 8 |
| md | 16 |
| lg | 24 |
| xl | 32 |
| 2xl | 48 |
Border Radius (ThemeTokens.radius)
| Token | Default (px) |
|-------|-------------|
| sm | 4 |
| md | 8 |
| lg | 16 |
| full | 9999 |
String Tokens
Accessed via useStrings(). Override any subset in your appConfig.strings.
| Group | Key | Default |
|-------|-----|---------|
| App | appName | 'App' |
| Onboarding | welcomeTitle | 'Welcome' |
| | welcomeSubtitle | 'Get started today' |
| | getStarted | 'Get Started' |
| Auth | signIn | 'Sign In' |
| | signUp | 'Sign Up' |
| | signOut | 'Sign Out' |
| | emailLabel | 'Email' |
| | passwordLabel | 'Password' |
| | forgotPassword | 'Forgot Password?' |
| Navigation | homeTab | 'Home' |
| | profileTab | 'Profile' |
| | settingsTab | 'Settings' |
| | paymentsTab | 'Payments' |
| Common | ok | 'OK' |
| | cancel | 'Cancel' |
| | confirm | 'Confirm' |
| | back | 'Back' |
| | next | 'Next' |
| | save | 'Save' |
| | delete | 'Delete' |
| | loading | 'Loading...' |
| | error | 'Something went wrong' |
| | retry | 'Try Again' |
| Errors | networkError | 'No internet connection' |
| | genericError | 'An unexpected error occurred' |
Callbacks Reference
Accessed via useCallbacks(). All callbacks default to no-ops — your app must override them with real implementations.
| Callback | Signature | Default | Description |
|----------|-----------|---------|-------------|
| onAuthSuccess | (user: User) => void | no-op | Called after successful authentication |
| onAuthSignOut | () => void | no-op | Called when the user signs out |
| trackEvent | (event: string, properties?: Record<string, unknown>) => void | no-op | Analytics event tracking |
| formatCurrency | (amount: number, currency: string) => string | '$0.00' placeholder | Format a monetary value |
| formatDate | (date: Date) => string | ISO string | Format a date for display |
| onDeepLink | (url: string) => void | no-op | Called when the app handles a deep link |
| onModuleActivated | (moduleName: string) => void | no-op | Called when a feature-flagged module becomes visible |
Example override:
callbacks: {
trackEvent: (event, properties) => {
Segment.track(event, { ...properties, appId: 'com.myapp' });
},
formatCurrency: (amount, currency) =>
new Intl.NumberFormat('de-DE', { style: 'currency', currency }).format(amount),
formatDate: (date) =>
new Intl.DateTimeFormat('de-DE').format(date),
onAuthSuccess: (user) => {
Sentry.setUser({ id: user.id, email: user.email });
},
},Feature Flags
How It Works
On mount, FeatureFlagProvider calls:
GET {apiBaseUrl}/flags?appId={appId}Expected response:
{
"module_home": true,
"module_profile": true,
"module_settings": true,
"module_payments": true
}The response is stored in AsyncStorage under @framework/feature_flags. On subsequent launches, the cached flags are used immediately while a fresh fetch happens in the background. If the API is unreachable (no network, timeout after 5 s), the cache is used as a fallback. If there is no cache, the built-in defaults apply.
Default Flag Values
| Flag | Key | Default | Description |
|------|-----|---------|-------------|
| Home module | module_home | true | Home tab |
| Profile module | module_profile | true | Profile tab |
| Settings module | module_settings | true | Settings tab |
| Payments module | module_payments | false | Payments tab — disabled by default, enable per app via API |
Enabling a Module Remotely
No app update required. Simply change your flags API response for a given appId:
// GET https://api.clienta.com/flags?appId=com.clienta.app
{
"module_payments": true
}The Payments tab will appear on next app launch (or after the background refresh completes).
Using Flags in Your Own Components
import { useFlag, useFlagContext } from '@org/framework';
// Single flag
function PayButton() {
const paymentsEnabled = useFlag('module_payments');
if (!paymentsEnabled) return null;
return <Button title="Pay Now" />;
}
// Full context
function Dashboard() {
const { flags, isLoading, refresh } = useFlagContext();
if (isLoading) return <ActivityIndicator />;
return <Text>Payments: {flags.module_payments ? 'ON' : 'OFF'}</Text>;
}Module System
How Modules Work
Each module is a self-contained screen registered in src/modules/registry.ts. The RootNavigator reads the flag map at runtime and renders only the tabs whose flags are true. Modules are lazy-loaded — the JS bundle for each screen is only evaluated when the user first visits that tab.
FLAG API → FeatureFlagProvider → RootNavigator → renders active tabs
↳ lazy-loads each module on first visitRegistered Modules
| Module | Flag Key | Tab Icon | Default |
|--------|----------|----------|---------|
| Home | module_home | home | ✅ On |
| Profile | module_profile | person | ✅ On |
| Settings | module_settings | settings | ✅ On |
| Payments | module_payments | credit-card | ❌ Off |
Tab order: Home → Payments → Profile → Settings (controlled by MODULE_ORDER in registry.ts).
Adding a New Module
- Create the screen —
src/modules/<name>/index.tsx(must have a default export)
// src/modules/notifications/index.tsx
import React from 'react';
import { View, Text } from 'react-native';
import { useTheme } from '../../theme/ThemeProvider';
export default function NotificationsScreen() {
const theme = useTheme();
return (
<View style={{ flex: 1, backgroundColor: theme.colors.background }}>
<Text style={{ color: theme.colors.text }}>Notifications</Text>
</View>
);
}- Add the flag key —
src/flags/types.ts
export type FlagKey =
| 'module_home'
| 'module_profile'
| 'module_settings'
| 'module_payments'
| 'module_notifications'; // ← add here
export const DEFAULT_FLAGS: Record<FlagKey, boolean> = {
// ...existing flags...
module_notifications: false, // ← add here, default off
};- Add the string key —
src/strings/defaults.ts
export const DEFAULT_STRINGS: StringTokens = {
// ...
notificationsTab: 'Notifications',
};- Register the module —
src/modules/registry.ts
export const MODULE_REGISTRY: Record<ModuleName, ModuleDescriptor> = {
// ...existing modules...
notifications: {
flag: 'module_notifications',
// eslint-disable-next-line @typescript-eslint/no-require-imports
load: () => Promise.resolve(require('./notifications')),
tabLabelKey: 'notificationsTab',
tabIconName: 'bell',
},
};
export const MODULE_ORDER: ModuleName[] = [
'home', 'payments', 'notifications', 'profile', 'settings'
];- Publish & enable — Bump
@org/frameworkversion, update client apps, then enable via the flags API perappId.
Why
require()instead ofimport()? Dynamicimport('./notifications')loses its file-relative context when Metro resolves modules from afile:-linked monorepo package.require()is resolved at bundle-graph build time with the correct file context. Wrapping inPromise.resolve()preserves the() => Promise<{ default: ComponentType }>signature expected byReact.lazy().
Provider Hooks
All hooks must be called from within a component tree that is a descendant of <FrameworkApp>.
| Hook | Returns | Description |
|------|---------|-------------|
| useTheme() | ThemeTokens | Full merged theme (colors, typography, spacing, radius) |
| useStrings() | StringTokens | Full merged strings |
| useCallbacks() | FrameworkCallbacks | All callback functions |
| useFlagContext() | { flags: Record<FlagKey, boolean>, isLoading: boolean, refresh: () => Promise<void> } | Full flag map + refresh |
| useFlag(key) | boolean | Single flag value |
import { useTheme, useStrings, useFlag } from '@org/framework';
function MyComponent() {
const theme = useTheme();
const strings = useStrings();
const paymentsEnabled = useFlag('module_payments');
return (
<View style={{ backgroundColor: theme.colors.surface, padding: theme.spacing.md }}>
<Text style={{ color: theme.colors.primary }}>{strings.appName}</Text>
{paymentsEnabled && <PaymentsWidget />}
</View>
);
}Peer Dependencies
These packages must be installed in the consuming app. They are not bundled in @org/framework.
| Package | Version |
|---------|---------|
| expo | >=54.0.0 |
| react | >=19.1.0 |
| react-native | >=0.81.0 |
| @react-navigation/native | >=7.0.0 |
| @react-navigation/bottom-tabs | >=7.0.0 |
| @react-navigation/stack | >=7.0.0 |
| @react-native-async-storage/async-storage | >=2.0.0 |
Development Setup
Prerequisites
- Node.js 20.19+ (
node --version) - npm 10+ (
npm --version) - Expo Go on your test device (iOS App Store / Google Play)
- Both devices on the same Wi-Fi network as your Mac
1. Clone and install
# Framework
cd framework
npm install --legacy-peer-deps
# Client A
cd ../apps/client-a
npm install --legacy-peer-deps
# Client B
cd ../apps/client-b
npm install --legacy-peer-deps2. Run on device
cd apps/client-a
npx expo start --clear- Android — Press
aor scan the QR code with your Android camera - iOS — Press
ior scan the QR code with the iPhone Camera app → tap "Open in Expo Go"
Both platforms are served from the same Metro bundler session simultaneously.
3. Hot reload
Because the framework is a file: dependency, changes to any file in framework/src/ are reflected in both client apps immediately — no re-link or rebuild required. Metro watches framework/src/ via watchFolders in each app's metro.config.js.
Metro Monorepo Notes
The metro.config.js in each client app uses a custom resolver.resolveRequest to force singleton packages (react, react-native, @react-navigation/*) to always resolve from the app's node_modules, not the framework's. This prevents duplicate React instances and "Invalid hook call" errors.
Client App Examples
Client A — Red Brand (com.clienta.app)
export const appConfig: FrameworkConfig = {
appId: 'com.clienta.app',
apiBaseUrl: 'https://api.clienta.com',
theme: {
colors: {
primary: '#E63946', // Brand red
primaryDark: '#C1121F',
primaryLight: '#FF6B6B',
secondary: '#457B9D',
},
},
strings: {
appName: 'Client A',
welcomeTitle: 'Welcome to Client A',
welcomeSubtitle: 'Your trusted partner',
},
callbacks: {
formatCurrency: (amount, currency) =>
new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount),
formatDate: (date) =>
new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(date),
},
};Client B — Green Brand (com.clientb.app)
export const appConfig: FrameworkConfig = {
appId: 'com.clientb.app',
apiBaseUrl: 'https://api.clientb.com',
theme: {
colors: {
primary: '#2D6A4F', // Brand green
primaryDark: '#1B4332',
primaryLight: '#52B788',
},
},
strings: {
appName: 'Client B',
welcomeTitle: 'Welcome to Client B',
welcomeSubtitle: 'Grow with us',
paymentsTab: 'Wallet', // Custom tab label
},
callbacks: {
// GB locale formatting
formatCurrency: (amount, currency) =>
new Intl.NumberFormat('en-GB', { style: 'currency', currency }).format(amount),
},
};Both apps share the same framework version, navigation, and module logic. The only differences are colours, copy, and callback implementations.
Scripts
Run these from the framework/ directory:
| Script | Command | Description |
|--------|---------|-------------|
| Type check | npm run typecheck | Run tsc --noEmit against all source files |
| Build | npm run build | Compile TypeScript to dist/ via react-native-builder-bob |
| Lint | npm run lint | Run ESLint on src/ |
Tech Stack
| Technology | Version | Role | |------------|---------|------| | Expo SDK | 54 | Managed workflow, native module access | | React Native | 0.81.5 | Core framework | | React | 19.1.0 | UI runtime | | expo-router | 6.x | File-based routing in client apps | | React Navigation | 7.x | Bottom-tab + stack navigators | | AsyncStorage | 2.x | Feature flag offline cache | | TypeScript | 5.3 | Full type safety across framework + apps | | Metro | bundled with Expo | JS bundler with monorepo resolver config |
License
UNLICENSED — Internal use only. Not for public distribution.
