Back to Journal
Mobile/Frontend

Complete Guide to Cross-Platform Architecture with Typescript

A comprehensive guide to implementing Cross-Platform Architecture using Typescript, covering architecture, code examples, and production-ready patterns.

Muneer Puthiya Purayil 18 min read

Building cross-platform applications with TypeScript offers the broadest reach of any technology choice: a single language powering iOS, Android, web, desktop, and server-side logic. This guide covers production-ready patterns for cross-platform TypeScript development, from project structure and shared business logic through platform-specific rendering and deployment.

Project Architecture

A well-structured cross-platform TypeScript project separates shared logic from platform-specific implementations using a monorepo with clear package boundaries.

1cross-platform-app/
2├── packages/
3 ├── shared/ # Pure TypeScript — no platform deps
4 ├── domain/ # Business rules, validation
5 ├── api/ # API client, types
6 ├── state/ # State management
7 └── utils/ # Shared utilities
8 ├── ui-mobile/ # React Native components
9 ├── ui-web/ # React/Next.js components
10 └── platform/ # Platform abstraction interfaces
11├── apps/
12 ├── mobile/ # React Native entry point
13 ├── web/ # Next.js entry point
14 └── desktop/ # Electron/Tauri entry point
15├── tsconfig.base.json
16├── package.json
17└── turbo.json
18 

The critical rule: packages/shared must have zero platform-specific imports. It contains only pure TypeScript that runs anywhere.

json
1// tsconfig.base.json
2{
3 "compilerOptions": {
4 "strict": true,
5 "target": "ES2022",
6 "module": "ESNext",
7 "moduleResolution": "bundler",
8 "declaration": true,
9 "declarationMap": true,
10 "sourceMap": true,
11 "composite": true,
12 "paths": {
13 "@app/shared/*": ["./packages/shared/*"],
14 "@app/platform": ["./packages/platform/index.ts"]
15 }
16 }
17}
18 

Shared Domain Logic

Business rules belong in the shared package. They are the highest-value code sharing target.

typescript
1// packages/shared/domain/account.ts
2export interface Account {
3 id: string;
4 name: string;
5 balance: number;
6 currency: Currency;
7 type: AccountType;
8 status: AccountStatus;
9}
10 
11export type Currency = 'USD' | 'EUR' | 'GBP' | 'AED';
12export type AccountType = 'checking' | 'savings' | 'investment';
13export type AccountStatus = 'active' | 'frozen' | 'closed';
14 
15export function canTransfer(from: Account, amount: number): TransferValidation {
16 if (from.status !== 'active') {
17 return { valid: false, reason: `Account ${from.id} is ${from.status}` };
18 }
19 if (from.type === 'investment') {
20 return { valid: false, reason: 'Cannot transfer from investment accounts directly' };
21 }
22 if (amount <= 0) {
23 return { valid: false, reason: 'Transfer amount must be positive' };
24 }
25 if (amount > from.balance) {
26 return { valid: false, reason: `Insufficient funds: ${from.balance} < ${amount}` };
27 }
28 return { valid: true };
29}
30 
31export function formatCurrency(amount: number, currency: Currency): string {
32 return new Intl.NumberFormat('en-US', {
33 style: 'currency',
34 currency,
35 minimumFractionDigits: 2,
36 }).format(amount / 100); // Store as cents
37}
38 
39interface TransferValidation {
40 valid: boolean;
41 reason?: string;
42}
43 
typescript
1// packages/shared/domain/validators.ts
2export interface ValidationResult {
3 valid: boolean;
4 errors: FieldError[];
5}
6 
7export interface FieldError {
8 field: string;
9 message: string;
10 code: string;
11}
12 
13export function validateEmail(email: string): FieldError | null {
14 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
15 if (!emailRegex.test(email)) {
16 return { field: 'email', message: 'Invalid email format', code: 'INVALID_EMAIL' };
17 }
18 return null;
19}
20 
21export function validateTransferRequest(request: TransferRequest): ValidationResult {
22 const errors: FieldError[] = [];
23 
24 if (!request.fromAccountId) {
25 errors.push({ field: 'fromAccountId', message: 'Source account is required', code: 'REQUIRED' });
26 }
27 if (!request.toAccountId) {
28 errors.push({ field: 'toAccountId', message: 'Destination account is required', code: 'REQUIRED' });
29 }
30 if (request.fromAccountId === request.toAccountId) {
31 errors.push({ field: 'toAccountId', message: 'Cannot transfer to the same account', code: 'SAME_ACCOUNT' });
32 }
33 if (request.amount <= 0) {
34 errors.push({ field: 'amount', message: 'Amount must be positive', code: 'INVALID_AMOUNT' });
35 }
36 
37 return { valid: errors.length === 0, errors };
38}
39 
40interface TransferRequest {
41 fromAccountId: string;
42 toAccountId: string;
43 amount: number;
44 currency: Currency;
45 note?: string;
46}
47 

