Packaging for Distribution
apps/docs/src/content/docs/components/distribution/packaging Click to copy 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.
Distribution Overview
Section titled “Distribution Overview”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:
- Vite library mode — optimized bundling for library distribution
- Multiple entry points — per-component imports for tree-shaking
- ESM-first output — modern JavaScript modules with side-effect hints
- Type declarations — TypeScript
.d.tsfiles for all exports - Custom Elements Manifest — machine-readable component metadata
Vite Library Mode Configuration
Section titled “Vite Library Mode Configuration”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.
Basic Configuration
Section titled “Basic Configuration”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 applicablename— 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) => stringformats— output formats (['es']for ESM-only;['es', 'umd']for dual-format distribution)
Why ESM-Only?
Section titled “Why ESM-Only?”hx-library targets ESM exclusively (formats: ['es']) for several reasons:
- Modern bundler compatibility — Vite, Rollup, Webpack 5+, and esbuild all consume ESM natively
- Tree-shaking optimization — ESM’s static import/export analysis enables dead code elimination
- Smaller bundle size — no UMD wrapper overhead or polyfill bloat
- Native browser support —
<script type="module">works in all evergreen browsers (IE11 is not a target for healthcare UIs in 2026) - 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>Entry Points (Per-Component Architecture)
Section titled “Entry Points (Per-Component Architecture)”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.
The Problem with Monolithic Entry Points
Section titled “The Problem with Monolithic Entry Points”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+ componentsResult: 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.
Multi-Entry Point Solution
Section titled “Multi-Entry Point Solution”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';Component Entry Point Convention
Section titled “Component Entry Point Convention”Each component’s index.ts serves as its entry point:
export { HxButton } from './hx-button.js';Why separate entry files?
- Re-export point — isolates component class exports from implementation details
- Side-effect boundary — clearly defines what code executes on import
- Type resolution — TypeScript’s
exportsfield mapping requires explicit entry files
Output Formats (ESM Deep Dive)
Section titled “Output Formats (ESM Deep Dive)”Vite’s library mode produces ECMAScript Modules (ESM) with optimizations for production consumption.
ESM Output Characteristics
Section titled “ESM Output Characteristics”Generated files use ES2020+ syntax:
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 CommonJSrequire()) - Class fields — modern JavaScript syntax (no transpilation to constructor assignments)
- Template literals —
htmlandcsstagged templates preserved - Side effects —
customElements.define()call executes on import
Minification Strategy
Section titled “Minification Strategy”hx-library uses esbuild for minification (minify: 'esbuild'):
export default defineConfig({ build: { minify: 'esbuild', },});Why esbuild over Terser?
| Feature | esbuild | Terser |
|---|---|---|
| Speed | 100x faster (Go-based) | Slower (JavaScript-based) |
| Output size | 1-2% larger | Slightly smaller |
| Compatibility | ES2020+ | 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 Optimization
Section titled “Tree-Shaking Optimization”Tree-shaking (dead code elimination) depends on three factors:
- ESM static analysis — bundlers trace
import/exportrelationships - Side-effect declarations —
package.jsonhints guide safe code removal - Pure annotations — explicit markers for effect-free functions
Side Effects Declaration
Section titled “Side Effects Declaration”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
HxButtonclass but never using it? The entire module can be removed.
What counts as a side effect?
// Side effect: modifies global statecustomElements.define('hx-button', HxButton);
// Side effect: mutates external objectwindow.HELIX_COMPONENTS = { HxButton };
// Side effect: network request on module loadfetch('/api/analytics').then(/*...*/);
// NOT a side effect: pure exportexport class HxButton extends LitElement { /*...*/}Why hx-library Uses sideEffects: false
Section titled “Why hx-library Uses sideEffects: false”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:
- Consumers always want registration — importing a component module means you want it registered; there’s no scenario where you import
hx-buttonbut don’t want<hx-button>available - Explicit imports signal intent —
import '@helixui/library/components/hx-button'is a deliberate action (unlike auto-imported polyfills) - Bundler compatibility — some bundlers (Webpack 4) mishandle array-based
sideEffectswith glob patterns
Result: Unused components are tree-shaken; imported components always register.
Verifying Tree-Shaking
Section titled “Verifying Tree-Shaking”Test tree-shaking with a minimal consumer:
import '@helixui/library/components/hx-button';import '@helixui/library/components/hx-card';Build and analyze:
npm run build -- --bundle-analyzerExpected output: Only hx-button, hx-card, their dependencies (Lit core), and shared chunks (design tokens). No hx-text-input, hx-select, etc.
Rollup Output Configuration
Section titled “Rollup Output Configuration”Vite uses Rollup under the hood for production builds. The rollupOptions field fine-tunes output behavior.
Entry File Names
Section titled “Entry File Names”Control how entry point files are named:
export default defineConfig({ build: { rollupOptions: { output: { entryFileNames: '[name].js', }, }, },});Pattern: [name].js → components/hx-button/index.js
Alternatives:
[name].[hash].js→components/hx-button/index.a1b2c3d4.js(cache busting)[name].min.js→components/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.0by version pin, not by content hash) - File paths are documented in API guides (stable names reduce docs churn)
Chunk File Names
Section titled “Chunk File Names”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:
import { tokens } from '@helixui/tokens';
// src/components/hx-card/hx-card.tsimport { 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)External Dependencies
Section titled “External Dependencies”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:
import { LitElement, html } from 'lit'; // Resolved by consumer's bundlerPackage.json Configuration
Section titled “Package.json Configuration”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.
Core Fields
Section titled “Core Fields”{ "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
.jsfiles as ESM (not CommonJS) - Require
.cjsextension for CommonJS files - Enable
import/exportsyntax in all.jsfiles
sideEffects: false — all modules are pure (safe to tree-shake). See Tree-Shaking Optimization.
Entry Points (Legacy Fields)
Section titled “Entry Points (Legacy Fields)”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.
Exports Field (Modern Resolution)
Section titled “Exports Field (Modern Resolution)”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 Path | Resolves To | Purpose |
|---|---|---|
@helixui/library | dist/index.js | Main entry (all components) |
@helixui/library/components/hx-button | dist/components/hx-button/index.js | Per-component entry |
@helixui/library/custom-elements.json | custom-elements.json | CEM for tooling |
Wildcard pattern: "./components/*" enables any component import without explicit listing:
import '@helixui/library/components/hx-button'; // ✅ Resolvesimport '@helixui/library/components/hx-card'; // ✅ Resolvesimport '@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.
Files Field (Distribution Whitelist)
Section titled “Files Field (Distribution Whitelist)”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 compileddist/)*.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
Custom Elements Manifest Field
Section titled “Custom Elements Manifest Field”{ "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.
Plugin Configuration
Section titled “Plugin Configuration”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.tsfiles insrc/)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.tsGenerated Declaration Example
Section titled “Generated Declaration Example”For a component:
import { LitElement } from 'lit';
export class HxButton extends LitElement { variant: 'primary' | 'secondary' | 'danger'; disabled: boolean;}Generated declaration:
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-checkedbutton.variant = 'invalid'; // ❌ Type errorDeclaration Maps (Source Navigation)
Section titled “Declaration Maps (Source Navigation)”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).
Build Script and Publishing Workflow
Section titled “Build Script and Publishing Workflow”hx-library’s build script orchestrates the full distribution pipeline.
Build Command
Section titled “Build Command”{ "scripts": { "build": "vite build", "clean": "rm -rf dist custom-elements.json" }}Full build process:
npm run clean # Remove previous build artifactsnpm run build # Vite library build (generates dist/)npm run cem # Generate custom-elements.jsonOutputs:
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)Pre-Publish Checklist
Section titled “Pre-Publish Checklist”Before publishing to npm, verify:
- TypeScript compiles —
npm run type-check(zero errors) - Tests pass —
npm run test(100% pass rate) - Build succeeds —
npm run build(no warnings) - CEM is current —
npm run cem(matches public API) - Bundle size acceptable —
ls -lh dist/(each component <5KB gzipped) - Version bumped —
npm version patch|minor|major(semantic versioning)
Publishing Command
Section titled “Publishing Command”npm publish --access publicFlags:
--access public— required for scoped packages (@helixui/library) on free npm tier--tag next— publish to@nextchannel (pre-release testing)--dry-run— preview what will be published (without uploading)
Testing the Package Locally
Section titled “Testing the Package Locally”Before publishing, test the package as a consumer would install it.
Method 1: npm link
Section titled “Method 1: npm link”Link the package globally, then link into a test project:
# In hx-library/npm run buildnpm link
# In test-project/npm link @helixui/libraryLimitation: npm link uses symlinks (not a real install); some resolution bugs slip through.
Method 2: npm pack (Recommended)
Section titled “Method 2: npm pack (Recommended)”Create a tarball (like npm publish does) and install it locally:
# In hx-library/npm run buildnpm pack# Generates helix-library-0.0.1.tgz
# In test-project/npm install /path/to/hx-library/helix-library-0.0.1.tgzBenefit: Replicates a real npm install (file copying, not symlinking).
Verification Steps
Section titled “Verification Steps”In the test project:
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); // trueconsole.log(button.tagName); // HX-BUTTONRun the app:
npm run devExpected result: <hx-button> renders with styles; no console errors; TypeScript autocomplete works.
Bundle Size Analysis
Section titled “Bundle Size Analysis”Consumers care about JavaScript payload. hx-library enforces per-component budgets:
| Component | Budget | Actual (gzipped) |
|---|---|---|
hx-button | <5KB | 3.2KB |
hx-card | <5KB | 2.8KB |
hx-text-input | <8KB | 6.1KB |
| Full library | <50KB | 42KB |
Analyzing Bundle Size
Section titled “Analyzing Bundle Size”Use rollup-plugin-visualizer to inspect output:
npm install -D rollup-plugin-visualizerimport { 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.
Common Bloat Culprits
Section titled “Common Bloat Culprits”- Polyfills — Remove
core-jsorregenerator-runtime(target modern browsers) - Unused Lit features — Avoid importing all of
lit; use subpaths (lit/decorators.js) - Inline SVGs — Extract to external files or use CSS masks
- Design token duplication — Ensure tokens are externalized or chunked (see Chunk File Names)
Advanced: Multi-Format Distribution
Section titled “Advanced: Multi-Format Distribution”Some use cases require UMD builds for legacy script tag support. Here’s how to add UMD alongside ESM.
Dual-Format Configuration
Section titled “Dual-Format Configuration”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.
Package.json Full Reference
Section titled “Package.json Full Reference”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" }}Key Takeaways
Section titled “Key Takeaways”- Vite library mode transforms application builds into library distributions with minimal configuration
- Multi-entry points enable tree-shaking by exposing per-component imports
- ESM-only output serves modern bundlers and browsers without UMD bloat
sideEffects: falsesignals to bundlers that unused modules can be safely removed- Shared chunks eliminate duplication of design tokens and utilities across components
exportsfield provides modern, precise module resolution for Node.js and bundlers- Type declarations (via
vite-plugin-dts) enable TypeScript consumers to type-check against hx-library - Pre-publish testing with
npm packprevents npm resolution bugs before release
Further Reading
Section titled “Further Reading”- Vite Library Mode Documentation — official guide to library builds
- Vite Build Options Reference — full API for
buildconfiguration - npm package.json exports field — Node.js module resolution
- Tree-shaking with Webpack — bundler-agnostic principles
- Publishing Web Components (Open WC) — ecosystem best practices
Sources: