Mobile Micro Frontends with React Native Expo: WebView Architecture and Real-World Implementation
How to implement micro frontend architecture in mobile apps with React Native Expo and WebViews, covering performance, proven patterns, and bundlers.
Mobile teams increasingly face the challenge of integrating multiple web-based services from different teams, each with their own deployment cycles and tech stacks. Tight delivery timelines compound this complexity when web teams can’t pause their development to assist with mobile integration.
The default worth reaching for is a native React Native shell that embeds each web app in its own WebView. Every web team keeps its own bundler and release cadence. The mobile side pays for that in memory pressure, bundle size, and navigation glue that has to be written by hand.
Mobile Micro Frontend Series
This is Part 1 of the mobile micro frontends series:
- Part 1 (You are here): Architecture fundamentals and WebView integration patterns
- Part 2: WebView communication patterns and service integration
- Part 3: Multi-channel architecture and production optimization
Part 2 covers the communication bridge between WebViews and native code. Part 3 covers multi-channel architecture and production optimization.
Why Mobile Micro Frontends?
Traditional mobile app development has a fundamental problem: native code deployment is slow. App store reviews, user update adoption, and coordinating releases across multiple teams create bottlenecks that web developers solved years ago.
Common constraints in this scenario include:
- 5 different web teams with existing React/Vue/Angular applications
- Weekly web deployments vs monthly mobile releases
- A/B testing requirements that cannot wait for app updates
- Compliance features that need immediate deployment capability
The approach that fits these constraints is to embed web-based micro frontends in the React Native app using WebViews.
Architecture Overview
Here’s the high-level architecture:
The native app acts as a shell that:
- Handles authentication and session management
- Provides native functionality (camera, biometrics, etc.)
- Manages navigation between micro frontends
- Implements a communication bridge between WebViews and native code
Alternatives Considered
WebViews win here on team autonomy and release speed. Three bundler-level alternatives are worth knowing before you settle on that default.
Option 1: Re.Pack with Module Federation
Re.Pack is Callstack’s solution for bringing Module Federation to React Native. It’s essentially webpack’s Module Federation running in React Native.
Example setup:
// Re.Pack config: Module Federation through Re.Pack's own plugin
const Repack = require('@callstack/repack');
module.exports = {
plugins: [
new Repack.plugins.ModuleFederationPlugin({
name: 'host',
remotes: {
booking: 'booking@http://localhost:3001/remoteEntry.js',
shopping: 'shopping@http://localhost:3002/remoteEntry.js',
},
shared: {
react: { singleton: true },
'react-native': { singleton: true }
}
})
]
};
Reasons against it:
- Complexity: Requires significant changes to existing webpack configurations
- Team coordination: Every team has to adopt Re.Pack at the same time
- Debugging: Module Federation failures in React Native are harder to trace than in a browser build
- Bundle overhead: The federation runtime and shared scope add weight to the host bundle
When to use Re.Pack:
- You’re starting fresh with a new project
- All teams can coordinate on the same bundler
- You need true runtime module sharing
- You’re building a “super app” with multiple independent teams
Option 2: Rspack with React Native
Rspack is a Rust-based bundler with a webpack-compatible configuration surface. It is another candidate for micro frontend builds.
Example setup:
// rspack.config.mjs
import { ModuleFederationPlugin } from '@module-federation/enhanced/rspack';
export default {
entry: './src/index.tsx',
module: {
rules: [
{
test: /\.tsx$/,
use: {
loader: 'builtin:swc-loader',
options: {
jsc: {
parser: {
syntax: 'typescript',
tsx: true
},
transform: {
react: {
runtime: 'automatic'
}
}
}
}
}
}
]
},
plugins: [
new ModuleFederationPlugin({
name: 'micro-frontend',
filename: 'remoteEntry.js',
exposes: {
'./App': './src/App.tsx'
}
})
]
};
Reasons against it:
- React Native support: Rspack itself targets web builds; React Native needs Re.Pack layered on top of it
- Ecosystem maturity: Fewer plugins and loaders compared to webpack
- Team adoption: Retraining every web team on a new bundler
- Production stability: A newer tool is a risk on a tight timeline
When to use Rspack:
- You’re building web-only micro frontends
- Build performance is critical, and the Rust core with the SWC transform is worth the switch
- You can afford to be an early adopter
- Your teams are comfortable with Rust-based tooling
Option 3: Vite + React Native
Vite is also worth evaluating for micro frontend builds, leveraging its fast HMR and modern build system.
Example setup:
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
utils: ['lodash', 'date-fns']
}
}
}
},
server: {
cors: true
}
});
Reasons against it:
- Module Federation: Vite needs a community plugin for Module Federation, and the Rollup output differs from webpack’s
- Production builds: The dev-server speed advantage does not carry over to the Rollup production build
- Plugin ecosystem: Fewer plugins for webpack-specific needs
- Team familiarity: Teams already running webpack pay a migration cost for little gain
When to use Vite:
- You’re building modern web applications
- Development speed is more important than production optimization
- You don’t need complex Module Federation
- Your teams prefer modern tooling
Option 4: Hybrid Approach (The Recommended Path)
After evaluating all options, a hybrid approach combines the strengths of each:
Why this holds up:
- Team autonomy: Each team uses its preferred bundler
- Gradual migration: Teams move to better tools on their own schedule
- Risk mitigation: A failure in one build pipeline leaves the others working
- Performance: Each team optimizes its own output
Implementation
Setting Up the WebView Architecture
Starting with Expo’s WebView quickly surfaces a first challenge. The initial implementation looks deceptively simple:
import React from 'react';
import { WebView } from 'react-native-webview';
import { SafeAreaView } from 'react-native-safe-area-context';
export function MicroFrontendContainer({ url }: { url: string }) {
return (
<SafeAreaView style={{ flex: 1 }}>
<WebView
source={{ uri: url }}
style={{ flex: 1 }}
/>
</SafeAreaView>
);
}
This holds up in development. On memory-constrained Android devices, the same WebView can render a blank white screen with nothing in the JavaScript console to explain it.
The Memory Problem
Each WebView loads its own browser engine instance, so memory adds up quickly. Keep several micro frontends alive at once and low-end devices reach their limit. One way to bound this:
import React, { useRef, useCallback, useState } from 'react';
import { WebView, WebViewMessageEvent } from 'react-native-webview';
import {
ActivityIndicator,
AppState,
AppStateStatus,
Button,
Text,
View
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
interface MicroFrontendConfig {
url: string;
preload?: boolean;
cachePolicy?: 'default' | 'reload' | 'cache-else-load';
}
class WebViewManager {
private static instance: WebViewManager;
private activeWebViews = new Map<string, {
ref: React.RefObject<WebView>;
lastUsed: number;
}>();
private maxWebViews = 3;
static getInstance(): WebViewManager {
if (!WebViewManager.instance) {
WebViewManager.instance = new WebViewManager();
}
return WebViewManager.instance;
}
registerWebView(id: string, ref: React.RefObject<WebView>): void {
this.activeWebViews.set(id, {
ref,
lastUsed: Date.now()
});
// Cleanup if we exceed limits
this.cleanupIfNeeded();
}
private cleanupIfNeeded(): void {
if (this.activeWebViews.size <= this.maxWebViews) return;
// Find least recently used WebView
const entries = Array.from(this.activeWebViews.entries());
const lru = entries.reduce((min, current) =>
current[1].lastUsed < min[1].lastUsed ? current : min
);
// Clear the WebView
lru[1].ref.current?.clearCache();
this.activeWebViews.delete(lru[0]);
console.log(`[WebViewManager] Cleared WebView ${lru[0]} due to memory limits`);
}
updateUsage(id: string): void {
const webView = this.activeWebViews.get(id);
if (webView) {
webView.lastUsed = Date.now();
}
}
}
export function OptimizedMicroFrontendContainer({
config
}: {
config: MicroFrontendConfig
}) {
const webViewRef = useRef<WebView>(null);
const [isLoading, setIsLoading] = useState(true);
const [hasError, setHasError] = useState(false);
const webViewManager = WebViewManager.getInstance();
const handleLoadStart = useCallback(() => {
setIsLoading(true);
setHasError(false);
}, []);
const handleLoadEnd = useCallback(() => {
setIsLoading(false);
webViewManager.updateUsage(config.url);
}, [config.url]);
const handleError = useCallback((error: any) => {
console.error('[WebView] Load error:', error);
setHasError(true);
setIsLoading(false);
}, []);
const handleMessage = useCallback((event: WebViewMessageEvent) => {
// Handle bridge messages (covered in Part 2)
console.log('[WebView] Message received:', event.nativeEvent.data);
}, []);
// Register with manager
React.useEffect(() => {
webViewManager.registerWebView(config.url, webViewRef);
}, [config.url]);
// Handle app state changes
React.useEffect(() => {
const handleAppStateChange = (nextAppState: AppStateStatus) => {
if (nextAppState === 'background') {
// Clear cache when app goes to background
webViewRef.current?.clearCache();
}
};
const subscription = AppState.addEventListener('change', handleAppStateChange);
return () => subscription?.remove();
}, []);
if (hasError) {
return (
<SafeAreaView style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text>Failed to load micro frontend</Text>
<Button title="Retry" onPress={() => setHasError(false)} />
</SafeAreaView>
);
}
return (
<SafeAreaView style={{ flex: 1 }}>
{isLoading && (
<View style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'white',
justifyContent: 'center',
alignItems: 'center',
zIndex: 1000
}}>
<ActivityIndicator size="large" />
</View>
)}
<WebView
ref={webViewRef}
source={{
uri: config.url,
headers: {
'X-Platform': 'react-native',
'X-App-Version': '1.0.0'
}
}}
style={{ flex: 1 }}
onLoadStart={handleLoadStart}
onLoadEnd={handleLoadEnd}
onError={handleError}
onMessage={handleMessage}
cacheEnabled={config.cachePolicy !== 'reload'}
cacheMode={config.cachePolicy === 'cache-else-load' ? 'LOAD_CACHE_ELSE_NETWORK' : 'LOAD_DEFAULT'}
// Critical for performance
javaScriptEnabled={true}
domStorageEnabled={true}
startInLoadingState={true}
scalesPageToFit={true}
// Security
allowsInlineMediaPlayback={false}
mediaPlaybackRequiresUserAction={true}
// Performance
removeClippedSubviews={true}
overScrollMode="never"
/>
</SafeAreaView>
);
}
The Bundle Size Problem
Initial micro frontend bundles often reach 2-3MB each, causing slow loading times and poor user experience. Here’s how to optimize:
// webpack.config.js - Optimized for WebView delivery
const { ModuleFederationPlugin } = require('webpack').container;
const TerserPlugin = require('terser-webpack-plugin');
const CompressionPlugin = require('compression-webpack-plugin');
module.exports = {
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
priority: 10
},
common: {
name: 'common',
minChunks: 2,
chunks: 'all',
priority: 5
}
}
},
minimizer: [
new TerserPlugin({
terserOptions: {
compress: {
drop_console: true, // Remove console.logs
drop_debugger: true
},
mangle: {
safari10: true // Fix Safari 10 issues
}
}
})
]
},
plugins: [
new CompressionPlugin({
algorithm: 'gzip',
test: /\.(js|css|html|svg)$/,
threshold: 10240,
minRatio: 0.8
}),
new ModuleFederationPlugin({
name: 'micro-frontend',
filename: 'remoteEntry.js',
exposes: {
'./App': './src/App.tsx'
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' }
}
})
]
};
Alternative: Rspack Configuration
For teams that want to try Rspack, here’s the equivalent configuration:
// rspack.config.mjs - Rspack version
import { ModuleFederationPlugin } from '@module-federation/enhanced/rspack';
export default {
entry: './src/index.tsx',
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
priority: 10
}
}
}
},
module: {
rules: [
{
test: /\.tsx$/,
use: {
loader: 'builtin:swc-loader',
options: {
jsc: {
parser: {
syntax: 'typescript',
tsx: true
},
transform: {
react: {
runtime: 'automatic'
}
},
minify: {
compress: {
drop_console: true,
drop_debugger: true
}
}
}
}
}
}
]
},
plugins: [
new ModuleFederationPlugin({
name: 'micro-frontend',
filename: 'remoteEntry.js',
exposes: {
'./App': './src/App.tsx'
}
})
]
};
Both configurations produce comparable output, so the switch is mostly mechanical: the loader chain and the split-chunks setup carry over. The build-time difference comes from the Rust core and the SWC transform, and it scales with module count and loader chain, so measure it on your own project before committing to a migration.
The Navigation Problem
WebView navigation doesn’t work like native navigation. Users expect the back button to work, but WebViews have their own history. Here’s a working solution:
import React from 'react';
import { useFocusEffect } from '@react-navigation/native';
import { BackHandler } from 'react-native';
import type { WebView } from 'react-native-webview';
export function WebViewWithNavigation({
webViewRef,
canGoBack,
onGoBack
}: {
webViewRef: React.RefObject<WebView>;
canGoBack: boolean;
onGoBack: () => void;
}) {
useFocusEffect(
React.useCallback(() => {
const onBackPress = () => {
if (canGoBack) {
// Try to go back in WebView first
webViewRef.current?.goBack();
return true; // Prevent default back behavior
} else {
// Let native navigation handle it
onGoBack();
return false;
}
};
const subscription = BackHandler.addEventListener('hardwareBackPress', onBackPress);
return () => subscription.remove();
}, [canGoBack, onGoBack])
);
return null;
}
Limits of the Default
WebView micro frontends hold when the features you need already exist as web apps, when the web teams ship faster than the mobile release train, and when the screens are content or form driven. The price is the work above: a WebView budget, a bundle diet, and back-button behaviour that the platform does not hand you for free.
Two cases call for something else. Screens that need sustained 60fps interaction, native gestures, or offline-first storage belong in native React Native code. And when every team can standardize on one bundler and one React version, Re.Pack with Module Federation shares runtime modules instead of spinning up isolated browser instances, which is the better trade for a super app.
Next in the Series
Part 2 covers:
- Building a robust communication bridge between WebViews and native code
- Type-safe message passing
- Handling authentication and native features
- Debugging and monitoring strategies
References
- react-native-webview - GitHub - Community-maintained cross-platform WebView component for React Native; the in-depth guide covers communication and injection patterns
- react-native-webview Guide - Practical guide covering the most common WebView use cases including JavaScript injection and message passing
- Expo WebView Documentation - Expo’s reference for the react-native-webview integration, including installation and platform-specific notes
- Re.Pack - Microfrontends - Re.Pack’s guide to building micro frontend architectures in React Native with Module Federation
- Micro Frontends - Martin Fowler - Foundational article on the micro frontend architectural pattern by Cam Jackson
- React Native Performance Overview - Official documentation on JavaScript thread performance, memory management, and optimization strategies
Mobile Micro Frontends with React Native
A comprehensive 3-part series on building mobile micro frontends using React Native, Expo, and WebViews. Covers architecture, communication patterns, and production optimization.
All Posts in This Series
Related posts
WebView-to-native communication patterns: message passing, service integration, and a type-safe request-response bridge with working code.
Patterns for shipping micro frontends across mobile, web, and desktop: performance, offline support, and production insights, with Rspack and Re.Pack approaches.
Server-Driven UI is the mobile analog of server-side composition. The hard part is not JSON rendering but a versioned component contract that survives old app versions.
Step-by-step guide to adding Sentry to a React Native Expo app: SDK setup, Expo Router instrumentation, session replay, and source maps for EAS.
A mobile binary can't be rolled back and old versions linger, so safety and speed move server-side: a BFF, consumer-driven contracts, and backward-compatible versioning.