reference guide

The Complete Guide to Building Chrome Extensions in 2026

Everything you need to build, test, publish, and ship a Chrome extension. Manifest V3, content scripts, background workers, permissions, the Web Store, monetization, and the parts the official docs leave out.

By Udaya PrakashUpdated June 8, 202630 min read

Chrome extensions are simultaneously the most accessible and the most confusing browser-platform development you can do. The technology stack is just HTML, CSS, and JavaScript. The execution model splits across three different contexts that talk to each other through message passing. The packaging is a directory of files with a specific manifest. The publishing process involves a Google review queue that mostly just works but occasionally rejects extensions for reasons nobody fully understands.

This guide is the resource I wish I had when I first built a Chrome extension. Most documentation is reference material — accurate, exhaustive, and assumes you already know what you're looking for. This is the opposite: a guided tour from "I have an idea" to "my extension is in the Chrome Web Store with users," written in the order things actually matter.

If you build many extensions, you'll come back to specific sections. If you build one, you can read it top to bottom and ship.

What a Chrome extension actually is

A Chrome extension is a folder of files that Chrome loads into a special privileged context inside the browser. At minimum, it needs a manifest.json describing what the extension is and what it wants to do. Everything else — the UI, the JavaScript, the icons, the stylesheets — is optional in the spec but usually present in any real extension.

When Chrome loads an extension, it does three things:

It reads the manifest. The manifest tells Chrome the extension's name, version, what permissions it requires, which scripts to run, where to run them, and what UI elements to expose.

It allocates execution contexts. Depending on what the manifest declares, Chrome creates one or more isolated JavaScript environments — a background service worker, content scripts for matching pages, a popup window if there's a default action, an options page if declared.

