Chapter 12
Packaging and distribution
Your library works in Storybook. Your tests pass. Now you need to package it so other projects can install and use it.
Configure Vite library mode
Vite’s library mode produces a clean build output with ECMAScript module (ESM) exports and TypeScript declarations. vite-plugin-dts generates .d.ts TypeScript declaration files alongside the compiled output so consumers get full type information without needing access to the library source. Install it:
npm install -D vite-plugin-dts
Add the library build configuration to vite.config.ts, alongside the test projects from chapter 11:
/// <reference types="vitest/config" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import dts from 'vite-plugin-dts'
import { resolve } from 'path'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const rootDir = path.dirname(fileURLToPath(import.meta.url))
export default defineConfig({
plugins: [
react(),
tailwindcss(),
dts({
include: ['src'],
exclude: [
'src/**/*.test.{ts,tsx}',
'src/**/*.stories.{ts,tsx}',
'src/test-setup.ts',
'src/stories/**',
],
}),
],
resolve: {
alias: {
'@': resolve(rootDir, 'src'),
},
},
build: {
lib: {
entry: resolve(rootDir, 'src/index.ts'),
formats: ['es'],
fileName: 'index',
},
rollupOptions: {
// Externalize every bare specifier. All runtime imports are declared in
// `dependencies`, so consumers resolve their own copies — critical for
// react-aria-components, whose React context breaks across duplicates.
// `@/` is our own source alias and must stay bundled.
external: (id) =>
!id.startsWith('.') && !id.startsWith('@/') && !path.isAbsolute(id),
output: {
// Single ES output, so one directive at the top marks every export as a
// client component for the Next.js App Router.
banner: "'use client';",
},
},
},
// …the `test` block from chapter 11 stays here…
})
Externalize everything, not just React
The obvious version of external is an array:
external: ['react', 'react-dom', 'react/jsx-runtime']
It is the version most guides show, and for a library with no runtime dependencies it is correct. This library has eleven, and with that array every one of them gets bundled into dist/index.js: React Aria, React Stately, @react-aria/utils, react-aria-components, Recharts, the three @dnd-kit packages, Iconify, clsx, and tailwind-merge.
Bundle size is the least of the problems. The real failure is duplicate module instances. A consumer who also uses react-aria-components directly now has two copies in their application, each with its own React context objects. Contexts compare by reference, so a component from your copy cannot read a provider from theirs. Nothing throws at build time. Menus stop closing, focus scopes stop containing, and the bug reproduces only in the consumer’s application.
The predicate form says: bundle anything that is a relative path, our @/ alias, or an absolute path, and externalize everything else. Every bare specifier becomes an import the consumer resolves, which means one copy of each package, deduplicated by their package manager.
That only works if the packages are actually declared as dependencies, which is the next section.
Declaration excludes and the 'use client' banner
dts() takes an exclude list. Without it, vite-plugin-dts walks everything under src, so the published package ships type declarations for 42 test files and 26 example pages. They are harmless and they roughly triple the size of what consumers download and what their editor indexes.
output.banner puts 'use client' at the top of the bundle. Because the build emits a single ES file, that one directive marks every export as a client component, so the library drops into a Next.js App Router Server Component without a re-export barrel. One limit: a Server Component cannot reach into a client module with dot notation, so RudiCard.Header and the other compound members from chapter 9 work only inside a client component. Consumers who are not on Next.js ignore it entirely.
Configure package.json exports
{
"name": "rudiment-ui",
"version": "0.3.0",
"description": "Accessible React component library built on React Aria, with layout primitives, charts, and a three-tier design token architecture.",
"type": "module",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/Rudiment-UI/rudiment-ui-components.git"
},
"scripts": {
"dev": "storybook dev -p 6006",
"build": "vite build",
"build:tokens": "style-dictionary build --config tokens/style-dictionary.config.mjs",
"build-storybook": "storybook build",
"prepare": "npm run build:tokens && npm run build",
"prepublishOnly": "npm run build:tokens && npm run build",
"test": "vitest",
"test:watch": "vitest --watch",
"test:coverage": "vitest --coverage"
},
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./styles": "./dist/index.css",
"./package.json": "./package.json"
},
"files": ["dist"],
"sideEffects": ["**/*.css"],
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@iconify/react": "^6.0.2",
"@react-aria/utils": "^3.33.1",
"clsx": "^2.1.1",
"react-aria": "^3.47.0",
"react-aria-components": "^1.16.0",
"react-stately": "^3.45.0",
"recharts": "^3.8.1",
"tailwind-merge": "^3.5.0"
}
}
Several of these fields are load-bearing in ways that are not obvious.
dependencies is required, not optional. Because the build externalizes every bare specifier, dist/index.js contains import { useButton } from 'react-aria' and nothing resolves it unless npm installs it for the consumer. Externalizing without declaring is the one combination that is strictly broken: the package installs cleanly and fails at import time.
React is a peer dependency, not a dependency. The consumer’s React must be the same instance as the library’s, which is precisely what a peer dependency expresses and a regular dependency does not.
types comes before import in exports. Node resolves export conditions in declaration order and takes the first match. import matches everything, so putting it first means the types condition is never reached and TypeScript falls back to guessing.
The CSS entry is dist/index.css. Vite names the emitted CSS asset after build.lib.fileName, which is index. Writing dist/style.css here, the default in older Vite versions, points the ./styles subpath at a file that does not exist, and every consumer’s import 'rudiment-ui/styles' fails to resolve.
sideEffects: ["**/*.css"] tells bundlers that CSS imports have effects and must not be dropped, while everything else is fair game. Be clear about what this does and does not buy you. The library builds from a single entry, so Vite emits one dist/index.js and one dist/index.css with every component’s rules already concatenated. A consumer importing only RudiButton therefore still receives the whole stylesheet; sideEffects protects the JavaScript side of the graph, and cannot split a file that was merged at build time. Shipping per-component CSS would mean preserveModules and a multi-entry build, which trades the single "use client" directive and one predictable output for a tree of files. That trade is worth revisiting when the library is large enough that the CSS payload actually hurts.
prepare and prepublishOnly both build tokens then code. prepublishOnly guarantees npm publish never ships a stale dist/. prepare covers installs from a Git URL, where npm has no published tarball and must build from source. Both run the token build first, because the CSS is generated and the component styles are meaningless without it.
files: ["dist"] means only the build output is published. Source, stories, tests, and tokens stay in the repository.
Build and verify
npm run build:tokens
npm run build
Vite generates dist/index.js, dist/index.d.ts, and dist/index.css.
Verifying the output is less straightforward than it looks. Because every dependency is external, you cannot import the bundle from a bare node -e and read its exports; Node resolves react-aria from the current directory and, in a fresh clone without node_modules, fails. Check what the build actually produced instead:
ls dist
grep -c "^import" dist/index.js # externalized imports, should be non-zero
head -1 dist/index.js # should print 'use client';
Then verify it properly, in a real consumer:
npm pack # produces rudiment-ui-0.3.0.tgz
cd ../some-test-app
npm install ../rudiment-ui/rudiment-ui-0.3.0.tgz
npm pack produces exactly the tarball npm publish would upload, so installing it into a scratch application exercises the real resolution path: the exports map, the peer dependency warning, the CSS subpath, and the type declarations. This catches packaging mistakes that no amount of inspecting dist/ will, and it takes two minutes.
Apply semantic versioning
For a component library past 1.0, the versioning rules are:
Major (1.0.0 → 2.0.0): A component’s prop interface changes in a way that breaks existing usage. A prop is removed or renamed. The rendered HTML structure changes in a way that breaks consumer CSS selectors. A token name changes.
Minor (1.0.0 → 1.1.0): A new component is added. A new prop is added with a default that preserves existing behavior. A new token is added.
Patch (1.0.0 → 1.0.1): A bug is fixed without changing the API. A dependency is updated. A documentation error is corrected.
Before 1.0, those rules do not apply, and pretending otherwise causes more confusion than it prevents. SemVer treats 0.x as unstable by design, so the working convention is to shift everything down a level: minor for new components and features, major reserved for the day you actually commit to stability.
That has a consequence worth being upfront about. Breaking changes ship in minor releases while you are on 0.x, which is exactly what the companion repository did at 0.2.0 when the token prefix changed and at 0.3.0 when the theme set was replaced. Both were breaking, both were minor bumps, and both were correct under 0.x. npm’s caret ranges already treat a 0.x minor as breaking, so ^0.2.0 never installs 0.3.0 on its own. The changelog entry is what tells consumers what broke.
So write the changelog properly from the first release. Follow Keep a Changelog: an [Unreleased] section at the top, then one section per version with a date, grouped into Added, Changed, Fixed, and Removed. Prefix anything breaking so it cannot be skimmed past:
## [0.3.0] - 2026-08-04
### Changed
- **BREAKING** — Replaced the previous theme set with Roomy, Soft, and
Compressed, each in light and dark. The old theme names no longer resolve.
### Added
- `RudiBreadcrumb`, `RudiPagination`, `RudiStepper`, `RudiRating`, `RudiTextarea`.
Consuming teams read this to decide whether to upgrade and what to test afterward. The version number tells them a release is breaking; the changelog tells them what broke.
Make the changelog entry part of the pull request rather than something assembled at release time. Reconstructing three weeks of changes from commit messages is how breaking changes go unannounced.
What you have now
A publishable library package with ESM output, TypeScript declarations, correctly externalized dependencies, a 'use client' banner for the Next.js App Router, an exports map, and a release workflow that rebuilds tokens and code before publishing.
The library is complete as a library. The remaining chapters extend it: theming, the components the guide has not yet covered, data visualization, and drag and drop.
This chapter has been corrected since it was first published: see what changed. Found an error? Report it.