ScreenshotNeo

BlogHow-to

How to Make a Web Browser in Python

Build a desktop web browser in Python with PySide6 and Qt WebEngine, then extend it with tabs, downloads, private profiles, and request controls.

By the ScreenshotNeo team1 October 20269 min read

Use PySide6 with Qt WebEngine. PySide6 supplies Python bindings for Qt, while Qt WebEngine embeds a maintained browser engine that handles HTML, CSS, JavaScript, networking, and page rendering. You can build a useful browser application without implementing a rendering engine yourself.

This tutorial starts with a runnable browser window and then adds navigation, tabs, downloads, private browsing, request interception, and production considerations. Qt’s official Simple Browser example follows the same broad separation between the application window, tab widget, web view, and web page.

What you are building

The finished first version has:

  • An address bar with URL normalization
  • Back, forward, reload, and stop controls
  • Multiple tabs
  • Page title and URL updates
  • Download handling with a save dialog
  • A separate private profile that keeps cookies, cache, and history in memory

A browser engine is a large standards-compliance project. Do not attempt to replace Qt WebEngine unless your goal is browser-engine research. Qt’s WebEngine overview is the appropriate foundation for an application browser.

1. Install Python and PySide6

Use Python 3.9 or newer in a virtual environment:

python -m venv .venv
# macOS/Linux
source .venv/bin/activate
# Windows PowerShell
.venv\\Scripts\\Activate.ps1

python -m pip install --upgrade pip
python -m pip install PySide6 PySide6-WebEngine

If your platform’s wheel bundles WebEngine with the main PySide6 distribution, the second package may already be satisfied. Verify the import before continuing:

python -c "from PySide6.QtWebEngineWidgets import QWebEngineView; print('Qt WebEngine is available')"

2. Create a minimal browser

Save this as browser.py. It is intentionally self-contained so you can run it before adding advanced features.

import sys
from PySide6.QtCore import QUrl
from PySide6.QtWidgets import (
    QApplication, QLineEdit, QMainWindow, QPushButton,
    QToolBar, QVBoxLayout, QWidget
)
from PySide6.QtWebEngineWidgets import QWebEngineView


class BrowserWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Python Browser")
        self.resize(1200, 800)

        self.view = QWebEngineView()
        self.address_bar = QLineEdit()
        self.address_bar.setPlaceholderText("Enter a URL")
        self.address_bar.returnPressed.connect(self.navigate)

        toolbar = QToolBar("Navigation")
        self.addToolBar(toolbar)

        for label, callback in [
            ("Back", self.view.back),
            ("Forward", self.view.forward),
            ("Reload", self.view.reload),
            ("Stop", self.view.stop),
        ]:
            button = QPushButton(label)
            button.clicked.connect(callback)
            toolbar.addWidget(button)

        toolbar.addWidget(self.address_bar)
        self.setCentralWidget(self.view)

        self.view.urlChanged.connect(self.update_url)
        self.view.titleChanged.connect(self.update_title)
        self.view.loadFinished.connect(self.load_finished)
        self.view.setUrl(QUrl("https://example.com"))

    def navigate(self):
        text = self.address_bar.text().strip()
        if not text:
            return
        if "://" not in text:
            text = "https://" + text
        self.view.setUrl(QUrl.fromUserInput(text))

    def update_url(self, url):
        self.address_bar.setText(url.toString())

    def update_title(self, title):
        self.setWindowTitle(f"{title} - Python Browser" if title else "Python Browser")

    def load_finished(self, ok):
        self.statusBar().showMessage("Loaded" if ok else "Load failed", 3000)


app = QApplication(sys.argv)
window = BrowserWindow()
window.show()
sys.exit(app.exec())

Run it with:

python browser.py

QWebEngineView displays content, while QWebEnginePage owns page state, navigation history, and actions. Calling setUrl() or load() starts navigation. If you load HTML directly with setHtml() and it contains relative links, provide a base URL so those links resolve correctly.

3. Add tabs, downloads, and private browsing

The next example is a more practical single-file browser. Each tab owns a view. A shared normal profile persists browser data; a private profile uses off-the-record storage. Downloads are accepted only after the user chooses a destination.

import sys
from pathlib import Path
from PySide6.QtCore import QUrl
from PySide6.QtWidgets import (
    QApplication, QFileDialog, QLineEdit, QMainWindow, QMessageBox,
    QTabWidget, QToolBar, QWidget
)
from PySide6.QtWebEngineCore import QWebEngineDownloadRequest, QWebEngineProfile
from PySide6.QtWebEngineWidgets import QWebEngineView


