Build a Custom Desktop Browser

Build a tabbed desktop browser with Electron using BaseWindow and WebContentsView.

Introduction

30 Second Summary

You use a web browser every single day, but have you ever wondered how browsers like Brave and Arc are actually built? They are desktop applications built on top of Chromium, the same engine that powers Google Chrome.

In this project, you will build your own fully functional desktop web browser using Electron. Your browser will have a dark-themed toolbar, navigation controls, a tabbed interface for managing multiple pages, and a custom new-tab homepage with quick-access shortcuts.

What You'll Build

A custom desktop web browser powered by Electron's modern BaseWindow and WebContentsView APIs, complete with navigation controls, tabbed browsing, and a styled new-tab page.

By the end of this project, you'll have:

  • A desktop browser application with back, forward, reload, and URL bar navigation controls.
  • A tabbed browsing system that lets you open, close, and switch between multiple web pages at once.
  • A custom new-tab page with shortcut links to your favourite sites, styled with a dark theme.
  • Secret Mission: Add a bookmarks system that lets you save, view, and open your favourite pages.

Are there any prerequisites?

You should have basic JavaScript, HTML, and CSS knowledge. Familiarity with using the terminal is helpful too.

Scaffold the Electron Project

Every browser you use daily is built on the same foundation: a rendering engine wrapped in custom UI. Electron gives you that same power by packaging Chromium into a desktop app you fully control.

In this step, you will scaffold a new Electron project using Electron Forge, verify the boilerplate app launches, and set up the files you need for your custom browser.

In this step, get ready to:
  • Scaffold a new Electron project with Electron Forge.
  • Launch the boilerplate app and confirm it works.
  • Create the additional files your browser project needs.
Run the Electron Forge scaffold command

Electron Forge is the official toolchain for building Electron apps. Its scaffold command generates a ready-to-run project with all the boilerplate wired up for you.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your Electron project by running the scaffold command:

macOS

NODE_INSTALLER=npm npx create-electron-app@latest my-browser

Windows

$env:NODE_INSTALLER='npm'; npx create-electron-app@latest my-browser

What does this command do?

npx create-electron-app@latest downloads and runs the latest Electron Forge scaffold tool. my-browser is the name of the folder it creates for your project.

The NODE_INSTALLER environment variable tells Forge to use npm as the package manager. This avoids conflicts if you have other package managers installed.

The scaffold downloads Electron and installs all dependencies. This can take a minute or two depending on your internet speed.

  • Once the scaffold finishes, move into your new project folder by running:
cd my-browser
  • Launch the boilerplate app to confirm everything works by running:
npm start
  • You should see a new desktop window appear with "Hello World" content inside it.
  • Close the Electron window once you have confirmed it launched successfully.

Don't see the window?

  • Make sure you ran cd my-browser before npm start. Running npm start from the wrong folder gives an error because there is no package.json to read.
  • If you see a permission error on macOS, check that your terminal has permission to open applications in System Settings.
  • help me troubleshoot why my Electron app won't start after scaffolding with create-electron-app
Examine the project structure

Electron apps have two separate execution environments: the main process (controls the OS window) and the renderer process (displays web content). Each file in your project maps to one of these environments.

  • Open the my-browser folder in your code editor.
  • Expand the src/ folder in the file explorer sidebar.

Here are the key files and what they do:

  • src/main.js is the main process file. It creates windows, manages app lifecycle events, and talks to the operating system.
  • src/preload.js is the bridge between the main process and the renderer. It uses contextBridge to safely expose selected APIs to the web page.
  • src/index.html is the renderer file. It contains the HTML that Electron displays inside the window.
  • src/renderer.js is the renderer logic file. It handles user interactions and DOM manipulation inside the web page.

Why two processes?

The main process has full access to Node.js and your operating system. The renderer process runs untrusted web content.

Separating them is a security measure. The preload script acts as a controlled gateway, only exposing the specific capabilities the web page needs.

Create additional project files

Your browser needs two more files that the scaffold does not include: a stylesheet for the dark-themed chrome, and an HTML page for the custom new-tab screen.

  • Create the two new files by running these commands:

macOS

touch src/styles.css src/newtab.html

Windows

New-Item src/styles.css, src/newtab.html
  • Confirm the files exist by running:
ls src/
  • You should see styles.css and newtab.html listed alongside the existing files (main.js, preload.js, index.html, index.css, renderer.js).

What about index.css?

The scaffold includes a default src/index.css file. You will replace it with src/styles.css in the next step when you build the browser UI. For now, you can leave index.css in place.

Files not appearing?

  • Make sure your terminal is inside the my-browser folder. Run pwd to check your current location.
  • On Windows, if touch is not recognized, use the PowerShell command New-Item instead.
  • help me create empty files in my Electron project folder

Your project is scaffolded and ready to customize. Next up, you will replace the boilerplate HTML with a sleek browser chrome UI complete with a tab bar, navigation buttons, and a URL input.

Build the Browser Chrome UI

Your Electron project is scaffolded and ready to go. Now it's time to build the interface that your browser users will actually see and interact with.

Every browser has a "chrome" layer. This is the toolbar, tabs, and buttons that wrap around the web content below. In this step, you'll create that chrome layer using HTML, CSS, and JavaScript.

In this step, get ready to:
  • Build the HTML layout for the tab bar and navigation toolbar.
  • Style the chrome with a dark theme using CSS.
  • Wire up event listeners and IPC callbacks in the renderer script.
Replace the HTML with the browser chrome layout

The boilerplate index.html from Electron Forge shows a "Hello World" page. You need to replace it entirely with the browser chrome structure: a tab bar at the top and a toolbar with navigation controls below it.

  • In your code editor, open src/index.html and replace its entire contents with the following:
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>My Browser</title>
    <link rel="stylesheet" href="styles.css" />
  </head>
  <body>
    <div id="tab-bar">
      <div id="tabs"></div>
      <button id="new-tab-btn" title="New Tab">+</button>
    </div>
    <div id="toolbar">
      <button id="back-btn" title="Back">&#8592;</button>
      <button id="forward-btn" title="Forward">&#8594;</button>
      <button id="reload-btn" title="Reload">&#8635;</button>
      <div id="url-bar-container">
        <input type="text" id="url-bar" placeholder="Enter a URL..." />
      </div>
    </div>
    <script src="renderer.js"></script>
  </body>
</html>

What does this HTML do?

  • The #tab-bar div holds a container for dynamically generated tabs and a "+" button to create new ones.
  • The #toolbar div holds back, forward, and reload navigation buttons plus a URL input field.
  • The renderer.js script at the bottom will handle all user interactions.
  • Save index.html.
  • You won't see a meaningful visual yet because the stylesheet is empty. The next chunk adds the dark theme.

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>My Browser</title>
    <link rel="stylesheet" href="styles.css" />
  </head>
  <body>
    <div id="tab-bar">
      <div id="tabs"></div>
      <button id="new-tab-btn" title="New Tab">+</button>
    </div>
    <div id="toolbar">
      <button id="back-btn" title="Back">&#8592;</button>
      <button id="forward-btn" title="Forward">&#8594;</button>
      <button id="reload-btn" title="Reload">&#8635;</button>
      <div id="url-bar-container">
        <input type="text" id="url-bar" placeholder="Enter a URL..." />
      </div>
    </div>
    <script src="renderer.js"></script>
  </body>
</html>
Style the tab bar with a dark theme

The browser chrome needs to look like a real browser. You'll style it with a dark colour palette inspired by Chrome's dark mode.

  • Open src/styles.css and paste the following styles for the reset, body, and tab bar section:
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  background: #202124;
  color: #e8eaed;
  overflow: hidden;
  height: 80px;
}

#tab-bar {
  display: flex;
  align-items: center;
  height: 36px;
  padding: 4px 8px 0;
}

#tabs {
  display: flex;
  flex: 1;
  overflow-x: auto;
  gap: 2px;
}

.tab {
  display: flex;
  align-items: center;
  padding: 4px 12px;
  background: #292b2e;
  border-radius: 8px 8px 0 0;
  cursor: pointer;
  max-width: 200px;
  min-width: 100px;
  height: 32px;
}

.tab.active {
  background: #35363a;
}

.tab:hover {
  background: #3c3d41;
}

.tab-title {
  flex: 1;
  font-size: 12px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.tab-close {
  margin-left: 8px;
  background: none;
  border: none;
  color: #9aa0a6;
  cursor: pointer;
  font-size: 14px;
  line-height: 1;
  padding: 2px 4px;
  border-radius: 50%;
}

.tab-close:hover {
  background: #5f6368;
  color: #e8eaed;
}

#new-tab-btn {
  background: none;
  border: none;
  color: #9aa0a6;
  font-size: 18px;
  cursor: pointer;
  padding: 4px 8px;
  border-radius: 50%;
}

