React Native WebView Tutorial with Expo (2026): postMessage, JavaScript Injection, and Security

Learn how to add a WebView to your Expo React Native app in 2026. This guide covers install, postMessage bridging, safe JavaScript injection, cookies, file uploads, debugging with Chrome DevTools, and the security defaults every production app needs.

React Native WebView Expo Tutorial (2026)

Updated: July 28, 2026

A React Native WebView embeds a full web browser view inside your app so you can render remote URLs, local HTML, or third-party widgets without leaving the native shell. In 2026, the recommended package is react-native-webview 14.x, which ships as a first-class Expo config plugin and works with the New Architecture on both iOS (WKWebView) and Android (WebView). This tutorial covers installation, two-way communication with postMessage, safe JavaScript injection, cookies, file uploads, and the security defaults you really need to set before shipping.

  • Install react-native-webview 14.x with npx expo install react-native-webview. No extra config plugin entry is needed for Expo SDK 54+.
  • Use injectedJavaScriptBeforeContentLoaded plus window.ReactNativeWebView.postMessage() to send structured JSON from web to native, and webviewRef.current.injectJavaScript() to push data the other way.
  • Always set originWhitelist to an explicit allow-list. The default ['*'] lets any origin load and isn't safe for production.
  • For OAuth or single-page auth flows, prefer expo-auth-session or expo-web-browser over an embedded WebView (Google, Facebook, and Apple all block sign-in inside embedded WebViews).
  • File uploads, camera capture, and downloads require platform-specific permissions in app.json plus a build via EAS or npx expo prebuild. Expo Go can't host them.
  • Debug WebView content with Safari Web Inspector on iOS and chrome://inspect on Android. Enable webviewDebuggingEnabled on release builds only when investigating a specific bug.

Installing react-native-webview in an Expo project

Start from an Expo SDK 54 or 55 project. Because react-native-webview ships native code, you can't preview it in the classic Expo Go client on iOS 18+, so you'll need either a development build or a bare workflow. From your project root, run:

npx expo install react-native-webview
npx expo prebuild --clean
npx expo run:ios   # or run:android

Expo SDK 54 pins react-native-webview to ^14.0.0. If your package.json lists an older major, remove it first. 13.x and 14.x differ in how onMessage resolves promises and how source URIs get validated (I hit this exact bug during a client upgrade last month, and the symptoms were maddeningly silent). When you use EAS Build, add the package to expo-doctor's allow-list or run npx expo-doctor to catch mismatched versions before submitting.

You don't need to add anything to the plugins array in app.json for basic playback. However, if you plan to enable file uploads on Android or camera access on iOS, add the permissions block described in the file uploads section below. For a broader look at Expo config plugins and custom mods, see our Expo Config Plugins guide.

Once the dev build launches, verify installation with a minimal component:

import { WebView } from 'react-native-webview';

export default function Browser() {
  return (
    <WebView
      source={{ uri: 'https://reactnative.dev' }}
      style={{ flex: 1 }}
      originWhitelist={['https://reactnative.dev']}
    />
  );
}

Wrap the WebView in a SafeAreaView so status bar insets don't clip the page header, and always set an explicit style or a parent with flex: 1. WebViews default to zero height and will silently render nothing otherwise. Honestly, this is the number-one "why is my screen blank" question in the react-native-webview issue tracker.

Rendering a URL, local HTML, and static assets

The source prop accepts three shapes: a remote URI, an inline HTML string, or a bundled asset. Each unlocks a different pattern.

Remote URLs are the simplest case. Pass { uri: 'https://...' } and set originWhitelist to the exact host you trust. For an inline landing page, use the html key with a baseUrl so relative asset paths resolve:

<WebView
  originWhitelist={['*']}
  source={{
    html: `<!doctype html>
      <html><head><meta name="viewport" content="width=device-width, initial-scale=1"></head>
      <body style="font-family: system-ui; padding: 24px">
        <h1>Hello from a local page</h1>
        <p>Rendered without a network request.</p>
      </body></html>`,
    baseUrl: '',
  }}
