Skip to content
HELiX

Packaging for Distribution

apps/docs/src/content/docs/components/distribution/packaging Click to copy
Copied! apps/docs/src/content/docs/components/distribution/packaging

Enterprise web component libraries require sophisticated packaging strategies to serve diverse consumption patterns — from CDN script tags to framework integrations to individual component imports. This guide explores hx-library’s distribution architecture, built on Vite’s library mode, with optimizations for tree-shaking, code-splitting, and developer experience.


hx-library is packaged as an npm module (@helixui/library) with multiple consumption patterns:

// Full library import (all components)
import '@helixui/library';
// Individual component import (tree-shakeable)
import '@helixui/library/components/hx-button';
// Direct component class import (advanced)
import { HxButton } from '@helixui/library/components/hx-button';

This flexibility is achieved through:

  1. Vite library mode — optimized bundling for library distribution
  2. Multiple entry points — per-component imports for tree-shaking
  3. ESM-first output — modern JavaScript modules with side-effect hints
  4. Type declarations — TypeScript .d.ts files for all exports
  5. Custom Elements Manifest — machine-readable component metadata

Vite’s library mode transforms the default application-focused build into a library distribution pipeline. Unlike application builds that target HTML entry points, library mode produces JavaScript modules consumable by other projects.

The foundation of hx-library’s build is defined in vite.config.ts:

import { defineConfig } from 'vite';
import { resolve } from 'path';
import dts from 'vite-plugin-dts';
export default defineConfig({
plugins: [
dts({
include: ['src/**/*.ts'],
exclude: ['**/*.test.ts', '**/*.stories.ts'],
}),
],
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'HelixLibrary',
fileName: 'helix-library',
formats: ['es'],
},
outDir: 'dist',
sourcemap: true,
minify: 'esbuild',
},
});

Key Options:

  • entry — the main entry point (src/index.ts); required for library mode since HTML entry points are not applicable
  • name — the global variable name when bundled as UMD/IIFE (required for those formats; unused in ESM-only builds)
  • fileName — the output file name pattern; can be a string or function (format) => string
  • formats — output formats (['es'] for ESM-only; ['es', 'umd'] for dual-format distribution)

hx-library targets ESM exclusively (formats: ['es']) for several reasons:

  1. Modern bundler compatibility — Vite, Rollup, Webpack 5+, and esbuild all consume ESM natively
  2. Tree-shaking optimization — ESM’s static import/export analysis enables dead code elimination
  3. Smaller bundle size — no UMD wrapper overhead or polyfill bloat
  4. Native browser support<script type="module"> works in all evergreen browsers (IE11 is not a target for healthcare UIs in 2026)
  5. Future-proof — CommonJS is deprecated in Node.js 16+; ESM is the standard

Trade-off: Legacy script tag usage (<script src="cdn/helix.js"></script>) is unsupported. For CDN consumption, consumers use module scripts:

<script type="module">
import '@helixui/library';
</script>

The most critical optimization for library distribution is granular entry points. Instead of forcing consumers to import the entire library, hx-library exposes each component as a standalone module.

A naive library build might have a single entry point:

// src/index.ts (anti-pattern)
export * from './components/hx-button';
export * from './components/hx-card';
export * from './components/hx-text-input';
// ... 50+ components

Result: When a consumer imports one component, they bundle all components:

import { HxButton } from '@helixui/library'; // Bundles entire library (500KB+)

This defeats tree-shaking and bloats production bundles.

hx-library configures Vite with 14+ entry points — one per component:

export default defineConfig({
build: {
lib: {
entry: {
index: resolve(__dirname, 'src/index.ts'),
'components/hx-button/index': resolve(__dirname, 'src/components/hx-button/index.ts'),
'components/hx-card/index': resolve(__dirname, 'src/components/hx-card/index.ts'),
'components/hx-container/index': resolve(__dirname, 'src/components/hx-container/index.ts'),
'components/hx-text-input/index': resolve(
__dirname,
'src/components/hx-text-input/index.ts',
),
'components/hx-checkbox/index': resolve(__dirname, 'src/components/hx-checkbox/index.ts'),
'components/hx-select/index': resolve(__dirname, 'src/components/hx-select/index.ts'),
'components/hx-radio-group/index': resolve(
__dirname,
'src/components/hx-radio-group/index.ts',
),
'components/hx-alert/index': resolve(__dirname, 'src/components/hx-alert/index.ts'),
'components/hx-textarea/index': resolve(__dirname, 'src/components/hx-textarea/index.ts'),
'components/hx-badge/index': resolve(__dirname, 'src/components/hx-badge/index.ts'),
'components/hx-switch/index': resolve(__dirname, 'src/components/hx-switch/index.ts'),
'components/hx-form/index': resolve(__dirname, 'src/components/hx-form/index.ts'),
'components/hx-prose/index': resolve(__dirname, 'src/components/hx-prose/index.ts'),
},
formats: ['es'],
},
},
});

