React 120 ⚛️ Building Reusable UI Component Libraries (npm Publishing and Storybook)
A reusable component library is not a folder of .tsx files copied between projects. It is a package with a stable public API, a build that produces both ESM and CommonJS output, a set of peer dependencies that prevents duplicate React instances, and a development environment where every component can be rendered in isolation. The npm registry is the distribution channel; Storybook is the workshop where the components are built and documented before they are shipped.
This chapter covers the workflow of building and publishing a React component library. It begins with the project structure and the build configuration that produces a consumable package, then explains the peerDependencies contract that keeps React out of the bundle. It addresses Storybook as the development and documentation environment, the story format that renders each component state, and the npm publishing process from versioning to scoped packages. The final sections cover the maintenance practices—semantic versioning, automated releases, and the distinction between a library and an application—that keep a published package usable over time.
Key point: A component library is a package for other developers, not an application for end users. The build must produce a consumable artifact, React must be a peer dependency, and every component must be documented with a story before it is published.
Why a component library needs a different setup
The bundler choice. An application bundles its dependencies into a single output because the browser loads one file. A library must not bundle its dependencies, because the consumer already has them. If React is bundled into the library, the consumer’s application ends up with two copies of React, and hooks break with the “Invalid hook call” error. The library bundler—Rollup or Vite in library mode—externalizes React and React DOM, and the package.json declares them as peerDependencies . This is the single most important configuration decision.
The output formats. The consumer’s environment determines which module format it needs. A modern bundler like Vite or Webpack 5 can consume ESM directly. An older CommonJS environment needs the CJS build. The package.json must declare both entry points: main for CJS and module for ESM. The exports field can map both conditions explicitly, which is the modern approach .
The type declarations. A TypeScript library must ship .d.ts files alongside the JavaScript. Without them, the consumer’s editor cannot provide autocomplete or type checking for the library’s components. The build configuration must emit declarations, and the types field in package.json must point to the entry declaration file .
The development environment. A component library cannot be developed inside a host application, because the host application’s configuration—routing, state management, providers—leaks into the component’s requirements. Storybook provides an isolated environment where each component is rendered with only the props and context it needs. The story file is both the development harness and the documentation, and it becomes the reference for how the component is intended to be used .
a. Project structure and build configuration
The library starts with a Vite project in library mode, or a manual Rollup configuration. Vite’s library mode is the simpler path because it handles the React plugin and the output formats in a single configuration file. The source lives in src/ or lib/, with an index.ts that re-exports every public component. Anything not exported from index.ts is private to the library and cannot be imported by consumers .
// src/index.ts — the public API of the library.
// Every component that consumers can import must be exported here.
export { Button } from './components/Button';
export { Card } from './components/Card';
export type { ButtonProps } from './components/Button';
The vite.config.ts in library mode specifies the entry point, the output formats, and the external dependencies. The rollup-plugin-peer-deps-external plugin is the critical piece: it reads the peerDependencies from package.json and excludes them from the bundle automatically, so the developer does not have to list React and React DOM as externals manually .
// vite.config.ts — library mode
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import peerDepsExternal from 'rollup-plugin-peer-deps-external';
import path from 'path';
export default defineConfig({
plugins: [react(), peerDepsExternal()],
build: {
lib: {
entry: path.resolve(__dirname, 'src/index.ts'),
name: 'MyLibrary',
formats: ['es', 'cjs'],
fileName: (format) => `index.${format === 'es' ? 'esm' : 'cjs'}.js`,
},
rollupOptions: {
// peerDepsExternal handles this, but explicit is safer.
external: ['react', 'react-dom'],
},
},
});
The package.json must declare the outputs, the peer dependencies, and the files that are published. The files array restricts the published package to the dist directory and the README, which keeps the package small. The peerDependencies declaration is the contract: the consumer must provide React, and the library will use that instance .
{
"name": "@your-scope/ui",
"version": "1.0.0",
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"types": "dist/index.d.ts",
"files": ["dist"],
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
},
"scripts": {
"build": "vite build && tsc --emitDeclarationOnly",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
}
}
The tsc --emitDeclarationOnly step generates the .d.ts files after Vite produces the JavaScript. The declaration and declarationDir options in tsconfig.json control the output location .
b. Storybook as the development and documentation environment
Storybook is initialized with npx storybook@latest init, which detects the Vite setup and configures the framework. The stories field in .storybook/main.ts must be updated to point at the library’s source directory, not the default stories/ folder. The CSS import in .storybook/preview.ts loads the library’s styles into the Storybook canvas .
// .storybook/main.ts
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(ts|tsx)'],
addons: ['@storybook/addon-essentials', '@storybook/addon-a11y'],
framework: { name: '@storybook/react-vite', options: {} },
};
export default config;
A story is a function that renders a component state. The Meta object declares the component and its title, and each named export is a story. The StoryObj type provides type checking for the story’s args. The args are the props passed to the component, and the argTypes control the Storybook UI’s editing experience .
// src/components/Button/Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
const meta = {
title: 'Components/Button',
component: Button,
tags: ['autodocs'], // generate docs from props automatically
argTypes: {
variant: { control: 'select', options: ['primary', 'secondary'] },
},
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {
args: { variant: 'primary', children: 'Click me' },
};
export const Secondary: Story = {
args: { variant: 'secondary', children: 'Cancel' },
};
The story file lives next to the component, not in a separate stories/ directory. This colocation keeps the component’s documentation and its implementation together, so a change to the component’s props is immediately visible in the story that uses them. The tags: ['autodocs'] generates a documentation page from the TypeScript types, which is the minimum documentation requirement for a published library .
The accessibility addon (@storybook/addon-a11y) runs axe-core against each story and reports violations in the Storybook panel. A component that fails the accessibility check should not be published, because the library’s consumers inherit the problem .
c. Publishing to npm
The publishing process has three steps: authentication, versioning, and the publish command. npm login authenticates the developer. npm version patch (or minor or major) increments the version in package.json and creates a git tag. npm publish uploads the package. For a scoped package that should be public, the --access public flag is required; scoped packages default to private .
# Authenticate.
npm login
# Build the library.
npm run build
# Increment the version.
npm version patch
# Publish. For a scoped package, --access public is required.
npm publish --access public
# Verify the published package.
npm view @your-scope/ui
The .npmignore file or the files array in package.json controls what is included in the published package. The dist/ directory and the README are the minimum. The source files, tests, and Storybook stories should be excluded to keep the package small. The files array is the modern approach because it is explicit and does not require a separate ignore file .
The semantic versioning convention is the contract with consumers. A patch version fixes a bug without changing the API. A minor version adds functionality in a backward-compatible way. A major version includes a breaking change. A library that increments the major version for every release forces consumers to update their package.json unnecessarily, while a library that makes breaking changes in a patch version breaks their builds without warning.
For teams that publish frequently, semantic-release or changesets automates the versioning based on commit messages. The CI pipeline runs the tests, builds the library, and publishes only when the version has changed. This is the practice that keeps a published library maintainable over time .
Complete Example Session
// ============================================
// PART 1: THE PROJECT STRUCTURE
// ============================================
// src/
// ├── index.ts ← public API
// └── components/
// └── Button/
// ├── Button.tsx
// ├── Button.stories.tsx
// └── Button.test.tsx
// ============================================
// PART 2: THE COMPONENT
// ============================================
// A simple button with variant and size props.
import React from 'react';
export interface ButtonProps {
variant?: 'primary' | 'secondary';
size?: 'sm' | 'md' | 'lg';
children: React.ReactNode;
onClick?: () => void;
}
export function Button({
variant = 'primary',
size = 'md',
children,
onClick,
}: ButtonProps) {
return (
<button className={`btn btn-${variant} btn-${size}`} onClick={onClick}>
{children}
</button>
);
}
// ============================================
// PART 3: THE PUBLIC API
// ============================================
// Only what is exported here is importable by consumers.
// src/index.ts
export { Button } from './components/Button/Button';
export type { ButtonProps } from './components/Button/Button';
// ============================================
// PART 4: THE BUILD CONFIGURATION
// ============================================
// Vite library mode with peerDepsExternal.
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import peerDepsExternal from 'rollup-plugin-peer-deps-external';
export default defineConfig({
plugins: [react(), peerDepsExternal()],
build: {
lib: {
entry: 'src/index.ts',
formats: ['es', 'cjs'],
fileName: (f) => `index.${f === 'es' ? 'esm' : 'cjs'}.js`,
},
},
});
// ============================================
// PART 5: THE PACKAGE MANIFEST
// ============================================
// peerDependencies, main, module, types, files.
// package.json
// {
// "name": "@your-scope/ui",
// "main": "dist/index.cjs.js",
// "module": "dist/index.esm.js",
// "types": "dist/index.d.ts",
// "files": ["dist"],
// "peerDependencies": { "react": ">=18", "react-dom": ">=18" },
// "scripts": {
// "build": "vite build && tsc --emitDeclarationOnly",
// "storybook": "storybook dev -p 6006"
// }
// }
// ============================================
// PART 6: THE STORY
// ============================================
// The development harness and the documentation.
// src/components/Button/Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
const meta = {
title: 'Components/Button',
component: Button,
tags: ['autodocs'],
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {
args: { variant: 'primary', children: 'Save' },
};
export const Secondary: Story = {
args: { variant: 'secondary', children: 'Cancel' },
};
// ============================================
// PART 7: THE STORYBOOK CONFIGURATION
// ============================================
// Point Storybook at the source directory.
// .storybook/main.ts
// stories: ['../src/**/*.stories.@(ts|tsx)']
// framework: { name: '@storybook/react-vite' }
// ============================================
// PART 8: THE BUILD AND TEST
// ============================================
// Build the library, run the tests, verify the output.
// npm run build
// ls dist/ → index.esm.js, index.cjs.js, index.d.ts
// ============================================
// PART 9: THE PUBLISH
// ============================================
// Authenticate, version, publish.
// npm login
// npm version patch
// npm publish --access public
// npm view @your-scope/ui
// ============================================
// PART 10: THE CONSUMER USAGE
// ============================================
// Install and import in another project.
// npm install @your-scope/ui
// import { Button } from '@your-scope/ui';
// <Button variant="primary">Click</Button>
The ten parts build a minimal but complete component library: the component, the public API, the build configuration, the package manifest, the story, and the publish commands. The key observation is that the library’s public API is defined by index.ts, and the build externalizes React so the consumer’s instance is the only one in the bundle.
Quick Reference
Project Setup
| Step | Command |
|---|---|
| Scaffold | npm create vite@latest my-library -- --template react-ts |
| Storybook init | npx storybook@latest init |
| Build | npm run build |
| Publish | npm publish --access public |
package.json Fields
| Field | Purpose |
|---|---|
main | CommonJS entry point |
module | ESM entry point |
types | TypeScript declarations |
files | What gets published |
peerDependencies | React and React DOM |
Storybook Configuration
| File | Purpose |
|---|---|
.storybook/main.ts | Stories glob, addons, framework |
.storybook/preview.ts | Global styles, parameters |
*.stories.tsx | Per-component stories |
Publishing Commands
| Command | Effect |
|---|---|
npm login | Authenticate |
npm version patch | Increment version |
npm publish --access public | Publish scoped public package |
npm view package-name | Verify published package |
Best Practices
✅ Do This:
// React as a peer dependency.
"peerDependencies": { "react": ">=18", "react-dom": ">=18" } // ✅
// Export everything from index.ts.
export { Button } from './components/Button'; // ✅
// Use peerDepsExternal in the build.
plugins: [react(), peerDepsExternal()] // ✅
// Publish scoped packages as public.
npm publish --access public // ✅
// Colocate stories with components.
src/components/Button/Button.stories.tsx // ✅
❌ Don’t Do This:
// Don't put React in dependencies.
"dependencies": { "react": "^18" } // consumer gets duplicate React // ❌
// Don't bundle React into the library.
// The build must externalize it. // ❌
// Don't publish without building.
npm publish // dist/ may be stale // ❌
// Don't export internals.
export { internalHelper } from './internal'; // not part of the API // ❌
// Don't forget --access public for scoped packages.
npm publish // scoped defaults to private // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| “Invalid hook call” | React bundled into library | Use peerDependencies and peerDepsExternal |
| Types not found | Declarations not emitted | tsc --emitDeclarationOnly, set types |
| Storybook cannot find stories | stories glob points to default folder | Point to ../src/**/*.stories.tsx |
| Publish fails with 403 | Scoped package without --access public | Add the flag |
| Consumer cannot import | main or module field missing | Declare both entry points |
| Package is too large | Source and tests included | Use files: ["dist"] |
Real-World Examples
1. Library Mode Build
build: { lib: { entry: 'src/index.ts', formats: ['es', 'cjs'] } }
2. Peer Dependencies
"peerDependencies": { "react": ">=18", "react-dom": ">=18" }
3. Story File
export const Primary: Story = { args: { variant: 'primary' } };
4. Storybook Stories Glob
stories: ['../src/**/*.stories.@(ts|tsx)']
5. Publish Scoped
npm publish --access public
6. Version Bump
npm version patch
7. Type Declarations
tsc --emitDeclarationOnly
8. Public API
export { Button } from './components/Button';
9. Consumer Install
npm install @your-scope/ui
10. Consumer Import
import { Button } from '@your-scope/ui';
Visual
The Library vs Application Build
┌─────────────────────────────────────────────────────────┐
│ APPLICATION BUNDLE │
│ │
│ app code + React + ReactDOM + all deps → one file │
│ (the browser loads everything) │
│ │
│ ───────────────────────────────────────────────────── │
│ │
│ LIBRARY BUNDLE │
│ │
│ library code only (React externalized) │
│ consumer provides React from their own node_modules │
│ │
│ If React is bundled into the library: │
│ app's React + library's React = two instances │
│ → "Invalid hook call" error │
└─────────────────────────────────────────────────────────┘
The Storybook Workflow
┌─────────────────────────────────────────────────────────┐
│ Component.tsx │
│ │ │
│ ├──► Component.stories.tsx │
│ │ (Storybook renders each state) │
│ │ │
│ ├──► Component.test.tsx │
│ │ (Vitest + Testing Library) │
│ │ │
│ └──► index.ts │
│ (exported for consumers) │
│ │
│ Storybook is the development environment. │
│ The story is the documentation. │
└─────────────────────────────────────────────────────────┘
The Publish Pipeline
┌─────────────────────────────────────────────────────────┐
│ 1. Write component │
│ 2. Write story │
│ 3. Write test │
│ 4. npm run build │
│ → dist/index.esm.js │
│ → dist/index.cjs.js │
│ → dist/index.d.ts │
│ 5. npm version patch │
│ 6. npm publish --access public │
│ 7. npm view @scope/pkg ← verify │
│ 8. npm install @scope/pkg in consumer project │
└─────────────────────────────────────────────────────────┘
The Peer Dependency Contract
┌─────────────────────────────────────────────────────────┐
│ LIBRARY package.json │
│ │
│ "peerDependencies": { │
│ "react": ">=18.0.0", │
│ "react-dom": ">=18.0.0" │
│ } │
│ │
│ CONSUMER package.json │
│ │
│ "dependencies": { │
│ "react": "^18.2.0", │
│ "react-dom": "^18.2.0", │
│ "@your-scope/ui": "^1.0.0" │
│ } │
│ │
│ The consumer provides React. The library uses it. │
│ One instance, hooks work, bundle stays small. │
└─────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Build tool | Vite library mode or Rollup |
| Critical plugin | rollup-plugin-peer-deps-external |
| Output formats | ESM (module) and CJS (main) |
| Types | tsc --emitDeclarationOnly |
| Peer dependencies | react, react-dom |
| Storybook init | npx storybook@latest init |
| Story location | Colocated with component |
| Publishing | npm publish --access public |
| Versioning | npm version patch/minor/major |
| Public API | Exports from src/index.ts |
Key takeaways:
- React must be a peer dependency. If React is bundled into the library, the consumer’s application ends up with two React instances, and hooks fail with “Invalid hook call” .
- The build must produce both ESM and CJS. Modern bundlers consume ESM; older environments need CJS. The
moduleandmainfields inpackage.jsondeclare both entry points . - The public API is defined by
index.ts. Everything exported from that file is available to consumers. Everything else is private. A component that is not exported cannot be imported . - Storybook is the development and documentation environment. A story renders a component state in isolation, with only the props it needs. The story is the reference for how the component is intended to be used .
- Stories belong next to components. Colocating the story with the component keeps the documentation and the implementation in sync. The
storiesglob in.storybook/main.tspoints to the source directory . - Scoped packages default to private. The
--access publicflag is required for a scoped package that should be installable by anyone . - The
filesarray controls what is published. Onlydist/and the README are needed. Publishing the source, tests, and stories wastes bandwidth and exposes implementation details . - Semantic versioning is the contract with consumers. A patch is a fix, a minor is a backward-compatible addition, and a major is a breaking change. Violating the convention breaks consumer builds without warning.
Remember: A component library is a product for other developers. Its build must externalize React, its package.json must declare the peer dependencies and the entry points, its public API must be explicit in index.ts, and its components must be documented with stories before they are published. Storybook is the workshop, npm is the distribution channel, and semantic versioning is the promise that the next release will not break the consumers who depend on the current one.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!