Why build your own React component library in 2026?
Big names like MUI, Ant Design, Chakra UI or shadcn/ui are great, but at some point every serious product team hits the same wall: you need your own buttons, inputs, modals and tokens, shared across multiple projects, with your own branding and your own logic. That is exactly what a private react component library solves.
In this practical tutorial, we will build a reusable React component library from scratch using Vite, TypeScript and Storybook, then publish it to npm so any of your apps can install it with a single command. We will also cover folder structure, versioning and the traps most teams fall into when scaling.

What you will build
- A typed React component library bundled as ESM + CJS
- A Storybook documentation site for your components
- An npm package you can install in any React project
- A folder structure ready to scale to 100+ components
Prerequisites
- Node.js 20+ (recommended in 2026)
- Package manager: pnpm (preferred) or npm
- Basic knowledge of React and TypeScript
- An npm account if you plan to publish publicly
1. Initialize the project
Create a fresh Vite library with the React + TypeScript template:
pnpm create vite@latest my-ui --template react-ts
cd my-ui
pnpm install
Then add the dependencies we will need for bundling and documentation:
pnpm add -D vite-plugin-dts @types/node
pnpm add react react-dom
pnpm add -D @vitejs/plugin-react typescript
Mark React as a peer dependency in package.json so consumers use their own version:
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}

2. The folder structure that scales
This is the structure we recommend after years of maintaining shared libraries. It separates concerns and stays readable even with hundreds of components.
my-ui/
├── src/
│ ├── components/
│ │ ├── Button/
│ │ │ ├── Button.tsx
│ │ │ ├── Button.stories.tsx
│ │ │ ├── Button.test.tsx
│ │ │ ├── Button.module.css
│ │ │ └── index.ts
│ │ └── Input/
│ ├── hooks/
│ ├── tokens/
│ │ ├── colors.ts
│ │ ├── spacing.ts
│ │ └── typography.ts
│ ├── utils/
│ └── index.ts
├── .storybook/
├── vite.config.ts
├── tsconfig.json
└── package.json
Each component lives in its own folder with its story, test, styles and an index.ts barrel. The root src/index.ts re-exports everything.
3. Write your first component with TypeScript
Inside src/components/Button/Button.tsx:
import { ButtonHTMLAttributes, forwardRef } from 'react';
import styles from './Button.module.css';
export type ButtonVariant = 'primary' | 'secondary' | 'ghost';
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
isLoading?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = 'primary', isLoading, children, ...rest }, ref) => {
return (
<button ref={ref} className={`${styles.btn} ${styles[variant]}`} {...rest}>
{isLoading ? 'Loading...' : children}
</button>
);
}
);
Button.displayName = 'Button';
Notice the forwardRef pattern and how we extend native HTML attributes. This is the single biggest mistake new libraries make: shipping components that do not forward refs or accept standard props.
4. Configure Vite for library mode
Update vite.config.ts:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import dts from 'vite-plugin-dts';
import { resolve } from 'path';
export default defineConfig({
plugins: [react(), dts({ insertTypesEntry: true })],
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'MyUI',
formats: ['es', 'cjs'],
fileName: (format) => `my-ui.${format}.js`
},
rollupOptions: {
external: ['react', 'react-dom', 'react/jsx-runtime'],
output: { globals: { react: 'React', 'react-dom': 'ReactDOM' } }
}
}
});
Then in package.json declare the entry points properly:
"main": "./dist/my-ui.cjs.js",
"module": "./dist/my-ui.es.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/my-ui.es.js",
"require": "./dist/my-ui.cjs.js"
}
},
"files": ["dist"],
"sideEffects": false

5. Add Storybook for documentation
Install Storybook 8 (current stable in 2026):
pnpm dlx storybook@latest init
Create Button.stories.tsx:
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
title: 'Components/Button',
component: Button,
tags: ['autodocs'],
argTypes: {
variant: { control: 'select', options: ['primary', 'secondary', 'ghost'] }
}
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Primary: Story = { args: { variant: 'primary', children: 'Click me' } };
export const Secondary: Story = { args: { variant: 'secondary', children: 'Click me' } };
export const Loading: Story = { args: { isLoading: true, children: 'Click me' } };
Run pnpm storybook and you get an interactive playground plus auto-generated docs from your TypeScript types. This is what differentiates a serious library from a folder of components.
6. Publish to npm
- Run
pnpm buildto generate thedist/folder - Login:
npm login - Bump version with
npm version patch|minor|major - Publish:
npm publish --access public(or scope it as@yourorg/ui)
For private libraries, use GitHub Packages or a private npm registry. We strongly recommend Changesets to automate versioning and changelogs across a team.
7. Consume the library in another app
pnpm add @yourorg/ui
import { Button } from '@yourorg/ui';
export default function App() {
return <Button variant="primary">Hello</Button>;
}

Common pitfalls to avoid
| Pitfall | Why it hurts | Fix |
|---|---|---|
| Bundling React inside the library | Duplicate React, broken hooks | Mark as peer dependency and external |
No forwardRef |
Tooltips, focus management, forms break | Always forward refs on interactive elements |
Missing sideEffects: false |
Consumers cannot tree-shake | Add it in package.json |
| Hardcoded colors | No theming, painful rebrand | Use design tokens and CSS variables |
| No accessibility tests | Components fail WCAG audits | Add @storybook/addon-a11y |
| Manual versioning | Breaking changes shipped as patches | Use Changesets + semver |
Bonus: design tokens for true reusability
If your library will live across multiple brands or products, expose design tokens (colors, spacing, radii, typography) as CSS variables. Consumers can override them at the root without forking your components. This is the same pattern shadcn/ui and Radix popularized, and it remains the standard in 2026.
FAQ
Should I build my own library or use MUI / shadcn?
If you maintain two or more products with shared branding, build your own. For a single app, start with shadcn/ui or MUI and extract a library later when duplication appears.
Storybook or a custom docs site?
Storybook is still the fastest path in 2026. It gives you isolated development, autodocs from TypeScript, accessibility checks and visual testing in one tool.
CSS Modules, Tailwind or CSS-in-JS?
CSS Modules and Tailwind are the safest bets today. CSS-in-JS runtimes have lost ground due to React Server Components compatibility issues.
How do I handle breaking changes?
Adopt semver strictly and use Changesets. Document migration steps in your changelog and keep deprecated props for at least one minor version.
Can I use this library with Next.js App Router?
Yes. Just remember to add 'use client' at the top of any component that uses hooks or browser APIs, or expose a client-only entry point.
Wrapping up
A well-built react component library pays for itself within weeks: consistent UI, faster shipping, easier onboarding and a single source of truth for your design system. Start small with three or four components, ship version 0.1.0 to npm, then iterate. Your future self, and every developer joining your team, will thank you.