It hooks the extension into the browser. Once loaded, the extension can listen to browser events (a tab opens, a page loads, the user clicks the extension's icon) and respond by running JavaScript, modifying page content, calling external APIs, storing data, showing notifications, or any combination of these.

That's the entire model. The complexity comes from the fact that the different execution contexts (background, content script, popup, options page) can't directly talk to each other — they communicate through message passing — and each has different access to Chrome's APIs.

The architectural mental model

Before any code, you need to internalize one diagram. The diagram is: a Chrome extension is three rooms that pass notes to each other.

Room 1 — Background service worker. This is the brain of the extension. It runs without any visible UI. It listens for events (extension icon clicked, alarm fires, message received), runs logic, calls APIs, and stores data. It has access to almost every Chrome API. It runs in the extension's own context — not on any webpage — and Chrome can shut it down and restart it whenever it wants. You cannot keep state in memory between invocations; use chrome.storage for persistent data.

Room 2 — Content scripts. These are JavaScript files that Chrome injects into webpages. They run in the context of the page they're injected into — meaning they can read and modify the page's DOM. But they're isolated from the page's own JavaScript: the page and the content script see different versions of window. Content scripts have limited Chrome API access — they can use chrome.storage and chrome.runtime for messaging, but they cannot use most APIs like chrome.tabs directly. They are sandboxed for security.

Room 3 — Popup and options pages. These are HTML pages that Chrome renders in special windows. The popup is what shows when a user clicks your extension's toolbar icon. The options page is what shows when a user opens your extension's settings. Both are regular HTML with JavaScript, both have full Chrome API access (similar to the background worker), and both close when the user navigates away from them.

The three rooms cannot read each other's memory. They cannot call each other's functions directly. They communicate through Chrome's message passing API: one room sends a message, another room listens and responds.

Why this matters: the most common reason newcomers fail at Chrome extensions is they put code in the wrong room. They try to read the DOM from the background worker (it can't — there's no DOM there). They try to use chrome.tabs.query from a content script (it can't — that API doesn't exist in that context). They expect a variable they set in the popup to still be there when the popup reopens (it isn't — popups die when they close).

Internalize the three-rooms model and most extension development feels obvious. Skip it and you'll fight the platform.

Manifest V3 — the contract Chrome enforces

Every Chrome extension has a manifest.json file at its root. This file is the contract between your extension and Chrome. It declares what your extension is and what it wants to do, and Chrome enforces those declarations strictly.

Manifest V3 is the current standard. Manifest V2 was deprecated in 2024 and is no longer accepted in the Chrome Web Store. Every reference in this guide assumes Manifest V3.

The minimum viable manifest looks like this:

{
  "manifest_version": 3,
  "name": "My Extension",
  "version": "1.0.0",
  "description": "An extension that does something useful."
}

This will load in Chrome but won't do anything — there's no code declared. A real extension adds at least one of:

{
  "manifest_version": 3,
  "name": "My Extension",
  "version": "1.0.0",
  "description": "An extension that does something useful.",
  "permissions": ["storage", "activeTab"],
  "host_permissions": ["https://example.com/*"],
  "background": {
    "service_worker": "background.js",
    "type": "module"
  },
  "content_scripts": [{
    "matches": ["https://example.com/*"],
    "js": ["content.js"],
    "css": ["content.css"]
  }],
  "action": {
    "default_popup": "popup.html",
    "default_icon": {
      "16": "icons/icon16.png",
      "48": "icons/icon48.png",
      "128": "icons/icon128.png"
    }
  },
  "options_ui": {
    "page": "options.html",
    "open_in_tab": true
  },
  "icons": {
    "16": "icons/icon16.png",
    "48": "icons/icon48.png",
    "128": "icons/icon128.png"
  }
}

Every field above is documented in detail in the Manifest V3 reference. For now, the key points:

manifest_version must be 3. Chrome rejects anything else.

name, version, and description are required. name shows in the Web Store and Chrome's extensions page. version follows semver (1.0.0, 1.0.1, 1.1.0) — Chrome won't accept an update with the same or lower version number.

permissions lists the Chrome APIs your extension will use. Asking for permissions you don't actually use is a Web Store review red flag. Ask for the minimum.

host_permissions lists URL patterns where your extension can run content scripts or make network requests with elevated privilege. <all_urls> works but triggers stricter review.

background.service_worker declares the path to your background script. The service worker is your extension's persistent (sort of) brain.

content_scripts declares scripts to inject into matching pages. Each entry has matches (URL patterns), js (script files), css (stylesheet files), and optional run_at controls.

action declares the toolbar icon and what happens when the user clicks it. Usually opens a popup. The icon must be provided in 16x16, 48x48, and 128x128 sizes.

options_ui declares a settings page accessible from Chrome's extensions list. Use open_in_tab: true for anything beyond trivial settings — the inline options popup is cramped.

icons provides the icons used in Chrome's UI (extensions page, Web Store listing). Required for Web Store submission.

Permissions — the security model

Chrome's permission model is designed to give users transparency about what extensions can do. Every permission an extension declares appears in the install prompt and in the extension's listing. Users see this and decide whether to trust your extension.

For developers, this means: ask for less, get installed more.

The full taxonomy of permissions is in the APIs reference, but the most common ones to understand:

storage lets your extension save data persistently. Almost every extension needs this. It's also a low-risk permission — users don't worry about it.

activeTab gives you access to the current tab when the user clicks your extension's icon. This is the most permission-efficient way to read or modify the current page. Use this instead of tabs whenever possible. activeTab doesn't trigger a permission warning at install time.

tabs gives broad access to all tabs — read URLs, switch tabs, open new ones. Required if your extension lists tabs (a tab manager) or operates on tabs the user hasn't clicked into. Triggers a "read your browsing history" permission warning that scares users.

scripting lets your extension inject scripts programmatically (vs. declaratively via content_scripts). Required if you only want to inject on certain conditions.

<all_urls> host permission lets your extension run on any site. Required for tools that work everywhere (translators, summarizers, save-to-X tools). Triggers the most aggressive Web Store review.

webRequest lets your extension observe network requests. In Manifest V3, blocking requests is now done via declarativeNetRequest (rules-based), not webRequest (script-based). This was the most controversial change in V3.

A practical permission strategy: start with the absolute minimum and add permissions only when you implement a feature that requires them. Don't add permissions "just in case." Chrome's reviewers and your users both notice.

Setting up your first extension

Open a new folder. Create manifest.json with this content:

{
  "manifest_version": 3,
  "name": "Hello Chrome",
  "version": "1.0.0",
  "description": "My first Chrome extension.",
  "action": {
    "default_popup": "popup.html"
  }
}

In the same folder, create popup.html:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <style>
    body { width: 200px; padding: 16px; font-family: system-ui; }
    h1 { margin: 0 0 8px; font-size: 16px; }
  </style>
</head>
<body>
  <h1>Hello Chrome</h1>
  <p>This is a Chrome extension.</p>
</body>
</html>

Now load it into Chrome:

  1. Open chrome://extensions/
  2. Toggle "Developer mode" on (top-right corner)
  3. Click "Load unpacked"
  4. Select your folder

A new extension appears in Chrome's toolbar (or in the extensions overflow menu — Chrome hides extensions by default; pin it to the toolbar). Click the icon. The popup renders.

You've shipped a Chrome extension. It does nothing useful, but the loading process is now demystified. Every Chrome extension is just this with more code.

A few important behaviors of the loaded extension:

It auto-reloads when you change manifest.json or static files. Reload the extension manually (click the reload icon on the extensions page) after changing background scripts or content scripts.

It runs in your own browser profile. Other people's Chrome installs can't load it from this folder. To share, you have to package it.

It's flagged as a developer-mode extension. Chrome shows a warning every time it starts. The only way to remove that warning is to publish to the Web Store (or use enterprise distribution).

Building something real — a worked example

Theory is fine. Building is better. Let's build a small but real extension: a "Save this article" button that appears on every webpage and saves the page's title and URL to a list, accessible from the popup.

The extension has three things:

  1. A content script that adds a "Save" button to the top-right of every page
  2. A background worker that listens for save requests and stores them
  3. A popup that shows the list of saved pages

The manifest

{
  "manifest_version": 3,
  "name": "Save This",
  "version": "1.0.0",
  "description": "Save any webpage to a quick-access list.",
  "permissions": ["storage"],
  "host_permissions": ["<all_urls>"],
  "background": {
    "service_worker": "background.js",
    "type": "module"
  },
  "content_scripts": [{
    "matches": ["<all_urls>"],
    "js": ["content.js"],
    "css": ["content.css"]
  }],
  "action": {
    "default_popup": "popup.html",
    "default_icon": {
      "16": "icons/icon16.png",
      "48": "icons/icon48.png",
      "128": "icons/icon128.png"
    }
  },
  "icons": {
    "16": "icons/icon16.png",
    "48": "icons/icon48.png",
    "128": "icons/icon128.png"
  }
}

The content script (content.js)

// Inject a floating "Save" button into the page
const button = document.createElement('button');
button.textContent = 'Save';
button.className = 'savethis-button';
document.body.appendChild(button);

// When clicked, send a message to the background worker
button.addEventListener('click', () => {
  chrome.runtime.sendMessage({
    type: 'SAVE_PAGE',
    title: document.title,
    url: window.location.href,
    savedAt: Date.now()
  });

  button.textContent = 'Saved';
  setTimeout(() => { button.textContent = 'Save'; }, 1500);
});

The content script's stylesheet (content.css)

.savethis-button {
  position: fixed;
  top: 16px;
  right: 16px;
  z-index: 999999;
  padding: 8px 16px;
  font-family: system-ui, sans-serif;
  font-size: 14px;
  background: #1a1a1a;
  color: white;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}

.savethis-button:hover {
  background: #333;
}

The background worker (background.js)

// Listen for save requests from content scripts
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === 'SAVE_PAGE') {
    // Read existing saved pages, add the new one, write back
    chrome.storage.local.get(['savedPages'], (result) => {
      const savedPages = result.savedPages || [];
      savedPages.unshift({
        title: message.title,
        url: message.url,
        savedAt: message.savedAt
      });
      chrome.storage.local.set({ savedPages });
    });
    sendResponse({ success: true });
  }
  return true; // Keep the message channel open for async response
});

