How to load local HTML files in WKWebView on iOS and macOS

If your app bundles HTML content — online help, offline documentation, a local web UI — you have probably run into confusing sandbox errors when trying to display it in a WKWebView. Since iOS 13, the old trick of passing a file:// URL to load(_ request:) no longer works. This post covers the correct approach, how to handle inter-page navigation, and when to reach for the more powerful WKURLSchemeHandler.

Why load(_ request:) stopped working

Before iOS 13, some apps loaded local HTML by wrapping a file:// URL in a URLRequest:

// Do NOT do this — broken since iOS 13
let request = URLRequest(url: fileURL)
webView.load(request)

iOS 13 tightened the WKWebView renderer process sandbox. The renderer no longer has read access to the file system by default, so load(_ request:) silently fails or logs something like:

WebPageProxy::Ignoring request to load this main resource because it is outside the sandbox

This was not a deprecation with a migration path — it was a security tightening. The fix is to use the purpose-built API instead.

The correct API: loadFileURL(_:allowingReadAccessTo:)

This method has been available since iOS 9. It grants the renderer process explicit read access to a directory you specify:

func loadFileURL(_ URL: URL, allowingReadAccessTo readAccessURL: URL) -> WKNavigation?

The second parameter defines the sandbox. The renderer can read any file within that directory tree — and nothing outside it.

Minimal access

If your initial page does not reference any other files, pointing allowingReadAccessTo at the file’s own directory is sufficient:

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

Whole-bundle access

For HTML that links to CSS, JavaScript, images, or other HTML pages within the bundle, grant access to the whole bundle:

guard let fileURL = Bundle.main.url(forResource: "index", withExtension: "html") else { return }
webView.loadFileURL(fileURL, allowingReadAccessTo: Bundle.main.bundleURL)

Any resource load that tries to reach outside this boundary is silently blocked, which is exactly what you want from a security standpoint.

Sandbox model for loadFileURL: the renderer process can only read files within the directory you pass as allowingReadAccessTo

Relative navigation between pages

With loadFileURL(_:allowingReadAccessTo:) and bundle-wide access, relative href links work as you would expect:

<!-- Both of these resolve correctly within the bundle -->
<a href="chapter2.html">Next chapter</a>
<a href="appendix/glossary.html">Glossary</a>

WKWebView resolves each relative link against the current file:// URL, and because the target is within the allowed access scope, the load proceeds.

The /nextPage problem

Absolute-path links — where the path starts with / — are a different story:

<a href="/nextPage">Next page</a>

WKWebView resolves this against the filesystem root, producing file:///nextPage. That path does not exist, and it is certainly outside your bundle sandbox, so it fails with the same sandbox error.

This comes up when your HTML was originally authored for a web server, where /nextPage means “the root of this site”, not “the root of the filesystem”. You have two options:

Option A — Fix the HTML. Change absolute paths to relative ones. If you control the HTML source, this is the cleanest fix:

<a href="nextPage.html">Next page</a>

Option B — Intercept and remap navigation. Implement WKNavigationDelegate and rewrite the URL before the load happens:

extension MyViewController: WKNavigationDelegate {

    func webView(
        _ webView: WKWebView,
        decidePolicyFor navigationAction: WKNavigationAction,
        decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
    ) {
        guard let url = navigationAction.request.url,
              url.scheme == "file",
              !FileManager.default.fileExists(atPath: url.path) else {
            decisionHandler(.allow)
            return
        }

        // Remap virtual path to an actual bundle file
        let htmlDir = Bundle.main.bundleURL.appendingPathComponent("html")
        let mapped = htmlDir.appendingPathComponent(url.lastPathComponent + ".html")
        webView.loadFileURL(mapped, allowingReadAccessTo: Bundle.main.bundleURL)
        decisionHandler(.cancel)
    }
}

This intercepts any file:// URL that does not actually exist on disk and remaps it to a file in your bundle’s html subdirectory. Cancel the original navigation and trigger a new load with the corrected URL.

When to use WKURLSchemeHandler

