Bundling & npm Publishing
|
This section documents modern ECMAScript and core browser JavaScript APIs — it is not tied to any specific framework or library (React, Vue, Angular, etc.). This content was generated with the assistance of AI and should be verified against the current ECMAScript specification and MDN documentation before relying on it in production, since JavaScript language features and browser API support continue to evolve. This section’s bibliography lists the reference material consulted while preparing these pages. |
Modern JavaScript is written as many small modules — see Modules — but
browsers pay a real cost for every separate file they fetch, and not every module system a project depends on
is natively understood by every browser. A bundler closes that gap: it walks a project’s module graph starting
from one or more entry points, resolves every import/require, and emits one or more browser-deliverable
files. This page covers what bundlers actually solve and how the current tooling landscape divides up that job,
then walks through the practical mechanics of shipping the result of that work as an open-source package on npm.
What Bundlers Solve
A bundler’s job breaks down into four related problems, and understanding them separately makes it much easier to reason about why a given tool is configured the way it is.
Module Resolution
Browsers only recently gained native support for ES modules (<script type="module">), and even where it’s
supported, fetching hundreds of individual files — one per module, plus every transitive dependency inside
node_modules — means hundreds of round trips before a page becomes interactive. A bundler resolves the same
import/require graph Node.js resolves at runtime, but does it once, ahead of time, and inlines every module
into a single file (or a small set of files) that the browser can fetch far more cheaply. This is the same
concern that motivates transpilation (see Transpilation with Babel), but
it addresses a different axis of the problem: transpilation makes newer syntax runnable on older engines;
bundling makes a module graph deliverable as a small number of HTTP requests.
Tree-Shaking
Because ES module import/export declarations are static — unlike CommonJS require(), which can be called
conditionally with a computed path — a bundler can statically determine exactly which exports of a module are
actually used by the rest of the graph, and discard the rest. This is tree-shaking: importing one function from
a utility library no longer means shipping the whole library, as long as both the library and the consuming code
are authored (or compiled) as ES modules and the unused code has no side effects the bundler can’t prove away.
Tree-shaking is a large part of why authoring a library as ESM, discussed in the second half of this page,
matters even for consumers who bundle their own application.
Code-Splitting
Not every line of a large application is needed on first paint. Code-splitting lets a bundler break the module
graph into multiple output chunks — an initial chunk needed immediately, and further chunks loaded on demand
(typically via a dynamic import(), which every major bundler recognizes as a split point). A route that’s
rarely visited, a rich-text editor, a charting library used on one admin page — all are natural candidates to
live in a separate chunk that’s fetched only when the user actually navigates there, keeping the initial payload
small.
Minification
Once the graph is resolved and split, the bundler (or a dedicated minifier it delegates to, such as Terser or esbuild’s own minifier) rewrites the output to be as small as possible: whitespace and comments are stripped, identifiers are renamed to short forms, dead branches are eliminated, and some constant expressions are folded. None of this changes runtime behavior, only the number of bytes the browser has to download and parse.
The Current Bundler Landscape
No single tool wins every one of the four jobs above equally well, and the ecosystem has settled into a small set of tools that are each usually reached for at a different point on the app-vs-library, and speed-vs-feature, spectrum.
Webpack
Webpack was the tool that popularized the "everything is a module" bundling model — CSS, images, fonts, and
JSON can all be import-ed and are handled by a loader chain, with a large plugin ecosystem covering nearly
every build concern imaginable. It remains the most configurable option and the most common choice for large,
long-lived application codebases that need fine control over chunking strategy, asset handling, or a dev-server
with Hot Module Replacement (HMR) — reflecting changed modules into a running page without a full reload. That
configurability comes at the cost of a comparatively large and often verbose webpack.config.js, and of being
the slowest of the four tools discussed here on both cold builds and rebuilds, since it does its module
resolution, transformation, and bundling entirely in JavaScript.
Rollup
Rollup was purpose-built around ES modules and produces notably clean, flat output — it pioneered
tree-shaking in the bundler space and is still generally regarded as producing the smallest, most readable
bundles for that reason. Its plugin API is simpler than Webpack’s, and it natively supports emitting the same
build as multiple output formats (esm, cjs, umd, iife) from one input in a single config. This combination — clean tree-shaken output, first-class multi-format output, a lighter config surface — is why Rollup (often
via Vite’s library mode, covered below) is the most common choice specifically for authoring a library meant
to be consumed by other projects, as opposed to bundling a deployable application.
esbuild
esbuild is written in Go rather than JavaScript and parallelizes aggressively, which typically makes it one to
two orders of magnitude faster than Webpack or Rollup on comparable input. It implements its own minifier,
supports code-splitting and tree-shaking, and can transpile TypeScript and JSX on the fly (though, unlike Babel,
it does not perform TypeScript type checking — that stays a separate tsc --noEmit step). Its plugin API is
intentionally smaller than Webpack’s or Rollup’s, trading some flexibility for that speed. In practice esbuild is
used two ways: directly, for projects whose bundling needs are simple enough that raw speed matters more than
plugin breadth, and indirectly, as the transform/bundle engine underneath higher-level tools — most notably
Vite’s development server.
Vite
Vite is not itself a from-scratch bundler so much as a build tool that combines the two approaches above at the
point where each is strongest: in development, it serves ES modules directly to the browser over native
<script type="module">, transforming each file on demand with esbuild rather than bundling the whole graph
up front, which is what gives Vite’s dev server its near-instant startup and its HMR updates that stay fast
regardless of application size. For a production build, it hands the same project off to Rollup, which is
better suited to the tree-shaken, code-split, multi-chunk output an app actually ships. Vite also ships a
library mode (build.lib in vite.config.js) that configures its underlying Rollup build to emit exactly the
dual ESM/CJS output a published package needs, which is why it’s a common on-ramp into the publishing workflow
covered in the second half of this page even for projects that never touch Rollup’s config directly.
Choosing Between Them
As a rough decision guide: reach for Webpack when an existing large application already depends on its plugin ecosystem or a bespoke asset pipeline; reach for Vite (dev server backed by esbuild, production build backed by Rollup) as the default for a new application, since it covers HMR-driven development and a production bundle with very little configuration; reach for Rollup (directly, or through Vite’s library mode) when the deliverable is a library rather than an app, because clean, tree-shakeable, multi-format output matters more there than a dev server; and reach for esbuild directly when the entire point is build speed and the project’s needs are simple enough not to need a richer plugin system.
Production Build Concerns
Minification and Source Maps
A production build should always run its output through minification — every tool above supports it, and Vite
and recent Webpack defaults enable it automatically for a production mode build. Minification, however, makes a
stack trace from a real user’s browser unreadable, since the reported line/column refers to the minified
output, not the source a developer wrote. The fix is a source map: a side file (or inlined data URI) that
maps each position in the minified output back to its original source location. Every bundler discussed above
can emit one (devtool in Webpack, sourcemap in Rollup/Vite, --sourcemap for esbuild); the only real
decision is whether to publish source maps alongside the production bundle (useful for error-tracking services
such as Sentry to symbolicate stack traces) or keep them private and upload them directly to such a service,
trading public debuggability for not exposing original source to end users.
Target Matrices and .browserslistrc
A bundler’s minifier and any syntax it’s asked to preserve both need to know how old a browser they’re
allowed to assume. That target is normally expressed once, in a .browserslistrc file (or a browserslist key
in package.json), as a list of queries such as:
> 0.5%
last 2 versions
Firefox ESR
not dead
This is the same file Babel’s @babel/preset-env reads to decide which syntax to transform and which polyfills
a project needs — see Transpilation with Babel for that side of the
picture. A bundler consumes the identical target list for a related but distinct purpose: esbuild and Vite use
it (or an explicit target option, e.g. es2020) to decide which output syntax their own transform step is
allowed to produce, and Webpack’s Terser-based minifier uses it to decide which minification transforms are
safe (for example, whether arrow functions or optional chaining may appear in the minified output at all).
Keeping one browserslist target shared between the transpilation step and the bundling step is what keeps a
project’s "how old a browser do we support" decision in exactly one place.
Publishing an Open-Source Library to npm
Everything above concerns building a deliverable bundle. Publishing that work as a package other developers
npm install is a separate, largely tooling-independent process, governed mostly by package.json metadata and
the npm CLI itself. The rest of this page walks through it end to end, grounded in npm’s own documentation
(https://docs.npmjs.com/).
package.json Essentials
A handful of package.json fields are what a consumer’s tooling (npm itself, a bundler, a TypeScript compiler)
actually reads to decide how to install and import a package:
{
"name": "@my-scope/my-library",
"version": "1.4.0",
"description": "A short, one-line description shown on the npm registry page.",
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.esm.js",
"require": "./dist/index.cjs.js"
},
"./package.json": "./package.json"
},
"files": [
"dist"
],
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0"
},
"license": "MIT"
}
| Field | What it controls for consumers |
|---|---|
|
The package’s identifier on the registry. Must be lowercase and URL-safe; a scoped name ( |
|
The exact release a given install resolves to, together with |
|
The legacy CommonJS entry point — what |
|
An unofficial (but widely respected) convention pointing bundlers at an ES module entry point, so a tree-shaking-capable bundler picks up ESM rather than falling back to `main’s CJS build. Not read by Node.js itself. |
|
The modern, authoritative way to declare a package’s public entry points and how each resolves per
environment ( |
|
Points TypeScript’s compiler at the package’s |
|
An allowlist of what gets included in the published tarball — anything not matched here (and not one of a
few files npm always includes, such as |
|
Declares a dependency the consumer’s project must supply (a matching version of React, for example) rather than one npm should install for the library itself. Prevents duplicate copies of a library like React ending up in the dependency tree, and gets flagged by the package manager if the consumer’s installed version falls outside the declared range. |
Authoring Dual ESM+CJS Output
Most published libraries still need to support both import (ESM) and require() (CommonJS) consumers, since
not every consumer’s own toolchain is ESM-only yet. The main/module/exports fields above are only
declarations — something still has to actually produce two build outputs from one source tree, and that’s a
job for one of the bundlers from the first half of this page. Rollup is the most common choice here specifically
because its output option accepts an array, letting one Rollup config emit both formats from a single input in
one build:
// rollup.config.js
export default {
input: "src/index.js",
output: [
{ file: "dist/index.cjs.js", format: "cjs", sourcemap: true },
{ file: "dist/index.esm.js", format: "esm", sourcemap: true }
],
external: ["react"] // don't bundle peerDependencies into the library itself
};
external matters here: a library should generally bundle its own regular dependencies but not its
peerDependencies — those are meant to be resolved once, from the consuming application’s own install, not
duplicated inside the library’s bundle. Vite’s library mode (build.lib in vite.config.js) configures the
same underlying Rollup behavior with less manual setup, and is a common alternative for projects already using
Vite for development.
An increasingly common alternative, for libraries that don’t need to support older CJS-only consumers, is to
skip dual output entirely and publish ESM-only, declaring a single import condition (and no require) in
exports. This is simpler to build and reason about, at the cost of breaking for any consumer still on plain
require().
Semantic Versioning
npm’s version field follows semantic versioning (semver): MAJOR.MINOR.PATCH, e.g. 2.5.1.
| Segment | Bump when |
|---|---|
|
A breaking change is introduced — anything that could require a consumer to change their own code to keep working (a removed export, a changed function signature, a changed default behavior). |
|
Backward-compatible functionality is added — a new export, a new optional parameter, a new feature that doesn’t change any existing behavior. |
|
A backward-compatible bug fix — the public API is unchanged, but something that was behaving incorrectly now behaves correctly. |
A leading 0.x.y version is a semver convention meaning "still unstable, no compatibility guarantee yet" — many libraries stay on 0.x until their API settles, at which point 1.0.0 signals the first stable release.
Prerelease and build-metadata suffixes (2.5.1-beta.0, 2.5.1+20260823) are also valid and are what most
npm dist-tag-based prerelease channels (next, beta) publish under. On the consuming side, the same
semver numbers drive the range operators used in dependencies/peerDependencies — ^2.5.1 allows any
2.x.y at or above 2.5.1 but not 3.0.0, while ~2.5.1 allows only patch-level updates within 2.5.x.
npm login and npm publish
Publishing requires an npm account and, for scoped packages published under an organization, membership with publish rights. Authenticate once per machine:
npm login
This opens a browser (or prompts for username/password/one-time password on older CLI versions) and stores an
auth token locally. From the package’s root — with version already bumped and the build already run so
dist/ (or whatever files points at) is up to date — publish with:
npm publish
If the account has two-factor authentication enabled, npm either prompts interactively for a one-time password
or accepts one directly via npm publish --otp=123456. Before actually publishing, npm pack --dry-run is
worth running to confirm the tarball’s contents match what files was meant to allow.
Scoped Packages and --access public
A scoped package name — @my-scope/my-library rather than a bare my-library — namespaces the package
under a user or organization account and is the standard way to publish under a personal or company brand
without colliding with the flat, unscoped registry namespace. Per npm’s documentation, a new scoped package
defaults to private/restricted visibility, which on the free tier means the publish will fail unless public
access is explicitly requested:
npm publish --access public
This flag is required on the first publish of a given scoped package to make it publicly installable (once
set, subsequent npm publish runs for the same package remember the access level, though passing it every time
is harmless). Unscoped package names, by contrast, have always been public-only — there’s no private option to
opt out of, so --access is irrelevant for them.
Two more constraints worth keeping in mind: an npm registry never allows re-publishing the same
name@version a second time, even if the original was unpublished — a broken release always needs a new patch
version, never a re-push of the old one; and npm deprecate <pkg>@<range> "message" is the supported way to
warn installers off a bad version without removing it outright, which is generally preferable to unpublishing
once a version has had real installs.
Creating an npm Account and Organization
Publishing to the registry at all requires an account, created at npmjs.com. A
package published under a scope (@my-scope/my-library, see above) needs that scope to exist as either a
personal user scope (automatic, matching the account’s own username) or an organization — created from the
npmjs.com dashboard (Add Organization) and given a name that becomes the scope — with the publishing account
added as a member with publish rights.
Generating an Access Token
npm login is the right choice for a human publishing interactively from a local machine; CI needs a
non-interactive credential instead:
npm token create # a classic token: full publish access, bypasses 2FA
The npm CLI’s token create takes no flag to pick a token type — a classic automation token specifically
(the kind meant for unattended CI, distinct from an ordinary read-write token) can currently only be created
from Account Settings → Access Tokens on npmjs.com, not the CLI. Either kind grants publish access to every
package the account can already publish, which is simple but broad. A Granular Access Token is the narrower,
generally preferable alternative: created from the web UI, it can be scoped to specific packages (or an entire
scope) and to specific permissions (read-only vs. read-and-write) and given an expiry date, so a leaked CI secret
exposes far less than a classic token would.
CI consumes either kind the same way, via the NODE_AUTH_TOKEN environment variable, which npm publish reads
automatically, or explicitly in .npmrc:
//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}
npm Trusted Publishing (OIDC)
npm’s Trusted Publishing (built on provenance and OpenID Connect) removes the need for a stored long-lived token at all: instead of a secret, the npm registry is configured to trust a GitHub Actions OIDC token asserting that a publish came from one specific workflow file in one specific repository. It is configured per package, on npmjs.com under that package’s Settings → Publishing access, by naming the trusted GitHub repository and workflow filename. Where a project’s CI setup can use it — a GitHub Actions workflow publishing a public package — this is the recommended approach over a token as described above, since there is no credential to rotate, leak, or scope incorrectly in the first place.
Automating Releases
Bumping version by hand and re-running npm publish for every release works, but most actively maintained
libraries automate it instead. npm version <patch|minor|major> updates package.json’s `version field
according to the semver rules above, creates a matching git commit, and tags it — leaving only git push --tags
and (in CI) npm publish to run afterward. Wiring that into a GitHub Actions workflow, tag-gated the same way as
the C# reference workflow (csharp/build-and-tooling.adoc, == Continuous Integration), keeps publishing
auditable and keeps any npm credential out of individual developers' environments. OIDC-based Trusted Publishing
(above) is the primary example below, since it needs no stored secret at all — only permissions: id-token:
write on the job:
name: build
on:
push:
branches: [ main ]
tags: [ 'v*' ]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
permissions:
id-token: write # required for npm Trusted Publishing (OIDC); omit if using NODE_AUTH_TOKEN instead
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
cache: 'npm'
- run: npm ci
- run: npm test
- run: npm run build
- name: Publish to npm
if: startsWith(github.ref, 'refs/tags/v')
run: npm publish --access public
For a registry or setup that doesn’t yet support Trusted Publishing, the only change needed is supplying
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} as an environment variable on the publish step, and dropping the
permissions: id-token: write block.
| npm CLI flags and default behaviors (particularly around two-factor authentication requirements and scoped-package defaults) have shifted between major CLI versions in the past. Verify current behavior against https://docs.npmjs.com/ before relying on any specific flag in a new project. |