In the previous lesson we turned the scripts field into the project's single interface: anyone arriving at Escena Viva knows that npm start boots the server and npm run check validates the code, without needing to know any internal paths. Until now we have always been on the same side of the counter: we were package consumers. In this lesson we switch sides and publish one.

The specific case is real and modest, which is exactly how these things should start. src/utils/format.js contains three functions —formatPrice, formatDate and generateTicketCode— that we are already copying and pasting into two other internal projects: the box-office dashboard and the accounting report generator. That is the symptom that justifies extracting a package. We are going to turn that file into @escena-viva/format, deciding precisely what can be imported from outside, what gets uploaded to the registry and what happens when you publish something you later want to withdraw.

Contents

  1. When to extract a package and when not to
  2. The Escena Viva case: @escena-viva/format
  3. The structure of a publishable package
  4. The public API: main, exports and types
  5. What gets uploaded: files versus .npmignore
  6. Scoped packages and publishConfig
  7. Testing before publishing: npm pack and npm link
  8. Publishing: login, --dry-run, 2FA and tokens
  9. New versions and distribution tags
  10. Unpublishing is almost never possible
  11. Common Mistakes and Tips
  12. Exercises
  13. Conclusion

  1. When to extract a package and when not to

Extracting code into a package has a permanent cost: one more repository, one more version cycle, and the obligation not to break whoever consumes it. That cost is only worth paying when there is real reuse.

Signal Extract? Reason
The same file copied into two or more projects Yes Copies drift apart and bugs get fixed only once
Stable code, with little functional drift Yes A package that changes every week is a burden on its consumers
A small, well-defined API (3-6 functions) Yes The surface you have to keep compatible is manageable
Used only in this project No A well-placed internal module is enough
It depends on the application's global state No A package should be a function of its inputs
It is extracted "just in case" or because it looks professional No Cost with no benefit

The practical rule is the three-copies rule: when you are about to make the third manual copy of a fragment, that fragment is asking to be a package.

Our format.js qualifies: it is copied into two projects, it has three pure functions, it touches neither disk nor network, and its behavior has not changed since module 1.

  1. The Escena Viva case: @escena-viva/format

This is the code we are going to extract, exactly as it stood in the project:

// src/utils/format.js (the version that lived in Escena Viva)
function formatPrice(priceCents) {
  const euros = (priceCents / 100).toFixed(2);
  return `${euros} EUR`;
}

function formatDate(isoDate) {
  const date = new Date(isoDate);
  const day = String(date.getUTCDate()).padStart(2, '0');
  const month = String(date.getUTCMonth() + 1).padStart(2, '0');
  return `${day}/${month}/${date.getUTCFullYear()}`;
}

function generateTicketCode(year, sequence) {
  const number = String(sequence).padStart(6, '0');
  return `EV-${year}-${number}`;
}

module.exports = { formatPrice, formatDate, generateTicketCode };

We create a directory that is a sibling of the project (not inside it) for the package:

mkdir escena-viva-format
cd escena-viva-format
npm init --scope=@escena-viva -y

The --scope flag makes the initial name @escena-viva/escena-viva-format; we adjust it by hand in package.json. The package's final manifest ends up like this:

{
  "name": "@escena-viva/format",
  "version": "0.1.0",
  "description": "Price, date and ticket code formatting for Escena Viva",
  "license": "MIT",
  "type": "commonjs",
  "main": "./index.js",
  "exports": {
    ".": "./index.js",
    "./price": "./lib/price.js",
    "./package.json": "./package.json"
  },
  "files": ["index.js", "lib/", "README.md", "LICENSE"],
  "engines": { "node": ">=20" },
  "publishConfig": { "access": "public" },
  "scripts": {
    "test": "node --test",
    "prepublishOnly": "npm test"
  }
}

Notice three things that are no longer there: private: true (a private package cannot be published), a main pointing at a server file, and dependencies. This package has no dependencies at all, and that is a virtue, not a shortcoming.

The code is reorganized into small modules inside lib/ plus an index.js that re-exports:

// lib/price.js — turns whole cents into a readable euro string.
function formatPrice(priceCents) {
  if (!Number.isInteger(priceCents)) {
    throw new TypeError('The price must be an integer number of cents');
  }
  const euros = (priceCents / 100).toFixed(2);
  return `${euros} EUR`;
}

module.exports = { formatPrice };
// index.js — the package's single entry point: it re-exports the public API.
const { formatPrice } = require('./lib/price.js');
const { formatDate } = require('./lib/date.js');
const { generateTicketCode } = require('./lib/code.js');

