Not every screen in your app needs to be built natively from scratch. Whether you are integrating a third-party payment gateway, displaying a dynamic privacy policy, or bridging a legacy web portal, embedding a web page directly into your UI is an essential requirement. We will use the official webview_flutter package to achieve this seamlessly.
This tutorial explores how to configure and control a modern WebView in Flutter. We will start with the necessary native platform permissions before moving into the core WebViewController API to manage navigation, track loading progress, and intercept errors. We will also tackle the often-tricky task of establishing two-way communication between your Dart code and the JavaScript running inside the browser.
Bear in mind that the webview_flutter package underwent a massive rewrite in version 4.0, shifting away from the old widget-based approach to a controller-first architecture. All the code snippets in this guide use the modern, current API, so ensure your package version is up to date before you begin.

The Complete Flutter Guide: Build Android, iOS and Web apps
Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.
Enrol nowConfiguring Native Platform Permissions
Before writing any Dart code, we must ensure our host platforms are allowed to access the web. A WebView is essentially an embedded browser, and mobile operating systems strictly govern network access.
For Android, you need to declare that your application requires internet access. Open your android/app/src/main/AndroidManifest.xml file and add the INTERNET permission just above the <application> tag:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Add this line -->
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:label="webview_demo"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<!-- ... -->
</application>
</manifest>
You should also verify your Android minimum SDK version. The current webview_flutter package supports Android SDK 24 and later. New Flutter projects use the SDK default through minSdk = flutter.minSdkVersion in android/app/build.gradle.kts; older projects may need that value raised before the plug-in will build.
On iOS, standard HTTPS connections work out of the box. Apple enforces App Transport Security (ATS), so a plain HTTP URL is blocked unless you add a deliberate exception. Prefer HTTPS. If a development or legacy host genuinely requires HTTP, scope the exception to that domain instead of disabling ATS for the entire application:
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>example.com</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
</dict>
</dict>
Initialising the WebViewController
With the permissions sorted, we can move to the Dart side. Add the package to your project by running flutter pub add webview_flutter in your terminal.
The core engine of the modern package is the WebViewController. You configure the URL, JavaScript execution rules, and background colour directly on the controller, not the widget. Let us build a stateful widget to house our controller.
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
class BrowserScreen extends StatefulWidget {
const BrowserScreen({super.key});
@override
State<BrowserScreen> createState() => _BrowserScreenState();
}
class _BrowserScreenState extends State<BrowserScreen> {
late final WebViewController _controller;
@override
void initState() {
super.initState();
// Initialize the controller
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(const Color(0x00000000))
..loadRequest(Uri.parse('https://flutter.dev'));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Flutter Website')),
// Bind the controller to the WebViewWidget
body: WebViewWidget(controller: _controller),
);
}
}
In the initState method, we use Dart's cascade operator (..) to chain our setup calls. setJavaScriptMode(JavaScriptMode.unrestricted) is vital; without it, most modern websites will fail to render properly because JavaScript is disabled by default for security reasons. Finally, WebViewWidget(controller: _controller) handles the actual rendering in the UI tree.
Tracking Loading Progress and Intercepting Navigation
A bare WebView works, but it offers a poor user experience. Users need visual feedback while a page loads. Furthermore, you might want to prevent users from navigating away to certain domains (like stopping them from clicking a link that opens YouTube inside your app).
To achieve this, we attach a NavigationDelegate to our controller. This delegate acts as a series of hooks into the browser's lifecycle.
class _BrowserScreenState extends State<BrowserScreen> {
late final WebViewController _controller;
int _loadingProgress = 0;
@override
void initState() {
super.initState();
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(
NavigationDelegate(
onProgress: (int progress) {
setState(() {
_loadingProgress = progress;
});
},
onPageStarted: (String url) {
debugPrint('Started loading: $url');
},
onPageFinished: (String url) {
debugPrint('Finished loading: $url');
},
onNavigationRequest: (NavigationRequest request) {
// Prevent navigation to YouTube
if (request.url.startsWith('https://www.youtube.com/')) {
debugPrint('Blocking navigation to $request');
return NavigationDecision.prevent;
}
// Allow all other URLs
return NavigationDecision.navigate;
},
),
)
..loadRequest(Uri.parse('https://flutter.dev'));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Web Portal')),
body: Stack(
children: [
WebViewWidget(controller: _controller),
if (_loadingProgress < 100)
LinearProgressIndicator(
value: _loadingProgress / 100.0,
color: Colors.blue,
backgroundColor: Colors.transparent,
),
],
),
);
}
}
We store the loading percentage in a local state variable, _loadingProgress. The onProgress callback updates this value rapidly as the page downloads assets. In the build method, we overlay a LinearProgressIndicator on top of the WebView using a Stack. The indicator disappears once the progress hits 100.
The onNavigationRequest callback is incredibly powerful. It fires whenever the user taps a link or the page attempts to redirect. By returning NavigationDecision.prevent, you can block the navigation entirely. This is commonly used to intercept mailto: or tel: links so you can hand them off to the url_launcher package instead of trying to open them in the browser.

