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
- Meet WebView and WebPage
- Navigation and Loading State
- JavaScript Interaction
- Advanced Usage: Configuration and Customization
- When to Use (and When Not To)
- Summary
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@Observablemodel 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
Navigation and Loading State
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:
estimatedProgressis aDoublefrom 0 to 1, identical to the oldWKWebView.estimatedProgress— but now it triggers SwiftUI updates automatically.canGoBackandcanGoForwardare observable booleans. The buttons disable themselves reactively without any manual KVO.page.titleis an optionalStringthat updates as the page loads. Binding it to.navigationTitlemeans your nav bar always reflects the current page.
Tip: If you want to observe URL changes to implement a custom address bar, read
page.urldirectly. 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.Configurationmust be set before the page loads its first request. You cannot reconfigure a page after creation. If you need different configurations, create separateWebPageinstances.
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
WebViewandWebPagereplace theWKWebView+UIViewRepresentableworkaround with a native SwiftUI solution. WebPageis@Observable, so navigation state, title, URL, and loading progress drive SwiftUI updates automatically.- JavaScript interop uses
async/awaitviacallJavaScript(_:arguments:), and script message handlers use a declarative.onReceiveScriptMessagemodifier. - Navigation policy decisions move from delegate methods to the
.onNavigationActionmodifier. - Use
WebViewwhen you need UI control or JavaScript interop. UseSFSafariViewControlleroropenURLfor 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.