Platform Abstraction Layer

Define interfaces for every platform-dependent capability. Each platform provides its implementation.

typescript
1// packages/platform/interfaces.ts
2export interface PlatformServices {
3 storage: SecureStorage;
4 biometrics: BiometricService;
5 notifications: NotificationService;
6 haptics: HapticService;
7 share: ShareService;
8}
9 
10export interface SecureStorage {
11 get(key: string): Promise<string | null>;
12 set(key: string, value: string): Promise<void>;
13 delete(key: string): Promise<void>;
14 clear(): Promise<void>;
15}
16 
17export interface BiometricService {
18 isAvailable(): Promise<boolean>;
19 authenticate(options: { reason: string }): Promise<boolean>;
20}
21 
22export interface NotificationService {
23 requestPermission(): Promise<'granted' | 'denied'>;
24 getToken(): Promise<string | null>;
25 onNotification(handler: (notification: AppNotification) => void): () => void;
26}
27 
28export interface HapticService {
29 impact(style: 'light' | 'medium' | 'heavy'): void;
30 notification(type: 'success' | 'warning' | 'error'): void;
31}
32 
33export interface ShareService {
34 share(content: ShareContent): Promise<boolean>;
35}
36 
37export interface AppNotification {
38 id: string;
39 title: string;
40 body: string;
41 data?: Record<string, unknown>;
42}
43 
44export interface ShareContent {
45 title?: string;
46 message: string;
47 url?: string;
48}
49 
typescript
1// packages/platform/mobile.ts — React Native implementation
2import * as SecureStore from 'expo-secure-store';
3import * as LocalAuthentication from 'expo-local-authentication';
4import * as Haptics from 'expo-haptics';
5import * as Sharing from 'expo-sharing';
6import type { PlatformServices } from './interfaces';
7 
8export const mobilePlatform: PlatformServices = {
9 storage: {
10 async get(key) { return SecureStore.getItemAsync(key); },
11 async set(key, value) { await SecureStore.setItemAsync(key, value); },
12 async delete(key) { await SecureStore.deleteItemAsync(key); },
13 async clear() { /* iterate known keys */ },
14 },
15 biometrics: {
16 async isAvailable() {
17 return LocalAuthentication.hasHardwareAsync();
18 },
19 async authenticate({ reason }) {
20 const result = await LocalAuthentication.authenticateAsync({
21 promptMessage: reason,
22 });
23 return result.success;
24 },
25 },
26 haptics: {
27 impact(style) {
28 const map = {
29 light: Haptics.ImpactFeedbackStyle.Light,
30 medium: Haptics.ImpactFeedbackStyle.Medium,
31 heavy: Haptics.ImpactFeedbackStyle.Heavy,
32 };
33 Haptics.impactAsync(map[style]);
34 },
35 notification(type) {
36 const map = {
37 success: Haptics.NotificationFeedbackType.Success,
38 warning: Haptics.NotificationFeedbackType.Warning,
39 error: Haptics.NotificationFeedbackType.Error,
40 };
41 Haptics.notificationAsync(map[type]);
42 },
43 },
44 notifications: {
45 async requestPermission() { return 'granted'; },
46 async getToken() { return null; },
47 onNotification() { return () => {}; },
48 },
49 share: {
50 async share(content) {
51 await Sharing.shareAsync(content.url ?? content.message);
52 return true;
53 },
54 },
55};
56 

Shared API Client with Type Safety

