React Native Web with Expo in 2026: Sharing Code Between Mobile and Web

Ship the same React Native codebase to iOS, Android, and the browser using Expo SDK 54 and Metro. A practical 2026 walkthrough covering setup, platform-specific code, styling, Expo Router on web, and EAS Hosting deployment.

React Native Web + Expo Guide (2026)

Updated: June 9, 2026

React Native Web with Expo lets you ship the same React Native codebase to iOS, Android, and the browser by mapping <View>, <Text>, and <Image> onto react-dom primitives, with Metro acting as the unified bundler for all three targets. In 2026, on Expo SDK 54 and the SDK 56 beta, this story is finally production-grade. Metro replaced Webpack, Expo Router runs the same file-based routes on web with static rendering, and React Server Components are in early preview. Here's how I bridge web and native in real apps (coming from a React Web background), and where the seams still show.

  • Expo uses Metro (not Webpack) as the web bundler since SDK 50, and ships first-class web support out of the box on SDK 54 and 56.
  • React Native Web (RNW) wraps react-dom primitives, so the same <View> component renders a <div> on web and a native view on iOS/Android.
  • Use .web.tsx / .native.tsx file extensions for platform-specific code, or Platform.OS for inline branches.
  • Expo Router supports static rendering (build-time HTML) on web today and streaming SSR (alpha) in SDK 55+, with generateMetadata for per-route SEO in SDK 56.
  • NativeWind v4 and Unistyles 3 both work on web; classic StyleSheet is statically extracted to CSS automatically.
  • Deploy with EAS Hosting for the simplest path. Next.js via @expo/next-adapter remains an option if you need existing Next.js infra.

What is React Native Web?

React Native Web (RNW) is a library by Nicolas Gallagher that re-implements React Native's primitives (View, Text, Image, Pressable, ScrollView, the StyleSheet API, Animated, Dimensions, and accessibility props) on top of react-dom. The point isn't to render mobile UI in a browser. It's to let one component tree describe the UI and have each platform produce its native shape. On native, <View> compiles to a UIView or ViewGroup; on web, it becomes a <div> with flexbox defaults already applied. This is the same approach that powers X (formerly Twitter)'s entire web client, so it's not a toy.

The mental model I use when explaining this to React-for-web developers: RNW gives you a CSS-in-JS layer with sensible flex defaults, a primitive component set that maps cleanly to a11y semantics, and a runtime that does the platform-specific resolving for you. You write once, but you still have to think about three render targets.

Does React Native Web work with Expo?

Yes. As of Expo SDK 50 onward, web support is built into the framework with Metro as the bundler. You don't install react-native-web separately in a new Expo app; the template includes it, and npx expo start --web just works. The bundler decisions made by the Expo team over the last two years have made this the recommended path: Metro now handles iOS, Android, and web with the same caching, the same resolver, and the same metro.config.js. Expo's "Why Metro?" guide explains the reasoning, but the practical upshot is that you no longer maintain two bundler configs.

The package.json of a fresh Expo SDK 54 app has a single start script and "web" as one of the platform targets in app.json. Run it once with npx expo start and press w to open the browser. The dev server, fast refresh, and source maps all work exactly the way they do for mobile, with no separate Webpack process.

Setting up Expo for web in 2026

For a new project, the entire setup is three commands. For an existing native-only Expo app, you'll add three keys to app.json and confirm a couple of dependencies. Here's the new-project path I run every time:

# Create a new universal app with the default Expo Router template
npx create-expo-app@latest my-universal-app

cd my-universal-app

# Start the dev server, then press "w" to open the web build
npx expo start

For an app that started mobile-only, edit app.json to declare web support and pick an output mode. "static" pre-renders every route at build time and is the right default for marketing sites and content apps. "single" outputs a classic SPA index.html shell, and "server" opts into the alpha SSR runtime.

// app.json
{
  "expo": {
    "name": "my-universal-app",
    "slug": "my-universal-app",
    "platforms": ["ios", "android", "web"],
    "web": {
      "bundler": "metro",
      "output": "static",
      "favicon": "./assets/favicon.png"
    }
  }
}

Two things to verify in package.json: react-native-web, react-dom, and @expo/metro-runtime must all be present. The Expo CLI will print a clear error and an npx expo install command if any are missing. That's the path I always take rather than hand-editing dependencies, because the CLI knows which versions match your SDK.

Handling platform-specific code

There are two mechanisms, and honestly I use both. For short branches inside a single file, Platform.OS is the right tool. For anything that needs different imports, different libraries, or more than a handful of lines of difference, I switch to platform-specific file extensions.