/>

To ship a bundled HTML file (useful for offline docs, print previews, or charting libraries), place the file in an assets/ folder, register it in metro.config.js, and pass it through require:

// metro.config.js
const { getDefaultConfig } = require('expo/metro-config');
const config = getDefaultConfig(__dirname);
config.resolver.assetExts.push('html');
module.exports = config;

// component
<WebView source={require('./assets/invoice.html')} />

For inline HTML, remember that the viewport meta tag isn't injected automatically. Text renders desktop-sized without it, so add the meta tag in your HTML string or the page will look zoomed out on high-density devices. When the source is dynamic (for example, HTML you fetched from an API), sanitize it with a library like dompurify before assigning to html. A WebView will happily execute any script the string contains.

How to communicate between WebView and React Native

Two-way messaging is the reason most teams reach for a WebView instead of an in-app browser. The library exposes a global window.ReactNativeWebView.postMessage(payload) function inside the page, and React Native receives strings through onMessage. Wrap payloads in JSON to keep them structured:

import { useRef } from 'react';
import { WebView } from 'react-native-webview';

const html = `
  <button id="ping">Send to native</button>
  <script>
    document.getElementById('ping').addEventListener('click', () => {
      window.ReactNativeWebView.postMessage(
        JSON.stringify({ type: 'PING', ts: Date.now() })
      );
    });
    // Native -> web listener
    document.addEventListener('message', (event) => {
      document.body.insertAdjacentHTML(
        'beforeend',
        '<p>From native: ' + event.data + '</p>'
      );
    });
  </script>
`;

export default function Bridge() {
  const ref = useRef<WebView>(null);

  return (
    <WebView
      ref={ref}
      originWhitelist={['*']}
      source={{ html }}
      onMessage={(event) => {
        const msg = JSON.parse(event.nativeEvent.data);
        if (msg.type === 'PING') {
          ref.current?.injectJavaScript(
            `document.dispatchEvent(new MessageEvent('message', { data: 'pong' })); true;`
          );
        }
      }}
    />
  );
}

Three details bite people in production. First, injectJavaScript requires the final expression to evaluate to a truthy value on iOS, so that trailing true; isn't optional. Second, on Android the built-in document.addEventListener('message', ...) pattern works, but on iOS you may need window.addEventListener. Test both platforms. Third, only strings cross the bridge, so always JSON.stringify before sending and validate the shape after parsing. A malformed payload will crash the parser and leave your app in a stuck state.

How to inject JavaScript into a React Native WebView

There are four injection points, each with different timing and safety trade-offs:

  • injectedJavaScriptBeforeContentLoaded: runs before the page's own scripts. Use this to define globals, seed window.__INITIAL_STATE__, or stub network calls before the page boots.
  • injectedJavaScript: runs after the page loads. Best for reading the DOM or attaching event listeners.
  • ref.injectJavaScript(code): runs on demand any time after mount. Best for pushing native events (auth token refresh, theme change) into an already-loaded page.
  • Inline <script> in the html source. Best for pages you own end-to-end.
<WebView
  source={{ uri: 'https://dashboard.example.com' }}
  originWhitelist={['https://dashboard.example.com']}
  injectedJavaScriptBeforeContentLoaded={`
    window.__APP_CONTEXT__ = ${JSON.stringify({ userId, theme, locale })};
    true;
  `}
  injectedJavaScript={`
    document.querySelector('header')?.remove();
    window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'READY' }));
    true;
  `}
/>

When you inject in response to a native event (say, toggling dark mode from a React Navigation header), call injectJavaScript from a useEffect so the page hears the change immediately. For patterns to persist that theme across restarts, our guide on React Native dark mode with Expo shows the AsyncStorage integration side.

