NPM Publishing for Dummies
I recently published a few packages on NPM — solid-number-flow, bagon-hooks, and a couple more. The first time I did it, I was fumbling through docs, half-guessing what main vs module vs types meant in package.json, and manually running npm publish like a caveman.
Then I watched Matt Pocock's approach and it clicked. There's a really clean pipeline you can set up: tsup bundles your code, changesets handles versioning and changelogs, and GitHub Actions publishes automatically when you merge. Once it's wired up, you literally just write code, run one command to describe your change, push, and merge a PR. That's it.
This is my cheatsheet for setting that up. I use Bun here, but swap bun for pnpm or npm and it's the same thing.
The Quick and Dirty
If you just want to publish something right now:
# Login to NPM (one-time)
npm login
# Check who's logged in
npm whoami
# Publish
npm publish
# Scoped package? (e.g. @carloweb/my-package)
npm publish --access=publicThat works. But if you're maintaining a package and want a proper workflow, keep reading.
The Proper Setup
Here's what we're wiring up:
- tsup — bundles your TypeScript into CJS + ESM with type declarations
- Changesets — manages version bumps and generates changelogs
- GitHub Actions — runs CI on every push, auto-publishes on merge to main
1. tsup (Bundling)
tsup is the easiest way to bundle a TypeScript library. Zero-config for simple cases, but here's a solid default:
bun add -D tsupCreate tsup.config.ts:
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.tsx'], // or src/index.ts
outDir: 'dist',
format: ['cjs', 'esm'],
dts: true,
splitting: true,
sourcemap: true,
clean: true,
});Then update your package.json — this is the part that trips people up:
{
"private": false,
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist"],
"scripts": {
"lint": "tsc",
"build": "tsup"
}
}main— CommonJS entry (forrequire())module— ESM entry (forimport)types— TypeScript declarationsfiles— only ship thedistfolder to NPM (keeps your package small)
Run bun run build and check that dist/ looks right before moving on.
2. Changesets (Versioning)
Changesets is what the big open-source projects use (Radix, Solid, etc). It lets you describe what changed, then it auto-bumps versions and writes changelogs for you.
bun add -D @changesets/cli @changesets/changelog-github
# Initialize (creates a .changeset/ directory)
bun changeset initAfter init, open .changeset/config.json and change "access" from "restricted" to "public" (otherwise scoped packages won't publish):
{
"access": "public"
}Add these scripts to package.json:
{
"scripts": {
"ci": "bun run lint && bun run build",
"publish-ci": "bun run lint && bun run build && changeset publish"
}
}How changesets work day-to-day
When you're ready to release:
bun changesetIt'll ask you:
- Which packages changed (for monorepos, or just hit enter for single packages)
- Is it a major, minor, or patch bump?
- Write a short summary of the change
This creates a markdown file in .changeset/ describing the change. Commit it with your code. The GitHub Action (next section) handles the rest.
3. GitHub Actions (CI + Auto Publish)
Two workflows: one for CI on every push, one for publishing on merge to main.
CI Workflow
.github/workflows/ci.yml:
name: CI
on:
push:
branches:
- '**'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: bun run ciPublish Workflow
.github/workflows/publish.yml:
name: Publish
on:
push:
branches:
- 'main'
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- run: bun install --frozen-lockfile
- run: bun run build
- name: Create Release Pull Request or Publish
id: changesets
uses: changesets/action@v1
with:
publish: bun run publish-ci
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}4. NPM + GitHub Setup
A few one-time things to wire up:
NPM Access Token
- Go to npmjs.com > Access Tokens > Generate New Token
- Choose Automation type (bypasses 2FA for CI)
- Copy the token
GitHub Secrets
- Go to your repo > Settings > Secrets and variables > Actions
- Add a new secret:
NPM_TOKENwith the token you just copied
GitHub Permissions
In your repo > Settings > Actions > General:
- Set Workflow Permissions to "Read and write permissions"
- Check "Allow GitHub Actions to create and approve pull requests"
This lets the changesets action create release PRs automatically.
The Workflow (Once Everything's Set Up)
Here's what your day-to-day looks like:
- Write code, commit, push
- When you're ready to release, run
bun changeset— describe what changed - Commit the changeset file and push to main
- The publish workflow automatically creates a "Release PR" that bumps versions and updates changelogs
- Merge the Release PR — this triggers the publish workflow again, which runs
changeset publishand pushes to NPM
That's it. No manual npm publish, no forgetting to bump versions, no changelog maintenance. Just code, changeset, push, merge.