🚀 Creating & Publishing Packages
So far you've been a customer of the npm warehouse. Now you become a supplier. Publishing a package is like opening a small shop in a global marketplace: you take a useful piece of code, wrap it professionally, put it on the shelf, and developers anywhere in the world can install it with one command. This lesson walks the whole journey — from a good idea to a maintained, versioned, published package.
Week 3 · Day 2 (Tuesday: NPM and Package Management) · Lecture 3
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Plan a package: check name availability and decide whether to scope it
- Lay out a professional package structure and control what gets published
- Write a clean entry point exporting a small, well-documented API
- Author a README and CHANGELOG that make your package trustworthy
- Publish to npm with
npm publish, dry-runs, scoped access, and 2FA - Maintain a package: bump versions by SemVer, deprecate, and automate with CI
Estimated Time: 80 minutes
Practice: Build a small string-utility package end-to-end and dry-run its publish.
In This Lesson
The Publishing Journey
Publishing isn't a single command — it's a small pipeline. Seeing the whole arc first makes each step feel like part of a plan rather than a surprise:
Notice the loop at the end: publishing version 1.0.0 isn't the finish line. Real packages iterate — fix a bug, add a feature, bump the version, publish again.
Planning & Naming
Ask five questions before you code
- Does this already exist, and done better? (Search npm first.)
- What single problem does it solve?
- Who is the audience — framework users, CLI users, everyone?
- What is the smallest useful scope for a v1?
- What will make someone choose it over the alternatives?
Check the name
# Is the name taken? A 404 means it's free.
npm view your-package-name
# Good names: clear, specific, searchable
# date-formatter @myorg/utils express-validator
# Weak names: too generic or meaningless
# utils my-awesome-package xYz123
Scoped vs. unscoped
An unscoped name (lodash) lives in the global namespace and must be globally unique. A scoped name (@yourname/lodash) lives under your username or org — so the short name only has to be unique within your scope. Scopes are the modern default for anything you publish under your own identity.
# Initialize a scoped package
npm init --scope=@yourusername
# Scoped packages are PRIVATE by default on publish —
# make it public explicitly (free for public packages):
npm publish --access=public
💡 Scopes prevent the "name is taken" wall
Every good short name in the public namespace was claimed years ago. Under your own scope, @yourname/date-utils is yours to take even if date-utils is long gone. It also makes ownership obvious at a glance.
Structure & Code
A professional layout
my-awesome-package/
├── src/ # source code
│ ├── index.js # main entry point
│ └── stringUtils.js
├── test/ # test files
│ └── stringUtils.test.js
├── examples/ # runnable usage examples
├── dist/ # built output (gitignored, published)
├── .gitignore
├── .npmignore # optional; overrides .gitignore for publish
├── LICENSE
├── README.md # your package's storefront
├── CHANGELOG.md
└── package.json
Control what ships
You almost never want your source, tests, and configs inside the published tarball. Two ways to trim it — prefer the files whitelist, which is harder to get wrong than a blacklist:
// Recommended: whitelist in package.json
{
"files": ["dist/", "README.md", "LICENSE"]
}
# Alternative: blacklist in .npmignore
src/
test/
examples/
*.test.js
.eslintrc.json
The entry point
Your index.js re-exports the public API from wherever the real code lives, giving consumers one clean import surface:
// src/index.js — ES module entry point
export { capitalize, toCamelCase, truncate } from './stringUtils.js';
Here's the implementation — preserved from real, working utility code, with JSDoc comments and input validation so the package behaves predictably:
// src/stringUtils.js
/**
* Capitalizes the first letter of a string.
* @param {string} str - The input string
* @returns {string} The capitalized string
*/
export function capitalize(str) {
if (typeof str !== 'string') {
throw new TypeError('Input must be a string');
}
if (str.length === 0) return str; // handle empty string safely
return str.charAt(0).toUpperCase() + str.slice(1);
}
/**
* Converts a string to camelCase.
* @param {string} str - The input string
* @returns {string} The camelCased string
*/
export function toCamelCase(str) {
return str
.toLowerCase()
.replace(/[^a-zA-Z0-9]+(.)/g, (_, chr) => chr.toUpperCase());
}
/**
* Truncates a string to a maximum length, adding an ending if cut.
* @param {string} str - The input string
* @param {number} length - Maximum length of the result
* @param {string} [ending='...'] - Appended when the string is truncated
* @returns {string} The truncated string
*/
export function truncate(str, length, ending = '...') {
if (str.length <= length) return str;
return str.slice(0, length - ending.length) + ending;
}
💡 CommonJS vs. ES modules — support both
Older tooling expects require() (CommonJS); modern code uses import (ES modules). Ship both by building two files and pointing npm at each via the exports map — a bundler like Rollup does this in one pass:
{
"main": "dist/index.cjs",
"module": "dist/index.mjs",
"exports": {
".": {
"require": "./dist/index.cjs",
"import": "./dist/index.mjs"
}
}
}
Docs & Tests
The README is your storefront
On npmjs.com, your README is the package page. A good one has a one-line pitch, an install command, a copy-paste quick start, and an API reference. Skeleton:
# package-name
A brief description of what your package does and why it's useful.
## Installation
```bash
npm install package-name
```
## Quick Start
```javascript
import { capitalize, truncate } from 'package-name';
capitalize('hello world'); // 'Hello world'
truncate('This is a long string', 10); // 'This is...'
```
## API
### capitalize(str)
Capitalizes the first letter. Throws TypeError on non-strings.
### truncate(str, length, ending?)
Truncates to `length`, appending `ending` (default `'...'`).
## License
MIT
Keep a CHANGELOG
Users need to know what changed between versions — especially before a major bump. Follow the Keep a Changelog convention:
# Changelog
## [1.1.0] - 2026-01-15
### Added
- TypeScript type definitions
- `truncate` now accepts a custom ending
### Fixed
- `capitalize` no longer throws on empty strings
## [1.0.0] - 2025-12-15
### Added
- Initial release: capitalize, toCamelCase, truncate
Test before you ship
Nobody trusts an untested package. A focused Jest suite covers the happy path and the edge cases you handled in code:
// test/stringUtils.test.js
import { capitalize, toCamelCase, truncate } from '../src/stringUtils.js';
describe('capitalize', () => {
test('capitalizes the first letter', () => {
expect(capitalize('hello')).toBe('Hello');
});
test('handles an empty string', () => {
expect(capitalize('')).toBe('');
});
test('throws on non-string input', () => {
expect(() => capitalize(123)).toThrow(TypeError);
});
});
describe('toCamelCase', () => {
test('converts separators to camelCase', () => {
expect(toCamelCase('hello world')).toBe('helloWorld');
expect(toCamelCase('hello-world')).toBe('helloWorld');
expect(toCamelCase('hello_world')).toBe('helloWorld');
});
});
describe('truncate', () => {
test('truncates long strings', () => {
expect(truncate('Hello world', 5)).toBe('He...');
});
test('leaves short strings alone', () => {
expect(truncate('Hi', 5)).toBe('Hi');
});
});
Publishing to npm
With code, docs, and tests in place, publishing is a short, well-guarded sequence:
The commands
# 1. Authenticate (opens a browser for login + 2FA)
npm login
npm whoami # confirm who you are
# 2. Preview EXACTLY what will be published — do this every time
npm publish --dry-run
# lists every file and the final package size
# 3. Publish
npm publish # unscoped, or private scoped
npm publish --access=public # public scoped package
✅ Always dry-run first
npm publish --dry-run prints the exact file list and tarball size without uploading anything. It's your last chance to catch a stray .env, a missing dist/, or a 40 MB package that should be 40 KB. Make it a reflex.
The prepublishOnly safety net
Wire your quality gates into the publish itself so you can never ship a broken build:
{
"scripts": {
"build": "rollup -c",
"test": "jest",
"prepublishOnly": "npm test && npm run build"
}
}
Because npm runs prepublishOnly automatically before packing, a failing test aborts the publish before anything reaches the registry.
Two-factor authentication
# Require a one-time code for both login AND publish (recommended)
npm profile enable-2fa auth-and-writes
# Supply the code non-interactively (e.g. in scripts)
npm publish --otp=123456
⚠️ Publishing is nearly permanent
You can only npm unpublish within 72 hours, and even then it's discouraged because it can break everyone who depends on you. Treat every publish as forever — which is exactly why the dry-run and prepublishOnly gates matter. The safe way to retract a bad version is to publish a fixed one and npm deprecate the old.
Versioning & Maintenance
Bump versions by meaning, not by hand
Never edit the version in package.json manually. npm version bumps the number and creates a matching Git tag/commit in one step — and it forces you to think in SemVer terms:
# Bug fix, backward compatible 1.0.0 -> 1.0.1
npm version patch
# New feature, backward compatible 1.0.0 -> 1.1.0
npm version minor
# Breaking change 1.0.0 -> 2.0.0
npm version major
# Pre-release 1.0.0 -> 1.0.1-beta.0
npm version prerelease --preid=beta
# Then publish the bumped version
npm publish
git push --follow-tags
💡 The SemVer promise binds you now
As a consumer you relied on SemVer; as an author you must honor it. If you rename a function or change its return type, that's a breaking change — it demands a major bump, no matter how small the diff. Sneaking a breaking change into a minor release is how you lose users' trust (and break their CI at 2 a.m.).
Deprecating instead of deleting
# Warn installers of a specific bad version
npm deprecate my-package@1.0.0 "Critical bug — please upgrade to 1.0.1"
# Deprecate a whole range
npm deprecate my-package@"< 2.0.0" "v1 is no longer supported"
# Un-deprecate by passing an empty message
npm deprecate my-package@1.0.0 ""
Deprecation shows a warning on install but keeps the version available, so you never break existing lockfiles — the responsible alternative to unpublishing.
Automating with CI
Manual publishing is fine for a first release, but a CI pipeline makes it repeatable and safe. This GitHub Actions workflow tests across several Node versions, then publishes automatically when you push a version tag:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [ main ]
tags: [ 'v*' ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x, 22.x]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci # clean, lockfile-exact install
- run: npm run lint
- run: npm test
- run: npm run build
publish:
needs: test
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
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 }}
💡 npm ci is the CI install
Use npm ci (not npm install) in pipelines: it installs strictly from package-lock.json, deletes any existing node_modules first, and fails fast if the lockfile and manifest disagree — giving you a clean, reproducible build every run.
Practice & Quiz
🏋️ Exercise 1: Build a package end-to-end
Goal: Create @yourname/string-helpers with three functions — reverseString(str), countWords(str), and toTitleCase(str) — plus tests, a README, and a files whitelist. Dry-run the publish (do not actually publish).
💡 Hint
reverseString: [...str].reverse().join('') (spread handles Unicode better than split('')). countWords: str.trim().split(/\s+/).filter(Boolean).length. toTitleCase: split on spaces, capitalize each word, rejoin.
✅ Solution
// src/index.js
export function reverseString(str) {
return [...str].reverse().join('');
}
export function countWords(str) {
return str.trim().split(/\s+/).filter(Boolean).length;
}
export function toTitleCase(str) {
return str
.split(' ')
.map(w => w ? w[0].toUpperCase() + w.slice(1).toLowerCase() : w)
.join(' ');
}
// package.json essentials
{
"name": "@yourname/string-helpers",
"version": "1.0.0",
"type": "module",
"main": "src/index.js",
"files": ["src/", "README.md"],
"scripts": { "test": "jest" },
"license": "MIT"
}
# preview the publish without uploading
npm publish --dry-run
🏋️ Exercise 2: Pick the version bump
Goal: You shipped 1.0.0. For each change below, decide whether it's a patch, minor, or major bump.
A. Fix a bug in countWords (same signature, correct result)
B. Add a new isPalindrome() function
C. Rename toTitleCase() to titleCase()
✅ Solution
A → patch (1.0.1): a backward-compatible bug fix.
B → minor (1.1.0): new functionality, nothing existing breaks.
C → major (2.0.0): removing/renaming a public function breaks anyone calling toTitleCase. (Kinder: keep the old name as a deprecated alias and remove it in the next major.)
🎯 Quick Quiz
Question 1: What does npm publish --dry-run do?
Question 2: You rename a public function in your library. Which version bump is required?
Question 3: A published version has a serious bug. What's the responsible way to steer users away from it?
Best Practices & Pitfalls
✅ Do
- Run
npm publish --dry-runbefore every real publish - Use a
fileswhitelist and gate quality withprepublishOnly - Enable 2FA (
auth-and-writes) on your npm account - Honor SemVer strictly — breaking change means a major bump
- Write a real README and keep a CHANGELOG; deprecate instead of unpublishing
❌ Don't
- Publish secrets — never ship
.env, keys, or*.pemfiles - Hand-edit the version field — use
npm versionso the Git tag stays in sync - Sneak a breaking change into a minor or patch release
- Rely on
npm unpublishas an undo — it breaks downstream lockfiles - Publish without tests — an untested package is one nobody trusts
⚠️ The secrets trap
Because a blacklist misses new files, secrets slip out most often when there's no files whitelist. Whitelist your dist/ and docs, run the dry-run, and scan the printed file list for anything that shouldn't be there. Once uploaded, treat any leaked secret as compromised and rotate it.
Summary
🎉 Key Takeaways
- Publishing is a pipeline: plan → structure → code → document → test → publish → maintain
- Scope your package (
@you/name) and control output with afileswhitelist - A README and tests are what make a package trustworthy — they're not optional
- Always
npm publish --dry-runfirst, enable 2FA, and gate withprepublishOnly - Bump versions with
npm versionby SemVer meaning; deprecate rather than unpublish
📚 Additional Resources
- npm Docs — Contributing packages to the registry
- npm Docs — npm publish
- npm Docs — npm version
- Keep a Changelog — CHANGELOG conventions
🚀 What's Next?
You can now consume and produce packages. Next we tackle the tooling that stitches many modules into one optimized file the browser can load fast: Module Bundling Concepts.
🎉 You're an author now!
Your code can ship to developers everywhere. You've closed the loop from consumer to contributor in the JavaScript ecosystem.