Cookies, localStorage, and session persistence

Cookies and localStorage live inside the WebView's own storage sandbox. They do not automatically sync with fetch calls made from React Native. That distinction is the number-one source of "why is my user logged out again?" bug reports.

To share cookies between platforms:

  • iOS: WKWebView shares its cookie store with SFSafariViewController but not with your app's URLSession. Use sharedCookiesEnabled to let the app's URLSession contribute cookies to the WebView.
  • Android: Cookies live in CookieManager. Toggle thirdPartyCookiesEnabled if you embed cross-origin iframes.
  • Both: Set incognito={true} for one-shot flows (payments, auth confirmations) where you don't want to persist state.

For long-lived sessions, prefer to hold the auth token in expo-secure-store on the native side and inject it into the page via injectedJavaScriptBeforeContentLoaded. That way the credential never touches WebView storage and can't be exfiltrated by a malicious script on the page. The same pattern powers the biometric-gated auth flow in our React Native authentication guide.

const token = await SecureStore.getItemAsync('session');

<WebView
  source={{ uri: 'https://app.example.com' }}
  originWhitelist={['https://app.example.com']}
  injectedJavaScriptBeforeContentLoaded={`
    window.__AUTH__ = ${JSON.stringify({ token })};
    true;
  `}
  sharedCookiesEnabled
/>

File uploads, camera capture, and downloads

File input elements (<input type="file">) work out of the box on iOS in react-native-webview 14.x. On Android, you have to add three things: the storage permissions, camera permission if you allow capture="camera", and the allowFileAccess prop.

// app.json
{
  "expo": {
    "android": {
      "permissions": [
        "android.permission.READ_MEDIA_IMAGES",
        "android.permission.CAMERA"
      ]
    },
    "ios": {
      "infoPlist": {
        "NSCameraUsageDescription": "Upload photos to your account",
        "NSPhotoLibraryUsageDescription": "Attach files to your account"
      }
    }
  }
}
<WebView
  source={{ uri: 'https://upload.example.com' }}
  originWhitelist={['https://upload.example.com']}
  allowFileAccess
  allowsInlineMediaPlayback
  mediaPlaybackRequiresUserAction={false}
  onFileDownload={({ nativeEvent }) => {
    Linking.openURL(nativeEvent.downloadUrl);
  }}
/>

Rebuild the dev client after editing app.json, because permissions are baked at compile time. For downloads, iOS emits an onFileDownload event but doesn't save to disk automatically. Hand the URL off to expo-file-system and use FileSystem.downloadAsync to write it into the app sandbox, then expo-sharing to expose it to the OS share sheet.

React Native WebView security checklist

WebViews expand your app's attack surface to include every URL you render. The 2026 defaults are safer than 2020's, but you still need to opt in. Work down this checklist before releasing:

  1. Set originWhitelist explicitly. The default of ['*'] permits navigations to any host, so a phishing link inside your controlled page can take the user anywhere. List each domain you trust.
  2. Gate navigation with onShouldStartLoadWithRequest. Return false for URLs outside your allow-list and hand them to Linking.openURL instead. This keeps external links out of your app's cookie jar.
  3. Disable file access (allowFileAccessFromFileURLs={false}) and universal access (allowUniversalAccessFromFileURLs={false}) unless you have a specific need. They let file-scheme pages read arbitrary files.
  4. Set mixedContentMode="never" on Android to block HTTP subresources on HTTPS pages.
  5. Verify certificates. Don't ship with onReceivedSslError handlers that call handler.proceed(). If you need pinning, use the trustedHosts prop.
  6. Sanitize injected data. Every string that flows from native into injectedJavaScript should be JSON.stringify'd.
  7. Handle errors defensively. Log onError and onHttpError to Sentry. A blank white WebView is a common way for silent failures to reach users. Our error handling guide shows the wiring.

For the underlying platform behaviour, read the react-native-webview API reference, and for browser messaging semantics the MDN Window.postMessage documentation.

