Install and the minimum that works
npx expo install react-native-webviewUse expo install rather than npm install — the library contains native code, and the version has to line up with your Expo SDK. It runs in Expo Go, so you do not need a development build just to try it. In a bare React Native project, install with npm and run npx pod-install.
import { useRef, useState } from 'react';
import { ActivityIndicator, StyleSheet, View } from 'react-native';
import { WebView } from 'react-native-webview';
export default function HelpCentre() {
const [loading, setLoading] = useState(true);
return (
<View style={styles.fill}>
<WebView
source={{ uri: 'https://example.com/help' }}
style={styles.fill}
onLoadEnd={() => setLoading(false)}
onError={(e) => console.warn('webview error', e.nativeEvent)}
onHttpError={(e) => console.warn('http', e.nativeEvent.statusCode)}
// stop taps on external links from hijacking the screen
setSupportMultipleWindows={false}
allowsBackForwardNavigationGestures
/>
{loading && <ActivityIndicator style={StyleSheet.absoluteFill} />}
</View>
);
}
const styles = StyleSheet.create({ fill: { flex: 1 } });The flex: 1 is not decoration. A WebView does not measure itself against its content the way a View does — with no height constraint it collapses to nothing and you get the blank screen that sends people to Stack Overflow. If the WebView sits inside a ScrollView, give it an explicit numeric height, because “as tall as the page” is information only the page has.
When a WebView is the right answer
| Use case | Verdict | Why |
|---|---|---|
| Third-party checkout or payment page | ✅ Good fit | You must not reimplement it, and PCI scope stays out of your app |
| Help centre, docs, changelog, legal text | ✅ Good fit | Content changes without a store release |
| OAuth / SSO consent screen | ⚠️ Use the auth session API | Providers increasingly block embedded WebViews |
| A rich text or code editor you already have on web | ⚠️ Workable | Great reuse, but keyboard and selection need real work |
| Your entire app, wrapped | ❌ Bad fit | Guideline 4.2 rejection, and it feels wrong on device |
| Rows inside a scrolling list | ❌ Bad fit | A browser engine per row — memory and jank |
The pattern in that table is a single question: is the web content a component of your app, or is it the app? A checkout inside an otherwise native shopping flow is a component. A login screen, a product list, and a settings page all served from your website is the app, and reviewers treat it as one.
The OAuth row deserves its own note. Google, and a growing number of identity providers, refuse to serve their consent screens inside an embedded WebView, because a host app can read everything typed into it. Use expo-auth-session or an in-app browser tab instead — the authentication guide has the working setup.
Talking to the page, and hearing back
The bridge is two one-way string channels. From the page, you post up; from React Native, you inject down.
const ref = useRef(null);
// runs once when the document loads — must end with true;
const INJECTED = `
document.addEventListener('click', (e) => {
const el = e.target.closest('[data-native]');
if (el) window.ReactNativeWebView.postMessage(
JSON.stringify({ type: 'action', id: el.dataset.native })
);
});
true;
`;
function onMessage(event) {
let msg;
try { msg = JSON.parse(event.nativeEvent.data); } catch { return; }
if (msg?.type === 'action') handleAction(msg.id); // never trust the shape
}
// native → web, any time after load
function applyTheme(theme) {
ref.current?.injectJavaScript(
`document.documentElement.dataset.theme = ${JSON.stringify(theme)}; true;`
);
}
<WebView
ref={ref}
source={{ uri: PAGE }}
injectedJavaScript={INJECTED}
onMessage={onMessage}
/>;Two details that cost people an afternoon each. Injected scripts must evaluate to a value — end the string with true; or iOS may warn and behave inconsistently. And an onMessage handler must exist for the bridge to be installed at all on some platforms, so if postMessage appears to do nothing, check you passed the prop.
Treat every message as untrusted input. Any script running on that page — including one you did not write, on a domain you do not control — can call window.ReactNativeWebView.postMessage. Validate the type, validate the fields, and never eval, navigate, or write to storage from a message body without checking it first. If the page loads third-party content, restrict what the bridge can trigger to a small allowlist of actions.
The five problems everyone hits
- Sessions do not carry over. The WebView keeps its own cookie jar. Set
sharedCookiesEnabledon iOS,thirdPartyCookiesEnabledwhere you need it on Android, and note thatsource={{ uri, headers }}applies those headers to the first request only — every link tapped afterwards goes out bare. - Android back closes the screen instead of going back. Wire
onNavigationStateChangeto trackcanGoBack, then handle the hardware back button and callref.current.goBack()while there is history left. - File inputs do nothing on Android. Uploading needs
allowFileAccessplus the camera and storage permissions declared in your app config. On iOS the picker works out of the box; on Android an<input type="file">silently fails without them. - Local HTML paths break in release builds.
source={{ html }}is reliable for a self-contained string; bundled asset files resolve differently once packaged. If you are shipping a local editor or renderer, inline the CSS and JavaScript into the HTML string and test a release build early. - The keyboard covers the field.The page’s own scrolling does not know about your native layout. Give the WebView a real flex container rather than nesting it in a scroll view, and see the KeyboardAvoidingView guide for the platform behaviour underneath it.
Build the screen natively, keep the WebView for what it is good at
The reason wrapped-website apps get rejected is also the reason users uninstall them: scrolling feels wrong, the back gesture does the wrong thing, and nothing works offline. A WebView earns its place when it is one screen out of fifteen, doing a job you genuinely should not rebuild.
If the reason you are reaching for a WebView is that building the native screens sounds slow, that is worth testing before you commit. Describe the app — “a store with a product list, product detail, cart, and a hosted checkout” — and ShipNative generates it as real React Native running on your phone, with the WebView left where it belongs: around the checkout. You own and export the code either way.