Skip to main content
Tutorials

WKWebView Hybrid App: Bridge JavaScript and Swift in WKWebView

Last updated

A WKWebView hybrid app needs two bridges. JavaScript calls Swift with window.webkit.messageHandlers.<name>.postMessage, and WKScriptMessageHandlerWithReply makes that call return a Promise when you need a result. Swift calls JavaScript with evaluateJavaScript, or callAsyncJavaScript when it has to wait for an async result. Below you create a project from the KXBuild WKWebView template, bundle your pages or load a remote URL, debug on an iPhone and build an .ipa. You need an Apple Developer account, an iPhone with a USB cable, and KXBuild installed. APIs are as of October 2026.

Should you bundle the pages or load a remote URL?

WKWebView can show pages bundled inside the app or open a URL on your server. You can mix both, for example bundling the home page and core flows while loading campaign pages remotely.

Bundled pages (in the app) Remote URL
First load Fast, no network needed Depends on the network and server
Offline Works Doesn’t work, you need your own offline message
Updating pages Rebuild and submit for review Live as soon as you change the server
How to load loadFileURL or a custom scheme load(URLRequest)
App Review Same as any app Reviewers are more likely to see it as a repackaged website, see App Review and guideline 4.2
Good for Utilities, offline forms, core screens that need a stable experience News and campaign pages that change often

App Review Guideline 2.5.2 doesn’t allow an app to download code that adds or changes its features. Remote pages follow the same rule, so don’t use them to ship features that weren’t in the version Apple reviewed.

Step 1: How do you create a project from the WKWebView template?

KXBuild includes an iOS App (WKWebView shell) template. The whole UI is one WKWebView that loads the bundled www folder by default, with a progress bar, the back-swipe gesture and a JS-to-native bridge example. Use either approach.

  • VS Code extension. Run KXBuild: New Project from the Command Palette and pick iOS App (WKWebView shell).
  • Command line. Run the command below. The project name can only contain letters, digits and underscores.
1kxbuild project create -t app_webview -n H5Shell -b com.example.h5shell -o D:\work

The generated project looks like this. The deployment target is iOS 15.

1H5Shell/
2  H5Shell.xcodeproj/
3  H5Shell/
4    AppDelegate.swift
5    SceneDelegate.swift        Sets WebViewController as the root view controller
6    WebViewController.swift    The WKWebView and the JS bridge
7    Info.plist                 Already includes NSAllowsArbitraryLoadsInWebContent
8    Assets.xcassets/
9    www/                       Web pages: index.html, app.js, style.css

www is a folder reference. The whole folder is copied into the app as is, subfolders included. You can add files and subfolders without touching the project file, and the next build picks them up.

If you already have a UIKit project, you can write a WebViewController by hand as shown below instead of using the template. See built-in templates for details on each template.

Step 2: How do you load pages bundled in the app?

Copy your frontend build into www

For Vue, React and similar projects, build the static files first, then copy the contents of the dist folder into www. Use relative asset paths. The default /assets/... absolute paths point to the root of the phone’s file system under file://, and the page comes up blank. In Vite add base: './' to the config, in Vue CLI set publicPath: './', and use hash mode for the router.

1npm run build
2Remove-Item -Recurse -Force D:\work\H5Shell\H5Shell\www\*
3Copy-Item -Recurse D:\work\myweb\dist\* D:\work\H5Shell\H5Shell\www\

Open it with loadFileURL

This is what the template does. The second argument gives WKWebView read access to the whole www folder, which the page needs to load its CSS, JS and images. If you only grant access to index.html, every other resource returns 404.

1guard let index = Bundle.main.url(forResource: "index", withExtension: "html", subdirectory: "www") else { return }
2webView.loadFileURL(index, allowingReadAccessTo: index.deletingLastPathComponent())

Use a custom scheme for fetch or history routing

