Native SwiftUI WebView: Replacing WKWebView with First-Party Web Content


Every iOS developer who has needed to display a web page inside SwiftUI knows the ritual: wrap WKWebView in a UIViewRepresentable, juggle coordinators, fight navigation delegates, and hope the view lifecycle cooperates. With iOS 26, Apple finally ships a native WebView and an @Observable-ready WebPage model that eliminate the entire boilerplate layer.

This post covers the new WebView API end to end — setup, navigation control, JavaScript interop, and the edge cases you will hit in production. We will not cover WKWebView migration in detail or SafariServices — those are separate concerns.

Contents

The Problem

Before iOS 26, displaying web content in SwiftUI required a UIViewRepresentable wrapper around WKWebView. Even a minimal implementation looked like this:

import SwiftUI
import WebKit

struct LegacyWebView: UIViewRepresentable {
    let url: URL

    func makeUIView(context: Context) -> WKWebView {
        WKWebView()
    }

    func updateUIView(_ webView: WKWebView, context: Context) {
        webView.load(URLRequest(url: url))
    }
}

This compiles, but it is effectively a toy. The moment you need navigation controls, loading indicators, JavaScript evaluation, or two-way communication with your SwiftUI state, the coordinator explodes in complexity. You end up maintaining a WKNavigationDelegate, a WKScriptMessageHandler, manual @Published bindings for title and URL, and a coordinator that rivals some view controllers in line count.

The core issue is impedance mismatch: WKWebView was designed for UIKit’s imperative lifecycle, not SwiftUI’s declarative state model.

Meet WebView and WebPage

iOS 26 introduces two types in the WebKit framework that solve this cleanly:

  • WebView — a native SwiftUI view that renders web content.
  • WebPage — an @Observable model that owns the web content state (URL, title, loading progress, navigation history).

The relationship mirrors SwiftUI’s existing patterns: WebPage is the model, WebView is the view. You own the model; the view renders it.

import SwiftUI
import WebKit

struct PixarNewsView: View {
    @State private var page = WebPage()

    var body: some View {
        WebView(page)
            .onAppear {
                page.load(URLRequest(url: URL(string: "https://www.pixar.com/news")!))
            }
    }
}

That is a fully functional in-app browser in seven lines of view code. No representable, no coordinator, no delegate.

WebPage conforms to Observable, so any SwiftUI view that reads its properties — title, url, isLoading, estimatedProgress — will automatically re-render when those values change. This is the same @Observable pattern you already use with your own models thanks to the Observation framework.

Apple Docs: WebView — WebKit

A real-world browser needs back/forward controls and a progress indicator. Because WebPage exposes its navigation state as observable properties, this is straightforward.

struct MovieBrowserView: View {
    @State private var page = WebPage()

    var body: some View {
        VStack(spacing: 0) {
            if page.isLoading {
                ProgressView(value: page.estimatedProgress)
                    .tint(.accentColor)
            }

            WebView(page)

            HStack {
                Button("Back") { page.goBack() }
                    .disabled(!page.canGoBack)
                Button("Forward") { page.goForward() }
                    .disabled(!page.canGoForward)
                Spacer()
                Button("Reload") { page.reload() }
            }
            .padding()
        }
        .navigationTitle(page.title ?? "Loading...")
        .onAppear {
            let pixarURL = URL(string: "https://www.pixar.com/feature-films")!
            page.load(URLRequest(url: pixarURL))
        }
    }
}

A few things to note:

  • estimatedProgress is a Double from 0 to 1, identical to the old WKWebView.estimatedProgress — but now it triggers SwiftUI updates automatically.
  • canGoBack and canGoForward are observable booleans. The buttons disable themselves reactively without any manual KVO.
  • page.title is an optional String that updates as the page loads. Binding it to .navigationTitle means your nav bar always reflects the current page.

Tip: If you want to observe URL changes to implement a custom address bar, read page.url directly. It updates on every navigation event, including fragment changes and pushState calls.

JavaScript Interaction

Many production use cases require calling JavaScript from Swift or receiving messages from the web page. WebPage provides an async API for both directions.

Evaluating JavaScript from Swift

Use callJavaScript(_:arguments:) to execute scripts and receive results. The method is async throws, fitting naturally into structured concurrency.

func fetchMovieTitle(from page: WebPage) async throws -> String {
    let result = try await page.callJavaScript(
        "document.querySelector('h1.film-title')?.textContent ?? 'Unknown Film'"
    )
    return result as? String ?? "Unknown Film"
}

The return value is Any? — you are responsible for casting. For complex data, return JSON from JavaScript and decode it on the Swift side.

Receiving Messages from JavaScript

