Skip to content

Build and Package

How manifest.config.ts produces manifest.json, how Vite and @crxjs/vite-plugin handle change detection in dev, and what pnpm run build and pnpm run package each drop on disk. Architecture set up the FSD tree; here it meets the manifest and turns into an artifact chrome can load.

graph LR
src[src/ — FSD slices]
manifest[manifest.config.ts]
vite[vite + @crxjs/vite-plugin]
dist[dist/ — loadable extension]
zip[packages/extension.zip]
src --> vite
manifest --> vite
vite --> dist
dist --> zip

The React/TS code in src/ and manifest.config.ts go into vite together, and pnpm run build produces dist/. pnpm run package only zips that directory.

The manifest.config.ts at the repo root exports a TypeScript object. Wrapping it in defineManifest from crxjs serializes it to manifest.json at build time.

import { defineManifest } from '@crxjs/vite-plugin'
export default defineManifest({
manifest_version: 3,
name: '__MSG_extName__',
version: '0.1.0',
permissions: ['storage', 'activeTab'],
host_permissions: [],
action: { default_popup: 'src/app/popup/index.html' },
options_page: 'src/app/options/index.html',
background: { service_worker: 'src/app/background/index.ts' },
content_scripts: [
{ matches: ['https://*.example.com/*'], js: ['src/app/content/index.ts'] },
],
})

The fields you usually touch are permissions, host_permissions, content_scripts.matches, action.default_popup, options_page, and side_panel.default_path. When you change permissions, route through the design-manifest skill so an ADR records the reasoning. Editing the file by hand still builds. What disappears is the reason that permission was granted, a month later you will not remember why.

vite.config.ts registers crx({ manifest }). The plugin reads the manifest and picks entry points automatically. content_scripts, background.service_worker, action.default_popup, and options_page all become vite build inputs.

The result is that vite compiles the React/TS source as a regular web build while also handling every entry point the manifest names. Adding an entry point is one edit in the manifest.

Terminal window
pnpm run dev

The vite dev server starts, and crxjs HMR uses a different strategy per entry point.

  • popup, options: regular vite HMR. Save and the change appears.
  • content_script: a fresh build is dropped and chrome reinjects automatically. A page reload may still be needed.
  • background service worker: any change kills the worker and restarts it.

The “Reload” button on chrome://extensions is rarely needed. Only a permission addition or a major manifest restructure asks for it.

Terminal window
pnpm run build # production build into dist/
pnpm run package # archive into packages/extension.zip

pnpm run build produces the production build under dist/ and writes manifest.json alongside it. From there, “Load unpacked” in chrome picks it up directly.

pnpm run package zips that same dist/ into packages/extension.zip. The structure inside the zip is identical to dist.

The split exists because the use cases differ. build is for loading on your own chrome; package is for sending a zip to a teammate or uploading to the Web Store.

The zip dropped by pnpm run package is where the base’s automation ends. What comes after — who receives it, where it gets hosted, where the .pem key lives, whether to list on the Chrome Web Store — is outside the tool’s reach. The DISTRIBUTION.md that build-and-package writes alongside the zip is a memo for those decisions, not an automated handoff.

The boundary is deliberate. Key files, store consoles, and corporate policy servers all expect a human identity to act, and any automation that crosses that line drags key leaks and review incidents behind it. The next section walks through the three paths and the key handling that goes with them.

Distribution comes next. The trade-offs between zip handoff, Web Store upload, and self-hosted .crx with update_url, plus .pem key handling and how chrome’s policy against external .crx files is worked around.