A page loaded from file:// has no regular origin. Reading local JSON with fetch or XHR, or loading type="module" scripts, can fail because of cross-origin rules, and history-mode routes can’t find their file after a reload. In that case, register your own scheme with WKURLSchemeHandler, for example app://local/, and let native code serve the files in www as if they were a website.

 1import Foundation
 2import WebKit
 3import UniformTypeIdentifiers
 4
 5/// Serves the files in the www folder through a custom scheme (app://local/),
 6/// so fetch, XHR, ES modules and history routing work the same as on a website.
 7final class AppSchemeHandler: NSObject, WKURLSchemeHandler {
 8    private let root: URL
 9
10    init(root: URL) {
11        self.root = root.standardizedFileURL
12    }
13
14    func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
15        guard let url = urlSchemeTask.request.url else {
16            urlSchemeTask.didFailWithError(URLError(.badURL))
17            return
18        }
19        var path = url.path
20        if path.isEmpty || path == "/" { path = "/index.html" }
21        var file = root.appendingPathComponent(String(path.dropFirst())).standardizedFileURL
22
23        // Frontend routes (no extension, no matching file) fall back to index.html.
24        if file.pathExtension.isEmpty && !FileManager.default.fileExists(atPath: file.path) {
25            file = root.appendingPathComponent("index.html")
26        }
27        // Don't allow ../ to read files outside www.
28        guard file.path.hasPrefix(root.path), let data = try? Data(contentsOf: file) else {
29            urlSchemeTask.didFailWithError(URLError(.fileDoesNotExist))
30            return
31        }
32
33        let mime = UTType(filenameExtension: file.pathExtension)?.preferredMIMEType ?? "application/octet-stream"
34        let headers = ["Content-Type": mime, "Content-Length": String(data.count)]
35        guard let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1",
36                                             headerFields: headers) else {
37            urlSchemeTask.didFailWithError(URLError(.cannotParseResponse))
38            return
39        }
40        urlSchemeTask.didReceive(response)
41        urlSchemeTask.didReceive(data)
42        urlSchemeTask.didFinish()
43    }
44
45    func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {}
46}

Register the handler before you create the WKWebView. It has no effect on a web view that already exists. The scheme can’t be one WebKit handles itself, such as http, https or file.

1let www = Bundle.main.url(forResource: "www", withExtension: nil)!
2let config = WKWebViewConfiguration()
3config.setURLSchemeHandler(AppSchemeHandler(root: www), forURLScheme: "app")
4let webView = WKWebView(frame: .zero, configuration: config)
5webView.load(URLRequest(url: URL(string: "app://local/index.html")!))

With a custom scheme, your frontend build can keep absolute /assets/... paths, and history routes survive a reload.

How do you load a remote URL in WKWebView?

Change startURL in WebViewController to your URL.

1let startURL: URL? = URL(string: "https://m.example.com/")

During development you can point the app at the frontend dev server on your computer. Save a change to a page and refresh it on the phone, with no rebuild.

  1. Make the dev server listen on your local network. For Vite, run npm run dev -- --host and note the http://192.168.x.x:5173 address it prints.
  2. Temporarily set startURL to that address and press F5 to install the app on your phone.
  3. Connect the phone and the computer to the same Wi-Fi network. Allow inbound connections on that port in Windows Firewall. The first time the app reaches the local network, iOS asks whether it may find devices on your local network. Tap Allow.
1New-NetFirewallRule -DisplayName "Vite 5173" -Direction Inbound -Protocol TCP -LocalPort 5173 -Action Allow

The dev server uses http, which only works because the template’s Info.plist includes an ATS exception, see ATS and http resources. Before release, set startURL back to nil or to your production https URL.

What does the complete WebViewController look like?

