+1 (415) 943-4271

One Codebase, Three Platforms: Shipping Web From Your React Native App

Sooner or later, every React Native project gets the question: "can we get a web version of this?" Sometimes it is marketing wanting a public, indexable surface. Sometimes it is support staff who need the same screens in a browser. Sometimes it is a customer who will not install an app at all.

The honest answer is "mostly, and it depends which parts." Universal React Native — iOS, Android, and web from one codebase — is genuinely production-viable in 2026, but it rewards teams who decide up front how much of the app is shared and where the seams are. This post walks through the moving pieces we use on client work, and the places where we deliberately stop sharing.

The pieces, briefly

react-native-web is the compatibility layer. It maps View, Text, Image, Pressable, ScrollView, Animated, and the StyleSheet API onto DOM elements and CSS. Your View becomes a div with flexbox defaults that match Yoga rather than the browser's.

Expo Router is what makes this practical rather than a science project. The same file-based routes that produce native stacks and tabs also produce real URLs on web, with a browser history, deep-linkable paths, and static or server rendering. If you already read our Expo Router in Practice post, the mental model carries over directly — app/ is your route tree on all three platforms.

react-strict-dom (RSD) is the newer, stricter approach: a constrained subset of the DOM and CSS APIs (html.div, css.props) that compiles to real DOM on web and to React Native primitives on native. It exists because react-native-web's job is to pretend the web is React Native, whereas RSD's job is to make both platforms agree on a small, fast, statically analysable subset. It is worth evaluating for new universal design systems; it is not something you retrofit onto a large existing app in a sprint.

You do not have to choose RSD to ship web. Most teams we work with ship react-native-web plus Expo Router, and treat RSD as the direction of travel for their component library.

Step 1: turn web on and see what breaks

In an Expo project:

npx expo install react-dom react-native-web @expo/metro-runtime
npx expo start --web

Then set your rendering mode in app.json:

{
  "expo": {
    "web": {
      "bundler": "metro",
      "output": "static"
    }
  }
}

output is the decision that matters most:

  • single — a classic single-page app. One HTML shell, client-side routing. Fine for an authenticated internal tool, bad for SEO.
  • static — one pre-rendered HTML file per route at build time. This is the right default for marketing pages, docs, and anything you want indexed.
  • server — request-time rendering with API routes, for pages that depend on the requesting user or fresh data.

Expect the first --web run to surface a pile of small failures. That is normal and it is cheap information: it tells you exactly which dependencies are native-only.

Step 2: platform extensions instead of if statements

Platform.OS === 'web' checks scattered through a component are how universal codebases rot. Metro resolves platform-specific files automatically, so use them:

components/
  MediaPicker.tsx          # shared interface + native implementation
  MediaPicker.web.tsx      # DOM <input type="file"> implementation
  BiometricGate.native.tsx
  BiometricGate.web.tsx

Import ./MediaPicker and Metro picks .web.tsx for the web bundle, .native.tsx (or the bare file) for iOS and Android. The win is not aesthetic: platform-only code stays out of the other platform's bundle, and each implementation gets to be idiomatic instead of a compromise.

The discipline to enforce in review: one shared prop contract, two implementations. If the web version needs three extra props, the abstraction is in the wrong place.

Step 3: audit your native dependencies

This is where universal projects actually get costed. Sort every native dependency into three buckets.

Works on web today. expo-image, expo-font, expo-constants, expo-router, react-native-svg, react-native-gesture-handler, react-native-reanimated, @shopify/flash-list, most of the async-storage family (backed by localStorage). Reanimated in particular has solid web support, though complex worklet-heavy animations deserve their own testing pass.

Has a web equivalent, but not the same API. Secure storage (expo-secure-store has no web analogue — see the note below), camera, notifications, maps, in-app purchases, biometrics. These get a platform-split module with a deliberately narrowed contract.

Does not and should not exist on web. Background tasks, silent pushes, live activities, StoreKit and Play Billing, anything talking to the OS's task scheduler. These belong behind a capability flag, not behind a broken button.