typescript
1// packages/shared/api/client.ts
2type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
3 
4interface RequestConfig {
5 method: HttpMethod;
6 path: string;
7 body?: unknown;
8 params?: Record<string, string>;
9 headers?: Record<string, string>;
10}
11 
12export class ApiClient {
13 constructor(
14 private baseUrl: string,
15 private getAuthToken: () => Promise<string | null>,
16 ) {}
17 
18 private async request<T>(config: RequestConfig): Promise<T> {
19 const url = new URL(config.path, this.baseUrl);
20 if (config.params) {
21 Object.entries(config.params).forEach(([k, v]) => url.searchParams.set(k, v));
22 }
23 
24 const token = await this.getAuthToken();
25 const response = await fetch(url.toString(), {
26 method: config.method,
27 headers: {
28 'Content-Type': 'application/json',
29 ...(token ? { Authorization: `Bearer ${token}` } : {}),
30 ...config.headers,
31 },
32 body: config.body ? JSON.stringify(config.body) : undefined,
33 });
34 
35 if (!response.ok) {
36 const error = await response.json().catch(() => ({ message: 'Unknown error' }));
37 throw new ApiError(response.status, error.message, error.code);
38 }
39 
40 return response.json();
41 }
42 
43 // Type-safe API methods
44 accounts = {
45 list: () => this.request<Account[]>({ method: 'GET', path: '/api/accounts' }),
46 get: (id: string) => this.request<Account>({ method: 'GET', path: `/api/accounts/${id}` }),
47 };
48 
49 transactions = {
50 list: (accountId: string, params?: { limit?: number; cursor?: string }) =>
51 this.request<PaginatedResponse<Transaction>>({
52 method: 'GET',
53 path: `/api/accounts/${accountId}/transactions`,
54 params: params as Record<string, string>,
55 }),
56 };
57 
58 transfers = {
59 create: (data: CreateTransferInput) =>
60 this.request<Transfer>({ method: 'POST', path: '/api/transfers', body: data }),
61 };
62}
63 
64export class ApiError extends Error {
65 constructor(
66 public readonly status: number,
67 message: string,
68 public readonly code?: string,
69 ) {
70 super(message);
71 this.name = 'ApiError';
72 }
73}
74 

Need a second opinion on your mobile/frontend architecture?

I run free 30-minute strategy calls for engineering teams tackling this exact problem.

Book a Free Call

Shared State Management

typescript
1// packages/shared/state/auth-store.ts
2import { create } from 'zustand';
3 
4interface User {
5 id: string;
6 email: string;
7 name: string;
8}
9 
10interface AuthState {
11 user: User | null;
12 token: string | null;
13 isAuthenticated: boolean;
14 setAuth: (user: User, token: string) => void;
15 clearAuth: () => void;
16}
17 
18export const useAuthStore = create<AuthState>((set) => ({
19 user: null,
20 token: null,
21 isAuthenticated: false,
22 setAuth: (user, token) => set({ user, token, isAuthenticated: true }),
23 clearAuth: () => set({ user: null, token: null, isAuthenticated: false }),
24}));
25 
26// packages/shared/state/accounts-store.ts
27interface AccountsState {
28 accounts: Account[];
29 selectedAccountId: string | null;
30 isLoading: boolean;
31 error: string | null;
32 setAccounts: (accounts: Account[]) => void;
33 selectAccount: (id: string) => void;
34 setLoading: (loading: boolean) => void;
35 setError: (error: string | null) => void;
36}
37 
38export const useAccountsStore = create<AccountsState>((set) => ({
39 accounts: [],
40 selectedAccountId: null,
41 isLoading: false,
42 error: null,
43 setAccounts: (accounts) => set({ accounts, isLoading: false, error: null }),
44 selectAccount: (id) => set({ selectedAccountId: id }),
45 setLoading: (isLoading) => set({ isLoading }),
46 setError: (error) => set({ error, isLoading: false }),
47}));
48 

Shared React Hooks

Custom hooks encapsulate data fetching logic shared across platforms.

typescript
1// packages/shared/hooks/use-accounts.ts
2import { useQuery } from '@tanstack/react-query';
3import { useApiClient } from './use-api-client';
4 
5export function useAccounts() {
6 const api = useApiClient();
7 
8 return useQuery({
9 queryKey: ['accounts'],
10 queryFn: () => api.accounts.list(),
11 staleTime: 30_000,
12 });
13}
14 
15export function useAccount(id: string) {
16 const api = useApiClient();
17 
18 return useQuery({
19 queryKey: ['accounts', id],
20 queryFn: () => api.accounts.get(id),
21 enabled: !!id,
22 });
23}
24 
25// packages/shared/hooks/use-transfer.ts
26import { useMutation, useQueryClient } from '@tanstack/react-query';
27import { validateTransferRequest } from '@app/shared/domain/validators';
28 
29export function useCreateTransfer() {
30 const api = useApiClient();
31 const queryClient = useQueryClient();
32 
33 return useMutation({
34 mutationFn: async (input: CreateTransferInput) => {
35 const validation = validateTransferRequest(input);
36 if (!validation.valid) {
37 throw new ValidationError(validation.errors);
38 }
39 return api.transfers.create(input);
40 },
41 onSuccess: () => {
42 queryClient.invalidateQueries({ queryKey: ['accounts'] });
43 queryClient.invalidateQueries({ queryKey: ['transactions'] });
44 },
45 });
46}
47 