The code below replaces WebViewController.swift from the template. It adds what most real projects run into.

  • Two message channels. native is fire-and-forget, nativeAsync returns a value.
  • Messages are forwarded through a weak reference. WKUserContentController holds its handlers strongly, so calling add(self, name:) directly means the view controller is never released.
  • Message origins are checked, so only bundled pages and your own domains can call native code.
  • Debug builds forward the page’s console to the native log and turn on isInspectable.
  • target="_blank" links, phone and email links, and the blank page after the web content process is terminated are all handled.
  1import UIKit
  2import WebKit
  3import os
  4
  5private let webLog = Logger(subsystem: Bundle.main.bundleIdentifier ?? "H5Shell", category: "web")
  6
  7/// Weak forwarding. WKUserContentController holds handlers strongly, so add(self) directly would keep the controller alive forever.
  8final class WeakMessageHandler: NSObject, WKScriptMessageHandler {
  9    weak var target: WKScriptMessageHandler?
 10
 11    init(_ target: WKScriptMessageHandler) {
 12        self.target = target
 13    }
 14
 15    func userContentController(_ userContentController: WKUserContentController,
 16                               didReceive message: WKScriptMessage) {
 17        target?.userContentController(userContentController, didReceive: message)
 18    }
 19}
 20
 21/// The version with a reply. postMessage on the JS side returns a Promise.
 22final class WeakReplyHandler: NSObject, WKScriptMessageHandlerWithReply {
 23    weak var target: WKScriptMessageHandlerWithReply?
 24
 25    init(_ target: WKScriptMessageHandlerWithReply) {
 26        self.target = target
 27    }
 28
 29    func userContentController(_ userContentController: WKUserContentController,
 30                               didReceive message: WKScriptMessage,
 31                               replyHandler: @escaping @MainActor @Sendable (Any?, String?) -> Void) {
 32        guard let target else {
 33            replyHandler(nil, "The web view container has been released")
 34            return
 35        }
 36        target.userContentController(userContentController, didReceive: message, replyHandler: replyHandler)
 37    }
 38}
 39
 40final class WebViewController: UIViewController, WKNavigationDelegate,
 41                               WKScriptMessageHandler, WKScriptMessageHandlerWithReply {
 42
 43    /// Remote start page. When nil, the app loads the bundled www/index.html.
 44    let startURL: URL? = nil
 45    /// Remote domains allowed to call native code.
 46    private let trustedHosts: Set<String> = ["m.example.com"]
 47
 48    private var webView: WKWebView!
 49
 50    override func viewDidLoad() {
 51        super.viewDidLoad()
 52        view.backgroundColor = .systemBackground
 53
 54        let contentController = WKUserContentController()
 55        contentController.add(WeakMessageHandler(self), name: "native")
 56        contentController.addScriptMessageHandler(WeakReplyHandler(self), contentWorld: .page, name: "nativeAsync")
 57        #if DEBUG
 58        contentController.add(WeakMessageHandler(self), name: "console")
 59        contentController.addUserScript(WKUserScript(source: Self.consoleBridge,
 60                                                     injectionTime: .atDocumentStart,
 61                                                     forMainFrameOnly: false))
 62        #endif
 63
 64        let config = WKWebViewConfiguration()
 65        config.userContentController = contentController
 66        config.allowsInlineMediaPlayback = true
 67
 68        webView = WKWebView(frame: view.bounds, configuration: config)
 69        webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
 70        webView.navigationDelegate = self
 71        webView.allowsBackForwardNavigationGestures = true
 72        #if DEBUG
 73        if #available(iOS 16.4, *) {
 74            webView.isInspectable = true
 75        }
 76        #endif
 77        view.addSubview(webView)
 78
 79        NotificationCenter.default.addObserver(self, selector: #selector(appDidBecomeActive),
 80                                               name: UIApplication.didBecomeActiveNotification, object: nil)
 81        loadStartPage()
 82    }
 83
 84    private func loadStartPage() {
 85        if let url = startURL {
 86            webView.load(URLRequest(url: url))
 87            return
 88        }
 89        guard let index = Bundle.main.url(forResource: "index", withExtension: "html", subdirectory: "www") else {
 90            webLog.error("www/index.html is not bundled in the app")
 91            return
 92        }
 93        // Grant read access to the whole www folder so relative CSS, JS and image paths load.
 94        webView.loadFileURL(index, allowingReadAccessTo: index.deletingLastPathComponent())
 95    }
 96
 97    /// Accept messages only from bundled pages and trusted domains.
 98    private func isTrusted(_ message: WKScriptMessage) -> Bool {
 99        let origin = message.frameInfo.securityOrigin
100        // Pages bundled in the app: file:// or the custom app:// scheme from above
101        if origin.protocol == "file" || origin.protocol == "app" { return true }
102        return origin.protocol == "https" && trustedHosts.contains(origin.host)
103    }
104
105    // MARK: - JS to native, no return value
106
107    func userContentController(_ userContentController: WKUserContentController,
108                               didReceive message: WKScriptMessage) {
109        if message.name == "console" {
110            webLog.debug("[H5] \(String(describing: message.body), privacy: .public)")
111            return
112        }
113        guard message.name == "native", isTrusted(message),
114              let body = message.body as? [String: Any],
115              let action = body["action"] as? String else { return }
116
117        switch action {
118        case "share":
119            let text = body["text"] as? String ?? ""
120            let sheet = UIActivityViewController(activityItems: [text], applicationActivities: nil)
121            sheet.popoverPresentationController?.sourceView = view
122            present(sheet, animated: true)
123        case "haptic":
124            UIImpactFeedbackGenerator(style: .medium).impactOccurred()
125        default:
126            webLog.error("Unknown action: \(action, privacy: .public)")
127        }
128    }
129
130    // MARK: - JS to native, with a return value
131
132    func userContentController(_ userContentController: WKUserContentController,
133                               didReceive message: WKScriptMessage,
134                               replyHandler: @escaping @MainActor @Sendable (Any?, String?) -> Void) {
135        guard isTrusted(message),
136              let body = message.body as? [String: Any],
137              let action = body["action"] as? String else {
138            replyHandler(nil, "Invalid call")
139            return
140        }
141
142        switch action {
143        case "deviceInfo":
144            let device = UIDevice.current
145            let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? ""
146            replyHandler(["model": device.model,
147                          "system": "\(device.systemName) \(device.systemVersion)",
148                          "appVersion": version], nil)
149        case "getItem":
150            let key = body["key"] as? String ?? ""
151            replyHandler(UserDefaults.standard.string(forKey: "h5." + key), nil)
152        case "setItem":
153            guard let key = body["key"] as? String, let value = body["value"] as? String else {
154                replyHandler(nil, "Missing key or value")
155                return
156            }
157            UserDefaults.standard.set(value, forKey: "h5." + key)
158            replyHandler(true, nil)
159        default:
160            replyHandler(nil, "Unknown action: \(action)")
161        }
162    }
163
164    // MARK: - Native to JS
165
166    /// Calls window.onNativeEvent({ event, data }) in the page.
167    func sendEvent(_ event: String, data: [String: Any] = [:]) {
168        let payload: [String: Any] = ["event": event, "data": data]
169        guard JSONSerialization.isValidJSONObject(payload),
170              let json = try? JSONSerialization.data(withJSONObject: payload),
171              let text = String(data: json, encoding: .utf8) else { return }
172        webView.evaluateJavaScript("window.onNativeEvent && window.onNativeEvent(\(text))") { _, error in
173            if let error {
174                webLog.error("evaluateJavaScript failed: \(error.localizedDescription, privacy: .public)")
175            }
176        }
177    }
178
179    /// Calls an async function in the page and gets its result. WebKit converts the arguments, so you don't build strings by hand.
180    func readCartCount() async -> Int {
181        do {
182            let result = try await webView.callAsyncJavaScript(
183                "return await window.app.getCartCount(userId)",
184                arguments: ["userId": "u_1001"],
185                contentWorld: .page)
186            return (result as? NSNumber)?.intValue ?? 0
187        } catch {
188            webLog.error("callAsyncJavaScript failed: \(error.localizedDescription, privacy: .public)")
189            return 0
190        }
191    }
192
193    @objc private func appDidBecomeActive() {
194        sendEvent("resume")
195        Task {
196            let count = await readCartCount()
197            webLog.info("Cart count: \(count)")
198        }
199    }
200
201    // MARK: - WKNavigationDelegate
202
203    func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction,
204                 decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void) {
205        guard let url = navigationAction.request.url, let scheme = url.scheme?.lowercased() else {
206            decisionHandler(.cancel)
207            return
208        }
209        // Hand phone, email and App Store links to the system.
210        if ["tel", "mailto", "sms", "itms-apps"].contains(scheme) {
211            UIApplication.shared.open(url)
212            decisionHandler(.cancel)
213            return
214        }
215        // target="_blank" links have no target frame and do nothing by default, so open them in the current page.
216        if navigationAction.targetFrame == nil {
217            webView.load(navigationAction.request)
218            decisionHandler(.cancel)
219            return
220        }
221        decisionHandler(.allow)
222    }
223
224    func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!,
225                 withError error: Error) {
226        webLog.error("Page failed to load: \(error.localizedDescription, privacy: .public)")
227        sendEvent("loadError", data: ["message": error.localizedDescription])
228    }
229
230    /// The page goes blank after the system terminates the web content process. Reload it.
231    func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
232        webView.reload()
233    }
234
235    // MARK: - Debugging: forward console output to the native log
236
237    private static let consoleBridge = """
238    (function () {
239      var h = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.console;
240      if (!h) return;
241      function send(level, args) {
242        var text = Array.prototype.map.call(args, function (a) {
243          if (typeof a === 'string') return a;
244          try { return JSON.stringify(a); } catch (e) { return String(a); }
245        }).join(' ');
246        h.postMessage(level + ': ' + text);
247      }
248      ['log', 'info', 'warn', 'error'].forEach(function (level) {
249        var original = console[level];
250        console[level] = function () {
251          send(level, arguments);
252          original.apply(console, arguments);
253        };
254      });
255      window.addEventListener('error', function (e) {
256        send('error', [e.message + ' @ ' + e.filename + ':' + e.lineno]);
257      });
258      window.addEventListener('unhandledrejection', function (e) {
259        send('error', ['unhandled rejection: ' + String(e.reason)]);
260      });
261    })();
262    """
263}