To handle messages sent from JavaScript via window.webkit.messageHandlers, configure a WebPage.Configuration with script message handlers before creating the page.

struct PixarQuizView: View {
    @State private var page: WebPage
    @State private var selectedCharacter: String = "None"

    init() {
        var config = WebPage.Configuration()
        config.userContentController.addScriptMessageHandler(
            named: "characterPicker"
        )
        _page = State(initialValue: WebPage(configuration: config))
    }

    var body: some View {
        WebView(page)
            .onReceiveScriptMessage(named: "characterPicker") { message in
                if let name = message.body as? String {
                    selectedCharacter = name
                }
            }
            .overlay(alignment: .bottom) {
                Text("Selected: \(selectedCharacter)")
                    .padding()
                    .background(.ultraThinMaterial)
            }
            .onAppear {
                page.load(URLRequest(url: URL(string: "https://example.com/pixar-quiz")!))
            }
    }
}

On the JavaScript side, the page sends a message with:

window.webkit.messageHandlers.characterPicker.postMessage('Woody');

The .onReceiveScriptMessage modifier keeps the handler in SwiftUI’s declarative world — no coordinator, no delegate method, no WKScriptMessageHandler conformance.

Warning: Script message names must match exactly between Swift and JavaScript. A typo will silently fail. Consider defining them as constants in a shared enum to avoid drift.

Advanced Usage: Configuration and Customization

Controlling Navigation Decisions

You can intercept navigation events using the .onNavigationAction modifier to allow or deny requests before they load:

WebView(page)
    .onNavigationAction { action in
        guard let host = action.request.url?.host else {
            return .cancel
        }
        // Only allow Pixar and Disney domains
        let allowedDomains = ["pixar.com", "disney.com", "www.pixar.com", "www.disney.com"]
        return allowedDomains.contains(host) ? .allow : .cancel
    }

This replaces the WKNavigationDelegate method decidePolicyFor: with a declarative modifier. The closure receives a WebNavigationAction and returns .allow or .cancel.

Custom User Scripts

Inject CSS or JavaScript that runs on every page load using WebPage.Configuration:

var config = WebPage.Configuration()
let hideHeaderScript = """
    document.querySelector('header')?.remove();
    document.querySelector('footer')?.remove();
"""
let userScript = WKUserScript(
    source: hideHeaderScript,
    injectionTime: .atDocumentEnd,
    forMainFrameOnly: true
)
config.userContentController.addUserScript(userScript)

let page = WebPage(configuration: config)

This is particularly useful for displaying third-party content in a “reader” mode — stripping navigation chrome, injecting custom styles, or adding analytics hooks.

Note: WebPage.Configuration must be set before the page loads its first request. You cannot reconfigure a page after creation. If you need different configurations, create separate WebPage instances.

When to Use (and When Not To)

Displaying your own web content (terms, help pages, blog): Use WebView. Full control, native feel, no app switch.

OAuth or third-party login flows: Use ASWebAuthenticationSession. It handles token extraction and security.

Previewing arbitrary external links: Use SFSafariViewController if you want shared cookies and autofill. Use WebView if you need UI customization.

Rich JavaScript interop with your own web app: Use WebView. Script message handlers and callJavaScript give you full two-way communication.

Rendering simple HTML strings (e.g., rich text from an API): Use WebView with page.loadHTMLString(_:baseURL:). Lighter than AttributedString for complex HTML.

Full browsing experience with address bar and tabs: Open the URL with openURL and let Safari handle it. Do not rebuild a browser.

The key decision heuristic: if you need to control the chrome around the web content or communicate bidirectionally with JavaScript, WebView is the right tool. If you just need to send the user to a URL, SFSafariViewController or openURL are simpler and give users the features they expect (passwords, extensions, reader mode).

Summary

  • iOS 26’s WebView and WebPage replace the WKWebView + UIViewRepresentable workaround with a native SwiftUI solution.
  • WebPage is @Observable, so navigation state, title, URL, and loading progress drive SwiftUI updates automatically.
  • JavaScript interop uses async/await via callJavaScript(_:arguments:), and script message handlers use a declarative .onReceiveScriptMessage modifier.
  • Navigation policy decisions move from delegate methods to the .onNavigationAction modifier.
  • Use WebView when you need UI control or JavaScript interop. Use SFSafariViewController or openURL for simple link-opening scenarios.

For a broader look at bridging UIKit components into SwiftUI — including cases where you still need UIViewRepresentable — see UIKit-SwiftUI Interop. And if you are updating your app’s navigation for iOS 26, SwiftUI Navigation Migration for iOS 26 covers tab bars, sidebars, and the new glass effects.