Platform-Specific UI

Mobile and web share hooks and business logic but render with platform-appropriate components.

typescript
1// apps/mobile/src/screens/AccountsScreen.tsx
2import { FlatList, RefreshControl } from 'react-native';
3import { useAccounts } from '@app/shared/hooks/use-accounts';
4import { AccountCard } from '@app/ui-mobile/AccountCard';
5 
6export function AccountsScreen() {
7 const { data: accounts, isLoading, refetch } = useAccounts();
8 
9 return (
10 <FlatList
11 data={accounts}
12 keyExtractor={(item) => item.id}
13 renderItem={({ item }) => <AccountCard account={item} />}
14 refreshControl={
15 <RefreshControl refreshing={isLoading} onRefresh={refetch} />
16 }
17 />
18 );
19}
20 
21// apps/web/src/app/accounts/page.tsx
22import { useAccounts } from '@app/shared/hooks/use-accounts';
23 
24export default function AccountsPage() {
25 const { data: accounts, isLoading } = useAccounts();
26 
27 if (isLoading) return <div className="animate-pulse">Loading...</div>;
28 
29 return (
30 <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
31 {accounts?.map((account) => (
32 <div key={account.id} className="bg-white rounded-lg shadow p-6">
33 <h3 className="font-semibold text-lg">{account.name}</h3>
34 <p className="text-2xl font-bold mt-2">
35 {formatCurrency(account.balance, account.currency)}
36 </p>
37 <span className="text-sm text-gray-500 capitalize">{account.type}</span>
38 </div>
39 ))}
40 </div>
41 );
42}
43 

Testing Shared Code

Shared packages are tested once and verified across all platforms.

typescript
1// packages/shared/domain/__tests__/account.test.ts
2import { describe, it, expect } from 'vitest';
3import { canTransfer, formatCurrency } from '../account';
4 
5describe('canTransfer', () => {
6 const activeAccount: Account = {
7 id: '1', name: 'Checking', balance: 10000,
8 currency: 'USD', type: 'checking', status: 'active',
9 };
10 
11 it('allows valid transfer', () => {
12 expect(canTransfer(activeAccount, 5000)).toEqual({ valid: true });
13 });
14 
15 it('rejects insufficient funds', () => {
16 const result = canTransfer(activeAccount, 15000);
17 expect(result.valid).toBe(false);
18 expect(result.reason).toContain('Insufficient funds');
19 });
20 
21 it('rejects frozen accounts', () => {
22 const frozen = { ...activeAccount, status: 'frozen' as const };
23 expect(canTransfer(frozen, 100).valid).toBe(false);
24 });
25 
26 it('rejects investment account transfers', () => {
27 const investment = { ...activeAccount, type: 'investment' as const };
28 expect(canTransfer(investment, 100).valid).toBe(false);
29 });
30});
31 
32describe('formatCurrency', () => {
33 it('formats USD amounts', () => {
34 expect(formatCurrency(150099, 'USD')).toBe('$1,500.99');
35 });
36 
37 it('formats EUR amounts', () => {
38 expect(formatCurrency(100000, 'EUR')).toBe('€1,000.00');
39 });
40});
41 

Conclusion

TypeScript enables a genuinely full-stack cross-platform architecture where business logic, API clients, state management, and validation run identically across iOS, Android, web, and desktop. The platform abstraction layer provides the clean boundary between shared code and platform-specific capabilities. Combined with React Native for mobile and Next.js for web, TypeScript offers the broadest platform reach with the strongest type safety guarantees.

Focus your sharing effort on the packages that provide the highest ROI: domain logic (98% shareable), API client (100%), and state management (95%). Accept that navigation and certain UI components will be platform-specific, and invest in the platform abstraction layer to keep native capabilities accessible without polluting shared code.

FAQ

Need expert help?

Building with mobile/frontend?

I help teams ship production-grade systems. From architecture review to hands-on builds.

Muneer Puthiya Purayil

SaaS Architect & AI Systems Engineer. 10+ years shipping production infrastructure across fintech, automotive, e-commerce, and healthcare.

Engage

Start a
Conversation.

For teams building at scale: SaaS platforms, agentic AI systems, and enterprise mobile infrastructure. Scope and fit are evaluated before any engagement begins.

Limited availability · Q3 / Q4 2026