LabHub

Blog

Browser Extension Development in 2026 — Manifest V3 / Plasmo / WXT / Side Panel API / Safari Extensions Deep Dive

한국어English日本語

Prologue — MV3 is here, for real this time

Browser extension developers heard "Manifest V3 will be enforced soon" for five years. Every time it was delayed. January 2022. January 2023. January 2024. And then in June 2024, Chrome finally kept its word.

Starting June 2024, MV2 extensions were disabled in the Chrome stable channel. New MV2 submissions to the Chrome Web Store had already been blocked a year earlier. In 2025, even the enterprise policy escape hatch (ExtensionManifestV2Availability) was phased out, and as of May 2026, MV2 is effectively a museum piece.

The weight of this change is not just "the manifest version went up."

At the same time, extension tooling evolved. Plasmo made React a first-class citizen. WXT brought a Vite-based, lighter, faster alternative. vite-plugin-web-extension offers an even more minimal path. This article surveys the whole landscape.


Chapter 1 · Browser Extensions in 2026 — The MV3 Landscape

The big picture first.

                    Browser Extension API
                            |
        +-------------------+-------------------+
        |                   |                   |
   Chrome (Blink)      Firefox (Gecko)     Safari (WebKit)
        |                   |                   |
   manifest_version: 3   manifest_version: 3   manifest_version: 3
   service_worker        background.scripts    background.scripts
                         (event page)          (persistent: false)
        |                   |                   |
   declarativeNetRequest   webRequest blocking  declarativeNetRequest
                           (MV2 compat kept)    (limited webRequest)
        |                   |                   |
   chrome.* namespace     browser.* + chrome.*  browser.* (Promise)
   Side Panel API         sidebarAction         partial support
   Offscreen Documents    backgroundScripts     partial support

That single diagram captures the essence of 2026 extension development.

All three browsers share the WebExtensions standard (W3C Browser Extensions Community Group) but diverge in the details. "Write once, run everywhere" is only half true.

The four components of an extension

+----------------------------------------------------+
|             Extension Package (.crx / .xpi / .pkg) |
+----------------------------------------------------+
|  manifest.json  (metadata + permissions)           |
|                                                     |
|  +-----------------+    +-----------------+        |
|  | Service Worker  |    |  Content Script |        |
|  | (background)    |<-->|  (page-injected)|        |
|  +-----------------+    +-----------------+        |
|         ^                       ^                   |
|         |                       |                   |
|         v                       v                   |
|  +-----------------+    +-----------------+        |
|  |   Popup / UI    |    |   Side Panel    |        |
|  | (browser_action)|    |  (Chrome 114+)  |        |
|  +-----------------+    +-----------------+        |
|                                                     |
|  Offscreen Documents (when DOM is required)        |
+----------------------------------------------------+

Each component is a separate execution context. They communicate via message passing (chrome.runtime.sendMessage and friends).


Chapter 2 · Manifest V3 — After the June 2024 deadline

The most basic manifest.json (Chrome MV3)

{
  "manifest_version": 3,
  "name": "My Extension",
  "version": "1.0.0",
  "description": "A simple MV3 extension",
  "icons": {
    "16": "icons/16.png",
    "48": "icons/48.png",
    "128": "icons/128.png"
  },
  "action": {
    "default_popup": "popup.html",
    "default_icon": "icons/16.png"
  },
  "background": {
    "service_worker": "background.js",
    "type": "module"
  },
  "content_scripts": [
    {
      "matches": ["https://*.example.com/*"],
      "js": ["content.js"]
    }
  ],
  "permissions": ["storage", "tabs", "sidePanel"],
  "host_permissions": ["https://api.example.com/*"],
  "side_panel": {
    "default_path": "sidepanel.html"
  }
}

Key differences from MV2 to MV3

ItemMV2MV3
Backgroundpersistent page / event pageService Worker (or event page on Firefox)
webRequest blockingallowedreplaced by declarativeNetRequest (Firefox keeps it partially)
Host permissionsauto-granted at installruntime opt-in possible (activeTab, optional_host_permissions)
Remote codeallowedforbidden (all JS must be in the package)
Content Security Policyobject formstricter object form
Action APIbrowser_action + page_actionunified action
Promise APIpartialall chrome.* APIs support Promises

