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.

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.

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.

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