class Browser(QMainWindow):
    def __init__(self):
        super().__init__()
        self.resize(1280, 850)
        self.setWindowTitle("Python Browser")

        self.tabs = QTabWidget(tabsClosable=True, movable=True)
        self.tabs.tabCloseRequested.connect(self.close_tab)
        self.tabs.currentChanged.connect(self.refresh_toolbar)
        self.setCentralWidget(self.tabs)

        toolbar = QToolBar("Navigation")
        self.addToolBar(toolbar)
        self.back_action = toolbar.addAction("Back", self.go_back)
        self.forward_action = toolbar.addAction("Forward", self.go_forward)
        self.reload_action = toolbar.addAction("Reload", self.reload)
        self.stop_action = toolbar.addAction("Stop", self.stop)
        self.address = QLineEdit()
        self.address.returnPressed.connect(self.navigate)
        toolbar.addWidget(self.address)
        toolbar.addAction("New tab", self.new_tab)
        toolbar.addAction("Private tab", self.new_private_tab)

        self.normal_profile = QWebEngineProfile.defaultProfile()
        self.normal_profile.downloadRequested.connect(self.download_requested)
        self.private_profile = QWebEngineProfile("private", self)
        self.private_profile.setPersistentCookiesPolicy(
            QWebEngineProfile.PersistentCookiesPolicy.NoPersistentCookies
        )
        self.private_profile.downloadRequested.connect(self.download_requested)

        self.new_tab(QUrl("https://example.com"), "New tab")

    def current_view(self):
        return self.tabs.currentWidget()

    def make_view(self, private=False):
        view = QWebEngineView()
        if private:
            page = view.page()
            page.deleteLater()
            view.setPage(__import__(
                "PySide6.QtWebEngineCore", fromlist=["QWebEnginePage"]
            ).QWebEnginePage(self.private_profile, view))
        view.urlChanged.connect(self.update_address)
        view.titleChanged.connect(lambda title, v=view: self.update_tab_title(v, title))
        view.loadProgress.connect(lambda value: self.statusBar().showMessage(f"Loading {value}%"))
        view.loadFinished.connect(lambda ok: self.statusBar().showMessage(
            "Loaded" if ok else "Load failed", 3000
        ))
        return view

    def new_tab(self, url=None, label="New tab"):
        view = self.make_view(False)
        index = self.tabs.addTab(view, label)
        self.tabs.setCurrentIndex(index)
        view.setUrl(url or QUrl("https://example.com"))

    def new_private_tab(self):
        view = self.make_view(True)
        index = self.tabs.addTab(view, "Private")
        self.tabs.setCurrentIndex(index)
        view.setUrl(QUrl("https://example.com"))

    def close_tab(self, index):
        if self.tabs.count() == 1:
            self.close()
            return
        widget = self.tabs.widget(index)
        self.tabs.removeTab(index)
        widget.deleteLater()

    def navigate(self):
        text = self.address.text().strip()
        if not text:
            return
        self.current_view().setUrl(QUrl.fromUserInput(
            text if "://" in text else "https://" + text
        ))

    def update_address(self, url):
        if self.sender() is self.current_view():
            self.address.setText(url.toString())

    def update_tab_title(self, view, title):
        index = self.tabs.indexOf(view)
        if index >= 0:
            self.tabs.setTabText(index, (title or "New tab")[:40])
        if view is self.current_view():
            self.setWindowTitle(title or "Python Browser")

    def go_back(self): self.current_view().back()
    def go_forward(self): self.current_view().forward()
    def reload(self): self.current_view().reload()
    def stop(self): self.current_view().stop()

    def refresh_toolbar(self, _index):
        view = self.current_view()
        if view:
            self.address.setText(view.url().toString())

    def download_requested(self, item: QWebEngineDownloadRequest):
        suggested = item.downloadFileName() or "download"
        path, _ = QFileDialog.getSaveFileName(self, "Save download", suggested)
        if not path:
            item.cancel()
            return
        item.setDownloadDirectory(str(Path(path).parent))
        item.setDownloadFileName(Path(path).name)
        item.accept()


app = QApplication(sys.argv)
window = Browser()
window.show()
sys.exit(app.exec())

The profile-level downloadRequested signal is where download policy belongs. In a production application, also show progress, expose cancellation, handle filename collisions, and avoid silently overwriting existing files.

4. Understand the important Qt WebEngine pieces

Component Responsibility
QWebEngineView Widget that displays a page and exposes user-facing navigation methods.
QWebEnginePage Page state, navigation history, JavaScript execution, and page actions.
QWebEngineProfile Cookies, HTTP cache, persistent storage, permissions, and downloads.
QTabWidget Owns one view per tab and forwards the active tab’s controls.
QWebEngineUrlRequestInterceptor Inspects, blocks, or modifies requests before they reach the network stack.

URL input

QUrl.fromUserInput() handles common addresses more safely than manually concatenating strings. The example adds https:// for a plain hostname. For a search box, detect text that is not a valid host and construct a URL for your chosen search provider instead.