The remote-code ban is a big shift. Ad blockers can no longer dynamically eval remote filter lists, and analytics/experimentation tools that loaded JS from the cloud at runtime had to be reworked.

CRX format — how extensions are packaged

Chrome extensions are distributed as .crx files. They are essentially ZIP files with a header.

[CRX3 Magic: "Cr24"] [Version: 3] [Header Length] [Header (ProtoBuf)]
  +- RSA signature (developer key)
  +- ECDSA signature (Google or self)
[ZIP archive of extension files]

The signing key determines the extension ID. Sign with the same key and you get the same ID. Sign with a different key and you get a different one. Once you publish to the Web Store, Google manages that key for you.


Chapter 3 · Service Worker — Replacing the Background Page

The defining MV3 change is the new background context model.

Service Worker lifecycle

[event fires] -> [worker wakes] -> [handler runs] -> [idle detected]
                                                          |
                                                          v
                                                 [terminated after 30s]

Service Workers can sleep. After about 30 seconds of idle time the browser terminates them. They wake again on the next event.

What this implies:

A typical service worker pattern

// background.ts
chrome.runtime.onInstalled.addListener(() => {
  console.log('Extension installed')
  chrome.storage.local.set({ installedAt: Date.now() })
})

chrome.action.onClicked.addListener(async (tab) => {
  if (!tab.id) return
  await chrome.scripting.executeScript({
    target: { tabId: tab.id },
    func: () => alert('Hello from extension!'),
  })
})

chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (msg.type === 'PING') {
    sendResponse({ type: 'PONG', timestamp: Date.now() })
  }
  return true // for async responses
})

// Periodic work via alarms
chrome.alarms.create('hourly-sync', { periodInMinutes: 60 })
chrome.alarms.onAlarm.addListener((alarm) => {
  if (alarm.name === 'hourly-sync') {
    syncData()
  }
})

async function syncData() {
  const res = await fetch('https://api.example.com/sync')
  const data = await res.json()
  await chrome.storage.local.set({ lastSync: data })
}

The keepAlive anti-pattern

Early on, developers confused by worker termination wrote setInterval(() => fetch('/ping'), 20000) to force their worker to stay alive. In 2026 that is a clear anti-pattern. Chrome will terminate such workers anyway, and policy discourages the practice. The proper approach is store state in storage and wake on each event.


Chapter 4 · declarativeNetRequest — Impact on Ad Blocking

The most controversial MV3 change. Blocking webRequest replaced by declarativeNetRequest (DNR).

Why the change

Chrome team's official position: performance and privacy. Blocking webRequest routed every network request through an extension worker for synchronous evaluation. That was slow, and extensions had overly broad visibility into all traffic.

The opposing view: stripping that power from extensions and constraining it to declarative rules means complex ad-blocking logic — especially cosmetic filters and dynamic blocking — got weaker.

Basic DNR usage

{
  "manifest_version": 3,
  "name": "Simple Ad Blocker",
  "version": "1.0.0",
  "permissions": ["declarativeNetRequest"],
  "host_permissions": ["<all_urls>"],
  "declarative_net_request": {
    "rule_resources": [
      {
        "id": "ruleset_1",
        "enabled": true,
        "path": "rules.json"
      }
    ]
  }
}
[
  {
    "id": 1,
    "priority": 1,
    "action": { "type": "block" },
    "condition": {
      "urlFilter": "||doubleclick.net^",
      "resourceTypes": ["script", "image", "sub_frame"]
    }
  },
  {
    "id": 2,
    "priority": 1,
    "action": { "type": "redirect", "redirect": { "url": "https://example.com/blocked" } },
    "condition": {
      "urlFilter": "||tracker.com^",
      "resourceTypes": ["main_frame"]
    }
  }
]

Rules are declarative. The extension does not decide per-request; the browser consults the rule set.

Constraints

uBlock Origin's author Raymond Hill maintains a separate uBlock Origin Lite for MV3 Chrome but states explicitly that "it cannot match MV2 uBO." On Firefox, full-featured uBO still works (Mozilla chose to keep blocking webRequest).


