Chrome Extension Navigation: Build Multi-Page Popups and Tab Routing (2026)

AppBooster Team · · 10 min read
Web application navigation and user interface design

Chrome extensions live in a constrained world. There is no browser address bar in a popup. There is no back button the user can click. The entire UI resets every time the popup closes. Yet users still expect to navigate between settings, dashboards, detail views, and help screens as if they were on a normal website.

Getting navigation right is one of the most underrated challenges in extension development. This guide covers every pattern you need — from simple view switching in a popup to deep linking across the entire extension surface.

The Popup Navigation Challenge

A browser action popup is an HTML page with a fixed maximum width of 800px and maximum height of 600px. When the user clicks away, Chrome destroys it. When they reopen it, Chrome creates a fresh instance — all JavaScript state gone.

This creates three problems that web navigation solutions do not solve out of the box:

  1. No persistent URL. You cannot rely on window.location to know where the user was.
  2. Ephemeral state. React component state, module-level variables, and DOM — all wiped on close.
  3. No native back stack. The browser does not maintain history for a popup.

The good news is that chrome.storage and the extension’s service worker give you durable persistence that survives popup lifetimes. Every pattern below builds on that foundation.

Simple View Switching with Vanilla JS

For extensions with two or three screens, a lightweight view-switcher is all you need. No framework required.

<!-- popup.html -->
<div id="view-home" class="view active">
  <h2>Dashboard</h2>
  <button data-nav="settings">Open Settings</button>
</div>

<div id="view-settings" class="view">
  <h2>Settings</h2>
  <button data-nav="home">Back</button>
</div>
.view { display: none; }
.view.active { display: block; }
// popup.js
const STORAGE_KEY = "popup_current_view";

async function navigate(viewId) {
  document.querySelectorAll(".view").forEach(v => v.classList.remove("active"));
  const target = document.getElementById(`view-${viewId}`);
  if (target) {
    target.classList.add("active");
    await chrome.storage.session.set({ [STORAGE_KEY]: viewId });
  }
}

async function restoreView() {
  const { popup_current_view } = await chrome.storage.session.get(STORAGE_KEY);
  navigate(popup_current_view || "home");
}

document.addEventListener("click", (e) => {
  const navTarget = e.target.closest("[data-nav]");
  if (navTarget) navigate(navTarget.dataset.nav);
});

restoreView();

chrome.storage.session is perfect here — it persists for the browser session but clears when the browser closes, which matches user expectations for transient popup state.

Hash-Based Routing in Options and Full-Page Extensions

When your extension opens a full page — an options page, a dashboard, or a tab opened via chrome.tabs.create — you have access to window.location, which means hash routing works exactly as it does on the web.

// options.js — a minimal hash router
const routes = {
  "#/": renderHome,
  "#/account": renderAccount,
  "#/notifications": renderNotifications,
  "#/advanced": renderAdvanced,
};

function router() {
  const hash = window.location.hash || "#/";
  const render = routes[hash] ?? renderNotFound;
  render();
}

function navigate(path) {
  window.location.hash = path;
}

window.addEventListener("hashchange", router);
document.addEventListener("DOMContentLoaded", router);

// Navigation links
document.querySelectorAll("[data-route]").forEach(link => {
  link.addEventListener("click", (e) => {
    e.preventDefault();
    navigate(link.dataset.route);
  });
});

Hash routing gives you a genuine browser back stack for free. The user can press the browser back button inside an options page and it works as expected. For an options page this is the right default choice.

Using React Router in Extension Pages

If your extension uses React, React Router v6 integrates cleanly. Use HashRouter instead of BrowserRouter — extensions do not have a server to handle path rewrites, so history-based routing breaks on hard reload.

// options/main.jsx
import { HashRouter, Routes, Route, Link, useNavigate } from "react-router-dom";

function App() {
  return (
    <HashRouter>
      <Nav />
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/account" element={<Account />} />
        <Route path="/notifications" element={<Notifications />} />
        <Route path="/item/:id" element={<ItemDetail />} />
      </Routes>
    </HashRouter>
  );
}

function Nav() {
  return (
    <nav>
      <Link to="/">Home</Link>
      <Link to="/account">Account</Link>
      <Link to="/notifications">Notifications</Link>
    </nav>
  );
}

For popup navigation with React, combine React Router’s MemoryRouter with session storage persistence:

// popup/main.jsx
import { MemoryRouter, Routes, Route } from "react-router-dom";
import { useState, useEffect } from "react";

const HISTORY_KEY = "popup_history";

function PopupApp() {
  const [initialEntries, setInitialEntries] = useState(null);

  useEffect(() => {
    chrome.storage.session.get(HISTORY_KEY).then(({ popup_history }) => {
      setInitialEntries(popup_history ?? ["/"]);
    });
  }, []);

  if (!initialEntries) return null;

  return (
    <MemoryRouter initialEntries={initialEntries} initialIndex={initialEntries.length - 1}>
      <RouterWithPersistence />
    </MemoryRouter>
  );
}