This code and the AppSchemeHandler from the previous section compile against the iOS SDK with an iOS 15 deployment target, with no warnings in either Swift 5 or Swift 6 language mode. Replace m.example.com in trustedHosts with your own domain.

How does JavaScript call Swift in a WKWebView?

How it works

When you register a name with add(_:name:), WebKit creates window.webkit.messageHandlers.<name>.postMessage(message) in the page. The message can be a string, number, boolean, array or plain object, which arrive in Swift as String, NSNumber, Array and Dictionary. Functions, DOM nodes and undefined fields don’t come through.

Approach Register with JavaScript gets Good for
WKScriptMessageHandler add(_:name:) No return value Sharing, haptics, analytics events, closing a page
WKScriptMessageHandlerWithReply (iOS 14 and later) addScriptMessageHandler(_:contentWorld:name:) A Promise Reading device info, reading and writing local storage, native features you have to wait for

On the reply channel, replyHandler(value, nil) resolves the Promise with that value, and a nil value reaches JavaScript as undefined. replyHandler(nil, "error text") rejects the Promise, and the message of the Error caught in JavaScript is that text. Call replyHandler at most once. If you never call it, the Promise also ends with an error. Return values can only be numbers, strings, dates, arrays, dictionaries and NSNull.

Register each name only once. According to the WebKit API docs, a name maps to a single handler within a content world, and the two handler types conflict with each other too. Registering a name twice throws an exception and crashes the app. That’s why the example uses two different names, native and nativeAsync.

