This repo shows the modern, low-config way to build a React component library and publish it to npm. It replaces the old Babel + hand-rolled Rollup config with tsup, an esbuild-powered bundler that needs almost no setup and outputs CJS, ESM, and type declarations from a single command.
- One command builds CJS + ESM +
.d.tsfiles — no separate type-bundling plugin - esbuild under the hood, so builds are near-instant even as the library grows
- Zero Babel config, zero manual plugin wiring
- Understands
"use client"directives, so your components stay compatible with Next.js App Router consumers
If you also want a live component playground with Storybook, or need custom CSS/Tailwind handling baked into the build, Vite library mode is the other solid 2026 choice — but for a pure component package, tsup is simpler.
src
├── components
│ └── Button
│ └── index.tsx
└── index.ts
mkdir custom-react-npm-module
cd custom-react-npm-module
npm init -yReact and React DOM are peer dependencies — the consuming app supplies them, you don't bundle them.
npm install --save-dev react react-dom @types/react @types/react-dom typescript tsup// src/components/Button/index.tsx
import React from "react";
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
label: string;
}
export const Button = ({ label, ...rest }: ButtonProps) => {
return <button {...rest}>{label}</button>;
};// src/index.ts
export { Button } from "./components/Button";
export type { ButtonProps } from "./components/Button";// tsup.config.ts
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["cjs", "esm"],
dts: true,
splitting: false,
sourcemap: true,
clean: true,
minify: true,
external: ["react", "react-dom"],
});The exports field is what makes the package work cleanly for both CJS and ESM consumers, and for tools that read types directly.
{
"name": "custom-react-npm-module",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
},
"files": ["dist"],
"sideEffects": false,
"scripts": {
"dev": "tsup --watch",
"build": "tsup",
"prepublishOnly": "npm run build"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
}Run the build:
npm run buildThis generates a dist/ folder with index.js (CJS), index.mjs (ESM), and index.d.ts (types) — all in one pass.
npm link still works and is the fastest local-testing loop:
# in this package's root
npm link
# in your test app's root
npm link custom-react-npm-moduleOr pack it into a tarball and install that directly, which is closer to what a real consumer will experience:
npm pack
# in the other project
npm install ../custom-react-npm-module/custom-react-npm-module-1.0.0.tgzimport { Button } from "custom-react-npm-module";
function App() {
return <Button label="Click me" onClick={() => console.log("clicked")} />;
}npm login
npm publishprepublishOnly runs the build automatically before publish, so dist/ is always fresh.
For anything beyond a solo throwaway package, Changesets is now the standard way to manage version bumps and changelogs instead of manually editing package.json:
npm install --save-dev @changesets/cli
npx changeset init
npx changeset # describe your change, pick a bump type
npx changeset version # bumps version + writes CHANGELOG.md
npx changeset publish # publishes to npmStorybook is still the standard tool for viewing and testing components without a host app:
npx storybook@latest initYou can simply prompt Claude Code or Codex to make one for you 🥲