#new-tab-btn:hover {
  background: #3c3d41;
}

What does this CSS do?

  • The global reset removes default margins and padding so elements render predictably.
  • The body height is set to 80px because this HTML only renders the chrome strip. The web content will be rendered in a separate view managed by the main process.
  • Tab styles use rounded top corners and subtle background shifts on hover and active states to mimic a real browser tab strip.
  • Save styles.css. You won't see changes yet because there's more CSS to add below.
Style the toolbar and URL bar
  • Below the #new-tab-btn:hover rule you just added, paste the toolbar and URL bar styles:
#toolbar {
  display: flex;
  align-items: center;
  height: 40px;
  padding: 4px 8px;
  gap: 4px;
}

#toolbar button {
  background: none;
  border: none;
  color: #9aa0a6;
  font-size: 16px;
  cursor: pointer;
  padding: 6px 10px;
  border-radius: 50%;
}

#toolbar button:hover {
  background: #3c3d41;
}

#url-bar-container {
  flex: 1;
  display: flex;
  align-items: center;
}

#url-bar {
  width: 100%;
  padding: 6px 12px;
  border: none;
  border-radius: 16px;
  background: #35363a;
  color: #e8eaed;
  font-size: 14px;
  outline: none;
}

#url-bar:focus {
  background: #202124;
  outline: 1px solid #8ab4f8;
}

#url-bar::placeholder {
  color: #9aa0a6;
}

.loading #reload-btn {
  animation: spin 1s linear infinite;
}

@keyframes spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}

What does this CSS do?

  • The toolbar buttons are styled as borderless circles that highlight on hover.
  • The URL bar uses a pill shape (border-radius: 16px) and a blue focus outline to match modern browser UIs.
  • The .loading #reload-btn rule spins the reload button while a page is loading. The loading class gets toggled on the body by JavaScript later.
  • Save styles.css.
  • Run npm start in your terminal. You should see a dark-themed window with a tab strip at the top and a navigation toolbar with a rounded URL bar below.

Seeing a blank or unstyled window?

  • Make sure the <link rel="stylesheet" href="styles.css" /> line in index.html matches the exact filename styles.css (not index.css or style.css).
  • Confirm both files are saved inside the src/ folder.
  • If the window launched but content is wrong, stop the app with Ctrl+C and run npm start again.

Still having trouble? Why isn't my dark theme showing in the Electron window?

  • Close the app by pressing Ctrl+C in the terminal.

✔️ Awesome, I've got everything!

Great. Double check you've saved styles.css before continuing.

ⓧ I'd like to double check the full code

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  background: #202124;
  color: #e8eaed;
  overflow: hidden;
  height: 80px;
}

#tab-bar {
  display: flex;
  align-items: center;
  height: 36px;
  padding: 4px 8px 0;
}

#tabs {
  display: flex;
  flex: 1;
  overflow-x: auto;
  gap: 2px;
}

.tab {
  display: flex;
  align-items: center;
  padding: 4px 12px;
  background: #292b2e;
  border-radius: 8px 8px 0 0;
  cursor: pointer;
  max-width: 200px;
  min-width: 100px;
  height: 32px;
}

.tab.active {
  background: #35363a;
}

.tab:hover {
  background: #3c3d41;
}

.tab-title {
  flex: 1;
  font-size: 12px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.tab-close {
  margin-left: 8px;
  background: none;
  border: none;
  color: #9aa0a6;
  cursor: pointer;
  font-size: 14px;
  line-height: 1;
  padding: 2px 4px;
  border-radius: 50%;
}

.tab-close:hover {
  background: #5f6368;
  color: #e8eaed;
}

#new-tab-btn {
  background: none;
  border: none;
  color: #9aa0a6;
  font-size: 18px;
  cursor: pointer;
  padding: 4px 8px;
  border-radius: 50%;
}

#new-tab-btn:hover {
  background: #3c3d41;
}

#toolbar {
  display: flex;
  align-items: center;
  height: 40px;
  padding: 4px 8px;
  gap: 4px;
}

#toolbar button {
  background: none;
  border: none;
  color: #9aa0a6;
  font-size: 16px;
  cursor: pointer;
  padding: 6px 10px;
  border-radius: 50%;
}

#toolbar button:hover {
  background: #3c3d41;
}

#url-bar-container {
  flex: 1;
  display: flex;
  align-items: center;
}

#url-bar {
  width: 100%;
  padding: 6px 12px;
  border: none;
  border-radius: 16px;
  background: #35363a;
  color: #e8eaed;
  font-size: 14px;
  outline: none;
}

#url-bar:focus {
  background: #202124;
  outline: 1px solid #8ab4f8;
}

#url-bar::placeholder {
  color: #9aa0a6;
}

.loading #reload-btn {
  animation: spin 1s linear infinite;
}

@keyframes spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}
Add event listeners and IPC callbacks in the renderer

The chrome looks right, but nothing happens when you click the buttons. The renderer script needs to grab each DOM element, attach click handlers, and listen for events from the main process.

  • Open src/renderer.js and replace its entire contents with the DOM references and event listeners:
// Grab references to all interactive DOM elements
const urlBar = document.getElementById('url-bar');
const backBtn = document.getElementById('back-btn');
const forwardBtn = document.getElementById('forward-btn');
const reloadBtn = document.getElementById('reload-btn');
const newTabBtn = document.getElementById('new-tab-btn');
const tabsContainer = document.getElementById('tabs');

let activeTabId = null;

// Navigate when the user presses Enter in the URL bar
urlBar.addEventListener('keydown', (e) => {
  if (e.key === 'Enter') {
    const url = urlBar.value.trim();
    if (url) {
      window.browser.navigate(url);
    }
  }
});

// Wire up navigation button click handlers
backBtn.addEventListener('click', () => window.browser.goBack());
forwardBtn.addEventListener('click', () => window.browser.goForward());
reloadBtn.addEventListener('click', () => window.browser.reload());
newTabBtn.addEventListener('click', () => window.browser.newTab());

What does this code do?

  • Each getElementById call caches a reference to a DOM element so you don't re-query the page every time you need it.
  • The keydown listener on the URL bar checks for the Enter key, then calls window.browser.navigate() which will be exposed by the preload script in the next step.
  • The button handlers call corresponding window.browser methods that map to IPC channels in the main process.
  • Save renderer.js. You won't see a change yet. The next chunk adds the tab rendering and event callbacks that the main process will trigger.
  • Below the button handlers you just added, paste the renderTab function and IPC listener callbacks:
// Create a tab element and add it to the tab bar
function renderTab(tab) {
  const tabEl = document.createElement('div');
  tabEl.className = 'tab';
  tabEl.dataset.id = tab.id;

  const titleEl = document.createElement('span');
  titleEl.className = 'tab-title';
  titleEl.textContent = tab.title || 'New Tab';

  const closeEl = document.createElement('button');
  closeEl.className = 'tab-close';
  closeEl.textContent = '\u00d7';
  closeEl.addEventListener('click', (e) => {
    e.stopPropagation();
    window.browser.closeTab(tab.id);
  });

  tabEl.appendChild(titleEl);
  tabEl.appendChild(closeEl);

  tabEl.addEventListener('click', () => {
    window.browser.switchTab(tab.id);
  });

  tabsContainer.appendChild(tabEl);
}

// Listen for events from the main process via IPC
window.browser.onUrlChanged((url) => {
  urlBar.value = url;
});

window.browser.onLoadingChanged((loading) => {
  document.body.classList.toggle('loading', loading);
});

window.browser.onTabCreated((tab) => {
  renderTab(tab);
});

window.browser.onTabClosed((id) => {
  const tabEl = tabsContainer.querySelector(`[data-id="${id}"]`);
  if (tabEl) tabEl.remove();
});

window.browser.onTabTitleUpdated(({ id, title }) => {
  const tabEl = tabsContainer.querySelector(`[data-id="${id}"]`);
  if (tabEl) {
    tabEl.querySelector('.tab-title').textContent = title;
  }
});

window.browser.onTabActivated((id) => {
  activeTabId = id;
  tabsContainer.querySelectorAll('.tab').forEach((el) => {
    el.classList.toggle('active', el.dataset.id === id);
  });
});