Wrap the bridge in JavaScript

Keep every call in one bridge.js so the rest of your code never touches window.webkit directly. When you debug the page in a desktop browser, window.webkit doesn’t exist, so the wrapper checks for it first and the page doesn’t throw.

 1// www/bridge.js: the single entry point between the page and native code
 2(function () {
 3  function handler(name) {
 4    return window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers[name];
 5  }
 6
 7  window.bridge = {
 8    // Whether the page runs inside the app. False when debugging in a browser
 9    get inApp() {
10      return !!handler('native');
11    },
12
13    // No return value: sharing, haptics, analytics events
14    post(action, data = {}) {
15      const h = handler('native');
16      if (!h) {
17        console.warn('Not running in the app, ignoring', action);
18        return;
19      }
20      h.postMessage({ action, ...data });
21    },
22
23    // With a return value: returns a Promise that rejects when native code reports an error
24    async call(action, data = {}) {
25      const h = handler('nativeAsync');
26      if (!h) throw new Error('Not running in the app: ' + action);
27      return h.postMessage({ action, ...data });
28    },
29  };
30
31  // Events pushed from native code through evaluateJavaScript
32  const listeners = {};
33  window.bridge.on = function (event, fn) {
34    (listeners[event] = listeners[event] || []).push(fn);
35  };
36  window.onNativeEvent = function (payload) {
37    (listeners[payload.event] || []).forEach((fn) => fn(payload.data));
38  };
39})();

