WebViews in React Native: react-native-webview Patterns and Pitfalls (2026)

A practical react-native-webview guide: the version to install, onMessage and injectJavaScript, navigation control, security settings, blank screen fixes.

WebViews in React Native: react-native-webview Patterns and Pitfalls

Every React Native app I have worked on ended up with a WebView somewhere. A terms page. A payment provider’s hosted checkout. A help center someone else owns. An OAuth screen that should not have been a WebView at all. The component looks like the simplest thing in the app, and it is the one that produces the strangest bug reports: a blank white screen after the app comes back from the background, a message from the page that arrives on iOS and silently vanishes on Android, a link that opens inside your app when it should have opened Safari.

This is the guide to react-native-webview I wish I had the first time. It covers which version to install in late 2026, loading content, two-way messaging between the page and your app, navigation control, the security settings that matter, and the failure modes that show up only in production.

Which react-native-webview version to install

react-native-webview is the community-maintained WebView for React Native (MIT, about 5.9 million downloads a week, roughly 7.2K stars). The version situation is less obvious than it should be, because the npm tags tell three different stories as of October 2026:

  • latest = 14.0.1 (June 20, 2026). 14.0.0 dropped support below Android 7.0, so API 24 is now the floor, and removed the legacy file upload paths that existed for older Android versions.
  • next = 16.0.0 (July 11, 2026). Removes the legacy architecture entirely. The New Architecture is required.
  • 15.0.0 (July 5, 2026) was a Windows release: the Windows implementation now requires react-native-windows on the New Architecture. The maintainer kept 14 as latest because of the size of the change.

If you use Expo, do not pick at all. Run npx expo install react-native-webview and let the SDK choose. SDK 57 pins 13.16.1 and the SDK 58 beta pins 14.0.1. For a bare React Native app on 0.86 or 0.87 with the New Architecture on (the default since 0.76), 14.0.1 is the safe choice today, and 16.0.0 is fine if you have tested it. If you are still on the legacy architecture for some reason, stay on 14.x and treat 16 as your deadline.

# Expo
npx expo install react-native-webview

# Bare React Native
npm install [email protected]
cd ios && pod install

The core React Native WebView was removed years ago, so if an old tutorial imports WebView from react-native, close the tab.

Loading a URL or inline HTML

The minimal component takes a source. Give it a size, because a WebView inside a View with no flex will happily render at zero height and look like a blank screen.

import { WebView } from 'react-native-webview';
import { SafeAreaView } from 'react-native-safe-area-context';

export function HelpScreen() {
  return (
    <SafeAreaView style={{ flex: 1 }}>
      <WebView
        source={{ uri: 'https://help.example.com' }}
        style={{ flex: 1 }}
        startInLoadingState
      />
    </SafeAreaView>
  );
}

For inline HTML, pass source={{ html }}. Set a baseUrl if the HTML references relative assets, and add a viewport meta tag, otherwise the page renders at desktop width and looks tiny on a phone:

const html = `
  <!doctype html>
  <html>
    <head><meta name="viewport" content="width=device-width, initial-scale=1" /></head>
    <body><h1>Receipt #1042</h1></body>
  </html>`;

<WebView originWhitelist={['*']} source={{ html, baseUrl: '' }} />

Note the originWhitelist={['*']}. Inline HTML has an about:blank style origin, and the default whitelist is http://* and https://*, so without it some platforms will try to hand the content to the OS. Keep the wildcard for inline HTML only. For remote pages, narrow it, which we get to below.

Loading states and errors

startInLoadingState plus renderLoading gives you a spinner until the first load finishes. onError fires for network level failures (DNS, offline, TLS). onHttpError fires for HTTP status codes of 400 and above, which onError does not report. You almost always want both, plus renderError for a screen that offers a retry:

const ref = useRef<WebView>(null);

<WebView
  ref={ref}
  source={{ uri }}
  startInLoadingState
  renderLoading={() => <ActivityIndicator style={StyleSheet.absoluteFill} />}
  onHttpError={(e) => log('http', e.nativeEvent.statusCode, e.nativeEvent.url)}
  renderError={() => (
    <RetryView onRetry={() => ref.current?.reload()} />
  )}
/>

Talking to the page: onMessage, postMessage, injectJavaScript

This is the part people actually need, and where most of the confusion lives. There are four channels and they are not symmetrical.

Page to app: window.ReactNativeWebView.postMessage

Inside the page, call window.ReactNativeWebView.postMessage(string). In the app, handle it with onMessage. Two rules from the official guide that cause most of the “my messages do not arrive” reports:

  1. You must set onMessage, or window.ReactNativeWebView.postMessage is not injected into the page at all.
  2. It takes one string. Send JSON and parse it on the other side.
