Publishing npm Packages
Reviewed & published by Brayan K
Publishing to npm means packaging your code with a correct package.json, versioning it with semver, and running npm publish so anyone can install it with npm install — turning your project into a reusable, shareable package.
Part of the free Node.js course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
In this lesson you'll learn the package.json fields that matter (name, version, main, exports, files), how semver decides each version bump, how to log in and publish, what scoped packages and .npmignore do, and why you often publish a built dist folder.
What You'll Learn in This Lesson
- The key package.json fields (name, version, main, files)
- How semver decides patch, minor, and major bumps
- Logging in and running npm publish
- Scoped packages and the --access public flag
- Controlling published files with files and .npmignore
- Why you often publish a built dist folder
1️⃣ The package.json Fields That Matter
Your package.json is the manifest npm reads. The critical fields: name (unique on npm), version (semver), main (the entry file loaded when someone imports you), exports (a modern, more precise entry map), and files (an allowlist of what to publish). The runnable example assembles one.
// Build a package.json object in Node, then print it.
// These are the fields npm reads when someone installs your package.
function buildPackageJson(opts) {
return {
name: opts.name, // unique on npm (or scoped: @you/name)
version: opts.version, // semver: MAJOR.MINOR.PATCH
description: opts.description,
main: opts.main, // the entry file when someone require()s you
files: opts.files, // which files actually get published
keywords: opts.keywords,
license: opts.license,
};
}
const pkg = buildPackageJson({
name: 'greet-cli',
version: '1.0.0',
description: 'A friendly greeting CLI',
main: 'index.js',
files: ['index.js', 'lib/'],
keywords: ['cli', 'greeting'],
license: 'MIT',
});
console.log(JSON.stringify(pkg, null, 2));2️⃣ Names & semver Validation
npm is strict about two fields. The name must be lowercase and URL-safe, and the version must follow semver: MAJOR.MINOR.PATCH. Bump patch for fixes, minor for new compatible features, major for breaking changes. The validator below flags a bad name and a malformed version.
// Validate the two fields npm is strictest about: name and version.
function validate(pkg) {
const errors = [];
// names must be lowercase and URL-safe (letters, digits, - _ . @ /)
if (!/^[a-z0-9@/-]+$/.test(pkg.name)) errors.push('name must be lowercase/url-safe');
// version must be semver: MAJOR.MINOR.PATCH
if (!/^\d+\.\d+\.\d+$/.test(pkg.version)) errors.push('version must be MAJOR.MINOR.PATCH');
return errors;
}
console.log('Valid: ', validate({ name: 'greet-cli', version: '1.0.0' }));
console.log('Invalid:', validate({ name: 'Greet CLI', version: '1.0' }));3️⃣ Logging In & Publishing
With a valid manifest, publishing is a few commands. npm login authenticates you, npm version patch|minor|major bumps the version (and tags git), npm publish --dry-run shows exactly what will ship, and npm publish sends it. Scoped packages are private by default, so add --access public.
# 1) Log in to npm (creates an auth token in ~/.npmrc).
npm login
# 2) Bump the version following semver. npm edits package.json AND tags git:
npm version patch # 1.0.0 -> 1.0.1 (bug fixes, no API change)
npm version minor # 1.0.0 -> 1.1.0 (new, backward-compatible features)
npm version major # 1.0.0 -> 2.0.0 (breaking changes)
# 3) See EXACTLY what will be published before you ship (dry run):
npm publish --dry-run
# 4) Publish. For a SCOPED package, it's private by default — make it public:
npm publish --access public
# A .npmignore file (or the "files" allowlist in package.json) keeps junk out:
# .npmignore
# src/
# tests/
# *.test.js
# .envYour turn. The version bumper below works once you fill in the single blank marked ___. Follow the 👉 hint, then run it.
// 🎯 YOUR TURN — replace each ___ then run it.
function bumpPatch(version) {
const [major, minor, patch] = version.split('.').map(Number);
return `${major}.${minor}.${patch + ___}`; // 👉 a patch adds 1 to patch
}
console.log(bumpPatch('1.0.0')); // 1.0.1
console.log(bumpPatch('2.3.9')); // 2.3.10
// ✅ Expected output:
// 1.0.1
// 2.3.10Mini-Challenge: A Scoped Package
No blanks — just a brief and an outline. Build a scoped package manifest that ships only the build folder and is published publicly. Build it, run it, and compare with the example output.
// 🎯 MINI-CHALLENGE: build a SCOPED package config
// 1. buildScoped(scope, name, version) returns a package.json object whose
// name is "@<scope>/<name>".
// 2. Set main to 'dist/index.js' and files to ['dist'] (publish only the build).
// 3. Add publishConfig: { access: 'public' } (scoped packages are private by default).
// 4. Build @acme/logger@0.1.0, print it, and print the resulting name.
//
// ✅ Example output (pretty-printed JSON, then):
// Publishes name: @acme/logger
// your code hereCommon Errors (and the fix)
- ❌ "403 You do not have permission" / name taken — the unscoped name already exists. ✅ Pick a unique name or use a scope (@you/name).
- ❌ "You cannot publish over the previously published version" — you forgot to bump. ✅ Run npm version patch before re-publishing.
- ❌ Scoped package publish fails as private — scoped packages default to private. ✅ Add --access public.
- ❌ Source/secrets leaked into the package — no allowlist or ignore. ✅ Set a files array or .npmignore, and check with --dry-run.
- ❌ Importers get "module not found" — wrong main/exports, or you didn't publish the built file. ✅ Point them at the real entry (e.g. dist/index.js) and include it in files.
Pro Tips
- 💡 Always --dry-run first: it prints the exact file list and size so you never publish surprises.
- 💡 Automate the build: a prepublishOnly script keeps your dist from going stale.
- 💡 Respect semver: a careless major change in a minor bump breaks everyone who trusted your version number.
📋 Quick Reference — Publishing
| Task | Command / Field |
|---|---|
| Entry file | "main": "index.js" |
| Files to ship | "files": ["dist"] |
| Authenticate | npm login |
| Bump version | npm version patch |
| Preview publish | npm publish --dry-run |
| Publish scoped | npm publish --access public |
| Exclude files | .npmignore |
🎉 Lesson Complete!
- ✅ package.json's name, version, main/exports, and files drive how a package installs
- ✅ semver: patch for fixes, minor for features, major for breaking changes
- ✅ npm login then npm publish ships your package
- ✅ Scoped packages need --access public to be public
- ✅ files or .npmignore control what's published — verify with --dry-run
- ✅ Build to dist and publish that when your source needs compiling
Practice quiz
Which command logs you in to npm before publishing?
- npm login
- npm auth
- npm signin
- npm connect
Answer: npm login. npm login creates an auth token in ~/.npmrc.
What does npm version patch do to 1.0.0?
- Makes it 2.0.0
- Makes it 1.0.1
- Makes it 1.1.0
- Leaves it unchanged
Answer: Makes it 1.0.1. A patch bump increments the last number: 1.0.0 to 1.0.1.
Which version bump is correct for a BREAKING change?
- npm version patch
- npm version minor
- npm version major
- npm version build
Answer: npm version major. Breaking changes increment the MAJOR number (e.g. 1.0.0 to 2.0.0).
How can you preview EXACTLY what will be published without shipping?
- npm publish --preview
- npm test
- npm pack --list
- npm publish --dry-run
Answer: npm publish --dry-run. npm publish --dry-run shows the file list without actually publishing.
Why do scoped packages need --access public to publish?
- They are private by default
- They are deprecated
- They cost money otherwise
- They require two-factor auth
Answer: They are private by default. Scoped (@you/name) packages default to private; --access public makes them public.
What does the "files" field in package.json control?
- Which tests run
- Which files actually get published
- The author list
- The license
Answer: Which files actually get published. The files allowlist limits what is included in the published tarball.
Which file keeps junk (like tests or .env) out of the published package?
- package-lock.json
- tsconfig.json
- .npmignore
- README.md
Answer: .npmignore. .npmignore (or the files allowlist) excludes files from publishing.
Which name is valid for an npm package?
- My Package
- GREET CLI
- Greet_CLI Pro
- greet-cli
Answer: greet-cli. npm names must be lowercase and URL-safe, like greet-cli.
What format must a package version follow?
- MAJOR.MINOR.PATCH (e.g. 1.0.0)
- A single number
- A date
- A word like stable
Answer: MAJOR.MINOR.PATCH (e.g. 1.0.0). Versions must be semver: three dot-separated numbers.
What does the "main" field in package.json specify?
- The author
- The entry file loaded when someone requires the package
- The test command
- The license URL
Answer: The entry file loaded when someone requires the package. main points to the file returned when someone imports/requires your package.
Continue this course
- Previous: Building CLI Tools (commander)
- Next: Debugging & Profiling Node — Use the inspector, timers, and heap snapshots to find bugs and leaks
- Quick reference: Node.js cheat sheet
Frequently asked questions
What is semantic versioning (semver) and when do I bump each number?
Semver formats a version as MAJOR.MINOR.PATCH (for example 2.4.1). You bump PATCH for backward-compatible bug fixes (1.0.0 to 1.0.1), MINOR for new backward-compatible features (1.0.0 to 1.1.0), and MAJOR for breaking changes that could force users to update their code (1.0.0 to 2.0.0). The npm version patch|minor|major command does the bump for you and creates a matching git tag. Semver is a promise to your users: a tool like ^1.2.0 in their dependencies will accept any 1.x release but never auto-upgrade to 2.0.0, so following it correctly is what keeps the ecosystem from breaking on every update.
How do I control which files get published?
By default npm publishes almost everything in your folder except a few always-ignored items, which usually means shipping source, tests, and config you don't need. You narrow it two ways. The cleaner approach is an allowlist: a files array in package.json listing exactly what to include (for example ["dist"]). The alternative is a denylist: a .npmignore file naming what to exclude (src/, tests/, *.test.js, .env). If both exist, files wins. Always run npm publish --dry-run first — it prints the exact file list and the package size so you can confirm you're not leaking source or secrets before you ship.
What is a scoped package and why is it private by default?
A scoped package has a name like @yourname/tool or @company/tool — the @scope/ prefix groups packages under a user or organization and avoids name collisions, since the global, unscoped namespace fills up fast. npm makes scoped packages private by default to protect paid private-registry workflows, so when you publish an open-source scoped package you must explicitly opt in with npm publish --access public (or set publishConfig.access to public in package.json). Unscoped packages are public automatically. Scopes are also how you publish to organizations and use private registries.
Should I build a dist folder before publishing?
If your source uses TypeScript, modern syntax that needs transpiling, or a bundler, then yes — you publish the compiled output (commonly a dist/ folder), not the raw source, because consumers run the built code. The usual setup is a build script (tsc, esbuild, rollup, etc.) that emits to dist, a main (and/or exports) field pointing at dist/index.js, and a files allowlist of ["dist"] so only the build ships. A prepublishOnly script can run the build automatically so you never publish a stale dist. If your package is plain JavaScript that runs as-is, there's nothing to build and you publish the source files directly.