Skip to content
Ayhan Sipahi Ayhan Sipahi

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 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:

React Native App

Native Shell

WebView Container 1

WebView Container 2

WebView Container 3

Team A: React App

Team B: Vue App

Team C: Angular App

Native Bridge

Authentication Service

Native Features

Analytics

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

After evaluating all options, a hybrid approach combines the strengths of each:

React Native App

Native Shell

WebView Container

Micro Frontend Loader

Team A: Webpack Build

Team B: Vite Build

Team C: Rspack Build

Shared Bridge

Authentication

Native Features

Analytics

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

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.

Progress 1/3 posts completed

All Posts in This Series

Related posts