Use it in your pages like this. In index.html, include bridge.js before app.js.

 1// www/app.js: using the bridge in a page
 2document.getElementById('share').addEventListener('click', () => {
 3  window.bridge.post('share', { text: 'Check out this app' });
 4});
 5
 6document.getElementById('info').addEventListener('click', async () => {
 7  try {
 8    const info = await window.bridge.call('deviceInfo');
 9    console.log('Device info', info);
10  } catch (e) {
11    console.error('Call failed', e.message);
12  }
13});
14
15window.bridge.on('resume', () => {
16  console.log('App is back in the foreground, refreshing data');
17});
18
19// Called from native callAsyncJavaScript. It can be async
20window.app = {
21  async getCartCount(userId) {
22    const saved = await window.bridge.call('getItem', { key: 'cart.' + userId });
23    return saved ? JSON.parse(saved).length : 0;
24  },
25};

Security

Registered channels are visible to every page the web view opens, including third-party sites the user reaches through a link and iframes embedded in your pages. The example’s isTrusted checks message.frameInfo.securityOrigin and only accepts bundled pages and https domains in trustedHosts. For sensitive actions such as reading files or user data, validate the parameters as well. Don’t build a generic reflection bridge that runs whatever method name JavaScript sends.

How does Swift call JavaScript in the page?

Method Minimum iOS Behavior Good for
evaluateJavaScript(_:completionHandler:) iOS 8 Runs a script string, and you build the arguments into it yourself Pushing events, calling synchronous functions
callAsyncJavaScript(_:arguments:in:contentWorld:) (async version) iOS 15 Takes a function body and an arguments dictionary, and waits for the Promise before returning the result Calling async functions in the page and getting the result

With evaluateJavaScript, always convert arguments to JSON with JSONSerialization before you put them into the script, as sendEvent does in the example. If you paste a string straight into single quotes, any quote or line break in it causes a syntax error, and user input could inject script.

The first argument of callAsyncJavaScript is a function body, not a function. The example passes return await window.app.getCartCount(userId), with userId supplied through the arguments dictionary, and WebKit converts the types. If the Promise returned by the function body is rejected, the Swift side throws.

Wait for the page to finish loading before you call JavaScript. Call it after webView(_:didFinish:), or have the page call native code once it’s ready and start pushing events from there.