// Inline branch — fine for one or two lines
import { Platform, View, StyleSheet } from 'react-native';

const styles = StyleSheet.create({
  card: {
    padding: 16,
    boxShadow: Platform.select({
      web: '0 2px 8px rgba(0,0,0,0.1)',
      default: undefined,
    }),
    elevation: Platform.OS === 'android' ? 4 : 0,
  },
});

For larger splits (say, a sharing component that uses navigator.share on web and expo-sharing on native), make two files and let the bundler pick the right one. Metro resolves ./ShareButton to ShareButton.web.tsx when bundling for web and ShareButton.native.tsx for iOS and Android. You can also use .ios.tsx and .android.tsx for finer-grained splits.

// components/ShareButton.web.tsx
export function ShareButton({ url, title }: { url: string; title: string }) {
  const onPress = async () => {
    if (navigator.share) {
      await navigator.share({ url, title });
    } else {
      await navigator.clipboard.writeText(url);
    }
  };
  return <button onClick={onPress}>Share</button>;
}

// components/ShareButton.native.tsx
import * as Sharing from 'expo-sharing';
import { Pressable, Text } from 'react-native';

export function ShareButton({ url }: { url: string; title: string }) {
  const onPress = () => Sharing.shareAsync(url);
  return (
    <Pressable onPress={onPress}>
      <Text>Share</Text>
    </Pressable>
  );
}

// Anywhere in the app — Metro picks the right file
import { ShareButton } from '../components/ShareButton';

Styling that survives the platform jump

The bad news first: many CSS properties that browsers take for granted simply don't exist in React Native's flexbox subset, and some that do behave differently. flex: 1 means flex-grow: 1; flex-shrink: 1; flex-basis: 0 on web but flex-grow: 1; flex-shrink: 1; flex-basis: 0% on native, and that % matters when a parent doesn't have an explicit height. position: fixed doesn't exist on native. Hover states (:hover) don't exist on touch devices, so you handle hover with the Pressable component's state callback on web.

The good news: RNW's StyleSheet is statically extracted to a real CSS file in the web build, which means no runtime style injection cost and no flash of unstyled content. If you're already using a styling library, here's how each option behaves on web in 2026. See our deeper comparison of NativeWind vs Unistyles vs Tamagui for the full breakdown.

Styling optionWeb supportStatic extractionSSR friendly
RN StyleSheetFirst-classYes, automaticYes
NativeWind v4First-classYes, via TailwindYes
Unistyles 3SupportedPartialRequires setup
TamaguiFirst-classYes, compiler-drivenYes
styled-componentsWorksNoRequires ServerStyleSheet

For most projects I start with vanilla StyleSheet.create and only reach for NativeWind when the team is already fluent in Tailwind. That's a slightly controversial take in 2026, I know, but the dependency surface stays tiny and the web bundle is measurably smaller.

Expo Router on the web: static, SSR, and RSC

Expo Router is the routing layer that makes universal apps actually feel universal. The same app/ directory of files becomes a stack on native and a set of routed pages on web. If you've used Next.js's pages/ or app/ directory, you already know the shape. There's a full walkthrough in our Expo Router guide. Here I'll focus on the web-specific behavior.

Three rendering modes ship today:

  • Static rendering ("output": "static"): every route is pre-rendered to HTML at build time. This is the production-ready default. RNW styles are statically injected, fonts loaded via expo-font are inlined and preloaded, and the result is fully crawlable.
  • Server rendering ("output": "server"): alpha in SDK 55, expanded in SDK 56 with streaming SSR via unstable_useServerRendering. Needs @expo/server and a Node-compatible runtime. Use this when route data depends on the request (auth, geolocation, A/B flags).
  • React Server Components: early preview in SDK 56. Lets a server component render on the server and stream an RSC payload to the client. The same component tree works on native, where the payload is fetched over the network.

For per-route SEO, SDK 56 introduces generateMetadata, which mirrors the Next.js App Router API:

// app/blog/[slug].tsx
import { createStaticLoader } from 'expo-router';

export const loader = createStaticLoader(async ({ params }) => {
  const post = await getPost(params.slug);
  return { post };
});

export function generateMetadata({ data }) {
  return {
    title: data.post.title,
    description: data.post.excerpt,
    openGraph: { images: [data.post.image] },
  };
}

export default function PostScreen({ loaderData }) {
  return <Article post={loaderData.post} />;
}