Chapter 5 · Side Panel API (Chrome 114+)

Introduced in Chrome 114 (June 2023), the Side Panel API fundamentally changed extension UX. If a popup is an ephemeral UI that closes on click, the Side Panel is a persistent UI alongside the browser.

Basic usage

{
  "manifest_version": 3,
  "name": "Side Panel Demo",
  "version": "1.0.0",
  "permissions": ["sidePanel"],
  "side_panel": {
    "default_path": "sidepanel.html"
  },
  "action": {
    "default_title": "Open Side Panel"
  }
}
// background.ts
chrome.runtime.onInstalled.addListener(() => {
  chrome.sidePanel
    .setPanelBehavior({ openPanelOnActionClick: true })
    .catch((error) => console.error(error))
})

// Activate the side panel only on certain tabs
chrome.tabs.onUpdated.addListener(async (tabId, info, tab) => {
  if (!tab.url) return
  const url = new URL(tab.url)
  if (url.origin === 'https://www.example.com') {
    await chrome.sidePanel.setOptions({
      tabId,
      path: 'sidepanel-example.html',
      enabled: true,
    })
  } else {
    await chrome.sidePanel.setOptions({
      tabId,
      enabled: false,
    })
  }
})

Why Side Panel matters

Edge supports side panels too (Edge's Copilot uses exactly this structure). Firefox has sidebarAction, a similar API that existed since MV2. Safari supports it in some versions.


Chapter 6 · Offscreen Documents + Scripting API

Offscreen Documents — when you need a DOM

Service Workers have no DOM. But extensions occasionally really need one — HTML parsing, clipboard access, audio playback, DOMParser, WebRTC, and so on.

Solution: Offscreen Document. You can run a hidden HTML page in the background.

{
  "permissions": ["offscreen"]
}
// background.ts
async function ensureOffscreenDocument() {
  const existing = await chrome.runtime.getContexts({
    contextTypes: ['OFFSCREEN_DOCUMENT'],
    documentUrls: [chrome.runtime.getURL('offscreen.html')],
  })
  if (existing.length > 0) return

  await chrome.offscreen.createDocument({
    url: 'offscreen.html',
    reasons: ['DOM_PARSER'],
    justification: 'Parse HTML for content extraction',
  })
}

chrome.action.onClicked.addListener(async () => {
  await ensureOffscreenDocument()
  const response = await chrome.runtime.sendMessage({
    target: 'offscreen',
    type: 'PARSE_HTML',
    data: '<html>...</html>',
  })
  console.log('parsed:', response)
})
<!-- offscreen.html -->
<!doctype html>
<html>
  <body>
    <script src="offscreen.js"></script>
  </body>
</html>
// offscreen.ts
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.target !== 'offscreen') return
  if (message.type === 'PARSE_HTML') {
    const parser = new DOMParser()
    const doc = parser.parseFromString(message.data, 'text/html')
    const title = doc.querySelector('title')?.textContent ?? ''
    sendResponse({ title })
  }
})

reasons must be drawn from a fixed enum: AUDIO_PLAYBACK, CLIPBOARD, DOM_PARSER, DOM_SCRAPING, IFRAME_SCRIPTING, LOCAL_STORAGE, TESTING, USER_MEDIA, WEB_RTC, BLOBS, WORKERS.

Scripting API — the future of content scripts

MV2's chrome.tabs.executeScript was replaced by MV3's chrome.scripting.executeScript.

// Inject a function
await chrome.scripting.executeScript({
  target: { tabId: 123 },
  func: (color) => {
    document.body.style.backgroundColor = color
  },
  args: ['lightblue'],
})

// Inject files
await chrome.scripting.executeScript({
  target: { tabId: 123, allFrames: true },
  files: ['content.js'],
})

// Insert CSS
await chrome.scripting.insertCSS({
  target: { tabId: 123 },
  css: 'body { filter: invert(1); }',
})