The popup (popup.html)

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <style>
    body { width: 320px; max-height: 480px; margin: 0; padding: 16px; font-family: system-ui; overflow-y: auto; }
    h1 { margin: 0 0 12px; font-size: 16px; }
    ul { list-style: none; padding: 0; margin: 0; }
    li { padding: 8px 0; border-bottom: 1px solid #eee; }
    a { color: #1a1a1a; text-decoration: none; }
    a:hover { text-decoration: underline; }
    .empty { color: #999; font-style: italic; }
  </style>
</head>
<body>
  <h1>Saved pages</h1>
  <ul id="saved-list"></ul>
  <script src="popup.js"></script>
</body>
</html>

The popup script (popup.js)

chrome.storage.local.get(['savedPages'], (result) => {
  const savedPages = result.savedPages || [];
  const list = document.getElementById('saved-list');

  if (savedPages.length === 0) {
    list.innerHTML = '<li class="empty">No saved pages yet.</li>';
    return;
  }

  savedPages.forEach(page => {
    const li = document.createElement('li');
    const a = document.createElement('a');
    a.href = page.url;
    a.textContent = page.title;
    a.target = '_blank';
    li.appendChild(a);
    list.appendChild(li);
  });
});

That's the whole extension. Drop the files into a folder with an icons/ subdirectory containing three PNG icon files (16x16, 48x48, 128x128 — placeholder colored squares are fine for testing), load it into Chrome, and visit any webpage. The "Save" button appears in the top-right. Click it. Open the extension popup. The saved page is there.

What just happened in the three-rooms model:

The content script injected the button into the page. It can't access chrome.storage directly (content scripts can, but it's better practice to centralize storage logic in the background).

When clicked, the content script sent a message to the background worker via chrome.runtime.sendMessage. The background worker received the message, read existing saved pages from storage, added the new one, wrote back.

When the popup opens, it reads saved pages from storage independently. The popup and the background worker both have storage access; they don't need to talk to each other.

This is the pattern you'll reuse for every extension you build: content scripts handle the DOM, background workers handle persistent logic and centralized state, popups show UI and read state.

Common extension patterns

After building a few extensions, you'll notice that most of them are variations on a small number of patterns. Recognizing the patterns saves time.

The page modifier. The extension adds, removes, or modifies elements on specific webpages. Examples: dark mode for any site, YouTube ad skipper, LinkedIn outreach helper. Implementation: content scripts inject the modifications. Optional: a popup to toggle features.

The data extractor. The extension reads data from a webpage and saves it or exports it. Examples: scraping Google Sheets data, exporting LinkedIn search results to CSV, saving Reddit threads. Implementation: content script extracts data, sends to background worker, popup or options page exports.

The AI assistant. The extension calls an external AI API (OpenAI, Anthropic, Gemini) and overlays AI-generated output on the page. Examples: page summarizers, AI writing assistants, translation overlays. Implementation: content script injects UI for triggering, background worker calls the API (BYOK pattern), result returned to content script for display.

The cross-site bridge. The extension connects two websites. Examples: save articles to Notion, copy LinkedIn profiles to your CRM, sync GitHub stars to a personal database. Implementation: content scripts on both sites, background worker coordinates and handles API calls.

The browser utility. The extension adds functionality to the browser itself, not to any specific page. Examples: tab managers, password generators, screenshot tools. Implementation: heavy use of chrome.tabs, chrome.windows, chrome.commands from the background worker; popup as primary UI.

The new tab replacer. The extension replaces Chrome's new-tab page with something custom. Examples: dashboards, focus pages, RSS readers. Implementation: declare a chrome_url_overrides.newtab in the manifest, point to your HTML.

These patterns combine. A complex extension might mix three of them. But knowing the patterns helps you start every project with a clear architecture instead of inventing one each time.

Storage — where to keep state

Chrome extensions have multiple storage options. Each has tradeoffs.

chrome.storage.local is the workhorse. Persists data on the local device. Available in all contexts (background, content scripts, popup, options). Quota: 10MB by default, much higher if you declare "unlimitedStorage" permission. Synchronous read/write API in Manifest V3.

chrome.storage.sync works the same but syncs the data across Chrome installs signed into the same Google account. Quota is much smaller (about 100KB total) and there's rate limiting. Best for user preferences, not for content. If you store more than a few KB, use local and sync via your own backend if you need cross-device.

chrome.storage.session is in-memory storage that survives across the background worker restarting but disappears when Chrome restarts. Use for ephemeral state that's expensive to recompute but doesn't need to persist forever.

IndexedDB is the same as on regular webpages — available in popup, options, and content script contexts. Use it for large structured data (thousands of items, complex queries). Service workers can also use IndexedDB but the API is awkward.

Avoid localStorage. It's available in popup and options contexts but doesn't work in service workers and is not the idiomatic choice. Use chrome.storage.local instead.

A practical pattern for typical extensions: chrome.storage.local for everything you can fit, chrome.storage.sync only for user preferences, and your own backend (Supabase, Firebase, custom) for anything multi-user.

Communication — how rooms talk

Message passing is the substrate of Chrome extension architecture. It's also where most architectural confusion happens. Three patterns to know.

One-shot messages (fire and forget or fire and wait once). Use chrome.runtime.sendMessage. Sender sends a message; receiver listens with chrome.runtime.onMessage.addListener and can optionally respond. Best for actions that complete quickly.

// Sender (any context)
chrome.runtime.sendMessage({ type: 'GET_USER_DATA' }, (response) => {
  console.log('Got data:', response);
});

// Receiver (any context, usually background)
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === 'GET_USER_DATA') {
    sendResponse({ username: 'alice', plan: 'pro' });
  }
});

