SwiftUI WebView and WebPage: The Complete Guide to Embedding Web Content in iOS 26

Learn how to embed, control, and communicate with web content in SwiftUI using the native WebView and WebPage APIs in iOS 26. Covers JavaScript integration, navigation control, and practical patterns with working code.

Why SwiftUI Finally Has a Native WebView

For years, embedding web content in a SwiftUI app meant wrapping UIKit's WKWebView inside a UIViewRepresentable bridge. It worked, sure—but it was clunky. You lost SwiftUI's declarative elegance, had to manage coordinators manually, and constantly fought against two fundamentally different UI paradigms. Honestly, it felt like duct-taping two worlds together.

With iOS 26, Apple finally fixed this by shipping a native WebView and its companion model WebPage, both designed from the ground up for Swift and SwiftUI.

This guide covers everything you need to embed, control, and communicate with web content using these new APIs. Whether you're building a simple in-app browser, a documentation viewer, or a hybrid app with deep JavaScript integration, you'll find working code and practical patterns here. So, let's dive in.

Getting Started: Your First WebView

The simplest way to display a web page in SwiftUI is literally a single line of code. Import WebKit alongside SwiftUI and pass a URL directly to WebView:

import SwiftUI
import WebKit

struct SimpleWebView: View {
    var body: some View {
        WebView(url: URL(string: "https://developer.apple.com/swift")!)
    }
}

That's it. Seriously.

You get a fully functional web browser view powered by the same WebKit engine that runs Safari. The user can scroll, tap links, and interact with the page just as they would in a real browser. Unlike the old UIViewRepresentable approach, this view participates naturally in SwiftUI's layout system, responds to view modifiers, and requires zero boilerplate.

Making the URL Dynamic with @State

In real apps, you rarely hardcode a URL. Use @State to make it reactive:

struct DynamicWebView: View {
    @State private var url = URL(string: "https://developer.apple.com")!

    var body: some View {
        VStack {
            WebView(url: url)

            HStack {
                Button("Swift Docs") {
                    url = URL(string: "https://docs.swift.org")!
                }
                Button("SwiftUI Docs") {
                    url = URL(string: "https://developer.apple.com/swiftui/")!
                }
            }
            .padding()
        }
    }
}

When the URL changes, WebView automatically loads the new page. Pure SwiftUI reactivity—no delegates, no manual reload calls. It just works.

WebPage: Taking Full Control of Web Content

The URL-based WebView initializer is great for simple cases, but most real apps need more than that: loading progress indicators, page titles for navigation bars, JavaScript execution, navigation policy control. That's where WebPage comes in.

WebPage is an Observable class that models a live web browsing session. You create it as a @State property, pass it to WebView, and then read its properties or call its methods to interact with the loaded content. Think of it as the "brain" behind your web view.

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

    var body: some View {
        VStack(spacing: 0) {
            // Navigation bar showing the page title
            HStack {
                Text(page.title ?? "Loading...")
                    .font(.headline)
                    .lineLimit(1)
                Spacer()
                if page.isLoading {
                    ProgressView()
                }
            }
            .padding()

            // Progress bar
            ProgressView(value: page.estimatedProgress)
                .opacity(page.isLoading ? 1 : 0)

            // The web view
            WebView(page)
        }
        .onAppear {
            page.load(URLRequest(url: URL(string: "https://swift.org")!))
        }
    }
}

Key WebPage Properties

Because WebPage conforms to Observable, all its properties trigger SwiftUI view updates automatically. Here are the ones you'll reach for most often:

  • title — The current page's <title> element, updated as the user navigates
  • url — The current URL, which changes as pages load and redirects happen
  • estimatedProgress — A Double from 0.0 to 1.0 representing load progress
  • isLoading — A Boolean indicating whether content is currently loading
  • hasOnlySecureContent — Whether the page was loaded entirely over HTTPS
  • backForwardList — The navigation history, handy for building back/forward controls

Building a Mini Browser with Navigation Controls

One thing the native WebView doesn't include is built-in navigation chrome—no back button, no forward button, no address bar. That's actually by design: Apple gives you the building blocks and lets you compose the UI you need.

I think this is the right call. It means you're never fighting against a default UI that doesn't match your app's design. Here's how to build a fully functional mini browser:

struct MiniBrowser: View {
    @State private var page = WebPage()
    @State private var urlText = "https://swift.org"

    var body: some View {
        VStack(spacing: 0) {
            // Address bar
            HStack {
                TextField("Enter URL", text: $urlText)
                    .textFieldStyle(.roundedBorder)
                    .textInputAutocapitalization(.never)
                    .keyboardType(.URL)
                    .onSubmit { loadURL() }

                Button("Go") { loadURL() }
                    .buttonStyle(.bordered)
            }
            .padding(.horizontal)
            .padding(.vertical, 8)

            // Progress indicator
            if page.isLoading {
                ProgressView(value: page.estimatedProgress)
            }

            // Web content
            WebView(page)

            // Navigation toolbar
            HStack(spacing: 24) {
                Button(action: { page.goBack() }) {
                    Image(systemName: "chevron.left")
                }
                .disabled(!page.canGoBack)

                Button(action: { page.goForward() }) {
                    Image(systemName: "chevron.right")
                }
                .disabled(!page.canGoForward)

                Button(action: { page.reload() }) {
                    Image(systemName: "arrow.clockwise")
                }

                Spacer()

                Button(action: { page.stopLoading() }) {
                    Image(systemName: "xmark")
                }
                .disabled(!page.isLoading)
            }
            .padding()
        }
        .onAppear { loadURL() }
        .onChange(of: page.url) { _, newURL in
            if let newURL {
                urlText = newURL.absoluteString
            }
        }
    }

    private func loadURL() {
        var urlString = urlText.trimmingCharacters(in: .whitespaces)
        if !urlString.hasPrefix("http://") && !urlString.hasPrefix("https://") {
            urlString = "https://" + urlString
        }
        guard let url = URL(string: urlString) else { return }
        page.load(URLRequest(url: url))
    }
}

This example demonstrates several key patterns: using canGoBack and canGoForward to conditionally enable navigation buttons, syncing the address bar with the current URL via onChange, and handling the loading state for both a progress bar and a stop button. It's not a ton of code for what you get.

JavaScript Communication with callJavaScript

Alright, this is where things get really interesting. One of WebPage's most powerful features is callJavaScript(_:arguments:in:contentWorld:). It lets you execute JavaScript on the loaded page and receive results back, all using Swift's native async/await syntax.

Basic JavaScript Execution

struct JavaScriptExample: View {
    @State private var page = WebPage()
    @State private var pageTitle = ""

    var body: some View {
        VStack {
            WebView(page)

            Text("JS Title: \(pageTitle)")
                .padding()

            Button("Get Title via JS") {
                Task {
                    do {
                        let result = try await page.callJavaScript(
                            "document.title"
                        )
                        if let title = result as? String {
                            pageTitle = title
                        }
                    } catch {
                        print("JS error: \(error)")
                    }
                }
            }
        }
        .onAppear {
            page.load(URLRequest(url: URL(string: "https://swift.org")!))
        }
    }
}

Passing Arguments Safely

Hard-coding values into JavaScript strings is both fragile and dangerous—it opens the door to injection attacks. The arguments parameter solves this by letting you pass Swift values that become local JavaScript variables:

// Scroll to a specific element by its ID
func scrollToElement(id: String) async {
    let script = """
    const el = document.getElementById(elementId);
    if (el) {
        el.scrollIntoView({ behavior: 'smooth' });
        return true;
    }
    return false;
    """
    do {
        let result = try await page.callJavaScript(
            script,
            arguments: ["elementId": id]
        )
        let found = result as? Bool ?? false
        print("Element \(id) found: \(found)")
    } catch {
        print("Scroll failed: \(error)")
    }
}

In this example, elementId is available as a local variable inside the JavaScript scope. You never need to worry about escaping special characters or quote handling—WebKit handles the serialization securely. This is one of those details that seems small but saves you from nasty bugs down the road.

Injecting Custom CSS

A really common use case is modifying the appearance of loaded web content to match your app's design. You can inject CSS by creating a <style> element via JavaScript:

func injectDarkModeCSS() async {
    let script = """
    const style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);
    """
    let css = """
    body {
        background-color: #1a1a1a !important;
        color: #e0e0e0 !important;
    }
    a { color: #58a6ff !important; }
    """
    do {
        try await page.callJavaScript(script, arguments: ["css": css])
    } catch {
        print("CSS injection failed: \(error)")
    }
}

This pattern is especially useful when you're embedding third-party content (like help docs or terms of service pages) and want them to respect your app's dark mode setting.

Controlling Navigation with NavigationDeciding

In many apps, you don't want users following arbitrary links inside your WebView. Maybe you're building a help viewer that should stay within your documentation domain, or you want external links to open in Safari instead. The WebPage.NavigationDeciding protocol gives you this control.

class DomainRestrictedNavigator: WebPage.NavigationDeciding {
    private let allowedDomains: Set<String>

    init(domains: [String]) {
        self.allowedDomains = Set(domains)
    }

    func decidePolicy(
        for action: WebPage.NavigationAction,
        preferences: inout WebPage.NavigationPreferences
    ) async -> WKNavigationActionPolicy {
        guard let url = action.request.url,
              let host = url.host() else {
            return .cancel
        }

        // Allow navigation within approved domains
        if allowedDomains.contains(where: { host.hasSuffix($0) }) {
            return .allow
        }

        // Open everything else in Safari
        await MainActor.run {
            UIApplication.shared.open(url)
        }
        return .cancel
    }
}

To use this, pass the navigator when creating your WebPage:

@State private var page = WebPage(
    navigationDecider: DomainRestrictedNavigator(
        domains: ["swift.org", "developer.apple.com"]
    )
)

Now any link pointing outside Swift.org or Apple's developer site opens in the system browser instead. Navigation within those domains works normally. Simple and effective.

Handling JavaScript Dialogs with DialogPresenting

Web pages can trigger alert(), confirm(), and prompt() dialogs. By default, WebView shows these as system alerts, but you might want to style them differently or log them for debugging. The WebPage.DialogPresenting protocol lets you intercept and replace these dialogs with native SwiftUI UI:

class SwiftUIDialogPresenter: WebPage.DialogPresenting {
    @Published var alertMessage: String?
    @Published var showAlert = false

    func webPage(
        _ page: WebPage,
        handleJavaScriptAlert message: String,
        initiatedByFrame frame: WebPage.FrameInfo
    ) async {
        await MainActor.run {
            alertMessage = message
            showAlert = true
        }
    }
}

Then in your view, bind the presenter's published properties to a SwiftUI .alert() modifier to present dialogs that match your app's visual style. It's a nice touch that makes your app feel more polished.

Configuring WebPage Behavior

The WebPage.Configuration struct lets you customize the browsing environment before the page loads. Here are the key options and when you'd use them:

func makeConfiguredPage() -> WebPage {
    var config = WebPage.Configuration()

    // Identify your app in the user agent string
    config.applicationNameForUserAgent = "MyApp/2.0"

    // Enable AirPlay for media-heavy content
    config.allowsAirPlayForMediaPlayback = true

    // Auto-detect phone numbers, addresses, and links
    config.dataDetectorTypes = [.phoneNumber, .link, .address]

    // Enable inline text predictions
    config.allowsInlinePredictions = true

    return WebPage(configuration: config)
}

One important thing to note: configuration options are set during initialization. The configuration is immutable after the WebPage is created, so plan ahead for what your browsing session needs.

Useful View Modifiers for WebView

SwiftUI's WebView supports several view modifiers that customize user interaction beyond what WebPage itself provides:

  • .webViewScrollPosition(_:) — Bind to a scroll position value for programmatic scrolling and reading the user's scroll offset
  • .webViewMagnificationGestures(enabled:) — Enable or disable pinch-to-zoom
  • .findNavigator(isPresented:) — Show the built-in find-in-page interface (the same one Safari uses, which is a nice detail)