Output Structure:

dist/
├── index.js # Main barrel export (all components)
├── index.d.ts # Type declarations for main export
├── components/
│ ├── hx-button/
│ │ ├── index.js # hx-button entry point
│ │ └── index.d.ts # Type declarations
│ ├── hx-card/
│ │ ├── index.js # hx-card entry point
│ │ └── index.d.ts # Type declarations
│ └── ...
└── shared/
└── tokens-a1b2c3d4.js # Shared chunks (design tokens, utils)

Consumer Benefit:

// Only bundles hx-button and its dependencies (~4KB)
import '@helixui/library/components/hx-button';

Each component’s index.ts serves as its entry point:

src/components/hx-button/index.ts
export { HxButton } from './hx-button.js';

Why separate entry files?

  1. Re-export point — isolates component class exports from implementation details
  2. Side-effect boundary — clearly defines what code executes on import
  3. Type resolution — TypeScript’s exports field mapping requires explicit entry files

Vite’s library mode produces ECMAScript Modules (ESM) with optimizations for production consumption.

Generated files use ES2020+ syntax:

dist/components/hx-button/index.js
import { LitElement, html, css } from 'lit';
import { property } from 'lit/decorators.js';
class HxButton extends LitElement {
@property({ type: String }) variant = 'primary';
static styles = css`
:host {
display: inline-block;
}
`;
render() {
return html`<button part="button"><slot></slot></button>`;
}
}
customElements.define('hx-button', HxButton);
export { HxButton };

Key Features:

  • Top-level import/export — native module syntax (no CommonJS require())
  • Class fields — modern JavaScript syntax (no transpilation to constructor assignments)
  • Template literalshtml and css tagged templates preserved
  • Side effectscustomElements.define() call executes on import

hx-library uses esbuild for minification (minify: 'esbuild'):

export default defineConfig({
build: {
minify: 'esbuild',
},
});

Why esbuild over Terser?

FeatureesbuildTerser
Speed100x faster (Go-based)Slower (JavaScript-based)
Output size1-2% largerSlightly smaller
CompatibilityES2020+ES5+ configurable
Build time (hx-library)~800ms~12s

For a library targeting evergreen browsers, esbuild’s speed advantage (10-15x faster CI builds) outweighs Terser’s marginal size reduction.


Tree-shaking (dead code elimination) depends on three factors:

  1. ESM static analysis — bundlers trace import/export relationships
  2. Side-effect declarationspackage.json hints guide safe code removal
  3. Pure annotations — explicit markers for effect-free functions

The sideEffects field in package.json tells bundlers which modules are safe to remove if unused:

{
"sideEffects": false
}

What false means:

  • All modules in this package are “pure” (no side effects)
  • Bundlers can safely drop any unused imports
  • Example: importing HxButton class but never using it? The entire module can be removed.

What counts as a side effect?

// Side effect: modifies global state
customElements.define('hx-button', HxButton);
// Side effect: mutates external object
window.HELIX_COMPONENTS = { HxButton };
// Side effect: network request on module load
fetch('/api/analytics').then(/*...*/);
// NOT a side effect: pure export
export class HxButton extends LitElement {
/*...*/
}

Every component module includes a side effect (customElements.define()), so technically sideEffects should be an array:

{
"sideEffects": ["src/components/*/index.ts", "dist/components/*/index.js"]
}

However, hx-library uses sideEffects: false because:

  1. Consumers always want registration — importing a component module means you want it registered; there’s no scenario where you import hx-button but don’t want <hx-button> available
  2. Explicit imports signal intentimport '@helixui/library/components/hx-button' is a deliberate action (unlike auto-imported polyfills)
  3. Bundler compatibility — some bundlers (Webpack 4) mishandle array-based sideEffects with glob patterns

Result: Unused components are tree-shaken; imported components always register.

Test tree-shaking with a minimal consumer:

consumer/src/main.js
import '@helixui/library/components/hx-button';
import '@helixui/library/components/hx-card';