What does this code do?

  • The renderTab() function dynamically builds a tab element with a title and a close button, then appends it to the tab bar.
  • Each window.browser.on* callback listens for events sent from the main process. When a page navigates, the URL bar updates. When loading starts, the reload button spins.
  • The onTabActivated callback toggles the active CSS class so only the current tab is highlighted.
  • Save renderer.js.
  • The buttons won't function yet because window.browser doesn't exist until you wire up the preload script and main process in the next step.

Seeing errors in the console?

If you run npm start now, you may see console errors like "Cannot read properties of undefined (reading 'onUrlChanged')". This is expected because window.browser hasn't been defined by the preload script yet. The next step resolves this.

Need help? Why am I getting 'Cannot read properties of undefined' errors in my renderer?

✔️ Awesome, I've got everything!

Great. Double check you've saved renderer.js before moving to the next step.

ⓧ I'd like to double check the full code

const urlBar = document.getElementById('url-bar');
const backBtn = document.getElementById('back-btn');
const forwardBtn = document.getElementById('forward-btn');
const reloadBtn = document.getElementById('reload-btn');
const newTabBtn = document.getElementById('new-tab-btn');
const tabsContainer = document.getElementById('tabs');

let activeTabId = null;

urlBar.addEventListener('keydown', (e) => {
  if (e.key === 'Enter') {
    const url = urlBar.value.trim();
    if (url) {
      window.browser.navigate(url);
    }
  }
});

backBtn.addEventListener('click', () => window.browser.goBack());
forwardBtn.addEventListener('click', () => window.browser.goForward());
reloadBtn.addEventListener('click', () => window.browser.reload());
newTabBtn.addEventListener('click', () => window.browser.newTab());

function renderTab(tab) {
  const tabEl = document.createElement('div');
  tabEl.className = 'tab';
  tabEl.dataset.id = tab.id;

  const titleEl = document.createElement('span');
  titleEl.className = 'tab-title';
  titleEl.textContent = tab.title || 'New Tab';

  const closeEl = document.createElement('button');
  closeEl.className = 'tab-close';
  closeEl.textContent = '\u00d7';
  closeEl.addEventListener('click', (e) => {
    e.stopPropagation();
    window.browser.closeTab(tab.id);
  });

  tabEl.appendChild(titleEl);
  tabEl.appendChild(closeEl);

  tabEl.addEventListener('click', () => {
    window.browser.switchTab(tab.id);
  });

  tabsContainer.appendChild(tabEl);
}

window.browser.onUrlChanged((url) => {
  urlBar.value = url;
});

window.browser.onLoadingChanged((loading) => {
  document.body.classList.toggle('loading', loading);
});

window.browser.onTabCreated((tab) => {
  renderTab(tab);
});

window.browser.onTabClosed((id) => {
  const tabEl = tabsContainer.querySelector(`[data-id="${id}"]`);
  if (tabEl) tabEl.remove();
});

window.browser.onTabTitleUpdated(({ id, title }) => {
  const tabEl = tabsContainer.querySelector(`[data-id="${id}"]`);
  if (tabEl) {
    tabEl.querySelector('.tab-title').textContent = title;
  }
});

window.browser.onTabActivated((id) => {
  activeTabId = id;
  tabsContainer.querySelectorAll('.tab').forEach((el) => {
    el.classList.toggle('active', el.dataset.id === id);
  });
});

Your browser chrome is built and styled. Next up, you'll rewrite the main process to use BaseWindow and WebContentsView so the chrome and web content live in separate views, and you'll wire up the preload.js bridge that makes window.browser available.

Wire Up the Main Process and Navigation

Your browser chrome looks great, but right now it's an empty shell. The toolbar buttons don't do anything because there's no main process logic behind them.

In this step, you'll rewrite the main process to use Electron's modern BaseWindow and WebContentsView APIs. You'll also build the IPC bridge that connects your UI controls to real browser navigation.

In this step, get ready to:
  • Rewrite the main process to use BaseWindow with two WebContentsViews (chrome + content).
  • Create a preload script that bridges the renderer and main process securely.
  • Add IPC handlers that power URL navigation, back, forward, and reload.
Rewrite the main process to use BaseWindow

Traditional Electron apps use BrowserWindow, which gives you one web page per window. A browser needs multiple views in the same window: one for the toolbar (chrome) and one for the actual web content.

Why BaseWindow instead of BrowserWindow?

BaseWindow lets you compose multiple WebContentsView instances inside a single window. Each view is an independent web page with its own renderer process.

This is the same architecture that production browsers like Brave and Arc use. The toolbar is one view, and each tab's web page is another view, all managed by the main process.

  • Open src/main.js in your editor.
  • Select all the boilerplate code and delete it.
  • Replace it with the following imports, globals, and window creation logic:
const { app, BaseWindow, WebContentsView, ipcMain } = require('electron');
const path = require('path');

let mainWindow;
let chromeView;
let tabs = [];
let activeTabId = null;

const TOOLBAR_HEIGHT = 80;

function createWindow() {
  mainWindow = new BaseWindow({
    width: 1200,
    height: 800,
    title: 'My Browser',
  });

  chromeView = new WebContentsView({
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      contextIsolation: true,
      nodeIntegration: false,
    },
  });
  mainWindow.contentView.addChildView(chromeView);
  chromeView.webContents.loadFile(path.join(__dirname, 'index.html'));

  createTab('newtab');

  mainWindow.on('resize', updateBounds);
  updateBounds();

  mainWindow.on('closed', () => {
    tabs.forEach((tab) => {
      if (!tab.view.webContents.isDestroyed()) {
        tab.view.webContents.close();
      }
    });
  });
}

function updateBounds() {
  const [width, height] = mainWindow.getContentSize();
  chromeView.setBounds({ x: 0, y: 0, width, height: TOOLBAR_HEIGHT });

  const activeTab = tabs.find((t) => t.id === activeTabId);
  if (activeTab) {
    activeTab.view.setBounds({
      x: 0,
      y: TOOLBAR_HEIGHT,
      width,
      height: height - TOOLBAR_HEIGHT,
    });
  }
}

What does this code do?

  • The imports pull in BaseWindow (a window without a built-in web page), WebContentsView (an embeddable web page), and ipcMain (for receiving messages from the renderer).
  • createWindow() builds a 1200x800 window, adds a chrome view (your toolbar UI) at the top 80px, and creates the first tab.
  • updateBounds() keeps the chrome and content views correctly sized whenever the window is resized.
  • The closed handler manually destroys each tab's webContents. Unlike BrowserWindow, BaseWindow does not auto-destroy child views.
  • Below updateBounds(), add the createTab function that creates a new web content view and registers navigation events:
function createTab(url) {
  const id = Date.now().toString();
  const view = new WebContentsView({
    webPreferences: {
      contextIsolation: true,
      nodeIntegration: false,
    },
  });

  mainWindow.contentView.addChildView(view);

  const tab = { id, view, title: 'New Tab', url: url };
  tabs.push(tab);

  view.webContents.on('did-navigate', (event, navUrl) => {
    tab.url = navUrl;
    if (tab.id === activeTabId) {
      chromeView.webContents.send('url-changed', navUrl);
    }
  });

  view.webContents.on('did-navigate-in-page', (event, navUrl) => {
    tab.url = navUrl;
    if (tab.id === activeTabId) {
      chromeView.webContents.send('url-changed', navUrl);
    }
  });

  view.webContents.on('page-title-updated', (event, title) => {
    tab.title = title;
    chromeView.webContents.send('tab-title-updated', { id: tab.id, title });
  });

  view.webContents.on('did-start-loading', () => {
    if (tab.id === activeTabId) {
      chromeView.webContents.send('loading-changed', true);
    }
  });

  view.webContents.on('did-stop-loading', () => {
    if (tab.id === activeTabId) {
      chromeView.webContents.send('loading-changed', false);
    }
  });

  switchTab(id);

  if (url === 'newtab') {
    view.webContents.loadFile(path.join(__dirname, 'newtab.html'));
  } else {
    view.webContents.loadURL(url);
  }

  chromeView.webContents.send('tab-created', { id, title: 'New Tab', url });

  return id;
}

What does createTab do?

  • It creates a fresh WebContentsView with security settings (contextIsolation: true, nodeIntegration: false) so untrusted web pages cannot access Node.js APIs.
  • The did-navigate and did-navigate-in-page events track when the page URL changes, sending updates to the chrome view so the URL bar stays in sync.
  • The did-start-loading and did-stop-loading events drive the loading spinner animation you styled in the previous step.
  • If the URL is 'newtab', it loads the local newtab.html file. Otherwise it loads a real web URL.
  • Below createTab, add the switchTab, closeTab, and app lifecycle code:
function switchTab(id) {
  if (activeTabId) {
    const currentTab = tabs.find((t) => t.id === activeTabId);
    if (currentTab) {
      currentTab.view.setBounds({ x: 0, y: TOOLBAR_HEIGHT, width: 0, height: 0 });
    }
  }

  activeTabId = id;
  const tab = tabs.find((t) => t.id === id);
  if (tab) {
    const [width, height] = mainWindow.getContentSize();
    tab.view.setBounds({
      x: 0,
      y: TOOLBAR_HEIGHT,
      width,
      height: height - TOOLBAR_HEIGHT,
    });
    chromeView.webContents.send('url-changed', tab.url || '');
    chromeView.webContents.send('tab-activated', id);
  }
}

function closeTab(id) {
  const index = tabs.findIndex((t) => t.id === id);
  if (index === -1) return;

  const tab = tabs[index];
  mainWindow.contentView.removeChildView(tab.view);
  if (!tab.view.webContents.isDestroyed()) {
    tab.view.webContents.close();
  }
  tabs.splice(index, 1);

  if (tabs.length === 0) {
    mainWindow.close();
    return;
  }

  if (activeTabId === id) {
    const newIndex = Math.min(index, tabs.length - 1);
    switchTab(tabs[newIndex].id);
  }

  chromeView.webContents.send('tab-closed', id);
}

How does tab switching work?

switchTab() hides the current tab by setting its bounds to 0x0 pixels. This keeps the tab alive (preserving scroll position and form data) without displaying it. Then it resizes the target tab to fill the content area.

closeTab() removes the view from the window, destroys its webContents, and activates an adjacent tab. If no tabs remain, the window closes entirely.

  • Save src/main.js.

The app won't work yet because the IPC handlers are still missing. You'll add those after creating the preload bridge.

Seeing a syntax error when you save?

  • Make sure you deleted ALL the original boilerplate before pasting. Leftover code from the scaffold will conflict with your new imports.
  • Check that every opening brace { has a matching closing brace }. The createTab function has several nested callbacks.

Help me find a syntax error in my main.js file

Create the preload bridge with contextBridge

The renderer (your toolbar UI) needs to send messages to the main process when the user clicks buttons or types a URL. Electron uses a preload script as a secure bridge between the two.

What is contextBridge?

contextBridge safely exposes specific functions from the main process to the renderer. The renderer can call window.browser.navigate(url) without having direct access to Node.js or Electron internals.

This prevents malicious web pages from escaping the sandbox and accessing your file system.

  • Open src/preload.js in your editor.
  • Delete the boilerplate and replace it with this contextBridge setup:
const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('browser', {
  navigate: (url) => ipcRenderer.invoke('navigate', url),
  goBack: () => ipcRenderer.invoke('go-back'),
  goForward: () => ipcRenderer.invoke('go-forward'),
  reload: () => ipcRenderer.invoke('reload'),
  newTab: (url) => ipcRenderer.invoke('new-tab', url),
  closeTab: (id) => ipcRenderer.invoke('close-tab', id),
  switchTab: (id) => ipcRenderer.invoke('switch-tab', id),
  onUrlChanged: (callback) =>
    ipcRenderer.on('url-changed', (event, url) => callback(url)),
  onLoadingChanged: (callback) =>
    ipcRenderer.on('loading-changed', (event, loading) => callback(loading)),
  onTabCreated: (callback) =>
    ipcRenderer.on('tab-created', (event, tab) => callback(tab)),
  onTabClosed: (callback) =>
    ipcRenderer.on('tab-closed', (event, id) => callback(id)),
  onTabTitleUpdated: (callback) =>
    ipcRenderer.on('tab-title-updated', (event, data) => callback(data)),
  onTabActivated: (callback) =>
    ipcRenderer.on('tab-activated', (event, id) => callback(id)),
});

What does this code do?

  • ipcRenderer.invoke() sends a request from the renderer to the main process and waits for a response. These are for actions like navigating or opening a tab.
  • ipcRenderer.on() listens for messages FROM the main process. These are for updates like URL changes, loading state, and tab events.
  • Everything is exposed under window.browser, which is the API your renderer.js already calls (e.g. window.browser.navigate(url)).
  • Save src/preload.js.

Not sure if the preload is loading?

The preload path in createWindow() must match the actual file location. Confirm that preload: path.join(__dirname, 'preload.js') points to src/preload.js (since main.js is also in src/).

Help me debug why my preload script isn't loading in Electron

✔️ Awesome, I've got everything!

Great. Make sure you've saved src/preload.js before continuing.

ⓧ I'd like to double check the full code

Here is the complete src/preload.js file:

const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('browser', {
  navigate: (url) => ipcRenderer.invoke('navigate', url),
  goBack: () => ipcRenderer.invoke('go-back'),
  goForward: () => ipcRenderer.invoke('go-forward'),
  reload: () => ipcRenderer.invoke('reload'),
  newTab: (url) => ipcRenderer.invoke('new-tab', url),
  closeTab: (id) => ipcRenderer.invoke('close-tab', id),
  switchTab: (id) => ipcRenderer.invoke('switch-tab', id),
  onUrlChanged: (callback) =>
    ipcRenderer.on('url-changed', (event, url) => callback(url)),
  onLoadingChanged: (callback) =>
    ipcRenderer.on('loading-changed', (event, loading) => callback(loading)),
  onTabCreated: (callback) =>
    ipcRenderer.on('tab-created', (event, tab) => callback(tab)),
  onTabClosed: (callback) =>
    ipcRenderer.on('tab-closed', (event, id) => callback(id)),
  onTabTitleUpdated: (callback) =>
    ipcRenderer.on('tab-title-updated', (event, data) => callback(data)),
  onTabActivated: (callback) =>
    ipcRenderer.on('tab-activated', (event, id) => callback(id)),
});
Add IPC handlers and test navigation

The preload bridge sends requests like 'navigate' and 'go-back' to the main process, but nothing is listening for them yet. You need ipcMain.handle() calls to receive those requests and act on them.

  • Return to src/main.js.
  • Find the closeTab function's closing brace. Below it, add these IPC handlers:
ipcMain.handle('navigate', (event, url) => {
  const tab = tabs.find((t) => t.id === activeTabId);
  if (!tab) return;

  let finalUrl = url;
  if (!url.startsWith('http://') && !url.startsWith('https://')) {
    finalUrl = 'https://' + url;
  }
  tab.view.webContents.loadURL(finalUrl);
});

ipcMain.handle('go-back', () => {
  const tab = tabs.find((t) => t.id === activeTabId);
  if (tab && tab.view.webContents.canGoBack()) {
    tab.view.webContents.goBack();
  }
});

ipcMain.handle('go-forward', () => {
  const tab = tabs.find((t) => t.id === activeTabId);
  if (tab && tab.view.webContents.canGoForward()) {
    tab.view.webContents.goForward();
  }
});

ipcMain.handle('reload', () => {
  const tab = tabs.find((t) => t.id === activeTabId);
  if (tab) {
    tab.view.webContents.reload();
  }
});

ipcMain.handle('new-tab', (event, url) => {
  createTab(url || 'newtab');
});

ipcMain.handle('close-tab', (event, id) => {
  closeTab(id);
});

ipcMain.handle('switch-tab', (event, id) => {
  switchTab(id);
});

What does this code do?

  • The 'navigate' handler prepends https:// if the user didn't type a protocol, then loads the URL in the active tab's webContents.
  • The 'go-back' and 'go-forward' handlers check whether navigation history exists before attempting to move.
  • The 'new-tab', 'close-tab', and 'switch-tab' handlers delegate directly to the functions you wrote earlier.
  • Below the IPC handlers, add the app lifecycle code that starts everything:
app.whenReady().then(createWindow);

app.on('window-all-closed', () => {
  app.quit();
});
  • Save src/main.js.
  • Launch the app by running:
npm start
  • Type google.com in the URL bar and press Enter. Google's homepage should load below the toolbar.
  • Click a few links on the page, then click the back button (left arrow) in your toolbar. The previous page should load.
  • Check that the URL bar updates to show the current page address as you navigate.

Page not loading when you press Enter?

  • If you see a blank area below the toolbar, check that the 'navigate' handler in main.js is spelled exactly as ipcMain.handle('navigate', ...). The channel name must match what preload.js sends.
  • If the toolbar itself doesn't appear, check that chromeView.webContents.loadFile points to path.join(__dirname, 'index.html').
  • If the window is completely blank, open your terminal for error messages. A missing comma or brace in main.js will prevent the app from starting.

Help me debug why my Electron browser isn't loading web pages

✔️ Awesome, I've got everything!

Great. Double check you've saved both src/main.js and src/preload.js.

ⓧ I'd like to double check the full code