struct EnhancedWebView: View {
    @State private var page = WebPage()
    @State private var showFind = false

    var body: some View {
        WebView(page)
            .findNavigator(isPresented: $showFind)
            .webViewMagnificationGestures(enabled: true)
            .toolbar {
                ToolbarItem {
                    Button("Find") { showFind.toggle() }
                }
            }
            .onAppear {
                page.load(URLRequest(
                    url: URL(string: "https://docs.swift.org")!
                ))
            }
    }
}

Loading Local HTML Content

You don't always need a remote URL. WebPage can load HTML strings directly, which is perfect for rendering formatted content, email previews, or dynamically generated documents:

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

    var body: some View {
        WebView(page)
            .onAppear {
                let html = """
                <!DOCTYPE html>
                <html>
                <head>
                    <meta name="viewport"
                          content="width=device-width, initial-scale=1">
                    <style>
                        body {
                            font-family: -apple-system, system-ui;
                            padding: 20px;
                            line-height: 1.6;
                        }
                        h1 { color: #007AFF; }
                        code {
                            background: #f0f0f0;
                            padding: 2px 6px;
                            border-radius: 4px;
                        }
                    </style>
                </head>
                <body>
                    <h1>Welcome</h1>
                    <p>This content is rendered from a local
                       HTML string using <code>WebPage</code>.</p>
                </body>
                </html>
                """
                page.loadHTML(html, baseURL: nil)
            }
    }
}

I've used this pattern a lot for displaying rich text content that's generated on-device—things like Markdown previews or styled error pages. It's surprisingly versatile.

Supporting Older iOS Versions

Since WebView and WebPage require iOS 26, you'll need a compatibility strategy if your app still supports earlier versions. The cleanest approach is a conditional wrapper:

struct CompatibleWebView: View {
    let url: URL

    var body: some View {
        if #available(iOS 26, *) {
            WebView(url: url)
        } else {
            LegacyWebView(url: url)
        }
    }
}

// Fallback for iOS 25 and earlier
struct LegacyWebView: UIViewRepresentable {
    let url: URL

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

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

This lets you adopt the new API right away while keeping your app available to users who haven't updated yet. Over time, as your minimum deployment target advances, you can remove the fallback entirely.

Known Caveats and Workarounds

The native SwiftUI WebView is a major step forward, but there are a few rough edges worth knowing about (as of the initial iOS 26 release):

  • Alerts in sheets: Presenting a WebView inside a .sheet or .fullScreenCover can cause issues where alerts trigger a page reload. Use NavigationStack or navigationDestination as an alternative to avoid this bug.
  • Safe area layout: Some developers have reported layout glitches when using .ignoresSafeArea() with WebView. Test your layout carefully on different device sizes—especially with the Dynamic Island.
  • Cookie management: While WebPage handles cookies within its session automatically, sharing cookies between your WebView and URLSession network requests still requires manual synchronization through WKHTTPCookieStore. This is a common gotcha.
  • Deep WKWebView configuration: If you need user scripts, custom URL scheme handlers, or other low-level configuration not yet exposed through WebPage.Configuration, you may still need to fall back to a UIKit-wrapped WKWebView. Hopefully Apple will close these gaps in future releases.

Real-World Example: Documentation Viewer

Let's put everything together into something practical—a documentation viewer that restricts navigation to specific domains, tracks page titles, provides search, and communicates with JavaScript:

struct DocumentationViewer: View {
    @State private var page = WebPage(
        navigationDecider: DomainRestrictedNavigator(
            domains: ["docs.swift.org", "developer.apple.com"]
        )
    )
    @State private var showFind = false
    @State private var headings: [String] = []

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