Build and analyze:

Terminal window
npm run build -- --bundle-analyzer

Expected output: Only hx-button, hx-card, their dependencies (Lit core), and shared chunks (design tokens). No hx-text-input, hx-select, etc.


Vite uses Rollup under the hood for production builds. The rollupOptions field fine-tunes output behavior.

Control how entry point files are named:

export default defineConfig({
build: {
rollupOptions: {
output: {
entryFileNames: '[name].js',
},
},
},
});

Pattern: [name].jscomponents/hx-button/index.js

Alternatives:

  • [name].[hash].jscomponents/hx-button/index.a1b2c3d4.js (cache busting)
  • [name].min.jscomponents/hx-button/index.min.js (clarity)

hx-library avoids hashes for library builds because:

  • npm versions serve as cache keys (consumers install @helixui/library@3.9.0 by version pin, not by content hash)
  • File paths are documented in API guides (stable names reduce docs churn)

Shared code (design tokens, utility functions) is extracted into chunks to avoid duplication:

export default defineConfig({
build: {
rollupOptions: {
output: {
chunkFileNames: 'shared/[name]-[hash].js',
},
},
},
});

Example: hx-button and hx-card both import design tokens:

src/components/hx-button/hx-button.ts
import { tokens } from '@helixui/tokens';
// src/components/hx-card/hx-card.ts
import { tokens } from '@helixui/tokens';

Without chunking: Tokens duplicated in both hx-button/index.js and hx-card/index.js (+12KB each).

With chunking: Tokens extracted to shared/tokens-a1b2c3d4.js (12KB once).

Output:

dist/
├── components/
│ ├── hx-button/index.js (imports ../shared/tokens-a1b2c3d4.js)
│ └── hx-card/index.js (imports ../shared/tokens-a1b2c3d4.js)
└── shared/
└── tokens-a1b2c3d4.js (shared dependency)

Dependencies that should not be bundled into the library (consumers provide them) are marked as external:

export default defineConfig({
build: {
rollupOptions: {
external: [/^lit/, /^@lit/, /^@helix\/tokens/],
},
},
});

Why externalize Lit?

  • Lit is a peer dependency (consumers install it separately)
  • Bundling Lit would duplicate it in every consumer’s bundle (Lit is ~50KB — significant waste)
  • Multiple versions of Lit in one app cause registration conflicts

Pattern matching: /^lit/ matches lit, lit/decorators.js, lit/directives/class-map.js, etc.

Result: Generated code imports Lit as an external module:

dist/components/hx-button/index.js
import { LitElement, html } from 'lit'; // Resolved by consumer's bundler

The package.json file serves as the contract between hx-library and its consumers. Proper configuration ensures correct module resolution across Node.js, bundlers, and TypeScript.

{
"name": "@helixui/library",
"version": "0.0.1",
"description": "Enterprise Web Component Library built with Lit 3.x",
"type": "module",
"sideEffects": false
}

type: "module" — declares this package uses ESM (not CommonJS). Node.js will:

  • Treat .js files as ESM (not CommonJS)
  • Require .cjs extension for CommonJS files
  • Enable import/export syntax in all .js files

sideEffects: false — all modules are pure (safe to tree-shake). See Tree-Shaking Optimization.

Pre-ES2020 bundlers and Node.js versions rely on legacy fields:

{
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts"
}

main — CommonJS entry point (unused in ESM-only packages, but required for backward compatibility).

module — ESM entry point (Webpack, Rollup, Parcel prioritize this over main).

types — TypeScript type declarations entry point.

Why point to dist/? After publishing, src/ is excluded ("files": ["dist"]); only built artifacts are distributed.

The exports field is the modern standard for module resolution (Node.js 12+, Webpack 5+, Vite):

{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./components/*": {
"types": "./dist/components/*/index.d.ts",
"import": "./dist/components/*/index.js"
},
"./custom-elements.json": "./custom-elements.json"
}
}

Mapping breakdown:

Import PathResolves ToPurpose
@helixui/librarydist/index.jsMain entry (all components)
@helixui/library/components/hx-buttondist/components/hx-button/index.jsPer-component entry
@helixui/library/custom-elements.jsoncustom-elements.jsonCEM for tooling

Wildcard pattern: "./components/*" enables any component import without explicit listing:

import '@helixui/library/components/hx-button'; // ✅ Resolves
import '@helixui/library/components/hx-card'; // ✅ Resolves
import '@helixui/library/components/hx-future-comp'; // ✅ Resolves (if it exists)