Long-lived connections (streaming). Use chrome.runtime.connect. One side opens a port; the other side handles incoming connections. Both sides can send and receive messages over the same port until it's closed. Best for ongoing communication like a popup that streams updates from the background.

// Popup
const port = chrome.runtime.connect({ name: 'live-updates' });
port.onMessage.addListener((msg) => {
  console.log('Update:', msg);
});

// Background
chrome.runtime.onConnect.addListener((port) => {
  if (port.name === 'live-updates') {
    const interval = setInterval(() => {
      port.postMessage({ time: Date.now() });
    }, 1000);
    port.onDisconnect.addListener(() => clearInterval(interval));
  }
});

Tab-targeted messages. Use chrome.tabs.sendMessage(tabId, message) from the background to send to a specific tab's content script. Required when the background needs to push to a content script rather than pull.

// Background — send to content script in tab 42
chrome.tabs.sendMessage(42, { type: 'HIGHLIGHT', selector: '.target' });

// Content script
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === 'HIGHLIGHT') {
    document.querySelectorAll(message.selector).forEach(el => {
      el.style.background = 'yellow';
    });
    sendResponse({ ok: true });
  }
});

A common gotcha: sendResponse only works synchronously by default. If you need to do async work and then call sendResponse, you must return true from the listener to keep the message channel open. Forget this and your response never reaches the sender.

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === 'FETCH_DATA') {
    fetch('https://api.example.com/data')
      .then(r => r.json())
      .then(data => sendResponse({ data }));
    return true; // Critical — keeps the channel open
  }
});

