packages/
core/ # Shared business logic, types, utilities
ui/ # Platform-agnostic components
web/ # Next.js/Vite web application
native/ # React Native application
config/ # Shared TSConfig, ESLint, Jest, Tailwind
### Step 2: Abstract Platform Boundaries
Shared components must never import platform-specific APIs directly. Instead, inject platform capabilities through abstract interfaces resolved at build time.
```typescript
// packages/ui/src/hooks/usePlatform.ts
export interface PlatformAPI {
getDeviceType: () => 'mobile' | 'tablet' | 'desktop';
triggerHaptic: () => void;
openNativeModal: (config: ModalConfig) => void;
}
// Platform-specific implementations live in .native.ts and .web.ts files
// TypeScript resolution swaps these automatically
Step 3: Implement Adaptive Component Architecture
Components should render platform-specific primitives while sharing layout, state, and styling logic. Use conditional rendering based on resolved platform modules, not runtime checks.
// packages/ui/src/components/Button/index.tsx
import { Platform } from 'react-native';
import { PlatformAPI } from '../../hooks/usePlatform';
import { getPlatformAPI } from './platform'; // Resolves to .web.ts or .native.ts
import { styles } from './styles';
interface ButtonProps {
label: string;
onPress: () => void;
variant?: 'primary' | 'secondary';
}
export function Button({ label, onPress, variant = 'primary' }: ButtonProps) {
const platform = getPlatformAPI();
const isWeb = Platform.OS === 'web';
const baseStyle = isWeb ? styles.webButton : styles.nativeButton;
const variantStyle = variant === 'primary' ? styles.primary : styles.secondary;
return (
<button
style={{ ...baseStyle, ...variantStyle }}
onPress={() => {
onPress();
if (!isWeb) platform.triggerHaptic();
}}
>
{label}
</button>
);
}
TypeScript must resolve platform-specific files automatically. Configure tsconfig with module resolution aliases and extension priority.
// packages/config/tsconfig.base.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@ui/*": ["../ui/src/*"],
"@core/*": ["../core/src/*"]
},
"moduleResolution": "node",
"resolveJsonModule": true,
"esModuleInterop": true,
"jsx": "react-jsx"
}
}
Add platform extension resolution to bundler configuration:
// packages/native/metro.config.js
module.exports = {
resolver: {
sourceExts: ['jsx', 'js', 'ts', 'tsx', 'native.ts', 'native.tsx'],
platform: 'native'
}
};
// packages/web/vite.config.js
export default {
resolve: {
alias: {
'.native.ts': '.web.ts',
'.native.tsx': '.web.tsx'
}
}
};
Testing must validate behavior across platforms without duplicating test suites. Use a unified test harness that mocks platform APIs and asserts against shared contracts.
// packages/ui/src/components/Button/__tests__/Button.test.tsx
import { render, fireEvent } from '@testing-library/react';
import { Button } from '../index';
import { mockPlatformAPI } from '../../../__mocks__/platform';
jest.mock('../platform', () => mockPlatformAPI);
describe('Button Component', () => {
it('calls onPress and triggers haptic on native', () => {
const onPress = jest.fn();
const { getByText } = render(<Button label="Submit" onPress={onPress} />);
fireEvent.press(getByText('Submit'));
expect(onPress).toHaveBeenCalledTimes(1);
expect(mockPlatformAPI.triggerHaptic).toHaveBeenCalled();
});
it('applies correct variant styles', () => {
const { getByText } = render(<Button label="Submit" variant="secondary" />);
expect(getByText('Submit')).toHaveStyle({ backgroundColor: '#e5e7eb' });
});
});
Architecture Decisions
- Separation of Concerns: Keep business logic, data fetching, and state management in
packages/core. UI components in packages/ui consume core modules but never mutate them directly.
- Platform Extension Resolution: Use
.web.ts and .native.ts file suffixes. Never use if (Platform.OS === 'web') for imports. Resolution happens at compile time, eliminating runtime branching.
- Styling Abstraction: Use CSS variables or design tokens for web,
StyleSheet.create for native, and a shared token layer (packages/config/tokens.ts) that maps to both. Avoid platform-specific CSS-in-JS libraries in shared code.
- State Management Boundaries: Shared state lives in core modules using lightweight stores (Zustand, Jotai). Platform-specific state (navigation, keyboard, orientation) remains in platform apps.
- Dependency Injection for Native Modules: Never import
react-native modules directly in shared components. Abstract them behind interfaces resolved via platform extension files.
Pitfall Guide
1. Over-Engineering the Abstraction Layer
Creating excessive adapter layers to "future-proof" components introduces indirection without measurable ROI. Mitigation: Abstract only when platform APIs diverge in behavior or signature. Keep adapters thin and deterministic.
Sharing components does not mean enforcing identical interactions. iOS users expect swipe-to-dismiss; Android users expect back-button navigation; web users expect hover states. Mitigation: Define interaction contracts per platform in design tokens and validate against platform HIGs during code review.
Importing react-native, window, or navigator directly into shared components breaks bundler resolution and forces runtime checks. Mitigation: Enforce lint rules that block platform-specific imports in packages/ui and packages/core. Use dependency injection exclusively.
Shared components can introduce unnecessary re-renders on native due to web-optimized dependency arrays or excessive context providers. Mitigation: Profile each platform independently using React Native Debugger and React DevTools. Memoize only where benchmarks show measurable impact.
5. Inadequate Accessibility Testing
Web accessibility (ARIA, focus management) differs from native accessibility (VoiceOver, TalkBack). Shared components often pass web audits but fail native screen readers. Mitigation: Implement platform-specific accessibility tests in CI. Use react-native-testing-library and axe-core in parallel pipelines.
6. Poor Versioning and Dependency Synchronization
When packages/ui updates, web and native apps may consume mismatched versions if workspace dependencies aren't pinned. Mitigation: Use pnpm workspace protocol ("ui": "workspace:*") and enforce version alignment in CI. Run pnpm dedupe in pre-commit hooks.
Production Bundle
Action Checklist
Decision Matrix
| Strategy | Ideal Team Size | Performance Criticality | Release Cadence | Maintenance Overhead |
|---|
| Siloed Native + Web | < 5 per platform | High | Slow | High |
| Single Cross-Platform Framework | 5-10 | Medium | Fast | Medium |
| Shared Component Architecture | 10-25 | High | Fast | Low |
| Hybrid (Shared Logic + Native UI) | 15-30 | Very High | Medium | Medium |
Choose shared component architecture when you require high performance, fast release cadence, and have sufficient engineering capacity to maintain a unified workspace. Avoid single-framework approaches when platform-specific animations, gestures, or native modules are core to the product experience.
Configuration Template
Copy this into your repository root to establish a production-ready shared component workspace.
// package.json
{
"name": "cross-platform-workspace",
"private": true,
"scripts": {
"dev": "turbo run dev",
"build": "turbo run build",
"test": "turbo run test",
"lint": "turbo run lint",
"typecheck": "turbo run typecheck"
},
"devDependencies": {
"turbo": "^1.13.0",
"typescript": "^5.4.0"
}
}
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"test": {
"dependsOn": ["build"]
},
"lint": {},
"typecheck": {
"dependsOn": ["^typecheck"]
}
}
}
// packages/config/tsconfig.base.json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowJs": true,
"checkJs": true,
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noEmit": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"baseUrl": ".",
"paths": {
"@ui/*": ["../ui/src/*"],
"@core/*": ["../core/src/*"],
"@config/*": ["../config/*"]
}
},
"include": ["../**/*.ts", "../**/*.tsx"],
"exclude": ["node_modules", "dist"]
}
Quick Start Guide
- Initialize Workspace: Run
pnpm init at the root, create packages/core, packages/ui, packages/web, packages/native, and packages/config. Add "workspaces": ["packages/*"] to root package.json.
- Configure Resolution: Install
typescript and turbo. Copy the tsconfig.base.json and turbo.json templates. Set up pnpm-workspace.yaml to enforce strict dependency hoisting.
- Extract First Component: Move a high-frequency UI primitive (e.g.,
Button, Card, Input) into packages/ui. Create index.web.ts and index.native.ts for platform-specific adapters. Wire TypeScript paths to resolve automatically.
- Validate & Iterate: Run
pnpm turbo run test to verify cross-platform behavior. Add lint rules blocking platform imports in shared code. Gradually migrate remaining components, measuring regression rates and build times to confirm ROI.
Cross-platform component sharing is not a framework migration. It is an architectural discipline that enforces deterministic boundaries, compiles platform differences away, and treats UI primitives as shared infrastructure. Teams that implement this pattern consistently ship faster, reduce regression overhead, and maintain design fidelity without sacrificing native performance.