Conditional exports: The types and import conditions ensure TypeScript resolves .d.ts files while runtime resolves .js files.

The files array specifies which files/directories to include in the published npm package:

{
"files": ["dist", "custom-elements.json"]
}

What’s excluded:

  • src/ — source code (consumers use compiled dist/)
  • *.test.ts — test files (no value to consumers)
  • *.stories.ts — Storybook stories (documentation concern, not runtime)
  • node_modules/ — dependencies (consumers install their own)
  • .git/, .github/ — version control artifacts

Why include custom-elements.json?

  • IDEs (VS Code with Lit plugin) consume it for autocomplete
  • Documentation generators (Storybook, custom-elements-manifest-to-markdown) consume it for API docs
  • Build tools (11ty, Astro) consume it for SSR support
{
"customElements": "custom-elements.json"
}

The customElements field is a web component ecosystem convention (supported by VS Code, Storybook, custom-elements-analyzer). It tells tools where to find the Custom Elements Manifest.


Type Declarations (TypeScript Integration)

Section titled “Type Declarations (TypeScript Integration)”

TypeScript consumers need .d.ts files to type-check against hx-library. The vite-plugin-dts plugin generates these automatically.

import dts from 'vite-plugin-dts';
export default defineConfig({
plugins: [
dts({
include: ['src/**/*.ts'],
exclude: ['**/*.test.ts', '**/*.stories.ts'],
}),
],
});

Options:

  • include — source files to process (all .ts files in src/)
  • exclude — files to skip (tests and stories have no public API)

Output: For every .ts source file, a .d.ts declaration file is generated in dist/:

src/components/hx-button/hx-button.ts
→ dist/components/hx-button/hx-button.d.ts
src/components/hx-button/index.ts
→ dist/components/hx-button/index.d.ts

For a component:

src/components/hx-button/hx-button.ts
import { LitElement } from 'lit';
export class HxButton extends LitElement {
variant: 'primary' | 'secondary' | 'danger';
disabled: boolean;
}

Generated declaration:

dist/components/hx-button/hx-button.d.ts
import { LitElement } from 'lit';
export declare class HxButton extends LitElement {
variant: 'primary' | 'secondary' | 'danger';
disabled: boolean;
}

Consumer usage:

import { HxButton } from '@helixui/library/components/hx-button';
const button: HxButton = document.querySelector('hx-button')!;
button.variant = 'primary'; // ✅ Type-checked
button.variant = 'invalid'; // ❌ Type error

Enable declarationMap for “Go to Definition” support in IDEs:

export default defineConfig({
plugins: [
dts({
include: ['src/**/*.ts'],
exclude: ['**/*.test.ts', '**/*.stories.ts'],
declarationMap: true, // Generates .d.ts.map files
}),
],
});

Benefit: When a consumer clicks “Go to Definition” on HxButton, their IDE jumps to the source .ts file (not the .d.ts declaration).


hx-library’s build script orchestrates the full distribution pipeline.

{
"scripts": {
"build": "vite build",
"clean": "rm -rf dist custom-elements.json"
}
}

Full build process:

Terminal window
npm run clean # Remove previous build artifacts
npm run build # Vite library build (generates dist/)
npm run cem # Generate custom-elements.json

Outputs:

dist/
├── index.js # Main barrel export
├── index.d.ts # Type declarations
├── components/
│ ├── hx-button/index.js
│ ├── hx-button/index.d.ts
│ └── ...
└── shared/
└── tokens-a1b2c3d4.js
custom-elements.json # CEM (package root)

Before publishing to npm, verify:

  1. TypeScript compilesnpm run type-check (zero errors)
  2. Tests passnpm run test (100% pass rate)
  3. Build succeedsnpm run build (no warnings)
  4. CEM is currentnpm run cem (matches public API)
  5. Bundle size acceptablels -lh dist/ (each component <5KB gzipped)
  6. Version bumpednpm version patch|minor|major (semantic versioning)
Terminal window
npm publish --access public

Flags:

  • --access public — required for scoped packages (@helixui/library) on free npm tier
  • --tag next — publish to @next channel (pre-release testing)
  • --dry-run — preview what will be published (without uploading)

Before publishing, test the package as a consumer would install it.

Link the package globally, then link into a test project:

Terminal window
# In hx-library/
npm run build
npm link
# In test-project/
npm link @helixui/library

