"Vanilla JS for one file is fine. Multi-file with npm dependencies and TypeScript is where you reach for a bundler. Lesson 5 is the minimum esbuild setup that turns src/ into dist/ in under a second, with sourcemaps, watch mode, and Web Store-clean output."
Why a Bundler
Up to about 200 lines per file, vanilla JS scattered across content.js / background.js / popup.js / panel.js / etc. is fine. The bundler earns its keep when:
- You want TypeScript for types.
- You import a third-party npm package with internal ES module imports (anything from npm that's not a single flat file).
- Your codebase has shared helpers used by multiple entry points (popup + panel + content all need the same clip-row renderer).
- You want one source layout in src/, one production layout in dist/, with the manifest and HTML copied/transformed during build.
esbuild as the Default
esbuild compiles JS/TS in milliseconds. For an extension that's mostly TypeScript with a few entry points, the entire build is:
esbuild \
src/background.ts \
src/content.ts \
src/popup.ts \
src/panel.ts \
--bundle \
--outdir=dist \
--target=chrome120 \
--sourcemap=inline \
--format=iife
Four entry points, one command, four output files (background.js, content.js, popup.js, panel.js) all bundled. Output is IIFE format which is what Chrome wants for content scripts and classic background pages. --target=chrome120 tells esbuild which JS features it can leave un-transpiled.
The Layout
clipdeck/
src/
background.ts
content.ts
popup.ts
panel.ts
shared/
clip-row.ts
escape-html.ts
types.ts
static/
manifest.json
popup.html
panel.html
icons/
16.png 32.png 48.png 128.png
vendor/
Readability.js
scripts/
build.mjs
dist/ # gitignored, generated
package.json
tsconfig.json
The build script copies static/ into dist/ and bundles src/ into dist/. After npm run build you have a chrome-loadable dist/.
The Build Script
esbuild's Node API gives you a single file you can run with node scripts/build.mjs:
import { build } from 'esbuild';
import { copyFile, mkdir, cp } from 'fs/promises';
await mkdir('dist', { recursive: true });
await cp('static', 'dist', { recursive: true });
await build({
entryPoints: ['src/background.ts', 'src/content.ts', 'src/popup.ts', 'src/panel.ts'],
bundle: true,
outdir: 'dist',
target: 'chrome120',
format: 'iife',
sourcemap: 'inline',
logLevel: 'info',
});
Add a --watch flag and esbuild's context API to get rebuild-on-save during development. Pair with a Chrome extension reload library (extension-reloader, or a tiny chrome.runtime.reload trigger via your dev SW) so saving a file triggers Chrome to reload the extension.
TypeScript Setup
tsconfig.json for an extension:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"types": ["chrome"],
"noEmit": true,
"skipLibCheck": true
},
"include": ["src"]
}
Install @types/chrome for chrome.* API types. noEmit: true because esbuild does the actual compilation; tsc only runs for type-checking. Add an npm run typecheck script that runs tsc --noEmit as a separate check.
The package.json Scripts
{
"scripts": {
"build": "node scripts/build.mjs",
"watch": "node scripts/build.mjs --watch",
"typecheck": "tsc --noEmit",
"package": "npm run typecheck && npm run build && cd dist && zip -r ../clipdeck.zip ."
},
"devDependencies": {
"esbuild": "^0.25.0",
"typescript": "^5.5.0",
"@types/chrome": "^0.0.300"
}
}
npm run package typechecks, builds, and zips in one shot. That zip is what you upload to the Web Store. npm run watch is your dev loop; pair with chrome://extensions auto-reload (browser extension or a tiny chrome.runtime.reload SW listener).
Migration From Vendor
Track 7's Readability.js was vendored as a flat file. With a bundler, you can npm install @mozilla/readability and import it normally:
// src/content.ts
import { Readability } from '@mozilla/readability';
// ...use exactly as before
The bundled content.js will inline Readability's code. Cleaner, version-tracked, tree-shaken where possible. The static/vendor/ directory becomes unused — delete it from the build output.