| iOS | Android |
2026-06-01.8.25.40.mov |
Screen_recording_20260601_202840.mp4 |
A React Native WebView built on Nitro Modules β pure Swift / Kotlin native sides, JSI-direct prop and event dispatch, no bridge round-trips.
nitro-webview is a drop-in WebView component for React Native that replaces the legacy bridge with Nitro Modules's JSI-direct dispatch. It targets two audiences:
- Experienced RN + Nitro developers who want a WebView that participates in the Nitro view contract β
getHostComponent, hybrid refs,callback(...)event handlers,Promise<T>method results β without paying for JSON serialization or thread-hops on every prop update or event. - Teams evaluating WebView libraries ("comparison shoppers") who already use
react-native-webviewand want to know what they keep, what changes, and what improves before they switch.
- Same conceptual props (
source,userAgent,injectedJavaScript,onLoadStart/onLoadEnd,onMessage,onError,onShouldStartLoadWithRequest,onFileDownload). - Same
window.ReactNativeWebView.postMessage(...)page-side contract. - Same
originWhitelist-style default (['http://*', 'https://*']) exposed asDEFAULT_ORIGIN_WHITELIST. - Same
WebViewNavigationTypestring union ('click' | 'formsubmit' | 'backforward' | 'reload' | 'formresubmit' | 'other') so existing call-sites compile unchanged.
- Event props must be wrapped in
callback(...)fromreact-native-nitro-modulesso Nitro can dispatch them on the right thread. onShouldStartLoadWithRequestreturnsPromise<boolean>directly β nolockIdentifierround-trip.asynccallbacks are awaited transparently.- Imperative methods (
goBack,evaluateJavaScript,getCookies,setCookie,clearCookies, β¦) live on the hybrid ref captured via thehybridRefprop, not on a Reactref. - Native packages:
io.github.l2hyunwoo.nitrowebview(Android) /NitroWebViewSwift module (iOS). MIT-licensed, npm-published asnitro-webview(unscoped).
Nitro Modules pipes props, methods, and event callbacks through JSI so a load event or a cookie read does not round-trip through NativeEventEmitter or the bridge's serialization queue. For a WebView β which is event-heavy (navigation, messages, errors, downloads) β that is the main practical win.
yarn add nitro-webview react-native-nitro-modules
cd ios && pod installreact-native-nitro-modules is a peer dependency β install it explicitly so your dependency graph stays deterministic.
import { NitroWebView, callback } from 'nitro-webview'
export default function Screen() {
return (
<NitroWebView
style={{ flex: 1 }}
source={{ uri: 'https://example.com' }}
onLoadEnd={callback(() => console.log('loaded'))}
/>
)
}Every event prop must be wrapped in callback(...) so Nitro can dispatch it on the right thread. Passing a raw function will throw at render time.
import { useRef } from 'react'
import { NitroWebView, callback, type NitroWebViewType } from 'nitro-webview'
export default function Screen() {
const ref = useRef<NitroWebViewType | null>(null)
return (
<>
<NitroWebView
style={{ flex: 1 }}
source={{ uri: 'https://example.com' }}
hybridRef={callback((r) => {
ref.current = r
})}
/>
<Button title="reload" onPress={() => ref.current?.reload()} />
</>
)
}iOS and Android both need a small amount of host-app configuration for file upload and download to work β see Platform setup below.
The exported React component. Backed by getHostComponent<NitroWebViewProps, NitroWebViewMethods>('NitroWebView', () => NitroWebViewConfig).
| Prop | Type | Notes |
|---|---|---|
source |
WebViewSource |
{ uri, headers? } or { html, baseUrl? }. Drives navigation. Required. |
defaultHeaders |
Record<string, string> |
Global HTTP headers attached to every main-frame navigation request. Per-request source.headers win on key conflict. |
userAgent |
string |
Overrides the platform default UA for every request (main-frame + sub-resource). undefined / empty restores the WebKit / Chromium default. |
javaScriptEnabled |
boolean |
Enable JS. Default true. Android: mutable. iOS: no-op - Nitro delivers props after WKWebView init, so this is never applied. |
domStorageEnabled |
boolean |
Enable localStorage / sessionStorage. Default true. Android: mutable. iOS: always on (no-op). |
cacheEnabled |
boolean |
HTTP cache. Android: cacheMode LOAD_DEFAULT / LOAD_NO_CACHE. iOS: URLRequest.cachePolicy on the next source load. |
incognito |
boolean |
Non-persistent store. iOS: no-op - Nitro delivers props after WKWebView init, so nonPersistent() is never applied. Android: no first-class mode; cookies stay process-global. |
scrollEnabled |
boolean |
iOS-only: scrollView.isScrollEnabled (mutable). Android: no-op (RNW does not implement it). |
bounces |
boolean |
iOS-only: scrollView.bounces (mutable). Android: no-op. |
scalesPageToFit |
boolean |
Android-only: loadWithOverviewMode + useWideViewPort. iOS: no-op. |
mediaPlaybackRequiresUserAction |
boolean |
Require a gesture before media plays. Default true. Android: mutable. iOS: no-op - Nitro delivers props after WKWebView init, so this is never applied. |
allowsInlineMediaPlayback |
boolean |
iOS-only: no-op - allowsInlineMediaPlayback is only read at WKWebView init, which Nitro's prop delivery always misses. Android: no-op (inline by default). |
allowsBackForwardNavigationGestures |
boolean |
iOS-only: back/forward swipe gestures (mutable). Android: no-op. |
thirdPartyCookiesEnabled |
boolean |
Android-only: setAcceptThirdPartyCookies for this WebView (mutable). iOS: no-op. |
sharedCookiesEnabled |
boolean |
iOS-only: no-op - Nitro delivers props after WKWebView init, so sharing HTTPCookieStorage is never applied. Android: no-op (one process-wide store). |
injectedJavaScript |
string |
Fire-and-forget script run at document-END on every page load. |
injectedJavaScriptBeforeContentLoaded |
string |
Script run at document-START, before the page's own scripts. iOS: WKUserScript(.atDocumentStart) (hard before-any-script guarantee). Android: WebViewCompat.addDocumentStartJavaScript when the WebView supports DOCUMENT_START_SCRIPT, else evaluateJavascript in onPageStarted (early, but not a strict before-first-script guarantee). Main frame only. |
onLoadStart |
(event: WebViewLoadEvent) => void |
Fired when the WebView begins loading content. |
onLoadEnd |
(event: WebViewLoadEvent) => void |
Fired when the WebView finishes loading content. |
onNavigationStateChange |
(state: WebViewNavigationState) => void |
URL / title / canGoBack / canGoForward / loading. |
onMessage |
(event: WebViewMessageEvent) => void |
Fires when the page calls window.ReactNativeWebView.postMessage(...). |
onError |
(event: NitroWebViewErrorEvent) => void |
Navigation failure (network, SSL). |
onFileDownload |
(event: FileDownloadEvent) => void |
Native intercepts a download and surfaces { url, mimeType?, fileName?, contentLength?, userAgent? }. Storage is the JS layer's responsibility. Also fires for blob: downloads, with url resolved to a local file:// (iOS) / data: (Android) URL β see FileDownload. |
onHttpError |
(event: NitroWebViewHttpErrorEvent) => void |
Main-frame HTTP 4xx/5xx ({ statusCode, url, description }). Disjoint from onError (transport/SSL). Sub-resource failures are dropped. |
onRenderProcessGone |
(event: NitroWebViewRenderProcessGoneEvent) => void |
Renderer crash / OS reclaim. nativeEvent.didCrash is Android-only (API 26+); always undefined on iOS. Recover by calling reload(). |
onScroll |
(event: NitroWebViewScrollEvent) => void |
Scroll stream. NOT throttled or deduped natively. iOS populates all geometry fields; Android populates contentOffset only. |
onShouldStartLoadWithRequest |
(event: ShouldStartLoadRequest) => boolean | Promise<boolean> |
Allow/block each navigation before it starts. Returning false (or a Promise resolving to false) cancels silently. Sub-frame (iframe) navigations surface with isTopFrame: false β always on iOS, and on Android only when interceptSubframeNavigation is enabled. |
interceptSubframeNavigation |
boolean |
Opt-in: also intercept sub-frame (iframe) navigations via onShouldStartLoadWithRequest, not just the main frame. Default false. Android note: each intercepted sub-frame navigation blocks the WebView thread up to 250 ms awaiting the JS decision; on iframe-heavy pages this stacks and risks jank / ANR β hence off by default. No effect on iOS (its decidePolicyFor parks asynchronously, so sub-frames already reach the handler). |
onOpenWindow |
(event: OpenWindowEvent) => void |
Fired for window.open / target=_blank. The WebView never spawns a second native web view; nativeEvent.url carries the requested URL and JS decides what to do. When the prop is unset, the URL loads in-place in the current WebView. Notify-only β the return value does not gate loading. |
SPA route changes (history.pushState / replaceState / popstate) surface via onNavigationStateChange β not onShouldStartLoadWithRequest, because a pushState already happened and cannot be vetoed.
The hybrid ref captured by hybridRef={callback((r) => ref.current = r)} exposes:
| Method | Return | Notes |
|---|---|---|
goBack() |
void |
Navigate back in history. |
goForward() |
void |
Navigate forward in history. |
reload() |
void |
Reload the current page. |
stopLoading() |
void |
Stop the current load. |
evaluateJavaScript(code) |
Promise<string> |
Result is the serialized string evaluation. iOS uses String(describing:); Android uses the JSON-encoded ValueCallback<String> result. Undefined/nil surfaces as ''. |
injectJavaScript(code) |
void |
Fire-and-forget execution β no result awaited. Use for side effects only. No-op if no page is loaded. |
postMessage(data) |
void |
Push a string into the page as a DOM message event (event.data === data). Listen on both targets for portability: window.addEventListener('message', ...) (iOS) and document.addEventListener('message', ...) (Android). Dispatched once, no buffering. data is escaped safely (quotes, newlines, </script>, unicode). |
getCookies(url) |
Promise<Cookie[]> |
iOS returns the full attribute set. Android CookieManager only exposes name and value on read β other fields are left undefined. |
setCookie(url, cookie) |
Promise<void> |
Cookie = { name, value, domain?, path?, expires?, secure?, httpOnly? }. expires is milliseconds since epoch (Date.now()-compatible). |
clearCookies() |
Promise<void> |
Bulk clear via WKWebsiteDataStore (iOS) / CookieManager.removeAllCookies (Android). The promise resolves only after the platform reports completion. |
clearCache() |
Promise<void> |
Clear the disk + memory resource cache only (NOT cookies/localStorage/history). iOS scopes removeData to {DiskCache, MemoryCache}; Android calls clearCache(true). |
clearHistory() |
Promise<void> |
Clear back/forward history. Android: WebView.clearHistory(). iOS: no-op β WKWebView.backForwardList is read-only with no public prune API (resolves without clearing; navigate to a fresh source for a pristine stack). |
requestFocus() |
Promise<void> |
Move input focus to the WebView. iOS becomeFirstResponder(); Android requestFocus(). Resolves regardless of the responder's own return value. |
type WebViewSource = UriSource | HtmlSource
interface UriSource {
uri: string
headers?: Record<string, string>
}
interface HtmlSource {
html: string
baseUrl?: string
}UriSource.headers are per-request HTTP headers attached only to the main-frame navigation a source change triggers. Redirects, sub-frames, and sub-resource requests do not re-apply them.
interface ShouldStartLoadRequest {
url: string
navigationType: WebViewNavigationType
mainDocumentURL?: string // iOS only
isTopFrame?: boolean // both platforms β false for iframe / sub-frame
hasTargetFrame?: boolean // iOS only β false for target=_blank
}
type WebViewNavigationType =
| 'click' | 'formsubmit' | 'backforward'
| 'reload' | 'formresubmit' | 'other'isTopFrame is populated on both platforms (targetFrame?.isMainFrame on iOS, WebResourceRequest.isForMainFrame on Android). mainDocumentURL and hasTargetFrame remain iOS-only and are undefined on Android; Android always reports navigationType: 'other'.
The JS callback may be async β the bridge transparently awaits any returned thenable before applying the decision.
interface OpenWindowEvent {
nativeEvent: { url: string }
}Fired by onOpenWindow when the page requests a new window. url is the absolute URL the page asked to open (iOS WKNavigationAction.request.url; Android the URL observed inside WebChromeClient.onCreateWindow).
interface WebViewNavigationState {
url: string
title: string
loading: boolean
canGoBack: boolean
canGoForward: boolean
}
interface WebViewLoadEvent {
nativeEvent: WebViewNavigationState
}interface WebViewMessageNativeEvent {
data: string // literal string from window.ReactNativeWebView.postMessage(...)
url: string
}
interface WebViewMessageEvent {
nativeEvent: WebViewMessageNativeEvent
}interface NitroWebViewErrorNativeEvent {
code: number // NSError.code (iOS) / WebResourceError.getErrorCode() (Android)
description: string // localizedDescription (iOS) / getDescription().toString() (Android)
url: string // empty string when neither delegate nor error provided one
domain: string // NSError.domain (iOS) / stable string mirror (Android)
}
interface NitroWebViewErrorEvent {
nativeEvent: NitroWebViewErrorNativeEvent
}
type WebViewErrorEvent = NitroWebViewErrorEvent // aliasinterface Cookie {
name: string
value: string
domain?: string // platform-derived from url when omitted
path?: string // defaults to '/'
expires?: number // ms since Unix epoch; omit for a session cookie
secure?: boolean // restrict to HTTPS
httpOnly?: boolean // hide from document.cookie
}interface FileDownload {
url: string // normal download: remote http/https URL.
// blob download: a resolved LOCAL reference (see below).
mimeType?: string
fileName?: string // iOS: URLResponse.suggestedFilename
// Android: DownloadUtils.guessFileName (Content-Disposition)
contentLength?: number // -1 or absent when the platform did not supply a length
userAgent?: string // typically absent on iOS
}
interface FileDownloadEvent {
nativeEvent: FileDownload
}Blob downloads (blob:) β deliberately platform-asymmetric. A blob:
URL is not fetchable natively (its bytes live only in the web context), so the
two platforms resolve it differently and onFileDownload.nativeEvent.url
carries a local reference instead of the blob: URL:
- iOS streams the blob to a temp file natively via
WKDownloadDelegate(iOS 14.5+) βurlis a localfile://URL. No bytes cross the JS bridge. - Android has no
WKDownloadDelegateequivalent, so it injects a reader that resolves the blob in-page (fetch β FileReader.readAsDataURL) and bridges it back βurlis adata:URL (base64). This is O(fileSize) in memory; fine for the common blob (generated CSV/PDF/image, a few MB), but a very large blob will strain the bridge.
Either way a consumer already listening to onFileDownload receives blob
downloads for free (no extra prop) and can fetch()/save url uniformly.
Pure-TS helpers for building allowlist-style policies on top of onShouldStartLoadWithRequest. They do not depend on React Native or Nitro at runtime, so they can be unit-tested in isolation.
import {
DEFAULT_ORIGIN_WHITELIST,
createOriginWhitelistGuard,
originMatches,
wrapWithOriginWhitelist,
} from 'nitro-webview'
import type {
OnShouldStartLoadWithRequest,
OriginWhitelistGuard,
} from 'nitro-webview'| Export | Signature | Notes |
|---|---|---|
DEFAULT_ORIGIN_WHITELIST |
readonly ['http://*', 'https://*'] |
Frozen. Mirrors react-native-webview's documented default. |
originMatches(url, patterns) |
(string, readonly string[]) => boolean |
Returns true iff the origin (scheme://host[:port]) of url matches one of the glob patterns. * is the only wildcard. Case-insensitive on scheme + host. Empty pattern list returns false. Unparseable URL returns false. |
createOriginWhitelistGuard(patterns?, inner?) |
(readonly string[], OnShouldStartLoadWithRequest?) => OriginWhitelistGuard |
Builds a guard that rejects non-matching origins immediately and delegates matching ones to inner (or allows them when inner is absent). |
wrapWithOriginWhitelist(handler, patterns?) |
(OnShouldStartLoadWithRequest, readonly string[]?) => OnShouldStartLoadWithRequest |
Fast-path wrapper: when patterns === DEFAULT_ORIGIN_WHITELIST (by reference), the returned guard short-circuits true and handler is never invoked. Otherwise delegates straight to handler(event). |
import { wrapWithOriginWhitelist, DEFAULT_ORIGIN_WHITELIST } from 'nitro-webview'
const handler = wrapWithOriginWhitelist(
(event) => !event.url.startsWith('https://example.org/'),
DEFAULT_ORIGIN_WHITELIST,
)import {
isHtmlSource,
isUriSource,
normalizeHtmlSource,
sourceToCommand,
} from 'nitro-webview'| Export | Signature | Notes |
|---|---|---|
isUriSource(source) |
(WebViewSource) => source is UriSource |
Structural narrowing on a non-empty uri string. |
isHtmlSource(source) |
(WebViewSource) => source is HtmlSource |
Structural narrowing on a string html field. |
normalizeHtmlSource(source) |
(WebViewSource) => LoadHtmlCommand | null |
Returns a loadHtml native command, or null when source is not an HtmlSource. |
sourceToCommand(source) |
(WebViewSource) => NativeViewCommand |
Maps the source prop to the native view command (loadUrl or loadHtml). Throws TypeError on malformed input. |
Lower-level builders used by NitroWebView internally. Exported for advanced consumers building custom event pipelines (e.g. for tests or mocks).
| Export | Signature |
|---|---|
createLoadStartDispatcher(onLoadStart?) |
(OnLoadStart | undefined) => LoadStartDispatcher |
createLoadDispatcher(onLoad?) |
(OnLoad | undefined) => LoadDispatcher |
createLoadEndDispatcher(onLoadEnd?) |
(OnLoadEnd | undefined) => LoadEndDispatcher |
Each dispatcher dedupes by navigationId so duplicate native fires never reach JS.
The injected window.ReactNativeWebView.postMessage(...) shim is built in pure TS so it can be unit-tested and shared across platforms.
import {
ANDROID_NATIVE_BRIDGE_NAME,
BRIDGE_NAME,
buildBridgeScript,
evaluateBridgeScript,
} from 'nitro-webview'| Export | Notes |
|---|---|
BRIDGE_NAME |
'ReactNativeWebView'. Public identifier installed on window. |
ANDROID_NATIVE_BRIDGE_NAME |
'ReactNativeWebViewNative'. Internal Android JavascriptInterface name. |
buildBridgeScript(platform) |
Returns the literal JavaScript source string for the injected bridge. Idempotent β never overwrites a page-defined postMessage. |
evaluateBridgeScript(platform, sandbox) |
Evaluates the script against an in-memory sandbox (used for tests). |
import { callback } from 'nitro-webview'Re-exported verbatim from react-native-nitro-modules. Every event prop (onLoadStart, onLoadEnd, onMessage, onError, onShouldStartLoadWithRequest, onFileDownload, hybridRef) must pass through this wrapper.
The system file picker on iOS reads from the camera, the photo library, and (for video capture) the microphone. iOS crashes the app the first time the picker accesses one of these subsystems without an explanatory string. Add all three usage descriptions to your app's Info.plist even if your web content only triggers one of them β iOS may surface the unified picker:
<key>NSCameraUsageDescription</key>
<string>This app uses the camera to let you upload photos and videos from web pages.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>This app needs photo library access to let you upload images from web pages.</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app uses the microphone to record audio when you upload a video from a web page.</string>The strings are shown verbatim in the iOS permission prompt β rewrite them in your app's voice and supported locales.
The library ships its own FileProvider declaration with authority ${applicationId}.nitrowebview.fileprovider. The consuming app must still declare the media permissions in its AndroidManifest.xml for the file chooser to surface photos / videos / camera capture:
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />The library also pulls org.mozilla.components:support-utils for its Content-Dispositionβaware DownloadUtils.guessFileName β the consuming app must expose Mozilla's Maven repository in its android/build.gradle:
allprojects {
repositories {
maven { url "https://maven.mozilla.org/maven2" }
}
}MIT.