Limitation: npm link uses symlinks (not a real install); some resolution bugs slip through.

Create a tarball (like npm publish does) and install it locally:

Terminal window
# In hx-library/
npm run build
npm pack
# Generates helix-library-0.0.1.tgz
# In test-project/
npm install /path/to/hx-library/helix-library-0.0.1.tgz

Benefit: Replicates a real npm install (file copying, not symlinking).

In the test project:

test-project/src/main.js
import '@helixui/library/components/hx-button';
const button = document.createElement('hx-button');
button.textContent = 'Click Me';
document.body.appendChild(button);
console.log(button instanceof HTMLElement); // true
console.log(button.tagName); // HX-BUTTON

Run the app:

Terminal window
npm run dev

Expected result: <hx-button> renders with styles; no console errors; TypeScript autocomplete works.


Consumers care about JavaScript payload. hx-library enforces per-component budgets:

ComponentBudgetActual (gzipped)
hx-button<5KB3.2KB
hx-card<5KB2.8KB
hx-text-input<8KB6.1KB
Full library<50KB42KB

Use rollup-plugin-visualizer to inspect output:

Terminal window
npm install -D rollup-plugin-visualizer
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
visualizer({
filename: 'dist/stats.html',
open: true,
gzipSize: true,
}),
],
});

After npm run build: Opens dist/stats.html with interactive treemap.

  1. Polyfills — Remove core-js or regenerator-runtime (target modern browsers)
  2. Unused Lit features — Avoid importing all of lit; use subpaths (lit/decorators.js)
  3. Inline SVGs — Extract to external files or use CSS masks
  4. Design token duplication — Ensure tokens are externalized or chunked (see Chunk File Names)

Some use cases require UMD builds for legacy script tag support. Here’s how to add UMD alongside ESM.

export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'HelixLibrary', // Global variable name for UMD
fileName: (format) => `helix-library.${format}.js`,
formats: ['es', 'umd'],
},
rollupOptions: {
external: [/^lit/, /^@lit/],
output: {
globals: {
lit: 'Lit',
'lit/decorators.js': 'Lit.decorators',
},
},
},
},
});

Output:

dist/
├── helix-library.es.js # ESM build
└── helix-library.umd.js # UMD build (global: window.HelixLibrary)

UMD usage:

<script src="https://cdn.example.com/lit.umd.js"></script>
<script src="https://cdn.example.com/helix-library.umd.js"></script>
<script>
const { HxButton } = window.HelixLibrary;
</script>

Trade-off: UMD builds are 20-30% larger (wrapper code) and require manual globals mapping for all dependencies. ESM is preferred for 2026+ projects.


Here’s hx-library’s complete package.json with annotations:

{
"name": "@helixui/library",
"version": "0.0.1",
"private": true,
"description": "Enterprise Web Component Library built with Lit 3.x",
"type": "module",
"sideEffects": false,
// Legacy entry points (pre-exports field)
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
// Modern module resolution
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./components/*": {
"types": "./dist/components/*/index.d.ts",
"import": "./dist/components/*/index.js"
},
"./custom-elements.json": "./custom-elements.json"
},
// Published files whitelist
"files": ["dist", "custom-elements.json"],
// CEM location for tooling
"customElements": "custom-elements.json",
"scripts": {
"dev": "vite build --watch",
"build": "vite build",
"type-check": "tsc --noEmit",
"test": "vitest run",
"cem": "custom-elements-manifest analyze --litelement",
"clean": "rm -rf dist custom-elements.json"
},
"dependencies": {
"@helixui/tokens": "*",
"lit": "^3.3.2"
},
"devDependencies": {
"@custom-elements-manifest/analyzer": "^0.11.0",
"typescript": "^5.7.2",
"vite": "^6.2.0",
"vite-plugin-dts": "^4.5.4"
}
}

  1. Vite library mode transforms application builds into library distributions with minimal configuration
  2. Multi-entry points enable tree-shaking by exposing per-component imports
  3. ESM-only output serves modern bundlers and browsers without UMD bloat
  4. sideEffects: false signals to bundlers that unused modules can be safely removed
  5. Shared chunks eliminate duplication of design tokens and utilities across components
  6. exports field provides modern, precise module resolution for Node.js and bundlers
  7. Type declarations (via vite-plugin-dts) enable TypeScript consumers to type-check against hx-library
  8. Pre-publish testing with npm pack prevents npm resolution bugs before release


Sources: