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

name

The package’s identifier on the registry. Must be lowercase and URL-safe; a scoped name (@scope/name, see below) groups related packages under one namespace and requires an explicit publish flag the first time.

version

The exact release a given install resolves to, together with name; every publish must use a version that has never been published before — see "Semantic Versioning" below.

main

The legacy CommonJS entry point — what require("my-library") resolves to in a Node.js/CJS consumer, and the fallback any tool that doesn’t understand exports will use.

module

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.

exports

The modern, authoritative way to declare a package’s public entry points and how each resolves per environment (import vs require) and per subpath. When present, it takes priority over main/module in Node.js and in bundlers that understand it, and it also restricts what a consumer can import to exactly the subpaths listed — reaching into any other file inside the package fails, which is a deliberate encapsulation boundary main never provided.

types (or typings)

Points TypeScript’s compiler at the package’s .d.ts declaration file, so consumers get type information without needing a separate @types/…​ package. Can also be set per-subpath inside exports (as shown above) for a package with multiple entry points.

files

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 package.json, README, LICENSE) is left out, keeping the published package limited to build output rather than source, tests, and config. files, when present, takes precedence over .npmignore (npm ignores it entirely); .npmignore is only consulted — as a blocklist, falling back to .gitignore if absent — when files isn’t set. npm pack --dry-run prints exactly what a publish would include without actually publishing anything.

peerDependencies

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

MAJOR

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).

MINOR

Backward-compatible functionality is added — a new export, a new optional parameter, a new feature that doesn’t change any existing behavior.

PATCH

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.