DEV Community

Tiger Liu
Tiger Liu

Posted on AI-assisted

How to ship a Chrome extension: the code is the easy half published: false

I have 18 extensions live on the Chrome Web Store. Writing them was never the hard part.

Every tutorial I read stopped at the same place: load unpacked, it works, ship it. All the failures that actually cost me time happen after that line. Three of my submissions came back rejected, each with a colour-coded reference ID that the docs mention but never really explain.

So here's the walkthrough in the proportion I wish someone had given me: the build, quickly, and then the review, slowly.

Part 1 — the extension

I use Plasmo. React and TypeScript, and the thing I actually stayed for: the manifest lives in package.json, so there's one file to edit instead of two that drift apart.

{
  "manifest": {
    "name": "__MSG_extName__",
    "description": "__MSG_extDesc__",
    "default_locale": "en",
    "minimum_chrome_version": "116",
    "permissions": ["bookmarks", "storage", "sidePanel", "contextMenus", "tabs"],
    "host_permissions": ["https://api.example.com/*"],
    "optional_host_permissions": ["http://*/*", "https://*/*"],
    "side_panel": { "default_path": "tabs/sidepanel.html" },
    "commands": {
      "toggle-spotlight": {
        "suggested_key": { "default": "Ctrl+Shift+K", "mac": "Command+Shift+K" },
        "description": "__MSG_cmdToggleSpotlight__"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The directory layout is convention, not configuration:

popup.tsx        the toolbar popup
tabs/            full pages inside the extension
contents/        content scripts
background/      the MV3 service worker
locales/         _locales messages, one folder per language
build/
  chrome-mv3-dev    <- load unpacked from here
  chrome-mv3-prod   <- the only thing you ever upload
Enter fullscreen mode Exit fullscreen mode

That's genuinely most of it. An afternoon gets you something that loads and does a thing.

Three notes that are not obvious, and that I got wrong first:

Put every piece of core logic in a function that doesn't touch the DOM. Payload builders, parsers, whatever your product actually does. Then you can test it in plain Node with no browser at all, and what's left for the slow browser tests is only the stuff that genuinely needs chrome.*.

Extension e2e tests need real Chromium, and "headless" isn't enough. Playwright's default headless mode runs chrome-headless-shell, which cannot load extensions at all. You need the full browser:

const ctx = await chromium.launchPersistentContext("", {
  channel: "chromium",
  headless: true,
  args: [`--disable-extensions-except=${EXT}`, `--load-extension=${EXT}`],
})

let [sw] = ctx.serviceWorkers()
if (!sw) sw = await ctx.waitForEvent("serviceworker")
const id = sw.url().split("/")[2]   // your extension id, at last
// now open chrome-extension://<id>/popup.html
Enter fullscreen mode Exit fullscreen mode

The single most valuable assertion in that suite isn't a feature check. It's collecting every console error and pageerror across all pages and asserting the total is zero at the end. That one catches white screens, which are otherwise the bug you find out about from a one-star review.

Your dev host permissions must not reach the store. The service worker needs http://localhost/* to talk to your local API, so it has to be in package.json or you can't develop. But shipping it means a reviewer asks why a published extension wants access to the user's localhost, and you have no good answer — plus it adds a permission warning on the install screen for every user.

Don't delete it. Strip it from the built manifest, after the build and before packaging, with a script that exits non-zero if it finds any left. The dev command never runs that script, so local development is untouched.

That's the shape of every fix in the rest of this post, by the way: a build step that fails loudly instead of a rule you have to remember.

Part 2 — the review

Here's the part nobody writes tutorials about.

A Chrome Web Store rejection arrives as an email naming a policy and a two-word reference ID. It tells you what you violated. It does not tell you how to fix it, and if your fix is wrong you find out several days later. I got three distinct ones.

Blue Argon — remotely hosted code

MV3 bans remotely hosted code. What took me a while to understand is that the check is static. A scanner reads your uploaded bundle and looks for two shapes:

  1. remote script references — <script src="https://…">, import("https://…"), any URL ending in .js / .mjs / .wasm, and the usual CDN hosts
  2. dynamic execution — eval(, and the Function constructor in both spellings

Whether the code can ever run is irrelevant. It's in the bundle, so it counts.

And the culprit is almost never your own code. It's a dependency. The one that actually got me was a PDF library whose preview output embeds a viewer script from a CDN as a string. A real remote script, sitting in the bundle, in a code path my product never calls. The same library also has the classic Function("return this")() globalThis fallback, and a zip library nearby had Function("" + fn) as a setImmediate shim. Neither branch can execute on Chrome 102+. Both get flagged anyway.

So I scan the build output — not the source — and gate on it:

const VIOLATIONS = [
  { name: "Function constructor / eval",
    re: /(^|[^.\w$])(new\s+Function|Function|eval)\s*\(/ },
  { name: "remote script URL",
    re: /https?:\/\/[^\s"'`)]+\.(?:js|mjs|wasm)\b/ },
]

// benign, do not touch: XML namespaces and licence comments
const BENIGN = [/https?:\/\/www\.w3\.org\//, /https?:\/\/purl\.org\//]
Enter fullscreen mode Exit fullscreen mode

Wired in as "package": "build && node scripts/check-rhc.mjs && package", so a bad bundle can't reach the zip step.

Two things I'd underline:

Write that regex in JavaScript, not shell. I tried grep -E first. POSIX ERE doesn't understand \w inside a bracket expression — it reads it as a literal backslash and a literal w — so [^.\w]Function\( happily matches the tail of headerFunction( and you get a pile of false positives. Node's regex engine has real \w.

Don't strip the benign URLs. http://www.w3.org/2000/svg is an XML namespace. Blank it out and your SVGs stop rendering, which is a much worse day than a rejection.

And: only ever upload the production build. Dev builds contain a localhost HMR script loader, which is, definitionally, remotely hosted code.

Yellow Argon — keyword spam

This one rejected me twice, and the two rejections together are what taught me the actual rule.

The extension integrates with six third-party products. Naturally I listed them. The first rejection quoted six separate passages back at me: the six names as a comma list appeared three times across the name, the short description and the detailed description; a list of nine file formats appeared three times; and thirteen UI language names were spelled out once.

Fair enough. I rewrote it so each name appeared exactly once, in prose, in a real sentence.

Rejected again. This time they quoted exactly one passage — that sentence. Six third-party product names in a single sentence is keyword spam whatever the grammar around it.

So the rule isn't a frequency budget. It's this:

  • Third-party proper nouns in your store copy: zero. Not "fewer", not "as prose". Zero. Users can still see what you support — the store page has a site-access panel generated from your host_permissions, which isn't your metadata and isn't your problem, and your screenshots show real product UI with real labels on it. Neither of those got flagged in either round.
  • Your own feature words are fine. One format name appears seven times in my current listing, every time inside an actual explanation of what the feature does. Never flagged once. What's policed is third-party names, not repetition.
  • "Irrelevant keywords" means what it says. A list of the languages your UI is translated into is the textbook case. Write "13 interface languages" and move on.
  • Your disclaimer counts. "Not affiliated with A, B, C, D, E or F" was, in my first draft, the second and third occurrence of the whole brand list. It rewrites cleanly to "not affiliated with, endorsed by or sponsored by the companies that operate the sites it supports" — same legal meaning, zero names. Just don't write "the sites listed above", because after this edit there is no list above.
  • The name field is not a keyword slot. Thing — Format A & Format B duplicates your short description and is judged on the same surface.

Red Nickel — promotional words

The store forbids promotional and superlative claims in listing copy and in your images: free, best, #1, new, recommended, and friends.

I knew that for the text. I did not know the images get OCR'd. A promo tile of mine had Free · Private · 100% Local set in type across it, and the rejection named the exact asset and the exact word.

Two consequences:

  • After you fix the wording you have to re-render the PNG. You upload an image, not the config file that generated it. Ask me how I know.
  • Substrings are a real risk. "A freely selected region" contains "free". I now avoid the sequence entirely and describe the behaviour instead — "a region you draw".

Factual words are safe and, honestly, better copy: Private, 100% Local, Offline, No account.

The listing form itself

A few shapes of the developer dashboard that cost me a resubmission each:

Every entry in permissions gets its own justification box, 1000 characters each. All of host_permissions shares one single box — no matter how many domains you declared. So you cannot write it domain by domain. I had five well-organised paragraphs, about 2000 characters, and nowhere to paste them. It has to be one paragraph merged by purpose: what you read from the page, at what rate you fetch, that you never write; one clause for your own backend; and your optional_host_permissions folded into the same paragraph rather than getting its own. Cutting to fit is easiest if you delete the topic sentence and open on the first domain.

Justify the permissions in the built manifest, not the ones in package.json. The bundler can add permissions you never wrote. A content script running in the MAIN world can't be declared in the manifest at all under MV3, so it gets registered at runtime by the service worker via chrome.scripting.registerContentScripts() — which means scripting appears in your shipped manifest out of nowhere. The reviewer asks about it. Your generated paperwork doesn't mention it, because it read the wrong file.

While you're there: justify scripting with what it actually does. The stock answer everyone copies is "we inject scripts on demand with executeScript". If that's not true for you, don't say it. Mine registers once, with matches identical to host_permissions, from files inside the package, and never calls executeScript. That's both truthful and less alarming.

The manifest description has a 132-character limit per locale. When it goes through __MSG_extDesc__, upload validates every _locales/<lang>/messages.json, not just your default one, and stops at the first one over. Translations run 20–60% longer than English, so I cap the English at 120 and check the rest in a script. (This is not the same field as the store listing's short description. Different limits, different places.)

Know which edits need a new package. Name and short description ship inside the zip, so changing them means a version bump and a re-upload. The detailed description exists only in the dashboard — edit and resubmit, no new build.

What I'd actually tell you

Write the permission justifications before you write the code.

It sounds like a process tip. It isn't. Every permission is a question you will have to answer in writing, in a 1000-character box, to a stranger who is looking for a reason to say no. Answering that question first changes what you build, and in my experience it usually makes the extension smaller.

The rest of it generalises to one line: every rule in this post is statically checkable, so none of them should live in your head. Remote code, leftover localhost permissions, over-length descriptions, banned words in copy — that's four scripts that fail your build. I still get rejected sometimes. I don't get rejected twice for the same thing anymore.

Written with AI assistance for the English drafting. The extensions, the rejections and the scripts are mine.

Top comments (0)