Packaging & Publishing

Publishing a framework-agnostic web component library to npm is a contract-design exercise, not a build step: the package.json you ship is the public API surface every consumer resolves against, and a single mis-ordered condition or over-eager sideEffects flag can break tree-shaking, hide your types, or silently drop the customElements.define() call that makes a tag work. This guide covers the modern, ESM-only packaging model for custom elements and the validation tooling that proves the package before it reaches a registry.

Packaging belongs to the broader Distribution, Testing & Tooling domain — the pipeline that turns working source into a dependency thousands of applications resolve, bundle, and ship without ever reading your source. A web component package has a peculiar property that ordinary JavaScript libraries do not: importing it usually has the intended side effect of registering an element in the global CustomElementRegistry. That single fact is what makes naive packaging defaults actively dangerous here, and it shapes nearly every decision below.

package.json exports resolution for a web component package An import specifier is matched against the exports map, then a condition stack (types, browser, import, default) selects the physical entry point, while the sideEffects allowlist decides whether the registration module survives tree-shaking. import specifier "my-lib/button" exports map subpath match "./button" CONDITION STACK (ordered) "types" → .d.ts (first!) "browser" → browser ESM "import" → ./dist/button.js "default" → safety net resolved entry physical .js file sideEffects allowlist ["**/define-*.js"] keeps customElements.define() alive "sideEffects": false bundler prunes the register module — tag never upgrades

Concept: the package as a resolved contract

When a consumer writes import { MyButton } from 'my-components', no file path is named. Node.js (and every bundler that follows it) resolves that bare specifier through the package’s package.json, specifically the exports field. The exports map is the only sanctioned public entry surface: anything not listed is unreachable, and anything listed is a promise you must keep across versions. This is governed by the Node.js package entry points specificationexports and conditional exports have been stable since Node 12.7 and unflagged since Node 12.17.

The shift this enforces is from file-based distribution (consumers reaching into node_modules/my-lib/src/...) to interface-based distribution. Once exports exists, deep imports are blocked unless you explicitly expose them. For a component library this is a feature: it lets you refactor internal file layout freely while keeping my-lib, my-lib/button, and my-lib/define/button stable. The first thing every package author should internalize is that the exports map is API, and breaking it is a major-version event.

Browser engine and toolchain integration points

A web component package is consumed in three distinct evaluation environments, and the packaging must satisfy all of them:

  1. The bundler’s static analysis pass (Rollup, esbuild, Vite, webpack, Parcel) reads exports, sideEffects, and "type" before any code runs, to decide which modules to include and which to discard. Tree-shaking is purely static — it never executes your customElements.define() call, so it can only learn that the call matters from the sideEffects declaration.
  2. The JavaScript engine at runtime evaluates the surviving modules. This is when class extends HTMLElement and customElements.define() actually mutate the global registry, an action the Custom Element Registry & Definition governs and which must run exactly once per tag name.
  3. The TypeScript / language-server pass resolves the types condition independently of runtime to type-check and power editor IntelliSense. Modern TypeScript (moduleResolution: "bundler" or "node16"/"nodenext") reads exports conditions, so the types condition must be present and correctly ordered or consumers lose autocompletion entirely.

These three readers consume the same package.json but care about different fields, which is why a packaging mistake often manifests in only one of them — types break while runtime works, or tree-shaking breaks while types are fine.

Core API surface: the fields that define a package

Field Purpose Recommended value for a web component lib
"type" Declares default module system for .js files "module" — ESM-only is the modern baseline
"exports" The public entry-point map; gates every import Object with subpaths + conditions (see configuring package.json exports)
"main" Legacy single CJS/ESM entry Omit if exports covers "."; keep only as fallback
"module" Non-standard ESM hint (bundler convention) Prefer exports import condition instead
"types" / "typesVersions" Top-level type entry (legacy) Use the types condition inside exports instead
"sideEffects" Tells bundlers which modules have side effects An allowlist array, never blanket false — see tree-shaking side-effect-free libraries
"files" Allowlist of files included in the tarball ["dist"] plus auto-included package.json, README, LICENSE
"publishConfig" Per-package publish overrides { "access": "public", "provenance": true }

A complete, production package.json

{
  "name": "@acme/components",
  "version": "2.4.0",
  "type": "module",
  "files": ["dist"],
  "sideEffects": ["**/define-*.js", "./dist/auto-register.js"],
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    },
    "./button": {
      "types": "./dist/button/button.d.ts",
      "import": "./dist/button/button.js"
    },
    "./define/*": {
      "types": "./dist/define/*.d.ts",
      "import": "./dist/define/*.js"
    },
    "./package.json": "./package.json"
  },
  "publishConfig": {
    "access": "public",
    "provenance": true
  },
  "scripts": {
    "prepack": "npm run build && publint && attw --pack"
  }
}

Three details in that file carry most of the weight. The types condition appears first in every entry, because Node resolves conditions in object insertion order and TypeScript follows that order — list it after import and tooling can resolve a .js file as if it were the type definition. The "./package.json": "./package.json" self-export exists because many tools (and the validators below) read require('my-lib/package.json'); without this entry the exports map blocks that access. And sideEffects is an allowlist, not false, which is the single most important line for a component library — it is the subject of the dedicated tree-shaking side-effect-free libraries deep-dive.

The split-entry pattern: pure class vs. auto-register

The cleanest design separates the definition of an element class from its registration. Export the class from a pure, side-effect-free module, and provide a separate define-* module that performs the registry mutation:

// dist/button/button.js — PURE: no side effects, fully tree-shakeable
export class AcmeButton extends HTMLElement {
  static observedAttributes = ['variant'];
  #internals;

  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.#internals = this.attachInternals();
  }

  connectedCallback() {
    this.shadowRoot.innerHTML = `<button part="btn"><slot></slot></button>`;
  }
}
// dist/define/button.js — SIDE-EFFECTFUL: registers the tag
import { AcmeButton } from '../button/button.js';