What if ATS blocks http resources?

App Transport Security (ATS) only allows https by default. The template’s Info.plist includes the key below. It only relaxes requests made by web content in WKWebView. Native networking such as URLSession is still subject to ATS.

1<key>NSAppTransportSecurity</key>
2<dict>
3  <key>NSAllowsArbitraryLoadsInWebContent</key>
4  <true/>
5</dict>

According to Apple’s documentation, if you set NSAllowsArbitraryLoadsInWebContent to YES, you need to give a justification when you submit for review. If your pages and APIs all use https, remove the key before release. If only a few domains still use http, add exceptions for just those domains with NSExceptionDomains, which is easier to justify than opening everything.

Double-click Info.plist in VS Code to edit it in the Property List Editor.

How do you debug the web pages in a WKWebView on iPhone?

Get the page working in a desktop browser first

Layout and business logic are fastest to debug with the developer tools in a desktop browser. In the browser, bridge.js takes the “not running in the app” branch and the page keeps working. Use the methods below only for problems that show up on the iPhone.

Forward console output to the native log

In the complete code, Debug builds inject a user script that forwards console.log, warn, error, uncaught exceptions and unhandled Promise rejections to the native Logger. After you press F5, the page’s output appears next to the native log in Output > KXBuild Console in VS Code, prefixed with [H5]. When you aren’t debugging, you can also see it with kxdevice ostrace --subsystem <your bundle ID>. See Read iPhone device logs with Logger and os_log, then filter and export them.

You can set breakpoints in the native message handlers. Put one in userContentController(_:didReceive:), tap a button in the page, and inspect the message body JavaScript sent. See Run and debug.

Use an in-page debug panel

To inspect network requests, the DOM and local storage on the phone, temporarily add an in-page debug panel such as vConsole or Eruda to the pages in your Debug build, and remove it before release.

Safari Web Inspector and isInspectable

If you have a Mac at hand, you can also connect Safari Web Inspector on the Mac to the WKWebView on your phone. Starting with iOS 16.4, WKWebView’s isInspectable defaults to false and you have to set it to true explicitly. The example only turns it on in Debug builds. On the phone, also turn on Web Inspector on the Advanced page of Safari’s settings in the Settings app.

Step 3: How do you run on your iPhone and build the .ipa?

  1. Put an Apple Development certificate and a provisioning profile that includes this iPhone in the keychain folder, see Certificates and signing.
  2. Connect the iPhone with a USB cable. The first time, follow Device setup to install drivers, trust the computer and turn on Developer Mode.
  3. Select the iPhone in the KXBuild view and press F5. KXBuild builds, signs, installs and launches the app, then attaches the debugger.

After you change pages in www, stop debugging and press F5 again so the new files get bundled. The Restart button on the debug toolbar doesn’t rebuild.

For the App Store, build with the Release configuration. The keychain folder needs an Apple Distribution certificate and an App Store provisioning profile. Use either approach.

  • VS Code extension. Run KXBuild: Build for App Store (.ipa) from the Command Palette or the project’s context menu.
  • Command line. Run the command below in the project folder.
1kxbuild build .\H5Shell.xcodeproj -c Release --export app-store

When the build finishes, build\H5Shell.ipa is ready to upload to App Store Connect. For uploading and submitting, see Publishing.

How does a hybrid app pass guideline 4.2 minimum functionality?

App Review Guideline 4.2 Minimum Functionality asks apps to offer features, content and UI beyond a repackaged website. An app that just puts a URL in a WKWebView is easily rejected under 4.2. For typical rejection wording and how to reply, see Common App Store rejection reasons.

Before you submit, go through the list below and add native value that a website can’t offer.