module.exports = { formatPrice, formatDate, generateTicketCode };

In Escena Viva, the change on the consumer side is minimal. You delete src/utils/format.js, replace the require in the files that used it and declare the dependency:

// Before, in src/reports/occupancy.js
const { formatPrice, formatDate } = require('../utils/format.js');

// After
const { formatPrice, formatDate } = require('@escena-viva/format');
"dependencies": {
  "@escena-viva/format": "^0.1.0",
  "dotenv": "^17.2.1"
}

We start at 0.1.0 on purpose. As we saw in 05-03, in the ^0.x.y range the caret only allows patch changes, so a 0.x leaves us room to reorganize the API without breaking anyone by accident. When it settles down, we will jump to 1.0.0 as a deliberate act.

  1. The structure of a publishable package

A small, responsible package fits in very few files:

escena-viva-format/
├── package.json      Manifest: name, version, exports, files
├── index.js          Entry point of the public API
├── lib/              price.js, date.js, code.js
├── test/format.test.js
├── README.md         What it does, how to install it, examples
├── CHANGELOG.md      What changed in each version
└── LICENSE           Full text of the license

The README.md is not decorative documentation: it is the page people see on the registry and, in practice, the first thing that decides whether your package gets used. It must contain the minimal example that works when copied and pasted: a one-line install and three lines of usage with their output (formatPrice(2450) // '24.50 EUR').

The LICENSE file with the full text matters more than it seems: without it, legally your code is "all rights reserved" and a company with lawyers will not be able to use it even though it is published in the open. The license field in package.json must match that file.

  1. The public API: main, exports and types

Historically, main was the only control: it named the file loaded by require('package'). Its problem is that it prevented nothing. Anyone could write require('@escena-viva/format/lib/price.js') and couple themselves to your internal structure; the day you renamed lib/ you would have broken a consumer without having changed a single public function.

The exports field solves that. It is now the correct way to declare the public API because it does two things at once: it maps public names to real files and it blocks everything not listed.

"exports": {
  ".": "./index.js",
  "./price": "./lib/price.js",
  "./package.json": "./package.json"
}

With that map, the behavior from outside the package is:

Import Result Reason
require('@escena-viva/format') Works The . key points to index.js
require('@escena-viva/format/price') Works Subpath declared explicitly
require('@escena-viva/format/lib/price.js') ERR_PACKAGE_PATH_NOT_EXPORTED The internal path is not in the map
require('@escena-viva/format/lib/date.js') ERR_PACKAGE_PATH_NOT_EXPORTED Same, even though the file exists
require('@escena-viva/format/package.json') Works Declared on purpose

That blocking is freedom for you: as long as index.js and ./price keep returning the same thing, you can reorganize lib/ without publishing a major version. Note that ./package.json is declared explicitly because several analysis tools read it; if you leave it out, it is blocked too.

exports also accepts conditions, useful when a package offers both CommonJS and ESM. Node picks the branch according to how the package is loaded, and default must always come last because they are evaluated in order:

"exports": {
  ".": {
    "require": "./index.js",
    "import": "./index.mjs",
    "default": "./index.js"
  }
}

About types: even if you do not use TypeScript, many of your consumers do. Declaring "types": "./index.d.ts" and writing a twenty-line file with the signatures makes your package autocomplete in their editors. It is optional, but it has one of the best effort-to-gratitude ratios out there.

  1. What gets uploaded: files versus .npmignore

When you publish, npm packs an entire directory into a tarball. Deciding what goes in has two possible mechanisms and they are not equivalent.

Mechanism How it works Risk
.npmignore Denylist: everything is uploaded except what is listed A new file is uploaded by default
files in package.json Allowlist: only what is listed is uploaded A new file is left out by default

files is safer for the same reason a firewall that denies by default is safer than one that allows by default. With .npmignore, the day someone adds internal-notes.md or a .env.production to the repository, that file will travel to the public registry unless somebody remembers to update the denylist. With files, that same file simply does not exist as far as npm is concerned.

There is a confusing detail: if there is neither files nor .npmignore, npm uses .gitignore as the denylist; and if there is an .npmignore, .gitignore stops applying entirely, which produces unpleasant surprises.

Some files are always included (package.json, README, LICENSE and the main file) and others are always excluded (node_modules/, .git/, package-lock.json, .npmrc). The fact that .npmrc is always excluded is an important safeguard, because that is where tokens live. But do not rely on safeguards: use files.

  1. Scoped packages and publishConfig

@escena-viva/format is a scoped package: the @escena-viva is a namespace associated with a user or organization on the registry. It avoids name collisions —plain format has been taken for years—, it groups the packages of a single organization and it allows shared access policies.

The detail that surprises everyone the first time is that scoped packages are private by default, and publishing privately requires a paid plan. If your package is open you have to say so with npm publish --access public, but typing that flag every time is an invitation to forget it. Better to leave it in the manifest:

"publishConfig": {
  "access": "public",
  "registry": "https://registry.npmjs.org/"
}

publishConfig also covers the opposite case, very common in companies: an internal package that must go to the company's private registry and never to the public one. Pinning the registry there prevents a code leak from an absent-minded npm publish.

  1. Testing before publishing: npm pack and npm link

Publishing is irreversible in practice, so checking beforehand is not optional.

npm pack builds exactly the same tarball that would be uploaded, but leaves it on your disk:

npm pack
npm notice 📦  @escena-viva/[email protected]
npm notice Tarball Contents
npm notice 1.1kB LICENSE
npm notice 842B  README.md
npm notice 318B  index.js
npm notice 402B  lib/code.js
npm notice 380B  lib/date.js
npm notice 455B  lib/price.js
npm notice 621B  package.json
npm notice total files: 7
npm notice filename: escena-viva-format-0.1.0.tgz

That list is the moment of truth. What you should actively look for:

  • Is there any .env? That would be a credential leak published in the open.
  • Is data/ there with the test sales? It could contain real e-mail addresses.
  • Is test/ there? Not dangerous, but it inflates the download for every consumer.
  • Is index.js or any lib/ file missing? The package would install broken.

npm pack --dry-run shows the same list without writing anything, and tar -tzf file.tgz inspects an already-created tarball. The definitive test is installing that tarball in the real project:

cd ../escena-viva
npm install ../escena-viva-format/escena-viva-format-0.1.0.tgz
npm run report

If the occupancy report still prints 24.50 EUR, the package works exactly as anyone else will receive it.

The alternative for day-to-day development is npm link, which creates symbolic links:

# In the package's directory
npm link

# In Escena Viva's directory
npm link @escena-viva/format

The first command links the package into npm's global folder; the second creates, inside node_modules/@escena-viva/format, a symbolic link to your working folder. Every change you save in the package is seen by the project instantly, with no reinstall. Its quirks are worth knowing before you lose an afternoon:

  • An npm install in the project can undo the link and pull the registry version back down.
  • The link does not honor files or exports as faithfully as a tarball: you may be using a file that will not be published.
  • If the package and the project depend on the same module, two different copies may be loaded (the classic problem with libraries that keep internal state).

A practical rule: npm link for fast iteration, npm pack plus installing the tarball for the final check. To undo it, npm unlink @escena-viva/format and npm install.

  1. Publishing: login, --dry-run, 2FA and tokens

The first step is authenticating. npm login opens the browser and stores the resulting token in your user .npmrc (~/.npmrc), never in the project's. Then a rehearsal that does the whole process —building the tarball, validating the manifest, checking permissions— except the upload, and only then the real publication:

npm login
npm whoami
npm publish --dry-run
npm publish

The last command first runs the prepublishOnly hook we defined, which in turn launches npm test. It is the practical application of what we saw in 05-04: a hook that prevents publishing a version with red tests.

On account security, two measures that are not negotiable if anyone else uses your package. The first, 2FA (two-factor): you enable it with npm profile enable-2fa auth-and-writes and it makes every publish ask for a temporary code; most of the known package hijackings have been hijackings of the maintainer's account, not registry failures. The second, granular access tokens for CI: a continuous integration server cannot type a 2FA code, so it needs a token, and it should not be a classic one with full permissions but a granular one, limited to the packages it must publish and with an expiry date, stored as a secret of the CI system and never in the repository. We will see the full setup for secrets and automated deployment in module 11.

  1. New versions and distribution tags

Never edit the version field by hand. npm version does it, and leaves the repository coherent as well:

npm version patch   # 0.1.0 -> 0.1.1  (compatible fix)
npm version minor   # 0.1.1 -> 0.2.0  (new functionality)
npm version major   # 0.2.0 -> 1.0.0  (breaking change)

Each of those commands, in a directory with git:

  1. Checks that the working tree is clean (if not, it aborts).
  2. Updates version in package.json and in package-lock.json.
  3. Creates a commit with the version number as the message.
  4. Creates a git tag v0.1.1 pointing at that commit.

That tag is what will let you, two years from now, recover the exact code of a published version. Remember to push it: git push --follow-tags. For pre-releases, npm version prerelease --preid=beta takes you from 1.0.0 to 1.0.1-beta.0.

And this is where distribution tags come in: movable aliases pointing at specific versions. When someone types npm install @escena-viva/format, npm resolves the latest tag. If you publish a beta with nothing else, it becomes latest and everyone gets it without asking. The correct way is npm publish --tag beta: that way latest keeps pointing at the last stable version and whoever wants the beta must ask for it with npm install @escena-viva/format@beta.

Tags are managed after publishing, with no need to republish anything:

npm dist-tag ls @escena-viva/format
npm dist-tag add @escena-viva/[email protected] next
npm dist-tag add @escena-viva/[email protected] latest
npm dist-tag rm @escena-viva/format beta

Moving latest is, in fact, the only clean way to "withdraw" a bad version: you do not delete it, but you stop serving it by default.

  1. Unpublishing is almost never possible

This is a good point to slow down, because intuition misleads. Publishing is not like uploading a file to a server of your own: it is a public commitment. The registry's policy is roughly this:

Situation Can it be unpublished?
Less than 72 hours since publication Yes, with npm unpublish
More than 72 hours, no dependents and a single version Only the whole package, under strict conditions
More than 72 hours and with other packages depending on it No
A specific version someone has in their package-lock.json No

The reason for this rigidity has a name. In 2016, the author of the left-pad package —eleven lines of code that padded a string on the left— unpublished it after a dispute over another package's name. left-pad was a transitive dependency of tools used by half the world, so for hours builds failed in thousands of organizations. The consequence was a tightening of the unpublish policy: the ecosystem's stability weighs more than the right to withdraw your own code.

The correct alternative when a version is bad or a package becomes obsolete is npm deprecate, which deletes nothing but warns on every install:

npm deprecate @escena-viva/[email protected] "Rounding error; use 0.1.1"
npm deprecate @escena-viva/format@"<0.2.0" "Unmaintained; migrate to 0.2.x"
npm deprecate @escena-viva/format "Replaced by @escena-viva/utils"
npm deprecate @escena-viva/[email protected] ""   # withdraw the notice

The message appears as a warning at install time, breaks nothing and gives people time to migrate. It is what a responsible maintainer does.

A special and urgent case: if you have published credentials by accident, unpublishing is not the solution. Even if you manage to withdraw it, the tarball has already been replicated in caches and mirrors. What you have to do is rotate those credentials immediately, and then publish a clean version and mark the bad one as deprecated.

Hence the correct order of work is always: npm pack → review the list → npm publish --dry-run → npm publish.

  1. Good practices of a responsible package

  • A README with runnable examples. A block you can copy that works first time is worth more than three paragraphs of description.
  • CHANGELOG.md. One entry per version, with breaking changes highlighted. It is the first thing anyone about to upgrade reads.
  • Honest SemVer. The version is not decided by your perception of the change's size, but by its effect on the consumer. If you rename a parameter, it is major even if it is two lines.
  • Zero dependencies if possible. Every dependency of yours becomes a transitive one for all your consumers, with its own risk surface. @escena-viva/format needs none.
  • engines declared and tests before publishing with prepublishOnly.
  • An accessible repository. The repository, bugs and homepage fields let people find the code and open issues.

Common Mistakes and Tips

Publishing with private: true in the manifest. npm refuses with This package has been marked as private. It is a deliberate protection: remove it only when the package really is publishable.

Forgetting --access public in a scoped package. The 402 Payment Required error does not mean npm wants to charge you for publishing open source; it means it is trying to publish it as private. Add publishConfig.access.

Using .npmignore and finding a .env in the tarball. The denylist fails silently in the face of new files. Switch to files and always verify with npm pack.

Publishing the wrong folder. npm publish packs the current directory. Check pwd first; --dry-run confirms it by showing the package name.

Editing version by hand and forgetting the git tag. A few months later you will have published versions with no identifiable commit behind them. Always use npm version.

Publishing a beta without --tag. It becomes latest and your whole user base installs it. It is one of the most expensive and easiest mistakes to make.

Trusting npm link as the final test. The symbolic link does not exercise files or exports the way a tarball does. Test the .tgz before publishing, and always work as if publication were final, because for practical purposes it is.

Exercises

Exercise 1: block an internal path

A teammate, in another project, has written require('@escena-viva/format/lib/code.js') to use generateTicketCode without loading the rest. Explain what error they will get with the exports we have defined, and write the exports that would give them access to that function through the public subpath @escena-viva/format/code without exposing the structure of lib/.

Exercise 2: review the tarball

In the package's directory you have accidentally added a .env file with NPM_TOKEN=... and a data/sales-teatro-almendra.json folder with test e-mail addresses. The package.json has files: ["index.js", "lib/", "README.md", "LICENSE"]. Answer: would those files be uploaded? Which command checks it? Would the answer change if instead of files there were an .npmignore with the line data/?

Exercise 3: a bad version published

You have published @escena-viva/[email protected] and four days later you notice that formatPrice(2450) returns '24.5 EUR', dropping the trailing zero of the cents. Two internal projects already have it in their package-lock.json. Write the complete sequence of commands to resolve it correctly and explain why you do not start with npm unpublish.

Solutions

Solution 1. They will get ERR_PACKAGE_PATH_NOT_EXPORTED, because the exports map denies by default any path not declared, even though the file physically exists inside the package. The corrected exports adds a public subpath pointing at the internal file:

"exports": {
  ".": "./index.js",
  "./price": "./lib/price.js",
  "./code": "./lib/code.js",
  "./package.json": "./package.json"
}

Now require('@escena-viva/format/code') works, and the path with lib/ stays blocked. The advantage is that the public name ./code and the real file ./lib/code.js are decoupled: if tomorrow you move the file, changing the map is enough, with no major version.

Solution 2. No, they would not be uploaded. files is an allowlist: only what is enumerated goes into the tarball, and neither .env nor data/ is there. On top of that, npm always excludes .npmrc, though that does not cover a .env.

You check it with npm pack --dry-run (or npm pack and then tar -tzf), reading the tarball's file list.

With .npmignore the answer changes dangerously: data/ would indeed be excluded because it appears in the list, but .env would be uploaded, since it is not listed and .npmignore disables .gitignore entirely. This is exactly why files is safer: it fails on the side of not publishing.

Solution 3. You do not start with npm unpublish because more than 72 hours have passed and, on top of that, there are projects already depending on that version; the registry will reject it, and even if it allowed it, you would break those installs. The correct sequence is to publish the fix and flag the bad version:

npm test                             # 1. Fix the code and cover it with a test
npm version patch                    # 2. 0.2.0 -> 0.2.1
git push --follow-tags
npm pack --dry-run                   # 3. Review the contents and rehearse
npm publish --dry-run
npm publish                          # 4. Publish the fix
npm deprecate @escena-viva/[email protected] "Incorrect decimal padding; upgrade to 0.2.1"
npm dist-tag ls @escena-viva/format  # 5. Confirm that latest is the good version

The affected projects will get the deprecation warning on their next install and, since their range is ^0.2.0, an npm update will bring them 0.2.1 with no further intervention. It is also worth adding the corresponding entry to CHANGELOG.md.

Conclusion

Publishing a package is above all an exercise in deciding boundaries. We have seen that extraction is justified by real reuse —the three-copies rule— and not by elegance, and we have turned src/utils/format.js into @escena-viva/format, a scoped package with no dependencies and a three-function API.

The important decisions concentrate in four manifest fields: main as the classic entry point, exports as the strict declaration of what can be imported and a wall against internal paths, files as the allowlist of what travels to the registry, and publishConfig.access so a scoped package gets published in the open. Around them, a verification flow that is best not skipped: npm pack to read the tarball with your eyes open, installing the .tgz for the real test, npm publish --dry-run as a rehearsal and only then npm publish, with 2FA on the account and granular tokens in continuous integration.

And a lesson worth internalizing before you need it: what is published is practically final. Outside the 72-hour window there is no going back, as the left-pad case taught; what there is, instead, is npm deprecate, new versions and distribution tags to steer people towards the good code.

In the next lesson, Dependency Security and Maintenance, we turn the argument around. If publishing means other people will run your code, installing means you run strangers' code: we will look at npm audit and how to really read it, npm ls to locate who drags in a vulnerable dependency, overrides to force a patched version, the attacks you need to recognize —typosquatting, hijacked accounts, abandoned packages— and the maintenance routine we will write down in Escena Viva's README.

Node.js Course: From Beginner to Advanced

Module 1: Introduction to Node.js

Module 2: Core Concepts

Module 3: File System and I/O

Module 4: HTTP and Web Servers

Module 5: NPM and Package Management

Module 6: The Express.js Framework

Module 7: Databases and ORMs

Module 8: Authentication and Authorization

Module 9: Testing and Debugging

Module 10: Advanced Topics

Module 11: Deployment and DevOps

Module 12: Real-World Projects

© Copyright 2026. All rights reserved