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.

·4 min read·React
How to Build a React Component Library from Scratch

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.

More Articles