// Register content scripts dynamically
await chrome.scripting.registerContentScripts([
  {
    id: 'dynamic-script',
    matches: ['https://*.example.com/*'],
    js: ['dynamic.js'],
    runAt: 'document_idle',
  },
])

You need the scripting permission plus host permissions (or activeTab).


Chapter 7 · Plasmo — The React-first framework

Traditional extension development meant writing the manifest by hand, configuring webpack or rollup, wiring up hot reload yourself. Tedious. Plasmo unwinds it all like Next.js.

30-second setup

pnpm create plasmo my-extension
cd my-extension
pnpm dev
my-extension/
├── popup.tsx          # automatically becomes the popup
├── options.tsx        # automatically the options page
├── background.ts      # automatically the service worker
├── contents/
│   └── plasmo.ts      # content script
├── sidepanel.tsx      # side panel
└── package.json       # part of the manifest

No need to write manifest.json directly. It is generated from file naming and directory conventions. Metadata lives in package.json.

{
  "name": "my-extension",
  "displayName": "My Extension",
  "version": "1.0.0",
  "description": "...",
  "manifest": {
    "permissions": ["storage", "tabs"],
    "host_permissions": ["https://*.example.com/*"]
  }
}

Plasmo strengths

Content Script UI example

// contents/inline.tsx
import type { PlasmoCSConfig } from 'plasmo'
import { useState } from 'react'

export const config: PlasmoCSConfig = {
  matches: ['https://*.example.com/*'],
}

export default function ContentUI() {
  const [count, setCount] = useState(0)
  return (
    <div style={{ position: 'fixed', top: 10, right: 10, background: 'white', padding: 12 }}>
      <button onClick={() => setCount((c) => c + 1)}>Count: {count}</button>
    </div>
  )
}

This gets injected into the page and just works. Shadow DOM isolation is an option.

Plasmo weaknesses


Chapter 8 · WXT — The Vite-based newcomer

If Plasmo is the "React/Next.js" analogue, WXT is the "Vite/Nuxt" one. Lighter, faster, more flexible. It has grown rapidly through 2025-2026 since launching in 2023.

Basic structure

pnpm dlx wxt@latest init my-extension
cd my-extension
pnpm dev
my-extension/
├── entrypoints/
│   ├── background.ts
│   ├── content.ts
│   ├── popup/
│   │   └── index.html
│   └── options/
├── wxt.config.ts
└── package.json
// wxt.config.ts
import { defineConfig } from 'wxt'

export default defineConfig({
  manifest: {
    permissions: ['storage', 'tabs'],
    host_permissions: ['https://*.example.com/*'],
  },
  modules: ['@wxt-dev/module-react'],
})
// entrypoints/background.ts
export default defineBackground(() => {
  console.log('Hello from background!')
  browser.runtime.onMessage.addListener((msg) => {
    return Promise.resolve({ pong: true })
  })
})

WXT strengths

WXT vs Plasmo at a glance

ItemPlasmoWXT
FoundationParcel/Next.js styleVite/Nuxt style
FrameworkReact-centric (Vue/Svelte also possible)All equal via modules
Manifest controlconvention-firstdirect authoring possible
Learning curvelow (just follow conventions)medium (explicit configuration)
Communityactive 2022-2024 then stalledrapid growth 2024-2026
Best forquick React-focused prototypeslong-running, multi-framework, precise control

For new projects in 2026, WXT has the edge. Plasmo is still good, but momentum shifted to WXT.


Chapter 9 · vite-plugin-web-extension — The minimal option

If framework weight bothers you, vite-plugin-web-extension may be the answer. One Vite plugin handles the extension build.

pnpm add -D vite vite-plugin-web-extension
// vite.config.ts
import { defineConfig } from 'vite'
import webExtension from 'vite-plugin-web-extension'
import path from 'path'

export default defineConfig({
  plugins: [
    webExtension({
      manifest: path.resolve('manifest.json'),
      additionalInputs: {
        html: ['sidepanel.html'],
      },
    }),
  ],
})

Write manifest.json directly. Vite discovers entries and builds them. No convention-based automation, but everything Vite offers is yours.

How to pick


Chapter 10 · Firefox MV3 — Some MV2 retained