Calling external APIs

Chrome extensions can call external APIs freely. There are a few constraints worth knowing.

CORS doesn't apply. Extensions with host_permissions for a domain (or <all_urls>) can make requests to that domain directly, including cross-origin requests that a regular webpage couldn't make. This is part of why extensions are powerful — they bypass the normal browser security model in controlled ways.

Where to call from. Network requests should generally be made from the background worker, not the content script. Content scripts run in the page's context and any requests they make appear in the page's network log, which can confuse users and complicate debugging. Background worker requests are cleaner.

API keys live in chrome.storage. Never hardcode API keys in extension source. Users would have to trust you with them, and anyone could extract them by inspecting the extension's files. Standard pattern: settings page where the user enters their own API key, stored in chrome.storage.local, read by the background worker at API call time. This is the "Bring Your Own Key" (BYOK) pattern that's increasingly common in AI-powered extensions.

// Background worker, calling OpenAI with the user's API key
async function summarizeWithOpenAI(text) {
  const { openaiApiKey } = await chrome.storage.local.get(['openaiApiKey']);

  if (!openaiApiKey) {
    throw new Error('No API key configured. Open settings to add one.');
  }

  const response = await fetch('https://api.openai.com/v1/chat/completions', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${openaiApiKey}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      model: 'gpt-4',
      messages: [
        { role: 'system', content: 'Summarize in 3 bullets.' },
        { role: 'user', content: text }
      ]
    })
  });

  const data = await response.json();
  return data.choices[0].message.content;
}

