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.
Three consequences follow directly from that order:
- With no
filesfield and no.npmignore,.gitignoredecides. Build output is almost always git-ignored, so it is excluded from the tarball. This is the most common cause of a published package missingdist/. - Creating an
.npmignoresilently stops.gitignorefrom applying at that level. Teams add.npmignoreto exclude tests and accidentally start shipping everything that was only listed in.gitignore— local.envfiles included. - A
filesallowlist 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:
- Delete
.npmignoreunless you have a specific reason to keep it. Two mechanisms are harder to reason about than one. - List only build output and assets consumers need:
dist, maybebin, maybe aschema.jsonorstyles/. Source and tests stay out. - Use negated globs for exclusions inside allowed folders.
"!dist/**/*.test.*"removes compiled tests that your build emits alongside the library code. Negations infilesare supported by npm 7 and later. - Do not list files that are always included.
package.json,README.mdandLICENSEship regardless, andCHANGELOG.mdis worth adding explicitly only if you want it on the registry page. - Check source maps deliberately. If you ship
*.js.mapfiles that reference../src/index.ts, either includesrcso the maps resolve or build withsourcesContentembedded. Half-shipped maps produce noisy warnings in consumer dev servers.
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.
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.
- Download the latest published tarball rather than packing locally:
npm pack your-lib@latestfetches exactly what users installed. Extract it withtar -xzf your-lib-*.tgzand list thepackage/directory. - 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).
- 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. - Write the
filesfield from the "consumers need this" list and delete.npmignore. - Diff the new tarball against the old one.
npm pack --dry-run --jsonbefore and after, piped throughjqanddiff, 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
filesfield, even if they are identical. Tooling such as publint and thepack --dry-runcheck then runs per package with no hidden inheritance. - Mark internal packages
"private": trueso 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
filesacross every published package and delete stray.npmignorefiles 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
- Shrinking Published Package Size with sideEffects and files goes further on trimming what consumers download and bundle.
- Smoke-Testing a Tarball with npm pack turns the packed file list into an automated test.
- Dry-Running a Publish Before Release adds the registry-side checks that complement tarball inspection.
- Understanding package.json Fields places
filesalongsideexports,mainandbin.