Area What to add
Native features Push notifications, camera scanning, the photo library, location, Sign in with Apple, Home Screen widgets
Native UI Build the tab bar, navigation bar and settings screens with native controls, and keep the web view for content
Offline use Bundle the core pages, and show a clear message and cached content when offline
Polish Pull to refresh, the back gesture, loading progress, retry on failure, and external links in the system browser
Accounts If users can create an account, they must be able to delete it in the app

Two more guidelines apply to hybrid apps. Remote pages can’t add new features after review, see 2.5.2. If your app is a platform that runs third-party HTML5 mini apps, follow 4.7 on content indexing, reporting and age restrictions, and don’t expose native APIs to those third-party pages without Apple’s permission.

How do you troubleshoot common WKWebView problems?

Symptom Cause Fix
Blank page on launch No index.html in www, or the build output uses absolute /assets/ paths Check the www folder and switch the frontend build to relative paths, or use a custom scheme
Page shows but images and styles are missing loadFileURL only granted access to one file Pass the www folder to allowingReadAccessTo
fetch of local JSON fails file:// pages are subject to cross-origin rules Use a custom scheme, or let native code read the data and return it through the bridge
http APIs or images don’t load Blocked by ATS Use NSAllowsArbitraryLoadsInWebContent for web content or per-domain exceptions, and preferably move to https
window.webkit is undefined The page runs in a desktop browser, or the channel name is misspelled Check bridge.inApp, and compare with the name registered in Swift
App crashes when registering a channel The same name was registered twice Register each name once, and use a different name for the reply channel
View controller isn’t released after closing add(self, name:) holds it strongly Forward through the weak WeakMessageHandler
evaluateJavaScript reports SyntaxError Arguments were pasted into the string and contain quotes or line breaks Convert to JSON with JSONSerialization first, or use callAsyncJavaScript
target="_blank" links do nothing WKWebView doesn’t handle new windows by default Open them in the current page in decidePolicyFor
Blank page after the app sat in the background The system terminated the web content process Implement webViewWebContentProcessDidTerminate and reload
Content hidden by the notch or home indicator The page doesn’t handle safe areas Add viewport-fit=cover to the viewport and pad with env(safe-area-inset-top) and related CSS
Changed pages don’t show up on the phone You used Restart on the debug toolbar Stop debugging and press F5 again

For anything not in the table, forward the console to the native log as described above, then set a breakpoint in userContentController(_:didReceive:) to see whether the message reaches native code. Most WKWebView hybrid app problems come down to paths, ATS or channel names, so check those three first.

FAQ

How do I call Swift from JavaScript in WKWebView and get a return value?

Make your handler conform to WKScriptMessageHandlerWithReply and register it with addScriptMessageHandler(_:contentWorld:name:). Then window.webkit.messageHandlers.name.postMessage returns a Promise in JavaScript. In Swift, call replyHandler(value, nil) to resolve it, or pass error text to reject it. This requires iOS 14 or later.

How do I call an async function in the page from Swift?

Use callAsyncJavaScript, available from iOS 15. Pass a function body as the first argument, such as return await window.app.getData(id), and put the arguments in the arguments dictionary. It waits for the page’s Promise and hands the result to Swift, and throws if the Promise is rejected.

How do I debug the web pages in a WKWebView on Windows?

Get the page logic working in a desktop browser first. For problems that only happen on the phone, forward the page’s console to the native log and read it in Output > KXBuild Console in VS Code, or temporarily add an in-page debug panel such as vConsole or Eruda to your Debug build.

Why can’t Safari inspect my WKWebView on iOS 16.4 and later?

Starting with iOS 16.4, WKWebView’s isInspectable defaults to false. Set it to true in Debug builds inside if #available(iOS 16.4, *), then turn on Web Inspector on the Advanced page of Safari’s settings on the phone.

Can an app that only wraps a website get on the App Store?

An app that only puts a website in a WKWebView with no native features is easily rejected under guideline 4.2 Minimum Functionality. Add things a website can’t do, such as push notifications, scanning, native navigation and offline use. Whether the app is approved is up to Apple’s App Review.