How to Build a React Component Library from Scratch
A step-by-step guide to building a React component library with TypeScript, bundling with tsup, testing with Vitest, and publishing to npm. Includes project setup.
Why Build Your Own Library
There comes a point where shared components stop living in a shared/ folder and need to become a proper library. The fundamentals are the same for internal design systems and open-source projects: a clean project structure, TypeScript types, a reliable build step, tests, and a publishing pipeline. If you would rather skip the npm route entirely, our explainer on what shadcn/ui is covers the copy-paste alternative.
Project Setup
Start with a minimal folder structure:
my-component-library/
src/
components/
Button/
Button.tsx
Button.test.tsx
index.ts
index.ts
package.json
tsconfig.json
tsup.config.ts
Initialize the project and install core dependencies:
npm init -y
npm install react react-dom --save-peer
npm install typescript tsup vitest @testing-library/react --save-dev
Setting React as a peer dependency is critical. Your library should use the consumer's React instance, not bundle its own.
TypeScript Configuration
Your tsconfig.json should target modern JavaScript and generate declaration files:
// tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"declarationDir": "dist",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist"
},
"include": ["src"],
"exclude": ["**/*.test.tsx", "**/*.test.ts"]
}
Strict mode is non-negotiable for a library. Your consumers rely on your types being accurate.
Bundling with tsup
tsup is the simplest way to bundle a modern component library. It handles ESM, CJS, and declaration files in a single config:
// tsup.config.ts
import { defineConfig } from "tsup"
export default defineConfig({
entry: ["src/index.ts"],
format: ["cjs", "esm"],
dts: true,
splitting: true,
sourcemap: true,
clean: true,
external: ["react", "react-dom"],
})
The external field ensures React is not bundled into your library. The dts flag generates TypeScript declaration files automatically.
Add build and dev scripts to your package.json:
// package.json (partial)
{
"main": "dist/index.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"files": ["dist"],
"scripts": {
"build": "tsup",
"dev": "tsup --watch"
}
}
Writing Your First Component
Keep components simple and well-typed:
// src/components/Button/Button.tsx
import { forwardRef } from "react"
const Button = forwardRef(function Button(props, ref) {
const { variant = "primary", size = "md", className = "", children, ...rest } = props
const baseStyles = "inline-flex items-center justify-center rounded-md font-medium"
const variants = {
primary: "bg-blue-600 text-white hover:bg-blue-700",
secondary: "bg-gray-200 text-gray-900 hover:bg-gray-300",
ghost: "bg-transparent hover:bg-gray-100",
}
const sizes = {
sm: "h-8 px-3 text-sm",
md: "h-10 px-4 text-sm",
lg: "h-12 px-6 text-base",
}
const classes = [baseStyles, variants[variant], sizes[size], className]
.filter(Boolean)
.join(" ")
return <button ref={ref} className={classes} {...rest}>{children}</button>
})
export { Button }
Always use forwardRef. Library consumers frequently need to attach refs for positioning, focus management, and animation libraries. Omitting it is a common mistake that forces breaking changes later. The other patterns worth internalizing here are covered in our guide to React component best practices.
Testing
Use Vitest with Testing Library for component tests. Pair these unit tests with automated accessibility checks — our accessible React components guide covers running axe inside your test suite.
// src/components/Button/Button.test.tsx
import { render, screen } from "@testing-library/react"
import { describe, it, expect } from "vitest"
import { Button } from "./Button"
describe("Button", () => {
it("renders children", () => {
render(<Button>Click me</Button>)
expect(screen.getByText("Click me")).toBeDefined()
})
it("applies variant classes", () => {
render(<Button variant="secondary">Test</Button>)
const button = screen.getByText("Test")
expect(button.className).toContain("bg-gray-200")
})
it("forwards refs", () => {
let buttonRef = null
render(<Button ref={(el) => { buttonRef = el }}>Ref test</Button>)
expect(buttonRef).toBeInstanceOf(HTMLButtonElement)
})
})
The Barrel Export
Your root src/index.ts should re-export everything consumers need:
export { Button } from "./components/Button"
Avoid default exports in libraries. Named exports produce better autocomplete, clearer import statements, and simpler tree-shaking.
Publishing to npm
Before your first publish, verify your package:
npm run build
npm pack --dry-run
The npm pack --dry-run command shows exactly which files will be included. Verify that only dist/ and package.json are present. No src/, no test files, no config files.
When everything looks right:
npm publish --access public
What Comes Next
Once the foundation is solid, you can layer on additional concerns: a Storybook for visual documentation, Changesets for version management, a CI pipeline that publishes on merge to main, and CSS extraction if you want to ship styles separately. If your library is built around utility classes, our guide to building a design system with Tailwind CSS covers how to keep tokens consistent across consumers.
The key is starting simple. A well-structured library with one excellent component is worth more than a sprawling collection of poorly tested ones.