function RouterWithPersistence() {
  const location = useLocation();

  useEffect(() => {
    // Save current path so it survives popup close
    chrome.storage.session.get(HISTORY_KEY).then(({ popup_history = ["/"] }) => {
      const updated = [...popup_history.slice(-9), location.pathname];
      chrome.storage.session.set({ [HISTORY_KEY]: updated });
    });
  }, [location.pathname]);

  return (
    <Routes>
      <Route path="/" element={<Dashboard />} />
      <Route path="/detail/:id" element={<Detail />} />
      <Route path="/settings" element={<Settings />} />
    </Routes>
  );
}

This pattern gives the popup a real back stack that survives close/reopen cycles within the same browser session.

State Persistence Across Popup Open/Close Cycles

Beyond routing, form state, scroll position, and selected tabs all disappear when the popup closes. A simple persistence hook solves this:

// usePersistedState.js
import { useState, useEffect } from "react";

export function usePersistedState(key, defaultValue) {
  const [value, setValue] = useState(defaultValue);

  useEffect(() => {
    chrome.storage.session.get(key).then((result) => {
      if (result[key] !== undefined) setValue(result[key]);
    });
  }, [key]);

  function setAndPersist(newValue) {
    setValue(newValue);
    chrome.storage.session.set({ [key]: newValue });
  }

  return [value, setAndPersist];
}

// Usage
function SearchView() {
  const [query, setQuery] = usePersistedState("search_query", "");
  const [activeTab, setActiveTab] = usePersistedState("search_tab", "all");
  // ...
}

Use chrome.storage.session for transient UI state and chrome.storage.local for user preferences that should survive browser restarts.

Deep Linking: Opening Specific Extension Pages from Content Scripts

Sometimes a content script needs to open a specific section of your extension UI — for example, showing the paywall screen after a free-tier limit is hit. Deep linking connects content scripts, the service worker, and your extension pages.

From a content script:

// content.js
chrome.runtime.sendMessage({
  type: "OPEN_EXTENSION_PAGE",
  route: "/upgrade",
  context: { source: "limit_reached" }
});

In the service worker:

// service-worker.js
chrome.runtime.onMessage.addListener((message, sender) => {
  if (message.type === "OPEN_EXTENSION_PAGE") {
    const url = chrome.runtime.getURL(
      `options.html#${message.route}`
    );

    // Store context so the page can read it on load
    chrome.storage.session.set({ pending_deep_link: message });

    chrome.tabs.create({ url });
  }
});

In the options page:

// options.js
async function checkDeepLink() {
  const { pending_deep_link } = await chrome.storage.session.get("pending_deep_link");
  if (pending_deep_link) {
    await chrome.storage.session.remove("pending_deep_link");
    navigate(pending_deep_link.route);
    // Use context if needed
    console.log("Opened from:", pending_deep_link.context?.source);
  }
}

document.addEventListener("DOMContentLoaded", checkDeepLink);

For popup deep linking (opening the popup on a specific view), you cannot programmatically open the popup — that requires a user gesture. Instead, store the target route and have the popup read it on open:

// service-worker.js — signal intent without opening popup
chrome.storage.session.set({ popup_deep_link: "/alerts" });
// Optionally set badge to prompt user to click
chrome.action.setBadgeText({ text: "!" });
// popup.js — check for pending deep link on open
async function init() {
  const { popup_deep_link } = await chrome.storage.session.get("popup_deep_link");
  if (popup_deep_link) {
    await chrome.storage.session.remove("popup_deep_link");
    navigate(popup_deep_link);
  } else {
    restoreView();
  }
}

Tab Management: Switching, Creating, and Updating Tabs Programmatically

Navigation in extensions is not just UI routing — sometimes navigating means moving the user to a specific browser tab.

// tab-manager.js

// Open a URL, reusing an existing tab if one matches
async function openOrFocusTab(url) {
  const [existing] = await chrome.tabs.query({ url: `${url}*` });
  if (existing) {
    await chrome.tabs.update(existing.id, { active: true });
    await chrome.windows.update(existing.windowId, { focused: true });
    return existing;
  }
  return chrome.tabs.create({ url });
}

// Open extension options on a specific route
function openOptions(route = "/") {
  const url = chrome.runtime.getURL(`options.html#${route}`);
  return openOrFocusTab(url);
}

// Navigate the current active tab to a URL
async function navigateActiveTab(url) {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  if (tab) {
    return chrome.tabs.update(tab.id, { url });
  }
}

// Group related tabs
async function groupTabs(tabIds, title) {
  const groupId = await chrome.tabs.group({ tabIds });
  await chrome.tabGroups.update(groupId, { title, color: "blue" });
  return groupId;
}