Rate limiting and backoff. External APIs rate-limit. Your extension should handle 429 responses gracefully — typically with exponential backoff and user-visible "please wait" UI. Extensions that hammer APIs and ignore rate limits get their host_permissions abused and Chrome's review team notices.

Streaming responses. Modern AI APIs return streaming responses (Server-Sent Events). Service workers can consume streams using the standard Fetch API and ReadableStream. The complication: passing stream chunks from background to content script (or popup) for live display requires using chrome.runtime.connect ports (described above) rather than one-shot messages.

The development loop

A productive Chrome extension development loop:

Use a real editor. VS Code or similar. Set up a project folder. The structure can be flat or nested — Chrome doesn't care, but a typical layout has src/ for source files, public/ for static assets, and a built dist/ folder that you load into Chrome.

Skip the build step if you can. For small extensions, plain JavaScript works fine. Chrome runs whatever JavaScript you point the manifest at. No transpilation needed unless you want TypeScript, React, or want to use modules that require bundling.

Add a build step if you need. For larger extensions or anything with React/TypeScript, use Vite or esbuild. Both have Chrome extension templates. The output is a folder Chrome loads as unpacked.

Reload patterns. Static files (HTML, CSS) reload when you reload the extension in chrome://extensions/. Content scripts reload when you refresh the tab they run on. Background workers can be tricky — they don't always reload cleanly when you change the file. Force it by clicking the "reload" icon on the extension card.

Debugging the popup. Right-click the extension icon → "Inspect popup". This opens DevTools for the popup. Console logs, network requests, the DOM — all available.

Debugging the background worker. chrome://extensions/ → "service worker" link on your extension card. Opens DevTools attached to the background context. Logs and breakpoints work normally.

Debugging content scripts. Open DevTools on any page where your content script runs. The script appears in the Sources panel under "Content scripts" (you might need to switch the context dropdown in the Console to your extension to see its logs).

Storage inspection. From any extension DevTools (popup or background), the Application tab has a "Storage" section showing chrome.storage contents. Useful for verifying data is being saved correctly.

A common dev annoyance: changes to the manifest require a full reload from chrome://extensions/. Changes to background scripts also require a reload. Changes to content scripts require both an extension reload and a page reload on the target page. Plan accordingly.

Publishing to the Chrome Web Store

The Web Store is Chrome's official distribution channel. Publishing is the final step that turns your private folder into something other people can install.

One-time setup:

  1. Go to chrome.google.com/webstore/devconsole
  2. Pay the $5 one-time developer fee (USD, payable by card)
  3. Verify your email and identity if Google asks

