React Native Web: One Codebase for Web and Native in 2026
Share React Native code across iOS, Android, and the browser in 2026 without rewriting everything. Here's what actually works and what to watch out for.
Why React Native Web Still Makes Sense in 2026
The pitch hasn't changed much since 2018, but the tooling finally caught up. React Native Web lets you write one component tree and ship it to iOS, Android, and a browser — and in 2026, with Expo SDK 52 and Metro's new web support, the story is genuinely good. Not perfect. Good.
Honestly, if you're building a SaaS with a mobile companion app, React Native Web is one of the few approaches where you're not constantly fighting the framework. You write <View>, <Text>, <Pressable>, and each platform gets what it needs. The web target compiles those primitives down to div, span, and button with the right ARIA semantics baked in.
That said, people still confuse this with React Native + a webview. It's not. There's no webview involved. The web bundle is real DOM, real CSS (injected via StyleSheet), running natively in a browser. Worth noting: the bundle size overhead is real — about 45KB gzipped for the RNW runtime alone — so don't reach for it if you're building a marketing site. It's for apps that need native and web parity.
What changed in 2026 specifically? The Expo Router v4 web target now does proper static export and edge-compatible SSR. That was the missing piece. Before that, you were stuck with SPA-only output and a blank page on first load.
Setting Up Your Monorepo in 2026
You'll want a monorepo. This is non-negotiable if you're sharing code across three platforms. The most common setup right now is Turborepo + pnpm workspaces, with an apps/mobile (Expo) and apps/web (Next.js or Expo web) directory, plus a packages/ui package where your shared components live.
Here's a minimal packages/ui setup that actually works:
``bash
pnpm create expo my-app --template tabs
cd my-app && npx expo install react-native-web react-dom @expo/webpack-config
`
Then in your package.json for the shared package:
`json
{
"name": "@acme/ui",
"main": "./src/index.ts",
"exports": {
".": "./src/index.ts"
}
}
``
One more thing — Metro needs to know about your monorepo boundaries. In metro.config.js:
``js
const { getDefaultConfig } = require('expo/metro-config');
const path = require('path');
const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, '../..');
const config = getDefaultConfig(projectRoot);
config.watchFolders = [workspaceRoot];
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, 'node_modules'),
path.resolve(workspaceRoot, 'node_modules'),
];
module.exports = config;
``
Quick aside: if you're using Next.js as the web target (instead of Expo web), you need the next-transpile-modules pattern replaced by Next 13+'s transpilePackages in next.config.js. That old plugin is dead. Use transpilePackages: ['react-native', 'react-native-web', '@acme/ui'] and you're done.
Writing Components That Work Everywhere
The golden rule: write to the React Native API surface, not to the web. The moment you drop a <div> into a shared component, you've broken native. Use <View> instead. Use StyleSheet.create() instead of CSS classes. Use Pressable instead of <button>. This discipline pays off.
Platform-specific code is handled with file extensions. If you need a native-only shadow that differs from web, create Card.native.tsx and Card.tsx side by side. Metro picks up .native.tsx on iOS and Android; webpack (and Metro web) falls back to Card.tsx. It's clean, and it scales.
``tsx
// Card.native.tsx — iOS/Android
import { View, StyleSheet } from 'react-native';
export function Card({ children }: { children: React.ReactNode }) {
return <View style={styles.card}>{children}</View>;
}
const styles = StyleSheet.create({
card: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.12,
shadowRadius: 12,
elevation: 6,
backgroundColor: '#fff',
borderRadius: 16,
padding: 20,
},
});
`
`tsx
// Card.tsx — Web
import { View, StyleSheet } from 'react-native';
export function Card({ children }: { children: React.ReactNode }) {
return <View style={styles.card}>{children}</View>;
}
const styles = StyleSheet.create({
card: {
// RNW maps this to box-shadow
boxShadow: '0 4px 24px rgba(0,0,0,0.10)',
backgroundColor: '#fff',
borderRadius: 16,
padding: 20,
},
});
``
In practice, you'll end up with about 70-80% shared code and 20-30% platform splits. That ratio is realistic. Anyone claiming 95% code share is either building a very simple app or lying. Navigation, gestures, and anything touching native APIs will diverge.
If you're building a design-forward product and want inspiration for your shared component tokens, the glassmorphism components on Empire UI show how backdrop-blur and alpha channels can map cleanly to both web CSS and React Native's rgba — worth seeing how the blur values translate when you're deciding on your design system primitives.
Navigation: The Part Everyone Underestimates
Navigation is where most React Native Web projects hit a wall. React Navigation v7 added proper URL support in 2024 and it's actually usable now — but it won't give you SSR, real <a> tags, or browser-native back/forward semantics out of the box. If web SEO matters, you want Expo Router instead.
Expo Router is built on top of React Navigation but layers a file-system routing model on top. Think Next.js App Router, but for Expo. Your app/ directory becomes your route tree, and on web it renders proper href links. Deep links on native just work because the same file structure generates both the URL scheme and the native navigation stack.
``
app/
_layout.tsx ← root layout (Stack or Tabs)
index.tsx ← / on web, initial screen on native
product/
[id].tsx ← /product/123 on web, dynamic route on native
(tabs)/
explore.tsx
profile.tsx
``
Look, if you're on React Navigation v7 for web and wondering why your browser back button feels broken — it's because @react-navigation/native uses its own history stack, not the browser's History API by default. You need @react-navigation/web and its createBrowserHistory config. Most tutorials skip this entirely, and people ship broken experiences.
Worth noting: in 2025 the Expo team deprecated the classic Expo web target (the webpack one) in favour of Metro web. If your project is still using @expo/webpack-config, migrate sooner rather than later. Metro web is faster and shares more code with your native build.
Styling Across Platforms Without Going Insane
You can't use Tailwind directly in shared RNW components — Tailwind is class-name based and React Native doesn't have class names. But you have options. NativeWind v4 is the most popular: it parses Tailwind utility strings at build time and generates RN StyleSheet objects. It's genuinely good and it supports Tailwind v4 syntax as of early 2026.
The trade-off with NativeWind is that you lose the dynamic styling that makes Tailwind fun on the web. Arbitrary values like w-[347px] work, but dynamic class concatenation with runtime values doesn't — RN needs all styles to be statically analyzable at build time. Plan your variant strategy early.
``tsx
import { View, Text } from 'react-native';
export function Badge({ label, variant }: { label: string; variant: 'success' | 'error' }) {
return (
<View className={variant === 'success' ? 'bg-green-500 px-3 py-1 rounded-full' : 'bg-red-500 px-3 py-1 rounded-full'}>
<Text className="text-white text-xs font-semibold">{label}</Text>
</View>
);
}
``
If you want richer visual effects — gradients, glass effects — these don't translate 1:1. expo-linear-gradient renders a native gradient on iOS/Android and a CSS gradient on web. For glass effects, you're stuck using rgba backgrounds on native (real backdrop blur isn't possible in RN without a third-party lib), while web gets backdrop-filter: blur(12px). Check out the glassmorphism generator to dial in your web values, then approximate with semi-transparent backgrounds for native.
One more thing — don't underestimate the value of the gradient generator and box shadow generator for establishing your web-side design tokens first. Once you have precise values (like a shadow of 0 8px 32px rgba(0,0,0,0.15)), you can translate them to their native RN equivalents systematically.
Performance Considerations You Can't Ignore
React Native Web bundles are heavier than a hand-rolled React web app. The RNW runtime is around 45KB gzipped, and if you pull in Reanimated for animations, add another 30-50KB. You're not building a 10KB landing page here. Accept this cost if the code-sharing value is there; reject RNW if it's not.
On the native side, RNW doesn't change anything about your app's native performance — those primitives compile to native views exactly as they would without RNW. The web target is where you pay the perf tax. Use code splitting aggressively. Expo Router's web target supports React.lazy and Suspense, so split at the route level at minimum.
``tsx
// In your Expo Router layout
import { Suspense, lazy } from 'react';
import { ActivityIndicator } from 'react-native';
const HeavyChart = lazy(() => import('../components/HeavyChart'));
export default function DashboardScreen() {
return (
<Suspense fallback={<ActivityIndicator />}>
<HeavyChart />
</Suspense>
);
}
``
For the react-concurrent-rendering patterns you might be using in your pure web app — startTransition, useDeferredValue — these work identically in RNW on the web target since it's just React. On native, Concurrent Mode has been default since React Native 0.71. You're good.
Image optimization is a sore point. On native you've got fast image loading from device cache. On web you need next/image or equivalent — but next/image wraps a standard <img> tag, not RN's <Image>. If you're using Expo Router's web output, reach for expo-image which bridges both targets and supports blurhash placeholders on both platforms.
Look, if your web Lighthouse score matters (and it should), profile your RNW bundle early. Don't wait until launch. Tree-shaking in Metro web isn't as aggressive as webpack's, and you'll find dead code that stays in the bundle unless you're deliberate about it.
When Not to Use React Native Web
Not every project should go this route. If your web app and mobile app have fundamentally different UX — think a dashboard-heavy web tool and a simple mobile companion — shared components won't actually save you time. You'll spend the same hours fighting the abstraction as you would just writing two separate UIs.
Pure web products that happen to also need a mobile experience are often better served by a PWA. A well-built PWA with a good manifest and service worker installs on home screens, works offline, and takes zero extra infrastructure. The RNW overhead isn't justified for a mobile-optimised responsive web app.
That said, if you have: a data layer you want shared (hooks, state management, API calls), meaningful parity requirements between web and native UI, and a team that knows React well — React Native Web is a legitimate choice in 2026. The tooling story is the best it's ever been. Expo Router doing SSR, Metro web shipping production bundles, NativeWind v4 closing the Tailwind gap — the pieces are there.
One more thing — if you're evaluating design systems for your RNW project, browsing the Empire UI component library is a useful reference even if you can't copy the components directly. The visual hierarchy patterns, spacing systems, and interaction models translate well when you're designing your shared primitive set.
FAQ
Yes, with caveats. Companies like Twitter (X) and Expo have been running RNW in production for years. The web bundle size and limited access to web-native APIs are the real trade-offs you're accepting.
Not directly — but NativeWind v4 compiles Tailwind utility classes into React Native StyleSheet objects at build time. It supports Tailwind v4 syntax and works well for most use cases.
With Expo Router v4, yes. You get static export and edge-compatible SSR on the web target. Plain React Navigation does not support SSR without significant custom work.
Plan for 70-80% shared code. Business logic, API hooks, and most UI primitives share well. Navigation, gestures, and native API integrations will always diverge.