Debugging WebView content in 2026

WebView content is invisible to the standard React Native DevTools, so you need the platform's browser inspector.

On iOS, connect the device or simulator to a Mac, open Safari, enable Develop menu → Simulator (or your device name), and pick the WebView from the list. Set webviewDebuggingEnabled to true in JS (14.x defaults it off on release builds).

On Android, open Chrome and visit chrome://inspect#devices. The WebView shows up when your app is in the foreground. You get the full Chrome DevTools: Elements, Console, Network, Sources, and the performance profiler.

<WebView
  source={{ uri: 'https://app.example.com' }}
  originWhitelist={['https://app.example.com']}
  webviewDebuggingEnabled={__DEV__}
  onError={({ nativeEvent }) => console.warn('WebView error', nativeEvent)}
  onHttpError={({ nativeEvent }) => console.warn('HTTP', nativeEvent.statusCode)}
/>

For performance work, capture a WebView trace with the Chrome DevTools Performance tab and correlate it with a React Native trace in the React Native DevTools panel. When the WebView is the bottleneck (long tasks, layout thrash), the fix is usually on the web side, not the native side. Our broader performance optimization guide covers native-side profiling if the trace points there.

react-native-webview vs expo-web-browser

These solve different problems, and choosing the wrong one is a very common mistake. Use this table before writing the first line of code:

Dimensionreact-native-webviewexpo-web-browser
Renders inside your view hierarchy?Yes (embedded)No (modal / new tab)
Two-way JS messagingFull (postMessage, injectJavaScript)Return URL only
OAuth flowsBlocked by Google, Facebook, AppleRecommended (uses SFSafariViewController / Chrome Custom Tabs)
Shares cookies with system browserNo (isolated store)Yes
Custom UI (headers, close button)Full controlLimited
Requires dev buildYesNo (works in Expo Go)
Use caseEmbedded dashboards, charts, widgets, in-app contentOAuth, one-shot external links, terms & conditions

The 30-second rule: if the page is your content and you need to script it, use react-native-webview. If the page belongs to someone else and the user is going there to prove identity, sign a document, or read something, use expo-web-browser. For OAuth specifically, the expo-auth-session documentation walks through the PKCE flow with the recommended in-app browser under the hood.

Frequently Asked Questions

Is react-native-webview deprecated in 2026?

No. It's actively maintained under the react-native-webview GitHub organization and moved to a new major (14.x) in early 2026 with New Architecture support. Expo removed the older expo-web preview in favour of this package, and it remains the standard way to embed browser content in a React Native app.

Why is my WebView blank on iOS?

Three usual causes: the parent view has no height (add flex: 1), the target URL uses HTTP on a page that also loads HTTPS resources, or App Transport Security is blocking a non-HTTPS URL. Check the Xcode console for ATS errors and set NSAppTransportSecurity exceptions in app.json only for hosts you control.

Can I use Google Sign-In inside a React Native WebView?

Google, Facebook, and Apple explicitly refuse to sign users in inside embedded WebViews as an anti-phishing measure, so you'll see a "browser not secure" screen. Use expo-auth-session with expo-web-browser instead, which opens SFSafariViewController on iOS and Chrome Custom Tabs on Android.

How do I pass data from React Native to a WebView?

Two options depending on timing. Before the page loads, use injectedJavaScriptBeforeContentLoaded to define a global (for example window.__STATE__). After the page is running, hold a ref to the WebView and call ref.current.injectJavaScript(code). Remember to end the code with a truthy expression like true; on iOS.

How do I clear cookies in a React Native WebView?

Import CookieManager from @react-native-cookies/cookies and call CookieManager.clearAll(true). For a one-off private session, set incognito={true} on the WebView. It starts with a fresh cookie jar and discards it when the component unmounts.

About the Author Editorial Team

Our team of expert writers and editors.