Custom HTML

html = "<h1>Local page</h1><a href='docs/start.html'>Docs</a>"
view.setHtml(html, QUrl("https://example.com/app/"))

The base URL is necessary for relative links and for navigation requests to resolve as expected.

Request interception

Subclass QWebEngineUrlRequestInterceptor when you need an allowlist, an offline mode, telemetry filtering, or resource blocking. Keep the policy explicit. Blocking scripts, cookies, or third-party resources can break sites, and silently weakening certificate or permission checks creates security problems.

Permissions and certificates

Camera, microphone, notifications, geolocation, authentication, and certificate errors require a user-facing policy. Ask for consent where appropriate and show certificate failures. Do not accept invalid certificates globally just to make a page load.

5. Performance, reliability, and packaging

  • Startup: Create one shared normal profile instead of a new profile for every tab. Profiles own caches and storage, so unnecessary profiles increase memory and disk use.
  • Memory: Tabs are full browser pages. Close unused tabs, avoid loading hidden pages indefinitely, and consider a tab limit for kiosk-style applications.
  • UI responsiveness: Keep navigation and page work in Qt WebEngine. Do not perform blocking network calls on the GUI thread.
  • Reliability: Connect loadStarted, loadProgress, loadFinished, and download signals so users can see failures and retry.
  • Data location: Use a named profile when you need persistent cookies and cache. Use an off-the-record profile for private windows and dispose of it when the private session ends.
  • Security: Treat downloaded files as untrusted, restrict custom JavaScript, validate external URLs, and make permission decisions visible.
  • Distribution: Test Qt WebEngine deployment on every target operating system. Package the Qt WebEngine resources and subprocess components required by your chosen PySide6 deployment method.

6. Common errors and fixes

Error Cause Fix
ModuleNotFoundError: PySide6.QtWebEngineWidgets WebEngine is not installed in the active environment. Activate the virtual environment and install PySide6-WebEngine, then rerun the import check.
Window opens but page is blank The URL is malformed, loading failed, or the WebEngine subprocess cannot start. Use QUrl.fromUserInput(), connect loadFinished, run from a terminal, and verify the PySide6 wheel matches your OS and Python architecture.
Back button does nothing The current page has no history entry. Disable the action when view.history().canGoBack() is false and test after visiting a second URL.
Relative links from setHtml() fail No base URL was supplied. Pass a suitable second QUrl argument to setHtml().
Downloads never appear The profile’s downloadRequested signal is not connected or the request was never accepted. Connect the signal before navigation, choose a path, set directory and filename, then call accept().
Private tabs retain data The tab was created with the default persistent profile. Create a separate off-the-record QWebEngineProfile and assign its page to the view.
Sites reject the application Some sites require specific user-agent, cookie, JavaScript, or permission behavior. Inspect console and load signals, keep the default engine behavior, and add narrowly scoped policy changes.

7. Testing checklist

  • Open an HTTPS site, a redirect, and a page that fails to load.
  • Verify back, forward, reload, stop, and address-bar navigation.
  • Create several tabs and close them in different orders.
  • Download a file, cancel it, retry it, and test an existing filename.
  • Confirm private tabs do not reuse normal cookies or history.
  • Test JavaScript-heavy pages, large pages, and pages with popups.
  • Test packaging on each operating system you support.

Or skip the browser setup

If your actual goal is to capture a website image or PDF from Python, ScreenshotNeo provides a one-call API instead of embedding a browser engine. It accepts a URL and returns PNG, JPEG, WebP, or PDF. Cookie and consent banners, newsletter popups, and chat widgets are removed before capture; bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and response headers identify the page verdict and billing result. Its MCP server exposes take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients. One thousand screenshots per month are free with no card, and paid plans start at $5 for 3,000 shots.

See the ScreenshotNeo API documentation for all options.

cURL

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

Python

import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)

Node.js

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' }); const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

Create a free ScreenshotNeo account to get 1,000 screenshots each month without a card.

FAQ

Can I build a browser without Qt WebEngine?

You can render a limited subset of HTML yourself, but a modern browser also needs CSS layout, JavaScript, networking, security, media, accessibility, and standards compatibility. Embedding an existing engine is the practical application path.

Does each tab need its own profile?

No. Normal tabs can share one persistent profile. Use a separate off-the-record profile for private browsing or a deliberately isolated session.

Can Python control page JavaScript?

Yes. QWebEnginePage.runJavaScript() can execute scripts asynchronously. Treat page content as untrusted and avoid exposing sensitive Python objects to JavaScript.

Keep the address field, detect input that is not a URL, and construct a search URL. Keep the provider configurable so users can change it later.

When should I use ScreenshotNeo instead?

Use it when you need rendered screenshots or PDFs from URLs and do not need an interactive desktop browser with tabs, permissions, and downloads.