Here is the complete src/main.js:

const { app, BaseWindow, WebContentsView, ipcMain } = require('electron');
const path = require('path');

let mainWindow;
let chromeView;
let tabs = [];
let activeTabId = null;

const TOOLBAR_HEIGHT = 80;

function createWindow() {
  mainWindow = new BaseWindow({
    width: 1200,
    height: 800,
    title: 'My Browser',
  });

  chromeView = new WebContentsView({
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      contextIsolation: true,
      nodeIntegration: false,
    },
  });
  mainWindow.contentView.addChildView(chromeView);
  chromeView.webContents.loadFile(path.join(__dirname, 'index.html'));

  createTab('newtab');

  mainWindow.on('resize', updateBounds);
  updateBounds();

  mainWindow.on('closed', () => {
    tabs.forEach((tab) => {
      if (!tab.view.webContents.isDestroyed()) {
        tab.view.webContents.close();
      }
    });
  });
}

function updateBounds() {
  const [width, height] = mainWindow.getContentSize();
  chromeView.setBounds({ x: 0, y: 0, width, height: TOOLBAR_HEIGHT });

  const activeTab = tabs.find((t) => t.id === activeTabId);
  if (activeTab) {
    activeTab.view.setBounds({
      x: 0,
      y: TOOLBAR_HEIGHT,
      width,
      height: height - TOOLBAR_HEIGHT,
    });
  }
}

function createTab(url) {
  const id = Date.now().toString();
  const view = new WebContentsView({
    webPreferences: {
      contextIsolation: true,
      nodeIntegration: false,
    },
  });

  mainWindow.contentView.addChildView(view);

  const tab = { id, view, title: 'New Tab', url: url };
  tabs.push(tab);

  view.webContents.on('did-navigate', (event, navUrl) => {
    tab.url = navUrl;
    if (tab.id === activeTabId) {
      chromeView.webContents.send('url-changed', navUrl);
    }
  });

  view.webContents.on('did-navigate-in-page', (event, navUrl) => {
    tab.url = navUrl;
    if (tab.id === activeTabId) {
      chromeView.webContents.send('url-changed', navUrl);
    }
  });

  view.webContents.on('page-title-updated', (event, title) => {
    tab.title = title;
    chromeView.webContents.send('tab-title-updated', { id: tab.id, title });
  });

  view.webContents.on('did-start-loading', () => {
    if (tab.id === activeTabId) {
      chromeView.webContents.send('loading-changed', true);
    }
  });

  view.webContents.on('did-stop-loading', () => {
    if (tab.id === activeTabId) {
      chromeView.webContents.send('loading-changed', false);
    }
  });

  switchTab(id);

  if (url === 'newtab') {
    view.webContents.loadFile(path.join(__dirname, 'newtab.html'));
  } else {
    view.webContents.loadURL(url);
  }

  chromeView.webContents.send('tab-created', { id, title: 'New Tab', url });

  return id;
}

function switchTab(id) {
  if (activeTabId) {
    const currentTab = tabs.find((t) => t.id === activeTabId);
    if (currentTab) {
      currentTab.view.setBounds({ x: 0, y: TOOLBAR_HEIGHT, width: 0, height: 0 });
    }
  }

  activeTabId = id;
  const tab = tabs.find((t) => t.id === id);
  if (tab) {
    const [width, height] = mainWindow.getContentSize();
    tab.view.setBounds({
      x: 0,
      y: TOOLBAR_HEIGHT,
      width,
      height: height - TOOLBAR_HEIGHT,
    });
    chromeView.webContents.send('url-changed', tab.url || '');
    chromeView.webContents.send('tab-activated', id);
  }
}

function closeTab(id) {
  const index = tabs.findIndex((t) => t.id === id);
  if (index === -1) return;

  const tab = tabs[index];
  mainWindow.contentView.removeChildView(tab.view);
  if (!tab.view.webContents.isDestroyed()) {
    tab.view.webContents.close();
  }
  tabs.splice(index, 1);

  if (tabs.length === 0) {
    mainWindow.close();
    return;
  }

  if (activeTabId === id) {
    const newIndex = Math.min(index, tabs.length - 1);
    switchTab(tabs[newIndex].id);
  }

  chromeView.webContents.send('tab-closed', id);
}

ipcMain.handle('navigate', (event, url) => {
  const tab = tabs.find((t) => t.id === activeTabId);
  if (!tab) return;

  let finalUrl = url;
  if (!url.startsWith('http://') && !url.startsWith('https://')) {
    finalUrl = 'https://' + url;
  }
  tab.view.webContents.loadURL(finalUrl);
});

ipcMain.handle('go-back', () => {
  const tab = tabs.find((t) => t.id === activeTabId);
  if (tab && tab.view.webContents.canGoBack()) {
    tab.view.webContents.goBack();
  }
});

ipcMain.handle('go-forward', () => {
  const tab = tabs.find((t) => t.id === activeTabId);
  if (tab && tab.view.webContents.canGoForward()) {
    tab.view.webContents.goForward();
  }
});

ipcMain.handle('reload', () => {
  const tab = tabs.find((t) => t.id === activeTabId);
  if (tab) {
    tab.view.webContents.reload();
  }
});

ipcMain.handle('new-tab', (event, url) => {
  createTab(url || 'newtab');
});

ipcMain.handle('close-tab', (event, id) => {
  closeTab(id);
});

ipcMain.handle('switch-tab', (event, id) => {
  switchTab(id);
});

app.whenReady().then(createWindow);

app.on('window-all-closed', () => {
  app.quit();
});

Your browser can now load real websites and navigate between pages. Next up, you'll implement full tabbed browsing so you can open, switch, and close multiple tabs.

Implement Tabbed Browsing

Your browser can load web pages and navigate with the back, forward, and reload buttons. But right now, you can only view one page at a time.

Every modern browser supports tabs so you can have multiple pages open simultaneously. In this step, you will walk through the tab management system you built in the previous step and verify that creating, switching, and closing tabs all work correctly.

In this step, get ready to:
  • Understand how createTab and switchTab manage multiple WebContentsView instances.
  • Understand how closeTab cleans up resources and handles the last-tab-closed edge case.
  • Test the renderer-side tab UI to verify switching, closing, and title updates work end to end.
Understand how createTab and switchTab work

Each tab in your browser is its own WebContentsView. When you create a new tab, the main process spins up a fresh view, attaches event listeners to it, and adds it to the window. Only one tab is visible at a time.

  • In your code editor, open src/main.js and find the createTab function.
  • Read through the function body to identify its four main responsibilities.
function createTab(url) {
  const id = Date.now().toString();
  const view = new WebContentsView({
    webPreferences: {
      contextIsolation: true,
      nodeIntegration: false,
    },
  });

  mainWindow.contentView.addChildView(view);

  const tab = { id, view, title: 'New Tab', url: url };
  tabs.push(tab);

  view.webContents.on('did-navigate', (event, navUrl) => {
    tab.url = navUrl;
    if (tab.id === activeTabId) {
      chromeView.webContents.send('url-changed', navUrl);
    }
  });

  view.webContents.on('did-navigate-in-page', (event, navUrl) => {
    tab.url = navUrl;
    if (tab.id === activeTabId) {
      chromeView.webContents.send('url-changed', navUrl);
    }
  });

  view.webContents.on('page-title-updated', (event, title) => {
    tab.title = title;
    chromeView.webContents.send('tab-title-updated', { id: tab.id, title });
  });

  view.webContents.on('did-start-loading', () => {
    if (tab.id === activeTabId) {
      chromeView.webContents.send('loading-changed', true);
    }
  });

  view.webContents.on('did-stop-loading', () => {
    if (tab.id === activeTabId) {
      chromeView.webContents.send('loading-changed', false);
    }
  });

  switchTab(id);

  if (url === 'newtab') {
    view.webContents.loadFile(path.join(__dirname, 'newtab.html'));
  } else {
    view.webContents.loadURL(url);
  }

  chromeView.webContents.send('tab-created', { id, title: 'New Tab', url });

  return id;
}

What does createTab do?

This function handles four main jobs every time a new tab is opened:

  • Creates a new WebContentsView with security settings (contextIsolation: true, nodeIntegration: false) and adds it to the window.
  • Registers event listeners on the new view's webContents so the chrome is notified when the URL changes, page title updates, or loading starts/stops.
  • Calls switchTab(id) to immediately activate the new tab.
  • Loads the appropriate content. If the URL is 'newtab', it loads the local newtab.html file. Otherwise it loads the URL from the internet.
  • Now find the switchTab function directly below createTab.