Mozilla had a subtle objection to Chrome's MV3 push. The result is that Firefox MV3 looks like Chrome MV3 but differs in places.

Firefox MV3 distinctions

Running the same code on both

// shared helper
const api = (typeof browser !== 'undefined' ? browser : chrome) as typeof browser

async function getCookie(url: string, name: string) {
  return api.cookies.get({ url, name })
}

The webextension-polyfill library makes chrome.* return Promises.

import browser from 'webextension-polyfill'

await browser.storage.local.set({ key: 'value' })

Plasmo and WXT embed this polyfill or an equivalent abstraction.

Manifest differences

Firefox may require browser_specific_settings in manifest.json.

{
  "manifest_version": 3,
  "name": "Cross-Browser Ext",
  "version": "1.0.0",
  "background": {
    "scripts": ["background.js"],
    "type": "module"
  },
  "browser_specific_settings": {
    "gecko": {
      "id": "myext@example.com",
      "strict_min_version": "115.0"
    }
  }
}

WXT handles this branching for you.


Chapter 11 · Safari Web Extensions — Mac/iOS

Apple adopted Web Extensions in Safari 14 in 2020. Starting with iOS 15 in 2021, extensions became possible on iPhone and iPad. That is not a minor thing — it is effectively the first viable mobile extension ecosystem.

Safari Web Extension characteristics

Conversion tool — safari-web-extension-converter

To port an existing Chrome/Firefox extension to Safari:

xcrun safari-web-extension-converter /path/to/extension

This scaffolds an Xcode project. It reads the manifest and warns about unsupported APIs.

Distribution

This barrier is why Safari's extension ecosystem is not as rich as Chrome's. But it is the only way to reach iOS extension users, so it matters more and more.


Chapter 12 · Edge Add-ons — Microsoft

Edge is Chromium-based so Chrome extensions mostly just work. But Microsoft runs its own Edge Add-ons store.

Publishing to Edge

  1. Register a developer account on Microsoft Partner Center (free).
  2. Upload the same .zip package.
  3. Review.

Compatibility with Chrome extensions

Edge-only capabilities

Edge sits at roughly 5-7% global share, which sounds small but is comparable to Chrome in enterprise environments. For B2B extensions, registering on the Edge store is essential.


Chapter 13 · AI in Extensions — Side Panel + LLM

The most interesting trend in 2026 extension development is AI integration. ChatGPT extensions, Perplexity, Glasp, Compose AI, Wisedocs — they all use the same pattern.

A typical AI extension architecture

[Web Page]
   |  (user action: text selection, summarize request)
   v
[Content Script] -- extract page context -->
   |
   v
[Service Worker] -- call LLM API (Claude / OpenAI / self-hosted) -->
   |
   v
[Side Panel] -- stream the result -->
   |
   v
[User]

Side Panel + streaming UI

// sidepanel/App.tsx (WXT + React)
import { useState } from 'react'

export default function SidePanelApp() {
  const [messages, setMessages] = useState<{ role: 'user' | 'assistant'; content: string }[]>([])
  const [input, setInput] = useState('')

  async function send() {
    const userMsg = { role: 'user' as const, content: input }
    setMessages((m) => [...m, userMsg])
    setInput('')

    const response = await chrome.runtime.sendMessage({
      type: 'ASK_LLM',
      messages: [...messages, userMsg],
    })

    setMessages((m) => [...m, { role: 'assistant', content: response.text }])
  }

  return (
    <div className="flex flex-col h-screen p-4">
      <div className="flex-1 overflow-auto">
        {messages.map((m, i) => (
          <div key={i} className={m.role === 'user' ? 'text-blue-600' : 'text-gray-800'}>
            {m.content}
          </div>
        ))}
      </div>
      <div className="flex gap-2">
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyDown={(e) => e.key === 'Enter' && send()}
          className="flex-1 border p-2"
        />
        <button onClick={send} className="bg-blue-500 text-white px-4">
          Send
        </button>
      </div>
    </div>
  )
}
// background.ts
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (msg.type === 'ASK_LLM') {
    callLLM(msg.messages).then((text) => sendResponse({ text }))
    return true // async
  }
})