Per-extension submission:

  1. Build your extension into a folder (already done if it loads as unpacked)
  2. Zip the folder. The zip should contain manifest.json at the root, not inside a subdirectory. This is the most common submission error.
  3. From the developer console, click "New item" and upload the zip
  4. Fill in the store listing: name (separately from the manifest), summary (max 132 characters), description (longer, supports basic formatting), category, language
  5. Upload screenshots (1280x800 or 640x400, at least one, up to five). These are what users see in the Web Store. They matter.
  6. Upload a promotional tile (440x280) for the Web Store homepage and search results
  7. Upload icons (these come from the manifest, but verify)
  8. Choose visibility: Public (anyone can find it), Unlisted (only people with the link), or Private (specific users via Google Workspace)
  9. Choose distribution: All regions or specific countries
  10. Set pricing: Free, paid (one-time), or in-app purchases. Most extensions are free.
  11. Fill in the privacy section: declare what data you collect, how you use it, and link to a privacy policy. Required since 2021.
  12. Submit for review

The review process:

Most extensions are reviewed within a few days. Simple extensions with minimal permissions are often approved within hours. Complex extensions or those with <all_urls> or tabs permission take longer — sometimes a week or two.

Reviewers check:

  • Does the extension do what it says? (They install and test it.)
  • Are the requested permissions justified by the functionality?
  • Is the privacy policy accurate?
  • Does the code do anything malicious or surprising?
  • Does the extension respect Chrome Web Store policies?

If rejected, you get a reason and can revise + resubmit. Common rejection reasons:

  • Requesting <all_urls> when the extension only needs a few specific sites
  • Privacy policy doesn't match what the extension actually does
  • Extension functionality doesn't match what's described in the listing
  • Obfuscated or minified code without a clear reason (reviewers may not be able to verify what it does)
  • Affiliate codes or telemetry the user didn't consent to

A practical tip: write the description and screenshots before you write the code. Knowing how you'll explain the extension to a reviewer helps clarify what the extension should actually do.

Distribution outside the Web Store

The Chrome Web Store is the default but not the only way to distribute extensions.

Unlisted Web Store listings. Publish to the Web Store but make the extension Unlisted. Anyone with the direct link can install. Doesn't appear in search. Good for beta testing or sharing with a small group.

.crx files. A packaged extension exported as a .crx file can be installed by users in developer mode, but Chrome blocks this from production users by default. Useful for enterprise deployments where IT controls Chrome policy.

Enterprise deployment. Organizations using Google Workspace or Chrome enterprise policy can force-install extensions on managed devices. Your extension doesn't need to be in the public Web Store — it just needs to be uploaded to a private enterprise listing.

GitHub release as unpacked. Provide a download link, instructions to enable developer mode, and a "Load unpacked" walkthrough. Works for power users only — most people won't enable developer mode.

For a typical extension targeting general users, the public Web Store is the right choice. For internal tools, enterprise deployment or unlisted listings are usually right.

Monetization options

Chrome extensions can make money in several ways. The Web Store itself supports paid extensions (one-time purchase) and in-app purchases, but most successful extensions use other models.

One-time purchase via Web Store. Set a price during submission. The Web Store handles payments. Friction is high — users prefer free trials. Few extensions use this model anymore.

Freemium with backend. Free extension, paid features unlocked by signing in. Implementation: extension calls your backend to check subscription status; gates premium features client-side based on the response. The Web Store doesn't get involved.

Subscription via Stripe/Paddle/etc. Same as freemium but explicitly recurring. Most popular among productivity extensions.

Bring-your-own-key + value-add. User provides their own API key (OpenAI, Anthropic, etc.) and pays the model provider. Extension is free; you make money via referrals, optional managed-key tiers, or selling adjacent services.

Affiliate revenue. Extension provides utility; clicks through to affiliate-linked services. Honey and Capital One Shopping use variants of this. Ethically contentious — disclosure is essential.

Lifetime deal channels (AppSumo, etc.). Launch via AppSumo for one-time lifetime pricing. Front-loads revenue, captures a specific buyer segment, requires careful pricing strategy.

A practical observation: the model matters less than the extension being worth paying for. Most extensions don't make significant money because users only pay for extensions that solve a clear, recurring problem. Validate willingness to pay before building monetization infrastructure.

Updating an extension

Once your extension is in the Web Store and users have installed it, updates work through the Web Store's auto-update system.

Versioning. Increment the version field in manifest.json for each release. Chrome rejects same-or-lower version numbers. Use semver: MAJOR.MINOR.PATCH.

