How to Build a React Component Library with TypeScript and Storybook in 2026

TL;DR — Build a production-ready React component library in 2026 with pnpm workspaces, Vite library mode, Storybook 8, Vitest, and Biome — then publish it to npm via Changesets. The whole stack scaffolds in under an hour and the starter repo is linked inside.

Every team eventually hits the point where copy-pasting a Button component between three apps stops being cute. The fix is a proper component library — versioned, typed, documented, and published. This is the 2026 react component library tutorial I wish existed when I last set one up: pnpm workspaces, Vite for builds, Storybook 8, Vitest, Biome for linting, and a clean npm publish flow.

Every file is walked through step by step below — copy the snippets straight into a fresh npm init repo, or fork the sections you need into an existing library.

react developer
react developer

The high-level steps

Here is the whole flow before we get into the weeds. Each step is a section below.

  1. Initialise a pnpm workspace monorepo with packages for the library and a demo app.
  2. Scaffold the component library with TypeScript, React 19, and Vite in library mode.
  3. Add Storybook 8 with the Vite builder for docs and visual testing.
  4. Wire up Vitest with jsdom and Testing Library for unit tests.
  5. Configure Biome as the single linter and formatter across the workspace.
  6. Set up the build outputs, exports map, and publish to npm.

Why bother with a component library in 2026?

Component library: a versioned, distributable package of UI primitives shared across multiple apps or teams. Worth doing once you have two or more React apps that share design tokens or interaction patterns — below that, it is overhead.

The 2026 toolchain finally makes this cheap. Vite library mode handles dual ESM/CJS output without webpack config archaeology, Storybook 8's Vite builder boots in seconds instead of minutes, and Biome means you stop arguing about ESLint plugin compatibility. If you set one of these up two years ago, half your config is now obsolete.

How do you scaffold the monorepo?

Use pnpm workspaces — it is the only sensible default for a small library + demo setup in 2026. Yarn is fine but pnpm's symlink strategy gives you faster installs and proper hoisting control.

mkdir my-ui && cd my-ui
pnpm init
mkdir -p packages/ui apps/playground

Add a pnpm-workspace.yaml at the root:

packages:
  - 'packages/*'
  - 'apps/*'

If you want a deeper monorepo walkthrough, the pnpm workspaces and Biome setup guide covers shared configs and CI quirks.

How do you scaffold the library package?

Use Vite in library mode with the React plugin and vite-plugin-dts for declaration files. Skip tsup unless you need CJS-first output — Vite handles both formats fine and you already need it for Storybook.

cd packages/ui
pnpm init
pnpm add -D vite @vitejs/plugin-react vite-plugin-dts typescript react react-dom @types/react
pnpm add -D --save-peer react react-dom

Create vite.config.ts:

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import dts from 'vite-plugin-dts';
import { resolve } from 'node:path';

export default defineConfig({
  plugins: [react(), dts({ rollupTypes: true })],
  build: {
    lib: {
      entry: resolve(__dirname, 'src/index.ts'),
      formats: ['es', 'cjs'],
      fileName: (format) => `index.${format === 'es' ? 'mjs' : 'cjs'}`,
    },
    rollupOptions: {
      external: ['react', 'react-dom', 'react/jsx-runtime'],
    },
  },
});

The rollupTypes: true flag rolls all your .d.ts files into one — saves you a separate api-extractor step. The docs don't push this option but it is the right default for a small library.

Write your first component at src/Button/Button.tsx and export it from src/index.ts. Keep one component per folder with a co-located .stories.tsx and .test.tsx — your future self will thank you.

How do you add Storybook 8?

Run the init command from inside the library package, pick the Vite builder when prompted, and delete every example story it generates. The defaults are sane but the demo content is noise.

cd packages/ui
pnpm dlx storybook@latest init --builder vite --type react

Storybook 8 ships visual testing via the Vitest addon — turn it on. In .storybook/main.ts:

export default {
  stories: ['../src/**/*.stories.@(ts|tsx|mdx)'],
  addons: [
    '@storybook/addon-essentials',
    '@storybook/addon-a11y',
    '@storybook/experimental-addon-test',
  ],
  framework: '@storybook/react-vite',
};

The addon-test integration means every story becomes a Vitest test automatically. Stop writing duplicate render tests for components you already have stories for.

vscode terminal
vscode terminal

How do you configure Vitest for unit tests?

