How to Build a Web View in SwiftUI
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
-
UIViewRepresentable bridge —
makeUIViewis called once; it pulls the sharedWKWebViewfromWebViewStore, sets the coordinator as itsnavigationDelegate, and fires the firstURLRequest.updateUIViewis intentionally left empty to avoid redundant reloads on every SwiftUI re-render. -
WebViewStore (@Observable) — Keeping
WKWebViewinside an@Observableclass prevents the view from being recreated (and the page from reloading) whenBrowserView's@Statechanges. This is the most common performance mistake in web view wrappers. -
Coordinator as WKNavigationDelegate — The
CoordinatorreceivesdidStartProvisionalNavigation,didFinish, anddidFailcallbacks, then writes back into the@Bindingproperties — flippingisLoading,canGoBack, andcanGoForwardto drive the SwiftUI layer reactively. -
Inline progress bar — The
ProgressView(.linear)is conditionally overlaid at the top of theZStackwhileisLoadingistrue, giving users instant visual feedback without blocking the already-rendered portion of the page. -
Accessible toolbar — Each toolbar button carries an
.accessibilityLabelmodifier ("Go back", "Go forward", "Reload page") so VoiceOver users can navigate the browser chrome without guessing icon meaning. Buttons are also.disabledwhen 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
-
Recreating WKWebView on every state change. If you instantiate
WKWebView()directly insidemakeUIViewand store it as@State, SwiftUI can tear down and re-create the representable — triggering a full page reload. Always hold the instance in a stable object like the@ObservableWebViewStoreshown above. -
Missing App Transport Security (ATS) entry for HTTP URLs. By default iOS 17 blocks plain-
http://loads. If you need to load non-HTTPS content, add anNSAllowsArbitraryLoadsInWebContentkey underNSAppTransportSecurityinInfo.plist. Never use the broaderNSAllowsArbitraryLoads— App Review will flag it. -
Forgetting to disable JavaScript for untrusted HTML. When rendering user-supplied HTML with
loadHTMLString, disable JS viaWKWebViewConfiguration→defaultWebpagePreferences.allowsContentJavaScript = falseto prevent XSS-style attacks in your own app. -
Not hooking
didFailalongsidedidFinish. If navigation fails (e.g., offline),didFinishis never called, leavingisLoadingstuck attrueforever. Always implementwebView(_:didFail:withError:)andwebView(_:didFailProvisionalNavigation:withError:)to reset loading state.
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.