// Guard against duplicate definition (HMR, multiple bundles)
if (!customElements.get('acme-button')) {
  customElements.define('acme-button', AcmeButton);
}

Consumers who want full control import the class (import { AcmeButton } from '@acme/components/button') and call define themselves; consumers who want plug-and-play import the registration module (import '@acme/components/define/button') for its side effect. Because the registration module name matches the sideEffects allowlist, bundlers keep it. This split is what reconciles aggressive tree-shaking with reliable custom-element upgrade.

Common failure modes & debugging steps

  1. The dual-package hazard (CJS + ESM identity split). If you ship both a CommonJS and an ESM copy of the same module, a consumer’s dependency graph can load both. Two AcmeButton classes then exist, and customElements.define('acme-button', ...) runs twice — the second call throws NotSupportedError: 'acme-button' has already been used. Worse, instanceof AcmeButton checks fail across the boundary because the two classes are distinct identities. Fix: ship ESM only ("type": "module", only an import condition). If a CJS consumer truly needs you, expose a thin CJS wrapper that dynamically import()s the single ESM build rather than duplicating it. Verify with attw (below), which flags dual-package hazards by name.

  2. Types resolve to any for exports-using consumers. A package with a top-level "types" field but no types condition inside exports will type-check fine under moduleResolution: "node" but fall to any under "bundler"/"nodenext". Fix: add a types condition, ordered first, to every exports entry. Debug: run attw --pack — it reports false-typed resolutions per module-resolution mode in a matrix.

  3. A subpath import throws ERR_PACKAGE_PATH_NOT_EXPORTED. Once exports exists, every importable subpath must be listed. A consumer importing my-lib/utils when only . and ./button are exported gets a hard resolution error. Fix: add the subpath (or a "./*" wildcard) to exports; never tell consumers to deep-import around it. Debug: reproduce with node --input-type=module -e "import('my-lib/utils')".

  4. define() silently dropped under tree-shaking. Covered in depth separately, the symptom is that <acme-button> renders as an inert HTMLElement with no shadow content because the registration module was pruned. Fix: scope sideEffects to the define-* modules. Debug: check whether customElements.get('acme-button') returns undefined at runtime, and inspect the bundle for the define call.

Framework interoperability

The packaging contract is framework-neutral by design, but the registration story differs by consumer:

The throughline: every framework needs the side-effectful registration module to actually execute, which is exactly why the sideEffects allowlist cannot be false.

Performance, bundle size & CDN distribution

ESM-only output is also a performance decision. A single ESM build with a precise sideEffects allowlist lets every consuming bundler perform per-component tree-shaking: an app importing only @acme/components/button ships only the button’s bytes, not the whole library. Measure this with a bundle analyzer (rollup-plugin-visualizer, source-map-explorer, or npx vite-bundle-visualizer) and assert in CI that importing one component does not pull in siblings.

For no-build consumption, CDNs serve the published tarball directly:

Because CDNs serve exactly what you publish, the files allowlist doubles as your CDN surface — never publish source maps pointing at unpublished sources, and keep dist self-contained. Pin versions in CDN URLs; an unpinned @latest import re-downloads and can break consumers on every release.

Publish-time integrity: provenance and validation

Two validators should gate every publish and run in CI:

Wire both into prepack (shown in the package.json above) so a broken package cannot be published. For supply-chain integrity, publish with npm provenance (npm publish --provenance, or publishConfig.provenance: true plus a trusted CI environment such as GitHub Actions with OIDC). Provenance cryptographically links the published tarball to the source commit and build, and npm displays a verified badge. Combined with --access public for scoped packages, this makes the artifact auditable end to end.

Treating the exports map as a versioned contract

Because the exports map is the public API surface, its evolution must follow semantic versioning the same way a function signature does. Adding a new subpath or a new condition is additive and ships in a minor release. Removing or renaming a subpath, changing which file a condition resolves to in a breaking way, or tightening a wildcard so previously importable modules stop resolving is a major release — consumers’ builds will throw ERR_PACKAGE_PATH_NOT_EXPORTED otherwise. Tag names registered by your define-* modules are part of the same contract: renaming acme-button to acme-btn breaks every consumer’s markup and is a major change even though no JavaScript signature moved.

A practical discipline is to snapshot the resolved entry points in CI and diff them across versions. The packed tarball’s exports object, expanded against the actual dist file list, is the artifact to compare; a removed key without a major-version bump should fail the pipeline. This makes the otherwise-invisible packaging contract enforceable in the same way a type signature or an event payload schema is, and it pairs naturally with the schema discipline described in Contract & Visual Testing.

Publishing from a monorepo

Most component libraries are developed in a workspace alongside their consumers and a documentation site, which introduces a publishing hazard: workspace tooling resolves @acme/components to your source directory during development, so a path that works locally (because the resolver sees src/) can be unreachable once published (because the consumer sees only dist/ through exports). Always validate against the packed tarball, not the working tree. npm pack --dry-run prints exactly the file list that will ship; pipe it through publint and attw --pack so the checks run against the real artifact. The files allowlist and the exports targets must agree — a subpath that points at ./dist/button/button.js is broken if files only ships ["dist/index.js"], and that mismatch is invisible until a consumer installs the package.

A web component package is only as trustworthy as its weakest resolution path. The exports map defines what consumers can import, the condition ordering defines what their tooling sees, the sideEffects allowlist defines what survives bundling, and publint + attw + provenance prove all of it before a single consumer resolves the dependency. Get these four right and the package behaves identically whether it is bundled by Vite, imported by Node for SSR, or loaded raw from a CDN.