Submission. Zip the new version and upload to the Web Store dashboard. Reviewers re-review only the diff (generally faster than initial submission).

Auto-update timing. After approval, Chrome auto-updates users within 24-48 hours. Users don't have to do anything. They may see a brief notification if you've added new permissions.

Permission changes. Adding new required permissions in an update triggers a "permission upgrade" flow. Users see a prompt explaining the new permissions and have to accept. Removing or downgrading permissions doesn't require user action.

Rollback. There's no built-in rollback. If you ship a broken update, the only recovery is to publish a new fixed version. Test thoroughly before submitting.

A useful pattern: maintain a CHANGELOG.md in your extension folder (not exposed to users) so you can track what changed between versions. Some extensions also include a public changelog page or in-extension "what's new" notification.

Troubleshooting common problems

"My extension loads but the popup is blank." Almost certainly a JavaScript error in popup.js or whatever script the popup loads. Right-click the extension icon → Inspect popup. The console will show the error.

"My content script doesn't run." Three things to check. (1) Is the URL pattern in content_scripts.matches actually matching the URL? Test the pattern against the URL in chrome.runtime.id or use a URL pattern tester. (2) Did you reload the extension after changing the manifest? (3) Did you refresh the page after reloading the extension? Content scripts only inject on new page loads.

"My background worker isn't receiving messages." Background workers in MV3 are service workers that can be terminated by Chrome when idle. They wake up when an event fires (message received, alarm, etc.). If your background never receives a message you know was sent, the listener probably wasn't registered before the worker terminated. Make sure all chrome.runtime.onMessage.addListener calls happen at the top level of the script, not inside other handlers.

"chrome.tabs is undefined." You're trying to use it from a content script, where it doesn't exist. Move the call to the background worker.

"Manifest validation error on load." Chrome will tell you the specific field that's wrong. Common issues: trailing commas in JSON (not allowed), missing required fields, invalid permission names, paths that don't exist.

"Extension works for me but not for users after publishing." Almost always a difference in development mode vs. production. The most common culprit: code that relied on chrome.runtime.id or local paths that don't transfer. Also check for console.log debug output that might be running into ad-blockers or strict CSP.

"Web Store rejected my extension and the reason is unclear." Re-read the policy section they cited carefully. The most common implicit rejection reasons are: requesting <all_urls> when narrower patterns would work, mismatched privacy policy, or extensions that "do too much" — single-purpose extensions get approved faster than multi-purpose ones.

"My extension is slow." Chrome extensions add overhead to page loads when content scripts run at document_start or do expensive DOM manipulation. Profile with Chrome DevTools' Performance tab. Common fixes: defer non-critical content script work until after page load (run_at: document_idle), avoid blocking the main thread, use Web Workers for heavy computation.

What to build next

You now have enough to build any Chrome extension. The next question is what to build.

A few directions that have working markets:

Tools for the workflows you do. The best extensions come from a personal frustration. If you spend hours in a specific webapp and notice friction, that friction is probably worth automating.

Replacements for paid SaaS extensions. Many paid Chrome extensions are simple wrappers around browser APIs with a subscription attached. Building free or self-owned versions has clear demand.

AI-powered productivity tools. AI summarizers, AI writing assistants, AI translation tools — there's still room. BYOK keeps costs low for users.

Niche professional tools. Sales tools for LinkedIn, research tools for academics, design tools for developers — every profession has unmet browser-specific needs.

If you don't want to write the code yourself, PlugThis builds Chrome extensions from plain-English descriptions. Describe what you want, get a working extension in under five minutes. The output is real Manifest V3 code you can edit, publish, and own.

For a complete reference to every Manifest V3 field, see the Manifest V3 reference. For every Chrome API documented with examples, see the Chrome extension APIs reference.

Continue reading

Build Chrome extensions without writing this code yourself

Describe what you want. PlugThis generates the manifest, the scripts, and the packaged ZIP — ready to load.

Open the builder