async function callLLM(messages: any[]) {
  const apiKey = (await chrome.storage.local.get('apiKey')).apiKey
  const res = await fetch('https://api.anthropic.com/v1/messages', {
    method: 'POST',
    headers: {
      'x-api-key': apiKey,
      'anthropic-version': '2023-06-01',
      'content-type': 'application/json',
    },
    body: JSON.stringify({
      model: 'claude-opus-4-7',
      max_tokens: 1024,
      messages,
    }),
  })
  const data = await res.json()
  return data.content[0].text
}

Using page context

// content.ts
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (msg.type === 'GET_PAGE_TEXT') {
    sendResponse({
      title: document.title,
      url: location.href,
      text: document.body.innerText.slice(0, 50000),
    })
  }
})
// sidepanel: get current tab text and request a summary
async function summarizeCurrentPage() {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
  if (!tab.id) return
  const pageData = await chrome.tabs.sendMessage(tab.id, { type: 'GET_PAGE_TEXT' })
  // ... call the LLM
}

Chrome Built-in AI (Gemini Nano)

Starting in 2024, Chrome began exposing on-device Gemini Nano to extensions (Origin Trial then GA). The chrome.ai namespace is rolling out progressively, and as of May 2026 some APIs (prompt, summarize, translate) are stable.

// check availability
const availability = await ai.languageModel.availability()
if (availability === 'available') {
  const session = await ai.languageModel.create()
  const response = await session.prompt('Summarize: ...')
}

Pros: local, free, private. Cons: small model — limited for sophisticated work.

Business models

Four common business models for AI extensions.

  1. BYOK (Bring Your Own Key): users plug in their API keys. No traffic cost to the developer.
  2. Free quota + paid: limited free use, then subscription.
  3. Pure SaaS: extension is the client; auth and billing live in your backend.
  4. On-device: Chrome Built-in AI or a quantized self-hosted model — zero cost.

Chapter 14 · Korea / Japan — Kakao, Naver, Pixiv

Korea

Japan

Kakao/Naver OAuth in extensions

// OAuth via chrome.identity.launchWebAuthFlow
async function loginWithKakao() {
  const clientId = 'YOUR_KAKAO_REST_API_KEY'
  const redirectUri = chrome.identity.getRedirectURL()
  const authUrl =
    `https://kauth.kakao.com/oauth/authorize?` +
    `client_id=${clientId}&` +
    `redirect_uri=${encodeURIComponent(redirectUri)}&` +
    `response_type=code`

  const responseUrl = await chrome.identity.launchWebAuthFlow({
    url: authUrl,
    interactive: true,
  })

  const code = new URL(responseUrl!).searchParams.get('code')
  // ship the code to your backend to exchange for tokens
  return code
}

The URL returned by chrome.identity.getRedirectURL() must be registered as a Redirect URI in the Kakao developer console. The shape is https://<extension-id>.chromiumapp.org/.

Realities of Korean/Japanese extension dev


Chapter 15 · Who should pick what — Solo / Team / Cross-browser / AI integration

Solo dev + quick prototype

Small team + long-running product

Cross-browser first

AI-integrated extensions

Ad blocking / content filtering

Enterprise / B2B


Chapter 16 · Extension Security — What to watch for

Keep host permissions minimal

{
  "permissions": ["activeTab"],
  "optional_host_permissions": ["https://*.example.com/*"]
}

<all_urls> is scrutinized in review. Prefer activeTab, and use optional_host_permissions for runtime-requested broader access.

Strict CSP

{
  "content_security_policy": {
    "extension_pages": "script-src 'self'; object-src 'self'"
  }
}

unsafe-eval and unsafe-inline are forbidden in MV3. External CDN scripts are forbidden too (all JS must be packaged).

externally_connectable

To let web pages message your extension:

{
  "externally_connectable": {
    "matches": ["https://yoursite.com/*"]
  }
}

This enables your own site to talk to the extension, but if the domain match is too broad it becomes a security hole.

XSS defense

Privacy


Chapter 17 · Chrome Web Store policies — How to get through review