Add Vitest with jsdom and Testing Library. If you migrated from Jest recently, the Vitest zero-config guide covers the gotchas, but for a fresh library it is three commands.

pnpm add -D vitest jsdom @testing-library/react @testing-library/jest-dom @vitest/coverage-v8

Extend your vite.config.ts with a test block — no separate vitest.config.ts needed:

test: {
  environment: 'jsdom',
  setupFiles: ['./vitest.setup.ts'],
  coverage: { provider: 'v8', reporter: ['text', 'lcov'] },
},

The setup file just imports @testing-library/jest-dom/vitest. That is the entire configuration.

Should you use Biome instead of ESLint here?

Yes — for a new library in 2026 there is no good reason to reach for ESLint + Prettier. Biome covers the linting and formatting rules a component library actually needs, runs about 25x faster, and means one config file at the root of the workspace instead of three.

pnpm add -D -w @biomejs/biome
pnpm dlx @biomejs/biome init

The one rule worth tweaking for a library: enable useExportType and useImportType so your published types stay clean. The edge cases are covered in the Biome vs ESLint deep-dive if you are migrating an existing codebase.

How do you publish to npm?

The exports map matters more than anything else. Get this wrong and consumers get phantom type errors or dual-package hazards. Your package.json needs:

{
  "name": "@your-org/ui",
  "version": "0.1.0",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.mjs",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    },
    "./styles.css": "./dist/style.css"
  },
  "files": ["dist"],
  "sideEffects": ["**/*.css"],
  "peerDependencies": { "react": ">=18", "react-dom": ">=18" }
}

Use changesets for versioning. It is the only release tool that handles a single-package or multi-package workspace without ceremony:

pnpm add -D -w @changesets/cli
pnpm changeset init

Then your release flow becomes pnpm changeset to record changes, pnpm changeset version to bump, and pnpm -r publish to ship. Wire this into a GitHub Actions workflow with the changesets/action step and you get automated release PRs.

Host the Storybook docs on Vercel — point it at the storybook-static output directory and you get preview deploys per PR for free. If you want pixel diffs on those previews, Chromatic is the standard answer but a self-hosted Playwright snapshot job also works.

What does the final structure look like?

The repo you should end up with is small and boring, which is the point.

my-ui/
├── apps/playground/        # Vite app consuming the library locally
├── packages/ui/
│   ├── src/
│   │   ├── Button/Button.tsx
│   │   ├── Button/Button.stories.tsx
│   │   ├── Button/Button.test.tsx
│   │   └── index.ts
│   ├── .storybook/
│   ├── vite.config.ts
│   └── package.json
├── biome.json
├── pnpm-workspace.yaml
└── package.json

One config file per concern, one tool per job. If you find yourself adding a fourth config file for the same problem, you are doing it wrong.

The takeaway

The honest version of building a component library in 2026: pnpm workspaces + Vite library mode + Storybook 8 + Vitest + Biome + changesets. Six tools, each doing one thing well, and the whole stack scaffolds in under an hour. Follow the steps above end-to-end and you can be publishing v0.1.0 to npm today — the whole stack scaffolds in under an hour once the pieces are wired up.

FAQs

Should I use Vite or tsup to build a React component library?

Use Vite if you are already running Storybook with the Vite builder — you avoid a second toolchain and Vite's library mode handles ESM + CJS + types via vite-plugin-dts. Reach for tsup only if you need CJS-first output for a Node-targeted library, which is not the case for React components.

Is Storybook 8 still worth it or should I use Ladle?

Storybook 8 wins for a public component library because the docs, a11y addon, and visual test integration are all first-party. Ladle is faster and lighter but the ecosystem gap on addons is real — pick it for an internal-only library where you just want a sandbox.

How do I avoid bundling React into my published library?

Mark react, react-dom, and react/jsx-runtime as external in your Vite Rollup options, and list them as peerDependencies in package.json. If a consumer's bundler reports duplicate React, this is almost always the missing config.

Do I need Changesets or can I just bump versions manually?

You can bump manually for a solo project, but Changesets pays for itself the first time you ship a breaking change and want a real CHANGELOG. The setup is one command and it generates release PRs automatically in CI.

Where should I host Storybook for free?

Vercel and Netlify both deploy a static Storybook build out of the box with PR previews. Point them at storybook-static after a storybook build step and you are done — no special config needed.

H
Hiten

Senior Frontend Engineer & Architect. 15+ years building fast, accessible web platforms. More at hiten.dev