                WebView(page)
                    .findNavigator(isPresented: $showFind)
            }
            .navigationTitle(page.title ?? "Documentation")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItemGroup(placement: .primaryAction) {
                    Button {
                        showFind.toggle()
                    } label: {
                        Image(systemName: "magnifyingglass")
                    }

                    Menu {
                        ForEach(headings, id: \.self) { heading in
                            Button(heading) {
                                Task {
                                    await scrollToHeading(heading)
                                }
                            }
                        }
                    } label: {
                        Image(systemName: "list.bullet")
                    }
                    .disabled(headings.isEmpty)
                }

                ToolbarItemGroup(placement: .bottomBar) {
                    Button(action: { page.goBack() }) {
                        Image(systemName: "chevron.left")
                    }
                    .disabled(!page.canGoBack)

                    Button(action: { page.goForward() }) {
                        Image(systemName: "chevron.right")
                    }
                    .disabled(!page.canGoForward)

                    Spacer()

                    Button(action: { page.reload() }) {
                        Image(systemName: "arrow.clockwise")
                    }
                }
            }
        }
        .onAppear {
            page.load(URLRequest(
                url: URL(string: "https://docs.swift.org/swift-book/documentation/the-swift-programming-language/")!
            ))
        }
        .onChange(of: page.isLoading) { _, isLoading in
            if !isLoading {
                Task { await extractHeadings() }
            }
        }
    }

    private func extractHeadings() async {
        let script = """
        Array.from(document.querySelectorAll('h2, h3'))
            .map(h => h.textContent.trim())
        """
        do {
            let result = try await page.callJavaScript(script)
            if let titles = result as? [String] {
                headings = titles
            }
        } catch {
            print("Failed to extract headings: \(error)")
        }
    }

    private func scrollToHeading(_ title: String) async {
        let script = """
        const headings = document.querySelectorAll('h2, h3');
        for (const h of headings) {
            if (h.textContent.trim() === targetTitle) {
                h.scrollIntoView({ behavior: 'smooth' });
                break;
            }
        }
        """
        do {
            try await page.callJavaScript(
                script,
                arguments: ["targetTitle": title]
            )
        } catch {
            print("Scroll failed: \(error)")
        }
    }
}

This viewer combines domain-restricted navigation, a dynamic table of contents extracted via JavaScript, find-in-page search, and standard browser navigation—all in pure SwiftUI with zero UIKit bridging. It's a solid foundation you can adapt for your own app.

Frequently Asked Questions

What is the difference between WebView and WebPage in SwiftUI?

WebView is a SwiftUI view that displays web content on screen. WebPage is an Observable model class that represents and controls the browsing session. Use WebView(url:) for simple one-line embeds. Use WebPage with WebView(page) when you need access to the page title, URL, loading state, JavaScript execution, or navigation control. Think of WebView as the display and WebPage as the engine.

Can I use SwiftUI WebView on iOS versions earlier than iOS 26?

No. The native WebView and WebPage APIs require iOS 26 as the minimum deployment target. For apps that support earlier iOS versions, use #available(iOS 26, *) to conditionally show the native WebView, and fall back to a UIViewRepresentable wrapper around WKWebView on older systems.

How do I execute JavaScript and get a return value from WebPage?

Use page.callJavaScript(_:arguments:), which is an async throws method. It returns Any?, which you cast to the expected Swift type. For example, let count = try await page.callJavaScript("document.images.length") as? Int. Always use the arguments parameter to pass dynamic values securely instead of string interpolation.

Does SwiftUI WebView have built-in back and forward buttons?

No. The WebView intentionally doesn't include navigation chrome. You build your own navigation UI using WebPage properties like canGoBack, canGoForward, and methods like goBack() and goForward(). This gives you full design control over the browsing experience.

How do I restrict which URLs a WebView can navigate to?

Implement the WebPage.NavigationDeciding protocol and pass your implementation when creating a WebPage. In the decidePolicy(for:preferences:) method, inspect the URL and return .allow or .cancel. You can redirect blocked URLs to Safari using UIApplication.shared.open(url) before returning .cancel.

About the Author Editorial Team

Our team of expert writers and editors.