type PageEvent =
  | { type: 'checkout:done'; orderId: string }
  | { type: 'height'; value: number };

function onMessage(e: WebViewMessageEvent) {
  let msg: PageEvent;
  try {
    msg = JSON.parse(e.nativeEvent.data);
  } catch {
    return; // ignore anything that is not ours
  }
  if (msg.type === 'checkout:done') navigation.replace('Thanks', { orderId: msg.orderId });
}

<WebView source={{ uri }} onMessage={onMessage} />

On the web side, guard the call so the same page still works in a normal browser:

function notifyApp(payload) {
  if (window.ReactNativeWebView) {
    window.ReactNativeWebView.postMessage(JSON.stringify(payload));
  }
}

App to page: injectJavaScript and postMessage

From the app, ref.current.injectJavaScript(code) runs a string of JavaScript in the page. The guide’s advice is worth repeating word for word: end the injected string with true;, “or you’ll sometimes get silent failures”.

ref.current?.injectJavaScript(`
  window.dispatchEvent(new CustomEvent('app:theme', { detail: ${JSON.stringify(theme)} }));
  true;
`);

There is also a postMessage(str) method on the ref, which delivers a message event inside the page. I prefer injectJavaScript with a custom event because it is explicit about what lands where, but either works. Use JSON.stringify for anything you interpolate. Building JavaScript by string concatenation from user input is how you inject someone else’s code into your own WebView.

Before the page loads: injectedJavaScriptBeforeContentLoaded and injectedJavaScriptObject

Two props run before or during load rather than on demand:

  • injectedJavaScript runs after the document finishes loading.
  • injectedJavaScriptBeforeContentLoaded runs before the page’s own scripts, which is where you set flags the page reads on boot (window.isNativeApp = true;).

Both default to the main frame only. On iOS you can set injectedJavaScriptForMainFrameOnly={false} to reach iframes; Android supports main frame only.

For passing data rather than code, injectedJavaScriptObject is cleaner. The page reads it with window.ReactNativeWebView.injectedObjectJson(). The docs carry a warning you should take literally: the object is readable by all frames of the page, so never put a token in it unless you control every frame and have a strict Content Security Policy.

<WebView
  source={{ uri: 'https://app.example.com/embedded' }}
  injectedJavaScriptObject={{ locale: 'en-GB', theme: 'dark' }}
/>

Controlling navigation

A WebView is a browser, and browsers follow links. Most apps want to keep users on one domain and send everything else to the system browser. onShouldStartLoadWithRequest is the hook: return true to let the WebView load the URL, false to block it.

import { Linking } from 'react-native';

const ALLOWED = 'https://help.example.com';

<WebView
  source={{ uri: ALLOWED }}
  originWhitelist={['https://*']}
  onShouldStartLoadWithRequest={(req) => {
    if (req.url.startsWith(ALLOWED)) return true;
    Linking.openURL(req.url);
    return false;
  }}
/>

originWhitelist is the coarser filter: anything outside it is handed to the OS. onShouldStartLoadWithRequest is the fine one. Use both. Links with target="_blank" and window.open calls go through onOpenWindow instead, so handle that too if the page uses them.

The Android back button

On Android, the hardware back button closes your screen even when the WebView has history. Track canGoBack from onNavigationStateChange and intercept back:

const [canGoBack, setCanGoBack] = useState(false);

useEffect(() => {
  const sub = BackHandler.addEventListener('hardwareBackPress', () => {
    if (canGoBack) {
      ref.current?.goBack();
      return true; // we handled it
    }
    return false;
  });
  return () => sub.remove();
}, [canGoBack]);

<WebView
  ref={ref}
  source={{ uri }}
  onNavigationStateChange={(s) => setCanGoBack(s.canGoBack)}
  allowsBackForwardNavigationGestures // iOS swipe back
/>

Headers and cookies

Custom headers in source.headers are sent on the first request only. Navigations after that go out without them, which is why “my auth header works until the user taps a link” is a recurring issue. If the page needs authentication across navigations, a cookie set by your backend is more reliable than a header. On iOS, sharedCookiesEnabled makes the WebView use the shared cookie store; on Android, thirdPartyCookiesEnabled controls third-party cookies.

Security settings that matter