That third bucket is the one to be blunt with stakeholders about. "The web build has the same screens minus purchases and background sync" is a fine answer. Discovering it three weeks before launch is not.

The security gotcha worth calling out

On native you may be keeping refresh tokens in the Keychain or Keystore. There is no browser equivalent, and localStorage is readable by any injected script. On web, use httpOnly, Secure, SameSite cookies issued by your backend, and keep the token exchange server-side — which usually means output: "server" or a separate API. Do not let a shared TokenStore module quietly downgrade your native security model to localStorage. Our auth and secure storage post covers the native half; the web half is a different threat model and needs its own review.

Step 4: make the web build feel like the web

Sharing rendering code does not mean sharing interaction assumptions. The things reviewers always flag:

  • Hover, focus, and keyboard. Pressable gives you hovered and focused states via its style callback on web. Use them. Every interactive element needs a visible focus ring and must be reachable by Tab.
  • Real text semantics. Headings should render as headings. In Expo Router, role="heading" / aria-level on Text produces the right element for screen readers and for search engines.
  • Scrolling and layout. Native apps assume fixed-height screens; the web assumes a scrolling document. flex: 1 wrappers that work perfectly on a phone can collapse to zero height in the browser. Test at 1440px wide, not just at phone widths, and add breakpoints rather than letting a 400px-wide column stretch to the full viewport.
  • Back navigation. Browser back must work. Expo Router handles this, but modals implemented as native-only presentation styles need a URL-addressable fallback.
  • Metadata. Static output plus per-route <head> tags (via expo-router/head) gives you titles, descriptions, and Open Graph images. Without them, a static build is indexable but indistinguishable.

Step 5: size and performance

Web users pay for your bundle on every cold visit, which is a very different economics from an app download. Three habits that pay off:

  1. Check what react-native-web pulled in. Run a bundle analysis on the web output before you celebrate. A single stray import of a large native-shim library can dominate the payload.
  2. Lazy-load heavy routes. A charting screen or a PDF viewer should not be in the entry chunk. File-based routing plus dynamic imports makes this straightforward.
  3. Watch your list rendering. Virtualised lists behave differently in a document that scrolls. If you tuned FlashList for native, re-measure on web — our list performance notes apply, but the numbers will not transfer.

Also add web to CI as a first-class target. A web build that only ever runs on one engineer's laptop breaks silently. Type-check, build, and run at least a smoke-level end-to-end pass against the web output on every PR.

When not to share

We advise clients against a universal web build in a few recurring situations:

  • The web audience wants something else entirely. If "we need web" really means "we need a fast, indexable marketing site," build that with a web-native stack. Do not pay universal-app tax to render a landing page.
  • The app is 80% device capability. A camera-first, BLE-first, or offline-field-work app that loses most of its function in a browser produces a web build that mainly generates support tickets.
  • One team, three platforms, no slack. Universal means three sets of bugs, three sets of release notes, and three sets of QA. It is cheaper than three codebases, but it is not free, and it is not one platform's worth of work.

Where it does pay off, it pays off well: admin and back-office screens, onboarding and account flows, shareable read-only views of in-app content, and support tooling that reuses your existing components and API client.

A sensible rollout order

If you are adding web to an existing React Native app, do it in this order rather than all at once:

  1. Enable web, get the app to build (not look right — build), and fix resolution errors.
  2. Split the native-only modules behind capability-flagged interfaces.
  3. Ship one low-risk route to web — an authenticated settings or account page, typically — behind a real URL.
  4. Add responsive breakpoints and keyboard/focus handling to your shared component library.
  5. Only then decide whether marketing pages belong in the same static build or in a separate site.

Each step is independently useful, and you can stop after step three with no regrets if the business case changes.


Need a second opinion before committing to a universal build? AppReactors reviews React Native codebases for web-readiness — dependency audits, a realistic capability matrix, and an estimate you can take to a stakeholder. Get in touch.