Back to core workflows Fix dependency resolution Tune package metadata Validate before publishing

Choosing Between the files Field and .npmignore

Every published package is a tarball, and what goes into it is decided by one of two opposite mechanisms: the files allowlist in package.json or the .npmignore denylist. Pick the wrong one — or combine them without understanding the precedence rules — and you either ship your .env, test fixtures and source maps to the public registry, or you ship a package with its dist/ folder missing. This guide explains how npm builds the file list, which approach to choose, and how to prove what is in the tarball before it leaves your machine.

Exact symptoms and error messages

Mistakes here rarely fail loudly at publish time. They surface later, in one of these shapes:

# Consumer side: the build output never made it into the tarball
Error: Cannot find module '/app/node_modules/your-lib/dist/index.cjs'
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/app/node_modules/your-lib/dist/index.js'
# Publisher side: npm pack reveals files that should never ship
npm notice === Tarball Contents ===
npm notice 1.2kB  .env.production
npm notice 48.3kB coverage/lcov-report/index.html
npm notice 912B   src/__fixtures__/customer-export.csv
npm notice === Tarball Details ===
npm notice package size:  2.1 MB
npm notice unpacked size: 11.8 MB

The first is the classic "works in the repo, broken for users" bug: the build output is ignored by .gitignore, and because there is no .npmignore and no files field, npm falls back to .gitignore and excludes dist/. The second is the leak: a denylist that forgot a new directory.

Root cause analysis

npm computes the tarball contents in a fixed order. Understanding that order is the whole fix, because it explains why a file you "ignored" still ships and why a file you never excluded goes missing. The precedence also applies to pnpm and Yarn, which reuse npm's packing rules (via npm-packlist semantics) for publishing.

How npm decides what goes into the tarball Five rules applied in order, from files that are always included to the files field, .npmignore or .gitignore, and files that are never included. 1 Always included package.json, README, LICENSE/LICENCE, and the file named in main and bin 2 files allowlist if present, only listed paths and globs are candidates 3 .npmignore subtracts from candidates; if it exists, .gitignore is not consulted at the root 4 .gitignore fallback used only when no .npmignore exists 5 Never included .git, node_modules, .npmrc, package-lock.json, *.orig and similar
The files allowlist narrows the set first; ignore files can only subtract from what it allows.

Three consequences follow directly from that order:

  1. With no files field and no .npmignore, .gitignore decides. Build output is almost always git-ignored, so it is excluded from the tarball. This is the most common cause of a published package missing dist/.
  2. Creating an .npmignore silently stops .gitignore from applying at that level. Teams add .npmignore to exclude tests and accidentally start shipping everything that was only listed in .gitignore — local .env files included.
  3. A files allowlist is safe by default. New directories are excluded until someone adds them to the list, so the failure mode is a missing file (caught by a smoke test) rather than a leak (caught by nobody).

Resolution and configuration patch

Use the files allowlist for every package you publish, and keep it short:

{
  "name": "your-lib",
  "version": "3.2.0",
  "files": [
    "dist",
    "!dist/**/*.test.*",
    "!dist/**/__fixtures__"
  ],
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    },
    "./package.json": "./package.json"
  }
}

Implementation steps:

  1. Delete .npmignore unless you have a specific reason to keep it. Two mechanisms are harder to reason about than one.
  2. List only build output and assets consumers need: dist, maybe bin, maybe a schema.json or styles/. Source and tests stay out.
  3. Use negated globs for exclusions inside allowed folders. "!dist/**/*.test.*" removes compiled tests that your build emits alongside the library code. Negations in files are supported by npm 7 and later.
  4. Do not list files that are always included. package.json, README.md and LICENSE ship regardless, and CHANGELOG.md is worth adding explicitly only if you want it on the registry page.
  5. Check source maps deliberately. If you ship *.js.map files that reference ../src/index.ts, either include src so the maps resolve or build with sourcesContent embedded. Half-shipped maps produce noisy warnings in consumer dev servers.
files allowlist versus .npmignore denylist Compares the two mechanisms on default safety, new-file behaviour, interaction with gitignore, monorepo fit and reviewability. files allowlist .npmignore denylist New folders by default excluded shipped Interaction with .gitignore independent replaces it silently Typical failure missing file, caught by smoke test leaked secret, rarely caught Reviewability in PRs one field in package.json separate dotfile Monorepo packages per-package, explicit easy to forget per package
The allowlist fails closed — a forgotten folder goes missing instead of leaking.

When .npmignore is still reasonable

A few packages legitimately ship most of their repository: code generators that include template directories, or packages whose source is the distribution. In those cases a denylist can be shorter than an allowlist. If you keep .npmignore, copy every entry from .gitignore into it first, then add the development-only paths, and treat the file as security-sensitive in code review.

