EmpireUI
Get Pro
← Blog8 min read#npm#package-publishing#react

Publishing npm Packages: From Local Component to Public Library

Turn your React component into a published npm package. Step-by-step guide covering package.json setup, bundling with tsup, peer deps, and publishing to the npm registry.

Terminal window showing npm publish command with package files listed

Why Publishing a Package Is Easier Than You Think

Honestly, most developers wait way too long before publishing their first npm package. They assume it's complicated, that you need some special setup or a team behind you. You don't. If you've built a component you keep copying between projects, that's already a package waiting to happen.

Publishing to npm means your component gets a version number, a changelog, and anyone on the internet can install it in three seconds. That's the whole deal. You don't need to build an entire design system first — a single well-scoped button or a utility hook is completely valid.

This guide walks through the actual steps: project structure, bundling, peer dependencies, and the publish command itself. No fluff. We're using real tools with real version numbers so you can follow along without guessing.

Setting Up Your Package Structure

Start with a clean directory. The folder structure matters more than people think because it directly affects what consumers of your package get when they install it. Keep source files in src/, put your built output in dist/, and never ship test files or storybook config in the final bundle.

Your package.json is the contract. Here's a minimal but production-ready starting point for a TypeScript React component package:

{
  "name": "@yourscope/my-button",
  "version": "1.0.0",
  "description": "A single-purpose button component for React",
  "main": "./dist/index.js",
  "module": "./dist/index.mjs",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.js",
      "types": "./dist/index.d.ts"
    }
  },
  "files": ["dist"],
  "peerDependencies": {
    "react": ">=18.0.0",
    "react-dom": ">=18.0.0"
  },
  "devDependencies": {
    "react": "^18.3.1",
    "typescript": "^5.4.5",
    "tsup": "^8.1.0"
  }
}

Notice there's no react in dependencies — only in peerDependencies. That's intentional. You don't want to ship React inside your package and cause version conflicts in the consumer's app. The files field is equally important: it's an allowlist that tells npm exactly what to include. Without it, you'd accidentally publish your node_modules or .env files.

Bundling with tsup (Stop Using Rollup by Hand)

tsup is built on esbuild and it handles TypeScript, ESM, CJS, and declaration files in one command. Version 8.1.0 is stable and fast. You configure it in tsup.config.ts at the root of your project.

// 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,
  external: ['react', 'react-dom'],
})

The external array tells tsup not to bundle React into your output. dts: true generates .d.ts declaration files so TypeScript users get autocomplete. splitting: false keeps things simple for a single-entry package — turn it on only if you have multiple entry points and want tree-shakeable subpath imports.

Add a build script to your package.json: "build": "tsup". Then npm run build produces dist/index.js, dist/index.mjs, and dist/index.d.ts in one shot. That's your publishable artifact.

Writing Your Component to Be Package-Friendly

A component written just for your own app often has assumptions baked in — hardcoded colors, direct imports from other local files, or Tailwind classes that only work because your project has the right config. Packaging forces you to make those explicit.

If you're shipping a component that uses Tailwind classes, you have two options. Either bundle the CSS yourself using a style.css that consumers import, or document that your package is Tailwind-compatible and consumers need Tailwind v4.0.2 or later in their project. Empire UI takes the second approach — components ship as TSX with Tailwind class names, and the consumer's Tailwind config processes them. If you want to see how that looks in practice with shadow utilities, our box shadow CSS guide covers what Tailwind generates under the hood.

Keep your component's props typed explicitly. Don't use any. Export the prop type alongside the component so consumers can extend it. If your button accepts a variant prop, export that union type too — export type ButtonVariant = 'primary' | 'ghost' | 'destructive'. Users building on top of your package will thank you.

Versioning and the npm Registry Workflow

Semantic versioning isn't optional when you're publishing publicly. Bug fix? Bump the patch. New prop that's backwards compatible? Minor. Breaking change to an existing prop? Major. npm won't enforce this for you — it's on you to follow it, but consumers will notice immediately when you don't.

The actual publish flow is straightforward. First, create an npm account if you don't have one. For scoped packages (@yourscope/package-name), you'll need to either use a paid org or publish with --access public:

# One-time login
npm login

# Build before every publish
npm run build

# For scoped public packages
npm publish --access public

# Dry run to check what gets published
npm publish --dry-run

Always do a dry run first. It shows you exactly which files would be included and the total unpacked size. If you see 47MB because you forgot to add dist/ to your files field and it's shipping all of node_modules, you'll catch it before embarrassing yourself publicly. Also, bump the version in package.json before each publish — npm will reject a publish if the version already exists in the registry.