What carries over from React Web (and what doesn't)

This is the section I wish someone had handed me three years ago. Coming from React DOM, the conceptual model is identical. Hooks behave the same way, context works the same way, suspense boundaries are the same, refs are refs. Where the seams show is the primitives.

What carries over cleanly

  • All of React: hooks, context, suspense, error boundaries, useTransition, useDeferredValue, server components (in the SDK 56 preview).
  • Data-fetching libraries: TanStack Query, SWR, Apollo Client, tRPC clients all work unchanged. We cover the state-management combo I use in another article.
  • State libraries: Zustand, Jotai, Redux Toolkit, Valtio. Pure JS, pure portable.
  • Forms: react-hook-form and zod validation work identically. Only the input components change.

What does not carry over

  • HTML primitives: no <div>, <span>, <p>, <a> in shared code. Use View, Text, Link from expo-router.
  • CSS selectors: no descendant selectors, no media queries inside StyleSheet (use useWindowDimensions or useMediaQuery hooks).
  • DOM events: onClick becomes onPress, and mouse events are different on touch. preventDefault() often doesn't apply.
  • Routing libraries: react-router-dom doesn't make sense in a universal app. Use Expo Router for both.
  • Browser-only APIs: window, document, localStorage work on web only. Wrap with Platform.OS === 'web' checks or use a universal storage adapter like MMKV.

Performance and bundle size on web

The honest answer: an RNW bundle is bigger than an equivalent React DOM bundle. Adding react-native-web adds around 30-40 KB gzipped to the JS payload, and shipping the RN component set adds another chunk. For a content-heavy site that needs Lighthouse 100, you'd build with React DOM directly. For an app where the web build is a secondary surface to a primary mobile experience, the savings from a single codebase massively outweigh the bundle delta.

The big 2026 wins on web come from three places. First, Metro's tree-shaking and dead-code elimination are now competitive with esbuild on most projects. Second, expo-image serves AVIF/WebP automatically with responsive srcset. Third, async routes mean each page only ships its own JS until the user navigates. We've covered the broader mobile angle in our React Native performance guide, but for web specifically I always run npx expo export --platform web --analyze and look at the per-route bundle breakdown before shipping.

Deploying with EAS Hosting

For 90% of apps, EAS Hosting is the path of least resistance. It supports static and server output modes natively, gives you preview URLs per branch, handles custom domains and TLS, and integrates with EAS Update so your web deploy is part of the same release flow as your native OTA updates. The deploy command:

# Build the web bundle, then deploy
npx expo export --platform web
eas deploy

If you already run on Vercel or Cloudflare, the @expo/server adapter has presets for both. The Cloudflare Workers preset in particular is appealing because it puts your SSR on the edge alongside your data. That's useful when you're sharing data-loader code between native (where the loader runs client-side) and web (where it runs at the edge).

Frequently Asked Questions

Is React Native Web production ready in 2026?

Yes. RNW powers the X (formerly Twitter) web client and Major League Soccer, among others. With Expo SDK 54+ as the recommended setup, the bundler, routing, styling, and deployment stories are all stable. The remaining alpha pieces are streaming SSR and React Server Components, which you can opt into without affecting the rest of the build.

What's the difference between React Native Web and React?

React is the rendering engine. React DOM is the renderer for browsers, React Native is the renderer for iOS/Android, and React Native Web is a third renderer that targets the DOM but exposes the React Native component API. So the same source code runs on all three. You're still using React under the hood; only the primitives differ.

Can I use Expo Router on the web?

Yes. Expo Router is universal: the same app/ directory becomes navigation stacks on native and routed pages on web. It supports static rendering out of the box, server rendering (alpha) in SDK 55+, and React Server Components (preview) in SDK 56.

Should I use React Native Web or Next.js for my web app?

If your primary product is a mobile app and the web is a companion surface, use RNW with Expo for one codebase. If your primary product is the website and a mobile app is secondary, use Next.js for the site and a separate React Native app, optionally with shared utility packages. For mixed cases, @expo/next-adapter lets you embed RNW components inside a Next.js app.

How do I handle browser-only APIs in a universal app?

Wrap access in a Platform.OS === 'web' check, or (better for non-trivial cases) use platform-specific files (storage.web.ts, storage.native.ts) so the wrong code never gets bundled for the wrong target. For storage, swap localStorage for AsyncStorage or MMKV in the native variant.

Anita Iyer
About the Author Anita Iyer

Cross-platform mobile developer who came to RN from web. Bridges the two worlds and explains the seams.