function switchTab(id) {
  if (activeTabId) {
    const currentTab = tabs.find((t) => t.id === activeTabId);
    if (currentTab) {
      currentTab.view.setBounds({ x: 0, y: TOOLBAR_HEIGHT, width: 0, height: 0 });
    }
  }

  activeTabId = id;
  const tab = tabs.find((t) => t.id === id);
  if (tab) {
    const [width, height] = mainWindow.getContentSize();
    tab.view.setBounds({
      x: 0,
      y: TOOLBAR_HEIGHT,
      width,
      height: height - TOOLBAR_HEIGHT,
    });
    chromeView.webContents.send('url-changed', tab.url || '');
    chromeView.webContents.send('tab-activated', id);
  }
}

Why set bounds to 0x0 instead of removing the view?

Setting a view's bounds to width: 0, height: 0 effectively hides it without destroying it. This preserves the tab's state (scroll position, form data, loaded page) so switching back is instant.

If you removed and re-added the view instead, the page would need to reload every time you switched tabs.

  • Start your app to test tab creation by running this command in your terminal:
npm start
  • Click the + button in the tab bar to open a new tab.
  • Click the first tab to switch back to it.

You should see a second tab appear in the tab bar. Clicking between tabs should switch the highlighted (active) tab.

Tabs not appearing in the bar?

  • Check your terminal for error messages. A common issue is a missing comma or bracket in main.js.
  • Make sure renderer.js is saved and that the onTabCreated listener calls renderTab(tab).

Why aren't my new tabs showing up in the tab bar?

Understand how closeTab cleans up resources

Closing a tab in Electron requires careful cleanup. Unlike a regular DOM element, each tab holds a WebContentsView with its own Chromium rendering process. If you forget to close it, you leak memory.

  • In src/main.js, find the closeTab function below switchTab.
function closeTab(id) {
  const index = tabs.findIndex((t) => t.id === id);
  if (index === -1) return;

  const tab = tabs[index];
  mainWindow.contentView.removeChildView(tab.view);
  if (!tab.view.webContents.isDestroyed()) {
    tab.view.webContents.close();
  }
  tabs.splice(index, 1);

  if (tabs.length === 0) {
    mainWindow.close();
    return;
  }

  if (activeTabId === id) {
    const newIndex = Math.min(index, tabs.length - 1);
    switchTab(tabs[newIndex].id);
  }

  chromeView.webContents.send('tab-closed', id);
}

What does closeTab do?

  • removeChildView(tab.view) detaches the view from the window so it stops rendering.
  • tab.view.webContents.close() destroys the Chromium rendering process for that tab, freeing memory.
  • If the closed tab was the active tab, the function activates an adjacent tab using Math.min(index, tabs.length - 1) to avoid an out-of-bounds index.
  • If no tabs remain after closing, the entire window closes and the app exits.
  • In your running app, open 3 tabs by clicking + twice.
  • Click the × button on the middle tab to close it.
  • Close all remaining tabs one by one.

When you close the middle tab, the browser should switch to an adjacent tab automatically. When you close the very last tab, the entire app window should close.

App crashes when closing a tab?

  • Check that you have the if (!tab.view.webContents.isDestroyed()) guard before calling .close(). Without it, closing an already-destroyed view throws an error.
  • Make sure tabs.splice(index, 1) runs before the tabs.length === 0 check. Otherwise the count is wrong.

Help me debug why my Electron app crashes when I close a tab.

Test the renderer tab UI end to end

The main process manages tab data and views. The renderer process is responsible for drawing the tab bar UI and responding to user clicks. These two halves communicate through IPC (Inter-Process Communication).

  • In your code editor, open src/renderer.js and find the renderTab function.
function renderTab(tab) {
  const tabEl = document.createElement('div');
  tabEl.className = 'tab';
  tabEl.dataset.id = tab.id;

  const titleEl = document.createElement('span');
  titleEl.className = 'tab-title';
  titleEl.textContent = tab.title || 'New Tab';

  const closeEl = document.createElement('button');
  closeEl.className = 'tab-close';
  closeEl.textContent = '\u00d7';
  closeEl.addEventListener('click', (e) => {
    e.stopPropagation();
    window.browser.closeTab(tab.id);
  });

  tabEl.appendChild(titleEl);
  tabEl.appendChild(closeEl);

  tabEl.addEventListener('click', () => {
    window.browser.switchTab(tab.id);
  });

  tabsContainer.appendChild(tabEl);
}

What does renderTab do?

  • Creates a .tab div with a .tab-title span and a .tab-close button.
  • Clicking the tab element calls window.browser.switchTab(tab.id) to tell the main process to show that tab's content.
  • Clicking the close button uses e.stopPropagation() so the click does not also trigger the tab-switch handler on the parent.
  • Now find the IPC listener callbacks at the bottom of renderer.js. These keep the tab bar UI in sync with the main process:
window.browser.onTabCreated((tab) => {
  renderTab(tab);
});

window.browser.onTabClosed((id) => {
  const tabEl = tabsContainer.querySelector(`[data-id="${id}"]`);
  if (tabEl) tabEl.remove();
});

window.browser.onTabTitleUpdated(({ id, title }) => {
  const tabEl = tabsContainer.querySelector(`[data-id="${id}"]`);
  if (tabEl) {
    tabEl.querySelector('.tab-title').textContent = title;
  }
});

window.browser.onTabActivated((id) => {
  activeTabId = id;
  tabsContainer.querySelectorAll('.tab').forEach((el) => {
    el.classList.toggle('active', el.dataset.id === id);
  });
});

How do these listeners keep the UI updated?

  • onTabCreated adds a new tab element to the bar whenever the main process creates one.
  • onTabClosed removes the tab element from the DOM.
  • onTabTitleUpdated changes the visible tab label as pages load (e.g. from "New Tab" to "Google").
  • onTabActivated toggles the .active CSS class so the current tab has a lighter background.
  • Restart your app if it is not already running by pressing Ctrl+C in the terminal, then running npm start again.
  • Open a new tab and type google.com in the URL bar, then press Enter.
  • Watch the tab label update from "New Tab" to "Google".
  • Click between your tabs and confirm the active tab is highlighted with a lighter background.
  • Switch to a different tab and confirm the URL bar updates to show that tab's URL.

You should see tab titles updating in real time as pages load. The active tab should have a visually distinct background. Switching tabs should update the URL bar to reflect the current page.

Tab titles not updating?

  • The page-title-updated event only fires after a page finishes loading its <title> tag. Give it a few seconds if the page is still loading.
  • Confirm your onTabTitleUpdated listener destructures the data correctly as ({ id, title }).

Help me figure out why tab titles aren't updating in my Electron browser.

✔️ Awesome, I've got everything!

Great. Double check that all your files are saved before moving on.

ⓧ I'd like to double check the full code

Here is the complete code for each file at this point. Compare yours against these.

const { app, BaseWindow, WebContentsView, ipcMain } = require('electron');
const path = require('path');

let mainWindow;
let chromeView;
let tabs = [];
let activeTabId = null;

const TOOLBAR_HEIGHT = 80;

function createWindow() {
  mainWindow = new BaseWindow({
    width: 1200,
    height: 800,
    title: 'My Browser',
  });

  chromeView = new WebContentsView({
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      contextIsolation: true,
      nodeIntegration: false,
    },
  });
  mainWindow.contentView.addChildView(chromeView);
  chromeView.webContents.loadFile(path.join(__dirname, 'index.html'));

  createTab('newtab');

  mainWindow.on('resize', updateBounds);
  updateBounds();

  mainWindow.on('closed', () => {
    tabs.forEach((tab) => {
      if (!tab.view.webContents.isDestroyed()) {
        tab.view.webContents.close();
      }
    });
  });
}

function updateBounds() {
  const [width, height] = mainWindow.getContentSize();
  chromeView.setBounds({ x: 0, y: 0, width, height: TOOLBAR_HEIGHT });

  const activeTab = tabs.find((t) => t.id === activeTabId);
  if (activeTab) {
    activeTab.view.setBounds({
      x: 0,
      y: TOOLBAR_HEIGHT,
      width,
      height: height - TOOLBAR_HEIGHT,
    });
  }
}