Writing a README That Actually Helps

Nobody reads your source code before installing your package. They read your README. A bad README means zero adoption even if the component is excellent. And what counts as a bad README? One that starts with 'This package provides a...' — nobody cares. Show the install command and a working code snippet in the first 20 lines.

Document every prop in a table. Include the type, default value, and a one-line description. If your component accepts className for style overrides, say so explicitly. Developers will want to extend it. If you built a card component and used rgba(255,255,255,0.15) for the frosted overlay, that's worth documenting as a design decision — readers interested in that effect might also find our glassmorphism technique walkthrough useful context.

Pin the versions of dev tools you tested with. Say 'tested with React 18.3.1 and TypeScript 5.4.5' rather than leaving people to guess. And if your component has peer dependency requirements — like needing Tailwind in the consumer's project — put that in a Prerequisites section at the top, not buried at the bottom.

Automating Releases with GitHub Actions

Manual publish steps get skipped. You forget to build, you publish the wrong version, you're in a hurry. A CI pipeline fixes all of that. Here's a minimal GitHub Actions workflow that builds and publishes on every push to a release branch:

# .github/workflows/publish.yml
name: Publish to npm

on:
  push:
    branches: [release]

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          registry-url: 'https://registry.npmjs.org'
      - run: npm ci
      - run: npm run build
      - run: npm publish --access public
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

Store your npm token in GitHub repository secrets as NPM_TOKEN. Generate it from npmjs.com under Access Tokens — use an Automation token type so it bypasses 2FA for CI. The workflow runs npm ci (not npm install) to get a deterministic install from your lockfile, then builds, then publishes.

Consider adding a step that validates the version in package.json doesn't already exist in the registry before running publish. You can check with npm view @yourscope/my-button versions --json and fail the workflow early if the version is taken. Saves a confusing error at the end of an otherwise successful pipeline.

Testing Your Package Before the World Uses It

Does your package actually work when installed from npm? You won't know until you test it in a separate project. Don't just run unit tests inside the package repo — those test your source, not your bundle. What you want is to verify the built artifact works correctly.

Use npm pack to generate a .tgz file locally, then install that tarball in a test app: npm install ../my-button/my-button-1.0.0.tgz. This is exactly what consumers get when they npm install your package. If something's broken in the bundle — a missing type export, an unresolved import, a missing CSS file — you'll catch it here instead of getting a GitHub issue at 2am.

Another approach is npm link. Run npm link in your package directory, then npm link @yourscope/my-button in your test app. Changes in your source are reflected immediately without republishing. It's useful during development but make sure you test with npm pack before final publish — the linked version skips the build step if you forget to run it. And if your component relies on Tailwind-generated gradients, testing gradient utilities in isolation will save you from visual regressions after publish.

FAQ

Do I need a paid npm account to publish a scoped package like @myname/component?

No. Scoped packages are free to publish as long as you pass the --access public flag: npm publish --access public. Paid orgs are only needed if you want private scoped packages.

Should I include Tailwind CSS in my package's dependencies or peerDependencies?

Neither, usually. If your component ships with Tailwind class names and expects the consumer's Tailwind config to process them, document it as a prerequisite but don't add it to peerDependencies. Only add it as a peer dep if your package actively imports from the tailwindcss package at runtime (rare).

What's the difference between main, module, and exports in package.json?

main is the CommonJS entry point for older bundlers and Node. module is the ESM entry point recognized by Webpack and Rollup. exports is the modern standard that overrides both in environments that support it — Node 12+ and all current bundlers. Define all three for maximum compatibility.

My package published successfully but TypeScript can't find the types. What's wrong?

Check that your types field in package.json points to an existing file in dist/. Run ls dist/ after building to confirm the .d.ts file was generated. If it's missing, make sure dts: true is set in your tsup config and that you don't have skipLibCheck or declaration disabled in your tsconfig.

How do I unpublish a version I accidentally published?

Run npm unpublish @yourscope/my-package@1.0.0. This works within 72 hours of publish. After 72 hours, npm restricts unpublishing to prevent breaking other packages that may have taken a dependency on your version. You'd need to contact npm support after that window.

Can I publish a component that uses CSS variables or custom properties?

Yes. CSS variables defined inside your component's styles work fine. Just document what variables consumers need to define at the root level of their app. For example, if your card component uses --card-bg and --card-border internally, list those in your README so consumers know to set them in :root.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

nx vs Turborepo: Monorepo Tooling for Component LibrariesBest VS Code Extensions for React Developers in 2026Open-Sourcing Your Component Library: Docs, Versioning, DXReact UI Components Complete Reference: 60+ Patterns with Code