The Complete Flutter Guide: Build Android, iOS and Web apps
Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.
Enrol nowTwo-Way Communication: Bridging Dart and JavaScript
Sometimes, embedding a page is not enough; you need the app and the web page to talk to each other. For example, a web-based form might need to tell your Flutter app when a submission is successful so you can pop the current screen.
To receive messages from JavaScript, we register a JavaScript channel on the WebViewController.
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..addJavaScriptChannel(
'AppBridge',
onMessageReceived: (JavaScriptMessage message) {
// This runs in Dart when JS calls AppBridge.postMessage()
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('JS says: ${message.message}')),
);
},
)
..loadHtmlString('''
<!DOCTYPE html>
<html>
<body style="font-size: 40px; text-align: center; padding-top: 50px;">
<button onclick="AppBridge.postMessage('Hello from the Web!')">
Send Message to Flutter
</button>
</body>
</html>
''');
In this snippet, instead of loading a remote URL, we use loadHtmlString to render raw HTML directly. We define a channel named AppBridge. Inside the HTML, the button's onclick event triggers AppBridge.postMessage(...). This instantly fires the onMessageReceived callback in Dart, allowing us to show a native SnackBar.
To send data in the opposite direction (from Dart to JavaScript), you can evaluate JavaScript dynamically using runJavaScript or runJavaScriptReturningResult.
FloatingActionButton(
onPressed: () async {
// Execute a JS alert inside the WebView
await _controller.runJavaScript("alert('Flutter triggered this alert!');");
// Or extract data from the page
final pageTitle = await _controller.runJavaScriptReturningResult("document.title");
debugPrint('Current title: $pageTitle');
},
child: const Icon(Icons.code),
)
Ensure the page has completely finished loading (wait for onPageFinished) before attempting to run JavaScript, otherwise the document might not be ready, and your script will fail silently.
Handling Errors and Offline Scenarios
Web-based features are fragile; if the user's internet drops, your embedded page will turn into an ugly native browser error screen. You should catch network failures gracefully and present a custom Flutter UI instead.
You can detect loading failures via the onWebResourceError callback inside your NavigationDelegate.
class _SafeBrowserState extends State<BrowserScreen> {
late final WebViewController _controller;
bool _hasError = false;
@override
void initState() {
super.initState();
_controller = WebViewController()
..setNavigationDelegate(
NavigationDelegate(
onWebResourceError: (WebResourceError error) {
// An image or favicon can fail while the main page is usable.
// Replace the screen only when the top-level navigation failed.
if (error.isForMainFrame != true || !mounted) return;
setState(() {
_hasError = true;
});
debugPrint('Failed to load: ${error.description}');
},
onPageStarted: (String url) {
setState(() {
_hasError = false; // Reset error state on new navigation
});
},
),
)
..loadRequest(Uri.parse('https://example.com'));
}
@override
Widget build(BuildContext context) {
if (_hasError) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.wifi_off, size: 64, color: Colors.grey),
const SizedBox(height: 16),
const Text('Failed to load the page.'),
ElevatedButton(
onPressed: () {
_controller.reload();
},
child: const Text('Try Again'),
),
],
),
),
);
}
return Scaffold(
body: WebViewWidget(controller: _controller),
);
}
}
When an error occurs, we flip a boolean flag. The build method reacts by swapping the WebViewWidget out for a native error view, complete with a retry button that calls _controller.reload(). This keeps the user experience entirely within your app's visual language.
Common Mistakes and Pitfalls
Working with embedded browsers introduces unique challenges that differ from standard widget trees. Keep an eye out for these frequent missteps:
- Using the deprecated v3 API: Code snippets older than 2023 often show a
WebViewwidget that accepts aninitialUrlproperty. This API is entirely removed. If you see this, you are looking at outdated documentation. Always useWebViewControllerandWebViewWidget. - Forgetting unrestricted JavaScript: By default,
JavaScriptMode.disabledis enforced. If a webpage appears blank or buttons do not work, missingsetJavaScriptMode(JavaScriptMode.unrestricted)is the most likely culprit. - Ignoring back navigation: Android users expect the physical back button to navigate backwards in the browser history, not immediately close the screen. You should wrap your
WebViewWidgetin aPopScope(orWillPopScopeon older Flutter versions) and checkawait _controller.canGoBack()to handle internal routing correctly. - Transparent backgrounds causing flashes: When the WebView first initialises, it can flash white. If your app uses a dark theme, call
setBackgroundColor(Colors.transparent)on the controller to prevent this jarring visual glitch.
Verifying Your Implementation
To ensure your integration is robust, perform a quick verification routine across your target platforms. Boot up both an Android emulator and an iOS simulator.
First, verify the network permissions by pointing loadRequest to a reliable URL like https://google.com. If it loads on Android but not iOS, or vice versa, double-check your Manifest or Info.plist. Next, navigate to a different page within the WebView and press the device back button (or swipe back on iOS); ensure it behaves as you intend, whether that means going back in history or popping the Flutter route.
Finally, temporarily disable your device's Wi-Fi and mobile data, then attempt to load the screen. You should see your custom error UI trigger via onWebResourceError rather than the default browser timeout page.
Further reads
Keep going with these related tutorials from this site.
- Flutter: The Complete Guide — the full Flutter learning path on this site
- Flutter url_launcher: Open Links, Email, Phone, and Maps — learn how to open URLs in external browsers instead of embedding them
- Flutter permission_handler: Request and Check Permissions — handle native device permissions effectively
- Flutter DevTools: Inspect Widgets and Debug Layouts — master the tools needed to debug complex visual hierarchies