A WebView runs someone’s JavaScript inside your app process boundary, next to a bridge you wrote. Treat it accordingly. We covered the app-wide picture in our React Native security checklist; here is the WebView slice.

  • Narrow originWhitelist for remote content. The default is all of HTTP and HTTPS.
  • Validate every onMessage payload as untrusted input. Check e.nativeEvent.url before acting on a message, so a page you navigated to by accident cannot trigger a native action meant for your own page.
  • Leave allowFileAccess off on Android (default false) unless you load local files, and keep allowFileAccessFromFileURLs and allowUniversalAccessFromFileURLs off.
  • Keep mixedContentMode at never (the Android default).
  • Leave setSupportMultipleWindows at true (the default). The docs warn that setting it to false can let a malicious iframe escape into the top-level DOM.
  • Turn webviewDebuggingEnabled on in development only. It defaults to false and lets Safari or Chrome inspect the page, which is exactly what you want in dev and exactly what you do not want in a release build: webviewDebuggingEnabled={__DEV__}.
  • Do not do OAuth in a WebView. Google blocks sign-in from embedded WebViews, and the user cannot tell whether the password field belongs to you. Use the system browser via expo-auth-session or react-native-app-auth with PKCE.

Production failure modes

The blank white screen after backgrounding (iOS)

iOS renders web content in a separate process, and that memory does not count toward your app. The OS can kill it independently while your app sits in the background. When the user returns, the WebView is white. The fix is onContentProcessDidTerminate:

<WebView
  ref={ref}
  source={{ uri }}
  onContentProcessDidTerminate={() => ref.current?.reload()}
  onRenderProcessGone={() => ref.current?.reload()} // Android equivalent, API 26+
/>

On Android, the matching event is onRenderProcessGone. Its nativeEvent.didCrash tells you whether the renderer crashed or the system reclaimed it for memory. Either way, reload or remount the WebView, because the instance you have is dead.

Video that goes fullscreen when it should not (iOS)

HTML5 video on iOS opens the native fullscreen player by default. For inline playback you need allowsInlineMediaPlayback on the WebView and the playsinline attribute on the <video> element (the docs mention webkit-playsinline). For autoplay, set mediaPlaybackRequiresUserAction={false}.

File downloads and uploads

Uploads via <input type="file"> mostly just work. On iOS you need the usage descriptions in Info.plist (NSCameraUsageDescription, NSPhotoLibraryUsageDescription, and NSMicrophoneUsageDescription for video), or the picker crashes the moment the user taps it. On Android no storage permission is needed, but if the input uses the capture attribute, add the IMAGE_CAPTURE intent to the <queries> block of your manifest. Downloads are the harder half: on iOS, onFileDownload fires when a response is an attachment or a type the WebView cannot render, and your app has to fetch and save the file itself (with expo-file-system or similar). On Android, downloads go through the system download manager.

Scroll fights inside a ScrollView

Putting a WebView inside a ScrollView gives you two scrollers fighting over the same gesture. Either let the WebView own the scroll and size it with flex: 1, or disable its scrolling with scrollEnabled={false} and send the document height out with postMessage so you can size it yourself. On Android, nestedScrollEnabled helps when you genuinely need a scrolling WebView inside a scrolling parent.

Text that blows up on Android

If the user has a large system font size, Android scales the page text along with it and your layout breaks. textZoom={100} pins it. Decide on purpose, because that same setting is an accessibility feature for the user.

When not to use a WebView

Expo’s DOM components ('use dom') render a React web component inside a WebView for you, with props and callbacks wired across the boundary, which is a much nicer API when the content is your own React code. For Markdown or rich text, a native renderer will scroll and select text better than a WebView. For a full checkout, the provider’s native SDK beats a hosted page if one exists. And for anything that is going to be the main screen of your app, a WebView wrapped in a native shell will feel like one, and users notice. If you are weighing that kind of trade-off for a whole app, our guide to Expo and React vs React Native cover where the line sits.

For the occasional web page, a help center or a terms page or a hosted form, react-native-webview is exactly the right tool. Set the size, set onMessage, filter navigation, handle the process termination events, and it will behave.

FAQ

Why is my React Native WebView blank?

Usually one of three things: the WebView has no height (give it flex: 1 or an explicit height), the page was blocked by originWhitelist (inline HTML needs ['*']), or on iOS the content process was terminated in the background and needs a reload in onContentProcessDidTerminate.

Why does window.ReactNativeWebView.postMessage not exist on my page?

It is only injected when the WebView has an onMessage prop. Add one, even an empty handler, and make sure you pass a string.

Does react-native-webview work with Expo Go?

Yes. It is included in Expo Go, and npx expo install react-native-webview picks the version that matches your SDK (13.16.1 for SDK 57, 14.0.1 for the SDK 58 beta).

Should I upgrade to react-native-webview 16?

Only if you are on the New Architecture, because 16.0.0 removes legacy architecture support. As of October 2026 it is on the next tag and latest is still 14.0.1.

How do I debug a WebView in React Native?

Set webviewDebuggingEnabled={__DEV__}, then open Safari’s Develop menu (iOS 16.4 and later) or chrome://inspect (Android) to get full devtools on the page.

Leave a Reply 0

Your email address will not be published. Required fields are marked *