CLI validation and debug commands

Never guess what is in the tarball. npm will tell you exactly:

# List every file and the final size, without writing a tarball
npm pack --dry-run

# Same data as JSON, easy to assert on in CI
npm pack --dry-run --json | jq -r '.[0].files[].path' | sort

# pnpm equivalent inside a workspace package
pnpm pack --pack-destination /tmp && tar -tzf /tmp/your-lib-3.2.0.tgz

# Fail if anything unexpected appears
npm pack --dry-run --json | jq -e '[.[0].files[].path | select(test("^(src|test|coverage)/|\\.env"))] | length == 0'

The last command is a useful CI gate: it exits non-zero if any path under src/, test/ or coverage/, or any .env file, would be published.

Tarball size before and after switching to a files allowlist Unpacked size of an example package with no rules, with .npmignore, and with a files allowlist. no rules (.gitignore only) 0.4 MB, dist missing .npmignore denylist 11.8 MB files allowlist 1.3 MB
The allowlist drops tests, coverage and fixtures from an example package, cutting the unpacked size by roughly ninety per cent.

Worked example: auditing an existing package in ten minutes

Suppose you maintain a package that has been published for years with an .npmignore nobody has touched since it was created. Before switching to an allowlist, find out what is really being shipped today — the answer is often surprising.

  1. Download the latest published tarball rather than packing locally: npm pack your-lib@latest fetches exactly what users installed. Extract it with tar -xzf your-lib-*.tgz and list the package/ directory.
  2. Classify every top-level entry into "consumers need this" (build output, type declarations, runtime assets, the licence) and "development only" (sources, tests, fixtures, CI configuration, editor settings, coverage).
  3. Search for secrets in the development-only set. grep -rIl -E "(API_KEY|SECRET|TOKEN|PRIVATE KEY)" package/ takes seconds. If anything turns up, rotate the credential immediately — assume it has been read, because public tarballs are mirrored and scanned continuously.
  4. Write the files field from the "consumers need this" list and delete .npmignore.
  5. Diff the new tarball against the old one. npm pack --dry-run --json before and after, piped through jq and diff, shows every path that disappears. Each removal should be something you meant to remove.

That final diff is the step people skip, and it is the one that catches a runtime asset — a JSON schema, a WASM binary, a locale folder — that lived outside dist/ and was only shipping because the old denylist let everything through. Add such paths to files explicitly.

How pnpm, Yarn and Bun apply the same rules

The packing rules are shared across tools, with small differences worth knowing. pnpm's pack and publish honour files and ignore files the same way npm does, and additionally rewrite workspace: ranges in the packed package.json. Yarn Berry's yarn pack also honours files, but reads .npmignore only when there is no files field and applies its own always-excluded list, which includes .yarn/ and .pnp.* files. Bun's bun pm pack follows the npm semantics. Whichever tool you publish with, verify with that tool's own pack command — the rules are close enough to feel identical and different enough to occasionally surprise you, especially around dotfiles.

Monorepos: one allowlist per published package

In a workspace, packing happens per package, and each package's files field is evaluated relative to that package's directory. A root-level .npmignore does not apply to packages/ui — the packer looks for ignore files inside the package being packed and in its subdirectories. Two practical rules keep this sane:

  • Give every publishable package its own files field, even if they are identical. Tooling such as publint and the pack --dry-run check then runs per package with no hidden inheritance.
  • Mark internal packages "private": true so they can never be published at all, regardless of what their file lists say. A private flag is a stronger guarantee than any ignore rule.

If you use a release tool that publishes many packages in one go — Changesets or Lerna — add the dry-run assertion to a pre-publish step that loops over every public package. It is far cheaper to fail a release job than to deprecate a version that leaked internal fixtures.

Prevention and CI/CD guardrails

  • Standardise on files across every published package and delete stray .npmignore files in the same change.
  • Gate releases on npm pack --dry-run --json, asserting both that required entry files exist and that forbidden paths do not.
  • Install the packed tarball in a fixture and import every public entry, as described in Smoke-Testing a Tarball with npm pack.
  • Watch the size. A sudden jump in unpacked size is the cheapest signal that something new is being shipped; record it in the release job output.

Frequently Asked Questions

Does the files field affect what gets installed from a git dependency? Yes. When npm installs from a git URL it runs prepare, then packs the repository with the same rules, so a files field shapes git installs exactly as it shapes registry installs.

Why is my README published even though it is not in files? README, LICENSE and package.json are always included, along with the files referenced by main and bin. You cannot exclude them with files or .npmignore.

Can I use both files and .npmignore? Yes: files defines the candidates and .npmignore files inside included folders subtract from them. It works, but it doubles the places to check during review, so prefer negated globs inside files instead.

Related

Understanding package.json Fields