A common pattern is a “launcher” popup that acts purely as a navigation hub — buttons open specific tabs or extension pages rather than changing the popup’s own view.

When an extension UI goes three or more levels deep — say, Settings > Integrations > Configure Webhook — breadcrumbs prevent users from feeling lost.

// nav-stack.js — a simple navigation stack
class NavStack {
  constructor(storageKey = "nav_stack") {
    this.storageKey = storageKey;
    this.stack = [];
  }

  async load() {
    const result = await chrome.storage.session.get(this.storageKey);
    this.stack = result[this.storageKey] ?? [{ id: "home", label: "Home" }];
    return this;
  }

  async push(view) {
    this.stack.push(view);
    await this._save();
    this._render();
  }

  async pop() {
    if (this.stack.length > 1) this.stack.pop();
    await this._save();
    this._render();
    return this.current();
  }

  current() {
    return this.stack[this.stack.length - 1];
  }

  async _save() {
    await chrome.storage.session.set({ [this.storageKey]: this.stack });
  }

  _render() {
    const crumbsEl = document.getElementById("breadcrumbs");
    if (!crumbsEl) return;
    crumbsEl.innerHTML = this.stack
      .map((crumb, i) => {
        const isLast = i === this.stack.length - 1;
        return isLast
          ? `<span class="crumb active">${crumb.label}</span>`
          : `<button class="crumb" data-index="${i}">${crumb.label}</button>`;
      })
      .join('<span class="sep">›</span>');

    crumbsEl.querySelectorAll("button.crumb").forEach((btn) => {
      btn.addEventListener("click", async () => {
        const index = parseInt(btn.dataset.index);
        this.stack = this.stack.slice(0, index + 1);
        await this._save();
        this._render();
        navigateToView(this.current().id);
      });
    });
  }
}

// Usage
const nav = await new NavStack().load();
navigateToView(nav.current().id);

document.querySelectorAll("[data-push]").forEach((btn) => {
  btn.addEventListener("click", () => {
    nav.push({ id: btn.dataset.push, label: btn.dataset.label });
    navigateToView(btn.dataset.push);
  });
});

document.getElementById("back-btn")?.addEventListener("click", async () => {
  const prev = await nav.pop();
  navigateToView(prev.id);
});

Side Panel Navigation Patterns

Chrome’s Side Panel API (available since Chrome 114) gives extensions a persistent panel that stays open while the user browses. Unlike popups, the side panel is not destroyed on user interaction, so standard SPA routing patterns apply directly.

// service-worker.js — open side panel on action click
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });

// Or open it programmatically in response to a content script message
chrome.runtime.onMessage.addListener(async (message, sender) => {
  if (message.type === "OPEN_SIDEPANEL") {
    await chrome.sidePanel.open({ tabId: sender.tab.id });
    // Optionally send route info to the panel
    chrome.storage.session.set({ sidepanel_route: message.route });
  }
});

Because the side panel persists across tab navigations, it needs to react to tab changes and update its content accordingly:

// sidepanel.js
chrome.tabs.onActivated.addListener(async ({ tabId }) => {
  const tab = await chrome.tabs.get(tabId);
  updatePanelForTab(tab);
});

chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
  if (changeInfo.status === "complete") updatePanelForTab(tab);
});

function updatePanelForTab(tab) {
  // Show tab-specific content or a generic view
  const isExtensionPage = tab.url?.startsWith("chrome-extension://");
  renderView(isExtensionPage ? "extension-info" : "page-tools");
}

For multi-section side panels, use the same hash router approach from the options page. The panel’s URL persists, so the user can bookmark or reload it. A tab-based navigation bar along the top of the panel works well spatially — side panels are narrow (typically 400px), so vertical space is the primary constraint.

Choosing the Right Pattern

ContextRecommended Pattern
Popup with 2-3 viewsVanilla JS view switcher + storage.session
Popup with ReactMemoryRouter + session persistence
Options / full pageHash router or React Router HashRouter
Side panelSPA with hash router, tab-aware updates
Cross-surface deep linkingMessage passing + storage.session pending link
Complex hierarchiesNavStack with breadcrumbs

The through-line in every pattern is chrome.storage.session for navigation state. It gives you the persistence you need across popup lifetimes without the privacy implications of storage.local for transient data.

Chrome extension navigation is not fundamentally harder than web navigation — it just requires understanding which browser primitives to substitute for the ones that are not available. With a clear routing strategy in place, your extension can feel as polished and predictable as any well-built web application.


Building navigation for your Chrome extension? Submit your extension to AppBooster for a full UX and code review.

Share this article

Build better extensions with free tools

Icon generator, MV3 converter, review exporter, and more — no signup needed.

Related Articles