Common rejection reasons

  1. Excessive permissions: insufficient justification for <all_urls> or broad host permissions.
  2. Single-purpose violations: each extension must have one clear purpose. Bundling utilities is grounds for rejection.
  3. Content policy: injecting ads, manipulating search results, hijacking affiliates — strict no.
  4. User data policy: when collecting data, a privacy policy URL is mandatory along with explicit disclosure.
  5. Source code obfuscation: deliberate obfuscation is forbidden. Minification is OK but must remain readable.
  6. Payment circumvention: limits on bypassing Chrome Web Store Payments via external payment.

Review timing

Beta channels

unlisted or private for beta testers first, then flip to public. Review happens again.

User data disclosure (Privacy Practices)

The Chrome Web Store requires explicit disclosure of:

False disclosure is immediate grounds for delisting.


Chapter 18 · Testing and CI/CD

Unit tests

// Vitest helper-function test
import { describe, it, expect, vi } from 'vitest'
import { syncData } from './background'

global.chrome = {
  storage: {
    local: {
      set: vi.fn(),
      get: vi.fn().mockResolvedValue({}),
    },
  },
  alarms: {
    create: vi.fn(),
    onAlarm: { addListener: vi.fn() },
  },
} as any

describe('syncData', () => {
  it('saves data to storage', async () => {
    await syncData()
    expect(chrome.storage.local.set).toHaveBeenCalled()
  })
})

E2E — Playwright with Extension

import { test, expect, chromium } from '@playwright/test'
import path from 'path'

test('extension loads', async () => {
  const pathToExtension = path.resolve(__dirname, '../.output/chrome-mv3')
  const context = await chromium.launchPersistentContext('', {
    headless: false,
    args: [
      `--disable-extensions-except=${pathToExtension}`,
      `--load-extension=${pathToExtension}`,
    ],
  })

  const page = await context.newPage()
  await page.goto('https://example.com')

  // verify content script was injected
  const injected = await page.evaluate(() => (window as any).__MY_EXT_LOADED__)
  expect(injected).toBe(true)

  await context.close()
})

CI/CD

# .github/workflows/release.yml
name: Build and Release
on:
  push:
    tags: ['v*']

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v3
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'
      - run: pnpm install
      - run: pnpm test
      - run: pnpm build --target chrome-mv3
      - run: pnpm build --target firefox-mv3
      - uses: actions/upload-artifact@v4
        with:
          name: extensions
          path: .output/

  publish-chrome:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
      - name: Upload to Chrome Web Store
        uses: PlasmoHQ/bpp@v3
        with:
          keys: $TEST_KEYS_PLACEHOLDER
          chrome-file: extensions/chrome-mv3.zip

The PlasmoHQ/bpp (Browser Plugin Publisher) action automates uploads to Chrome/Firefox/Edge.


Chapter 19 · Common pitfalls

"My Service Worker dies and the work gets cut off"

"My WebSocket drops"

"Content script cannot access page variables"

"Works on Chrome, broken on Firefox"

"Rejected in review — permissions too broad"

"Extension was suddenly disabled — policy violation"


Chapter 20 · Epilogue — Are extensions dead or alive?

In the early 2020s people said "PWAs will replace extensions." As of 2026, that prediction is only half right. PWAs took some of the territory (installable web apps), but the ability to interact with every page in the browser belongs only to extensions. So extensions are not dead.

If anything, mandatory MV3 plus the AI wave triggered an extension renaissance. ChatGPT, Claude, and Perplexity extensions have millions of users, and new AI-assist extensions ship every day. The Side Panel API became the canvas for chat UIs, and being able to send page context freely to an LLM is a unique extension strength.

If we compress the 2026 extension-dev recommendation into one line:

Start with WXT or Plasmo. Learn Manifest V3, Service Workers, and the Side Panel API. Chrome first; Firefox/Edge via automated builds; Safari as a separate project if the market warrants. For AI integration, Side Panel + LLM API. Keep permissions minimal, follow review policies precisely, disclose user data honestly.

That is the right answer for May 2026. A year from now it will change again — that is what the extension world is like.


References

Comments

No comments yet.

Sign in to leave a comment