```html SwiftUI: How to Build a Web View (iOS 17+, 2026)
Soarias ← All SwiftUI guides

How to Build a Web View in SwiftUI

iOS 17+ Xcode 16+ Beginner APIs: WKWebView, UIViewRepresentable Updated: May 11, 2026
TL;DR

SwiftUI doesn't ship a built-in web view for iOS 17, so you wrap WKWebView from WebKit using UIViewRepresentable. Hand it a URL and it renders any web page natively inside your SwiftUI hierarchy.

import SwiftUI
import WebKit

struct WebView: UIViewRepresentable {
    let url: URL

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

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

// Usage:
WebView(url: URL(string: "https://example.com")!)

Full implementation

The minimal wrapper above works, but real apps need back/forward navigation, a reload button, and a loading progress bar. The implementation below adds a Coordinator that acts as the WKNavigationDelegate, tracks canGoBack/canGoForward via @State, and drives a ProgressView while the page loads. The outer BrowserView composes everything into a NavigationStack with a toolbar.

import SwiftUI
import WebKit

// MARK: - UIViewRepresentable wrapper

struct WebView: UIViewRepresentable {
    let url: URL
    @Binding var isLoading: Bool
    @Binding var canGoBack: Bool
    @Binding var canGoForward: Bool

    // Expose the underlying WKWebView so toolbar buttons can call
    // goBack() / goForward() / reload() directly.
    let webViewStore: WebViewStore

    func makeCoordinator() -> Coordinator { Coordinator(self) }

    func makeUIView(context: Context) -> WKWebView {
        let wv = webViewStore.webView
        wv.navigationDelegate = context.coordinator
        wv.load(URLRequest(url: url))
        return wv
    }

    func updateUIView(_ webView: WKWebView, context: Context) {}

    // MARK: Coordinator

    class Coordinator: NSObject, WKNavigationDelegate {
        let parent: WebView

        init(_ parent: WebView) { self.parent = parent }

        func webView(_ webView: WKWebView,
                     didStartProvisionalNavigation navigation: WKNavigation!) {
            parent.isLoading = true
        }

        func webView(_ webView: WKWebView,
                     didFinish navigation: WKNavigation!) {
            parent.isLoading    = false
            parent.canGoBack    = webView.canGoBack
            parent.canGoForward = webView.canGoForward
        }

        func webView(_ webView: WKWebView,
                     didFail navigation: WKNavigation!,
                     withError error: Error) {
            parent.isLoading = false
        }
    }
}

// MARK: - Observable store that holds the WKWebView instance

@Observable
final class WebViewStore {
    let webView = WKWebView()
}

// MARK: - Host view

struct BrowserView: View {
    let url: URL
    @State private var isLoading    = false
    @State private var canGoBack    = false
    @State private var canGoForward = false
    @State private var store        = WebViewStore()

    var body: some View {
        NavigationStack {
            ZStack(alignment: .top) {
                WebView(
                    url: url,
                    isLoading: $isLoading,
                    canGoBack: $canGoBack,
                    canGoForward: $canGoForward,
                    webViewStore: store
                )
                .ignoresSafeArea(edges: .bottom)

                if isLoading {
                    ProgressView()
                        .progressViewStyle(.linear)
                        .tint(.blue)
                        .frame(maxWidth: .infinity, alignment: .top)
                }
            }
            .navigationTitle(url.host() ?? "Browser")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItemGroup(placement: .bottomBar) {
                    Button {
                        store.webView.goBack()
                    } label: {
                        Image(systemName: "chevron.backward")
                    }
                    .disabled(!canGoBack)
                    .accessibilityLabel("Go back")

                    Spacer()

                    Button {
                        store.webView.goForward()
                    } label: {
                        Image(systemName: "chevron.forward")
                    }
                    .disabled(!canGoForward)
                    .accessibilityLabel("Go forward")

                    Spacer()

                    Button {
                        store.webView.reload()
                    } label: {
                        Image(systemName: "arrow.clockwise")
                    }
                    .accessibilityLabel("Reload page")
                }
            }
        }
    }
}

// MARK: - Preview

#Preview {
    BrowserView(url: URL(string: "https://webkit.org")!)
}

How it works

  1. UIViewRepresentable bridgemakeUIView is called once; it pulls the shared WKWebView from WebViewStore, sets the coordinator as its navigationDelegate, and fires the first URLRequest. updateUIView is intentionally left empty to avoid redundant reloads on every SwiftUI re-render.
  2. WebViewStore (@Observable) — Keeping WKWebView inside an @Observable class prevents the view from being recreated (and the page from reloading) when BrowserView's @State changes. This is the most common performance mistake in web view wrappers.
  3. Coordinator as WKNavigationDelegate — The Coordinator receives didStartProvisionalNavigation, didFinish, and didFail callbacks, then writes back into the @Binding properties — flipping isLoading, canGoBack, and canGoForward to drive the SwiftUI layer reactively.
  4. Inline progress bar — The ProgressView(.linear) is conditionally overlaid at the top of the ZStack while isLoading is true, giving users instant visual feedback without blocking the already-rendered portion of the page.
  5. Accessible toolbar — Each toolbar button carries an .accessibilityLabel modifier ("Go back", "Go forward", "Reload page") so VoiceOver users can navigate the browser chrome without guessing icon meaning. Buttons are also .disabled when the action isn't available, conveying state to assistive technologies.

Variants

Loading a local HTML string

Sometimes you need to display dynamically generated HTML — a terms-of-service snippet, a rich-text preview, or a chart rendered by a JS library. Pass the string directly to loadHTMLString(_:baseURL:) instead of a URLRequest.

struct HTMLView: UIViewRepresentable {
    let html: String

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

    func updateUIView(_ webView: WKWebView, context: Context) {
        webView.loadHTMLString(html, baseURL: nil)
    }
}

// Usage — inject CSS for dark mode support:
let styledHTML = """
<html>
<head>
  <meta name="viewport" content="width=device-width">
  <style>
    body { font: -apple-system-body; color: #1c1c1e;
           padding: 16px; }
    @media (prefers-color-scheme: dark) {
      body { background: #1c1c1e; color: #f2f2f7; }
    }
  </style>
</head>
<body>\(markdownBody)</body>
</html>
"""
HTMLView(html: styledHTML)

Native WebView on iOS 18+ (SwiftUI 6)

Apple shipped a first-party WebView in SwiftUI 6 / iOS 18. If your deployment target allows it, you can drop the UIViewRepresentable boilerplate entirely:

import SwiftUI
import WebKit   // still needed for WKWebViewConfiguration

// iOS 18+ only
@available(iOS 18.0, *)
struct NativeBrowserView: View {
    var body: some View {
        WebView(url: URL(string: "https://webkit.org")!)
    }
}

#Preview {
    if #available(iOS 18.0, *) {
        NativeBrowserView()
    }
}

For apps that still support iOS 17, wrap the native call in if #available(iOS 18, *) and fall back to the UIViewRepresentable wrapper.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement a web view in SwiftUI for iOS 17+.
Use WKWebView wrapped in UIViewRepresentable with a Coordinator
  that conforms to WKNavigationDelegate.
Track isLoading, canGoBack, and canGoForward as @State.
Add back, forward, and reload toolbar buttons with .disabled states.
Show a linear ProgressView while loading.
Make it accessible (VoiceOver labels on all toolbar buttons).
Add a #Preview with realistic sample data (webkit.org URL).

In the Build phase of Soarias, paste this prompt into the active file context — Claude Code will scaffold the full BrowserView, wire the bindings, and drop in the preview so you can hot-reload the result instantly.

Related guides

FAQ

Does this work on iOS 16?

Yes — the UIViewRepresentable + WKWebView pattern works back to iOS 14. The only iOS-17-specific construct used above is the @Observable macro in WebViewStore. Replace it with ObservableObject + @StateObject if you need iOS 16 support.

How do I intercept navigation and block certain URLs?

Implement webView(_:decidePolicyFor:decisionHandler:) in your Coordinator. Inspect navigationAction.request.url and call decisionHandler(.cancel) for URLs you want to block, or decisionHandler(.allow) to let them through. You can also redirect by loading a different request and cancelling the current one.

What's the UIKit equivalent?

In UIKit you instantiate WKWebView directly in viewDidLoad, add it as a subview, and set self as navigationDelegate. The delegate callbacks are identical. SwiftUI's UIViewRepresentable is simply a bridge that lets you reuse that same WKWebView inside a declarative view tree.

Last reviewed: 2026-05-11 by the Soarias team.

```