Turning a React Native app into an installable PWA with Webpack
A deep-dive walkthrough of taking a React Native codebase and shipping it as a fast, installable, offline-capable Progressive Web App - with a hand-rolled Webpack 5 config, react-native-web, and a real service worker.
React Native gives you one codebase for iOS and Android, but shipping a third target - the web, installable and working offline - usually means either standing up Expo's web support or reaching for Vite with a React Native shim. Neither is wrong, but if your app already has a mature, hand-tuned Webpack config on the native side (custom Metro-adjacent transforms, monorepo aliasing, native modules mocked for web), the pragmatic move is often to keep Webpack and point it at react-native-web instead of rewriting the build. This is the walkthrough of doing exactly that: a React Native app, compiled to the DOM by Webpack 5, wrapped in a real service worker, and installable from the browser's address bar.
The core trick: aliasing react-native to react-native-web
react-native-web re-implements the RN component and API surface (View, Text, StyleSheet, Animated, Pressable, and friends) on top of DOM primitives. Your components don't change - what changes is which package `import { View } from 'react-native'` actually resolves to at build time. Webpack's `resolve.alias` is the entire mechanism.
// webpack.config.js
const path = require('path');
module.exports = {
entry: './index.web.js',
resolve: {
alias: {
'react-native$': 'react-native-web',
// Native-only modules that have no web equivalent - stub them
'react-native-vector-icons': 'react-native-vector-icons/dist',
},
extensions: ['.web.tsx', '.web.ts', '.web.js', '.tsx', '.ts', '.js'],
},
};The `extensions` order matters as much as the alias: Webpack tries `.web.tsx` before `.tsx`, so any component that genuinely needs a different implementation on web (native-only APIs like Haptics, or a platform-specific layout) gets a sibling `Component.web.tsx` file that silently wins the resolution, no `Platform.OS` branching required inside the component itself.
Why the trailing $ on react-native$ is not decorative
The `$` at the end of `'react-native$'` tells Webpack's resolver to match the import request exactly, not as a prefix. Without it, `resolve.alias` does substring-prefix matching, which means `import { View } from 'react-native'` resolves correctly but so does anything that merely starts with that string - `react-native-web` itself, `react-native-vector-icons`, `react-native-safe-area-context` - because Webpack sees the string `react-native` at the start of the request and happily rewrites it, mangling the path into nonsense like `react-native-web-vector-icons`. That failure mode is brutal to debug because the error - a module-not-found deep in a package you didn't touch - points nowhere near the actual cause.
resolve: {
alias: {
// Exact match only - 'react-native' the string, nothing else
'react-native$': 'react-native-web',
// No $ here on purpose: we WANT this to match
// 'react-native-vector-icons/MaterialIcons' too, not just the bare import
'react-native-vector-icons': 'react-native-vector-icons/dist',
},
},The rule of thumb we use: reach for the `$` whenever the thing on the right of the alias could itself be matched by the pattern on the left as a prefix - `react-native` -> `react-native-web` is the textbook case. Leave it off when you deliberately want subpath imports (`react-native-vector-icons/MaterialIcons`) to be caught by the same alias, which is exactly what the vector-icons line above relies on.
Platform-specific files: how .web.tsx resolution actually works
React Native's Metro bundler has always supported platform suffixes (`.ios.tsx`, `.android.tsx`) as a first-class resolution feature. Webpack has no built-in concept of 'platform' - the `extensions` array trick above is how you recreate the same behavior generically, by exploiting the fact that Webpack tries each entry in order and takes the first file that exists on disk. Given a bare `import Avatar from './Avatar'`, and both `Avatar.web.tsx` and `Avatar.tsx` present in the same folder, the `extensions` order in the config above means `Avatar.web.tsx` wins on a web build and is never even looked at by the native (Metro) build, which uses its own, separate resolution rules.
src/components/Avatar/
Avatar.tsx # shared logic + native (iOS/Android) rendering
Avatar.web.tsx # web-only override - wins on Webpack builds only
Avatar.styles.ts # shared, imported by bothTwo things trip people up here. First, the override is per-file, not per-export - you can't `.web.tsx` a single named export out of a larger file, so components that need a web-specific tweak to just one small piece are often better served by an internal `Platform.OS === 'web'` branch than a whole duplicate file. Second, `.web.tsx` files are invisible to Metro/native builds by default, so it's easy to end up with a `.web.tsx` that quietly rots out of sync with its native sibling - a component's prop signature changes on native and nobody remembers to mirror it into the web override until a build breaks. Treat the pair as one contract with two implementations, not two independent files.
// Avatar.web.tsx - only ever bundled by Webpack
import { View, Image } from 'react-native';
import type { AvatarProps } from './Avatar';
// Same props contract as Avatar.tsx - keep this in sync deliberately
export default function Avatar({ uri, size = 40 }: AvatarProps) {
return (
<View style={{ width: size, height: size, borderRadius: size / 2, overflow: 'hidden' }}>
{/* Web gets native <img> loading="lazy" for free - no equivalent on native */}
<Image source={{ uri }} loading="lazy" style={{ width: size, height: size }} />
</View>
);
}Shims: stubbing out native-only modules that have no web meaning
Some native modules aren't just 'implement this differently on web' - they're APIs with genuinely no DOM equivalent (native biometrics, a Bluetooth SDK, a push-notification token registration call). Importing them unmodified breaks the Webpack build outright, because they're often native binary bindings or reference APIs like `NativeModules.SomeIOSModule` that don't exist in a browser at all. The fix is a shim: a tiny `.web.ts` file with the same exported shape as the real module, implemented as either a no-op or the closest Web API equivalent.
// haptics.ts - native implementation, wraps a native module
import { HapticFeedback } from 'react-native-haptic-feedback';
export function triggerImpact() {
HapticFeedback.trigger('impactMedium');
}// haptics.web.ts - shim, same exported shape, resolved instead on Webpack builds
export function triggerImpact() {
// Closest Web API equivalent, and only where supported -
// most desktop browsers simply have no vibration hardware
if (typeof navigator !== 'undefined' && 'vibrate' in navigator) {
navigator.vibrate(10);
}
}For modules with no reasonable web equivalent at all - a native camera roll picker, a Face ID prompt - the shim is a deliberate, typed no-op rather than a silent one, so a developer hitting the feature on web gets an explicit signal instead of quietly-broken behavior:
// biometrics.web.ts
export async function authenticateWithBiometrics(): Promise<{ success: boolean; reason?: string }> {
return { success: false, reason: 'unsupported-platform' };
}For third-party native packages you don't control the source of - so you can't drop a `.web.ts` sibling next to their files - `resolve.alias` does the same job at the package level, redirecting the whole import to a local shim module:
resolve: {
alias: {
'react-native$': 'react-native-web',
// Third-party native module with no web build at all -
// redirect the whole package to our own shim
'react-native-ble-manager$': path.resolve(__dirname, 'src/shims/ble-manager.web.ts'),
},
},We keep every shim in one `src/shims/` folder with a short comment naming the native module it stands in for and why it can't just be `.web.tsx`'d in place (usually: 'third-party package, no source access' or 'binary native binding, no JS equivalent exists'). That inventory is the first thing we check when a web-only bug report comes in that doesn't reproduce on native - it's very often a shim quietly returning its no-op default.
Babel: compiling RN's Flow/JSX for the browser
React Native's own source and many of its ecosystem packages ship untranspiled Flow-typed JSX, which browsers obviously can't run and which most default Babel presets don't handle. `babel-loader` needs `babel-preset-react-native` (Flow-aware) alongside your usual React preset, and - important for a monorepo - you have to widen `babel-loader`'s `include` past `node_modules`, because RN packages intentionally publish source, not compiled output.
{
test: /\.(js|jsx|ts|tsx)$/,
include: [
path.resolve(__dirname, 'src'),
// RN packages ship untranspiled source - compile them too
/node_modules[\\/](react-native|@react-native|react-native-.*)[\\/]/,
],
use: {
loader: 'babel-loader',
options: {
presets: ['module:metro-react-native-babel-preset'],
plugins: ['react-native-web'],
},
},
}The `react-native-web` Babel plugin is worth calling out separately: it rewrites `StyleSheet.create` calls into flattened, deduped style objects at compile time instead of resolving them at runtime, which is a meaningful bundle-size and runtime-perf win over letting the library do it lazily.
Assets: images, fonts, and platform icon differences
RN resolves `require('./logo.png')` through Metro's asset pipeline; on the web that becomes Webpack's asset modules (Webpack 5 dropped `file-loader`/`url-loader` in favor of a built-in `asset/resource` type). Vector icon fonts need the same treatment plus a small CSS injection so `@font-face` actually loads them.
module.exports = {
module: {
rules: [
{
test: /\.(png|jpe?g|gif|webp)$/i,
type: 'asset/resource',
generator: { filename: 'assets/images/[hash][ext]' },
},
{
test: /\.ttf$/,
type: 'asset/resource',
include: path.resolve(__dirname, 'node_modules/react-native-vector-icons'),
},
],
},
};Making it a PWA: manifest, icons, and a real service worker
A compiled RN app is just a React app at this point, so it needs the same three things any PWA needs: a web app manifest, an HTML shell that references it, and a service worker that actually caches something. `webpack-pwa-manifest` generates the manifest and every icon size from one source image; `workbox-webpack-plugin` generates the service worker from your build output instead of hand-writing cache logic.
const WebpackPwaManifest = require('webpack-pwa-manifest');
const { GenerateSW } = require('workbox-webpack-plugin');
module.exports = {
plugins: [
new WebpackPwaManifest({
name: 'Appesto',
short_name: 'Appesto',
display: 'standalone',
start_url: '/',
background_color: '#0b0e14',
theme_color: '#0b0e14',
icons: [{ src: path.resolve('src/assets/icon.png'), sizes: [192, 512, 1024] }],
}),
new GenerateSW({
clientsClaim: true,
skipWaiting: true,
runtimeCaching: [
{
urlPattern: /\/api\//,
handler: 'NetworkFirst',
options: { cacheName: 'api-cache', networkTimeoutSeconds: 3 },
},
{
urlPattern: /\.(png|jpe?g|svg|ttf)$/,
handler: 'CacheFirst',
options: { cacheName: 'asset-cache' },
},
],
}),
],
};`NetworkFirst` on API calls means users see live data whenever they're online and a cached snapshot the moment they're not - the difference between a blank screen and a stale-but-usable one on flaky mobile networks, which is exactly the audience most likely to install a PWA from their home screen in the first place.
Things that don't translate, and how we handled them
- Native navigation gestures (swipe-back) don't exist on the web - we kept React Navigation's stack but disabled gesture handling behind `Platform.OS === 'web'` and relied on browser back/forward instead.
- Haptics, camera, and biometric APIs have no DOM equivalent - each got a `.web.ts` sibling file with a graceful no-op or a Web API fallback (e.g. `navigator.vibrate` for haptics where supported).
- Deep font-weight and letter-spacing differences between iOS/Android rendering and browser font rendering meant a pass of `StyleSheet` tweaks scoped to web via `Platform.select`.
- SafeAreaView is meaningless on desktop browsers but still matters on mobile Safari/Chrome with notches - react-native-web forwards it to `env(safe-area-inset-*)` CSS, so we kept it rather than stripping it.
Was it worth it over Expo or Vite?
For a codebase that already had a heavily customized Webpack setup on the native side - custom resolver logic for a monorepo, an internal design-token loader, SVG-to-component transforms - reusing that investment and pointing it at react-native-web was less total work than adopting Expo's web pipeline or standing up a parallel Vite config with its own alias and asset rules. If you're starting fresh with no existing Webpack investment, Expo's web target or Vite will get you there with dramatically less configuration. Bring your own build tool only when you already have one worth keeping.
Written by Appesto Engineering.