loadFileURL(_:allowingReadAccessTo:) covers most cases, but there is a harder problem: same-origin policy. When a page loaded from file:// tries to use fetch() or XMLHttpRequest to load another file, the browser applies same-origin checks. With file:// URLs, same-origin behaviour is inconsistent across WebKit versions and can block what should be legitimate local requests.

WKURLSchemeHandler, available since iOS 11, gives you a clean solution. You register a custom URL scheme — say, bundle:// — and your app serves all content. Every page shares the same origin, so relative navigation and fetch() calls just work.

Step 1 — Implement the handler

import WebKit
import UniformTypeIdentifiers

final class BundleSchemeHandler: NSObject, WKURLSchemeHandler {

    func webView(_ webView: WKWebView, start task: WKURLSchemeTask) {
        guard let url = task.request.url else {
            task.didFailWithError(URLError(.badURL))
            return
        }

        // Map bundle://app/html/index.html → html/index.html in bundle
        let relativePath = url.path.hasPrefix("/") ? String(url.path.dropFirst()) : url.path
        guard let fileURL = Bundle.main.url(forResource: relativePath,
                                            withExtension: nil) else {
            task.didFailWithError(URLError(.fileDoesNotExist))
            return
        }

        do {
            let data = try Data(contentsOf: fileURL)
            let mimeType: String
            if #available(iOS 14, macOS 11, *) {
                mimeType = UTType(filenameExtension: fileURL.pathExtension)?
                               .preferredMIMEType ?? "application/octet-stream"
            } else {
                mimeType = mimeTypeFor(pathExtension: fileURL.pathExtension)
            }
            let response = URLResponse(url: url,
                                       mimeType: mimeType,
                                       expectedContentLength: data.count,
                                       textEncodingName: "utf-8")
            task.didReceive(response)
            task.didReceive(data)
            task.didFinish()
        } catch {
            task.didFailWithError(error)
        }
    }

    func webView(_ webView: WKWebView, stop task: WKURLSchemeTask) {}

    private func mimeTypeFor(pathExtension ext: String) -> String {
        switch ext.lowercased() {
        case "html", "htm": return "text/html"
        case "css":         return "text/css"
        case "js":          return "application/javascript"
        case "json":        return "application/json"
        case "png":         return "image/png"
        case "jpg", "jpeg": return "image/jpeg"
        case "svg":         return "image/svg+xml"
        default:            return "application/octet-stream"
        }
    }
}

Step 2 — Register the handler and load the first page

let config = WKWebViewConfiguration()
config.setURLSchemeHandler(BundleSchemeHandler(), forURLScheme: "bundle")

let webView = WKWebView(frame: view.bounds, configuration: config)

// Relative links in the HTML resolve against bundle://app/html/
let startURL = URL(string: "bundle://app/html/index.html")!
webView.load(URLRequest(url: startURL))

Note that you cannot register handlers for the reserved schemes http, https, file, about, or blob. Use any other scheme name you like — bundle://, app://, and local:// are all fine.

WKURLSchemeHandler request flow and same-origin comparison with file:// URLs

Choosing the right approach

Scenario Recommended approach
Simple HTML, no inter-page navigation loadFileURL(_:allowingReadAccessTo:) with parent directory
Multiple pages with relative links loadFileURL(_:allowingReadAccessTo:) with Bundle.main.bundleURL
Pages authored for a web server (absolute paths) Navigation delegate to remap + loadFileURL
JavaScript fetch/XHR between pages, or dynamic content generation WKURLSchemeHandler with custom scheme

A note on the simulator

The original iOS 13 sandbox change was not reproducible in the Simulator at the time. If you are debugging local file loading issues and the Simulator behaves differently from a real device, always test on hardware. The Simulator’s sandbox is less strict in some edge cases.

 



Avoid Delays and Rejections when Submitting Your App to The Store!


Follow my FREE cheat sheets to design, develop, or even amend your app to deserve its virtual shelf space in the App Store.

* indicates required

When you subscribe you’ll also get programming tips, business advices, and career rants from the trenches about twice a month. I respect your e-mail privacy.

0 thoughts on “How to load local HTML files in WKWebView on iOS and macOS

Leave a Reply