function createTab(url) {
  const id = Date.now().toString();
  const view = new WebContentsView({
    webPreferences: {
      contextIsolation: true,
      nodeIntegration: false,
    },
  });

  mainWindow.contentView.addChildView(view);

  const tab = { id, view, title: 'New Tab', url: url };
  tabs.push(tab);

  view.webContents.on('did-navigate', (event, navUrl) => {
    tab.url = navUrl;
    if (tab.id === activeTabId) {
      chromeView.webContents.send('url-changed', navUrl);
    }
  });

  view.webContents.on('did-navigate-in-page', (event, navUrl) => {
    tab.url = navUrl;
    if (tab.id === activeTabId) {
      chromeView.webContents.send('url-changed', navUrl);
    }
  });

  view.webContents.on('page-title-updated', (event, title) => {
    tab.title = title;
    chromeView.webContents.send('tab-title-updated', { id: tab.id, title });
  });

  view.webContents.on('did-start-loading', () => {
    if (tab.id === activeTabId) {
      chromeView.webContents.send('loading-changed', true);
    }
  });

  view.webContents.on('did-stop-loading', () => {
    if (tab.id === activeTabId) {
      chromeView.webContents.send('loading-changed', false);
    }
  });

  switchTab(id);

  if (url === 'newtab') {
    view.webContents.loadFile(path.join(__dirname, 'newtab.html'));
  } else {
    view.webContents.loadURL(url);
  }

  chromeView.webContents.send('tab-created', { id, title: 'New Tab', url });

  return id;
}

function switchTab(id) {
  if (activeTabId) {
    const currentTab = tabs.find((t) => t.id === activeTabId);
    if (currentTab) {
      currentTab.view.setBounds({ x: 0, y: TOOLBAR_HEIGHT, width: 0, height: 0 });
    }
  }

  activeTabId = id;
  const tab = tabs.find((t) => t.id === id);
  if (tab) {
    const [width, height] = mainWindow.getContentSize();
    tab.view.setBounds({
      x: 0,
      y: TOOLBAR_HEIGHT,
      width,
      height: height - TOOLBAR_HEIGHT,
    });
    chromeView.webContents.send('url-changed', tab.url || '');
    chromeView.webContents.send('tab-activated', id);
  }
}

function closeTab(id) {
  const index = tabs.findIndex((t) => t.id === id);
  if (index === -1) return;

  const tab = tabs[index];
  mainWindow.contentView.removeChildView(tab.view);
  if (!tab.view.webContents.isDestroyed()) {
    tab.view.webContents.close();
  }
  tabs.splice(index, 1);

  if (tabs.length === 0) {
    mainWindow.close();
    return;
  }

  if (activeTabId === id) {
    const newIndex = Math.min(index, tabs.length - 1);
    switchTab(tabs[newIndex].id);
  }

  chromeView.webContents.send('tab-closed', id);
}

ipcMain.handle('navigate', (event, url) => {
  const tab = tabs.find((t) => t.id === activeTabId);
  if (!tab) return;

  let finalUrl = url;
  if (!url.startsWith('http://') && !url.startsWith('https://')) {
    finalUrl = 'https://' + url;
  }
  tab.view.webContents.loadURL(finalUrl);
});

ipcMain.handle('go-back', () => {
  const tab = tabs.find((t) => t.id === activeTabId);
  if (tab && tab.view.webContents.canGoBack()) {
    tab.view.webContents.goBack();
  }
});

ipcMain.handle('go-forward', () => {
  const tab = tabs.find((t) => t.id === activeTabId);
  if (tab && tab.view.webContents.canGoForward()) {
    tab.view.webContents.goForward();
  }
});

ipcMain.handle('reload', () => {
  const tab = tabs.find((t) => t.id === activeTabId);
  if (tab) {
    tab.view.webContents.reload();
  }
});

ipcMain.handle('new-tab', (event, url) => {
  createTab(url || 'newtab');
});

ipcMain.handle('close-tab', (event, id) => {
  closeTab(id);
});

ipcMain.handle('switch-tab', (event, id) => {
  switchTab(id);
});

app.whenReady().then(createWindow);

app.on('window-all-closed', () => {
  app.quit();
});
const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('browser', {
  navigate: (url) => ipcRenderer.invoke('navigate', url),
  goBack: () => ipcRenderer.invoke('go-back'),
  goForward: () => ipcRenderer.invoke('go-forward'),
  reload: () => ipcRenderer.invoke('reload'),
  newTab: (url) => ipcRenderer.invoke('new-tab', url),
  closeTab: (id) => ipcRenderer.invoke('close-tab', id),
  switchTab: (id) => ipcRenderer.invoke('switch-tab', id),
  onUrlChanged: (callback) =>
    ipcRenderer.on('url-changed', (event, url) => callback(url)),
  onLoadingChanged: (callback) =>
    ipcRenderer.on('loading-changed', (event, loading) => callback(loading)),
  onTabCreated: (callback) =>
    ipcRenderer.on('tab-created', (event, tab) => callback(tab)),
  onTabClosed: (callback) =>
    ipcRenderer.on('tab-closed', (event, id) => callback(id)),
  onTabTitleUpdated: (callback) =>
    ipcRenderer.on('tab-title-updated', (event, data) => callback(data)),
  onTabActivated: (callback) =>
    ipcRenderer.on('tab-activated', (event, id) => callback(id)),
});
const urlBar = document.getElementById('url-bar');
const backBtn = document.getElementById('back-btn');
const forwardBtn = document.getElementById('forward-btn');
const reloadBtn = document.getElementById('reload-btn');
const newTabBtn = document.getElementById('new-tab-btn');
const tabsContainer = document.getElementById('tabs');

let activeTabId = null;

urlBar.addEventListener('keydown', (e) => {
  if (e.key === 'Enter') {
    const url = urlBar.value.trim();
    if (url) {
      window.browser.navigate(url);
    }
  }
});

backBtn.addEventListener('click', () => window.browser.goBack());
forwardBtn.addEventListener('click', () => window.browser.goForward());
reloadBtn.addEventListener('click', () => window.browser.reload());
newTabBtn.addEventListener('click', () => window.browser.newTab());

function renderTab(tab) {
  const tabEl = document.createElement('div');
  tabEl.className = 'tab';
  tabEl.dataset.id = tab.id;

  const titleEl = document.createElement('span');
  titleEl.className = 'tab-title';
  titleEl.textContent = tab.title || 'New Tab';

  const closeEl = document.createElement('button');
  closeEl.className = 'tab-close';
  closeEl.textContent = '\u00d7';
  closeEl.addEventListener('click', (e) => {
    e.stopPropagation();
    window.browser.closeTab(tab.id);
  });

  tabEl.appendChild(titleEl);
  tabEl.appendChild(closeEl);

  tabEl.addEventListener('click', () => {
    window.browser.switchTab(tab.id);
  });

  tabsContainer.appendChild(tabEl);
}

window.browser.onUrlChanged((url) => {
  urlBar.value = url;
});

window.browser.onLoadingChanged((loading) => {
  document.body.classList.toggle('loading', loading);
});

window.browser.onTabCreated((tab) => {
  renderTab(tab);
});

window.browser.onTabClosed((id) => {
  const tabEl = tabsContainer.querySelector(`[data-id="${id}"]`);
  if (tabEl) tabEl.remove();
});

window.browser.onTabTitleUpdated(({ id, title }) => {
  const tabEl = tabsContainer.querySelector(`[data-id="${id}"]`);
  if (tabEl) {
    tabEl.querySelector('.tab-title').textContent = title;
  }
});

window.browser.onTabActivated((id) => {
  activeTabId = id;
  tabsContainer.querySelectorAll('.tab').forEach((el) => {
    el.classList.toggle('active', el.dataset.id === id);
  });
});

Your browser now supports full tabbed browsing. You can open multiple pages, switch between them without losing state, and close tabs cleanly. Next up, you will build a custom new-tab homepage and add a loading indicator so your browser feels polished.

Build the Custom New Tab Page and Polish

Your browser can open, switch, and close tabs. It navigates to real websites with back/forward support. The one thing missing is a proper homepage for new tabs.

In this step, you'll build a custom new-tab page with quick-access shortcuts, then verify the loading indicator and test everything end-to-end.

In this step, get ready to:
  • Build a custom new-tab page with shortcut links.
  • Verify the loading indicator animates while pages load.
  • Test the complete browser end-to-end.
Create the new-tab page with shortcut links

Every browser has a homepage that appears when you open a new tab. Right now, your browser loads src/newtab.html when a new tab opens, but that file is empty.

  • Open src/newtab.html in your editor and replace its contents with the following:
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>New Tab</title>
    <style>
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      body {
        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
          sans-serif;
        background: #202124;
        color: #e8eaed;
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
        height: 100vh;
      }
      h1 {
        font-size: 48px;
        font-weight: 300;
        margin-bottom: 24px;
        color: #8ab4f8;
      }
      .shortcuts {
        display: grid;
        grid-template-columns: repeat(4, 1fr);
        gap: 16px;
        margin-top: 40px;
      }
      .shortcut {
        display: flex;
        flex-direction: column;
        align-items: center;
        padding: 16px;
        border-radius: 12px;
        cursor: pointer;
        text-decoration: none;
        color: #e8eaed;
        width: 100px;
      }
      .shortcut:hover {
        background: #35363a;
      }
      .shortcut-icon {
        width: 48px;
        height: 48px;
        border-radius: 50%;
        background: #35363a;
        display: flex;
        align-items: center;
        justify-content: center;
        font-size: 20px;
        margin-bottom: 8px;
      }
      .shortcut-name {
        font-size: 12px;
        text-align: center;
      }
    </style>
  </head>
  <body>
    <h1>My Browser</h1>
    <div class="shortcuts">
      <a class="shortcut" href="https://www.google.com">
        <div class="shortcut-icon">G</div>
        <span class="shortcut-name">Google</span>
      </a>
      <a class="shortcut" href="https://github.com">
        <div class="shortcut-icon">GH</div>
        <span class="shortcut-name">GitHub</span>
      </a>
      <a class="shortcut" href="https://www.youtube.com">
        <div class="shortcut-icon">YT</div>
        <span class="shortcut-name">YouTube</span>
      </a>
      <a class="shortcut" href="https://www.wikipedia.org">
        <div class="shortcut-icon">W</div>
        <span class="shortcut-name">Wikipedia</span>
      </a>
    </div>
  </body>
</html>

What does this page do?

This is a self-contained HTML page with inline CSS. It uses the same dark color palette (#202124 background, #e8eaed text) as your browser chrome so it feels cohesive.

The four shortcut links use anchor tags with full URLs. When a shortcut is clicked, the WebContentsView navigates to that URL just like any regular web page link would.

  • Save src/newtab.html.
  • Return to your terminal and run the app by running:
npm start

You should see your custom new-tab page with "My Browser" in blue text and four shortcut icons arranged in a row below it.

Seeing a blank page in the new tab?

Make sure you saved src/newtab.html. The file must be in the src/ directory alongside main.js.

Check the terminal for error messages. If you see a file-not-found error, confirm the filename matches exactly: newtab.html (all lowercase, no spaces).

Why is my new-tab page blank even though the file exists?

Verify the loading indicator and URL bar updates

Your browser already has a loading indicator and URL-bar sync built into the code from earlier steps. Now that you have a working new-tab page with clickable links, you can see them in action.

  • Click one of the shortcut icons (like **Google**) on your new-tab page.
  • Watch the reload button (the circular arrow) in the toolbar. It should spin while the page is loading.
  • Check the URL bar. It should update to show the address of the site you navigated to.

How does the loading animation work?

The main process fires did-start-loading and did-stop-loading events from the content view's webContents. These send a loading-changed message to the chrome view via IPC.

The renderer toggles a .loading class on the body element. The CSS rule .loading #reload-btn applies a spin animation to the reload button while that class is present.

  • Open a second tab by clicking the **+** button in the tab bar.
  • Click between your two tabs. The URL bar should update to show the address of whichever tab is active.

Reload button not spinning?

Some sites load so fast that the spin animation is barely visible. Try navigating to a heavier site by typing a URL in the address bar and pressing **Enter**.

If it never spins, confirm that src/styles.css contains the .loading #reload-btn rule with the @keyframes spin animation.

Why isn't my loading animation showing when I navigate to a URL?

Test the complete browser end-to-end

You have a fully functional desktop browser. Time to give it a thorough test to make sure all the pieces work together.

  • Open 3-4 tabs using the **+** button.
  • Navigate each tab to a different website using the URL bar (type a URL and press **Enter**).
  • Click between tabs in the tab bar. Confirm the content area switches and the URL bar updates for each tab.
  • Test the **back** and **forward** buttons after navigating a few links within one tab.
  • Close a tab by clicking the **X** on it. Confirm the adjacent tab becomes active.
  • Click a shortcut on the new-tab page. Confirm it navigates to that site and the tab title updates.

What should I see?

Each tab should display its own page title in the tab bar. Switching tabs shows the correct content below without losing the other tab's state. The reload button spins briefly when any page loads. Closing the last tab exits the application.

Tabs not switching properly?

If clicking a tab in the tab bar does nothing, check that src/renderer.js has a click event listener on each tab element that calls window.browser.switchTab(tab.id).

If the content area stays blank after switching, confirm that switchTab() in main.js sets the new tab's bounds to the full content size (not 0x0).

My Electron browser tabs are not switching correctly. Help me debug.

✔️ Awesome, I've got everything!

Great. Double check you've saved all your files.

ⓧ I'd like to double check the full code

Here is the complete src/newtab.html file:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>New Tab</title>
    <style>
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      body {
        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
          sans-serif;
        background: #202124;
        color: #e8eaed;
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
        height: 100vh;
      }
      h1 {
        font-size: 48px;
        font-weight: 300;
        margin-bottom: 24px;
        color: #8ab4f8;
      }
      .shortcuts {
        display: grid;
        grid-template-columns: repeat(4, 1fr);
        gap: 16px;
        margin-top: 40px;
      }
      .shortcut {
        display: flex;
        flex-direction: column;
        align-items: center;
        padding: 16px;
        border-radius: 12px;
        cursor: pointer;
        text-decoration: none;
        color: #e8eaed;
        width: 100px;
      }
      .shortcut:hover {
        background: #35363a;
      }
      .shortcut-icon {
        width: 48px;
        height: 48px;
        border-radius: 50%;
        background: #35363a;
        display: flex;
        align-items: center;
        justify-content: center;
        font-size: 20px;
        margin-bottom: 8px;
      }
      .shortcut-name {
        font-size: 12px;
        text-align: center;
      }
    </style>
  </head>
  <body>
    <h1>My Browser</h1>
    <div class="shortcuts">
      <a class="shortcut" href="https://www.google.com">
        <div class="shortcut-icon">G</div>
        <span class="shortcut-name">Google</span>
      </a>
      <a class="shortcut" href="https://github.com">
        <div class="shortcut-icon">GH</div>
        <span class="shortcut-name">GitHub</span>
      </a>
      <a class="shortcut" href="https://www.youtube.com">
        <div class="shortcut-icon">YT</div>
        <span class="shortcut-name">YouTube</span>
      </a>
      <a class="shortcut" href="https://www.wikipedia.org">
        <div class="shortcut-icon">W</div>
        <span class="shortcut-name">Wikipedia</span>
      </a>
    </div>
  </body>
</html>

No changes were needed to any other files in this step. The main.js, preload.js, renderer.js, styles.css, and index.html files remain exactly as they were at the end of the previous step.

Secret mission

Add a Bookmarks System

Your browser can navigate and manage tabs, but there's no way to save your favourite pages for later. Add a persistent bookmarks system that lets you save, view, and open pages with a single click. Bookmarks survive app restarts by writing to a JSON file on disk.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • Local my-browser project folder on your Desktop (includes all source files and node_modules).
  • Bookmarks data file (bookmarks.json) stored in Electron's userData directory.

Keep everything running

No action needed. Choose this if you're still actively building or experimenting with your browser.

  • Your my-browser folder stays on your Desktop, ready to run with npm start any time.
  • Your saved bookmarks persist in the userData directory between launches.

Pause - I'll come back to this later

Since this is a local project with no running processes or servers, there's nothing to pause. The app only runs when you launch it.

  • Close the browser app window if it's still open.
  • Your project folder and bookmarks data remain on disk until you're ready to come back.

Delete - I don't want to use this again

Remove all project files and stored data to free up disk space.

  • Close the browser app if it's still running.
  • Delete the project folder by running this command (macOS/Linux):
rm -rf ~/Desktop/my-browser

On Windows, run this in PowerShell:

Remove-Item -Recurse -Force ~\Desktop\my-browser
  • Delete the bookmarks data stored in Electron's userData directory. On macOS:
rm -rf ~/Library/Application\ Support/my-browser

On Windows (PowerShell):

Remove-Item -Recurse -Force "$env:APPDATA\my-browser"

On Linux:

rm -rf ~/.config/my-browser

Nice Work!

Nice Work!

Nice work! You just built a fully functional desktop web browser from scratch using Electron.

You've learned how to:

  • Built a custom desktop browser using Electron's BaseWindow and WebContentsView APIs to compose multiple web views in a single window.
  • Implemented IPC communication between main and renderer processes to power navigation controls, URL updates, and loading indicators.
  • Created a tabbed browsing system that manages multiple WebContentsView instances with open, close, and switch functionality.
  • Secret Mission: Added a persistent bookmarks system with file-based JSON storage that survives app restarts.

Ready to quiz yourself?