Core JavaScript Package Workflows
Modern JavaScript package management demands deterministic resolution, strict module boundaries, and reproducible build pipelines. This guide establishes production-grade workflows for library authors, frontend engineers, and platform teams managing monorepos and dual-packaged distributions. Every section below maps to a deeper topic page, and the diagram that follows shows how those topics fit together into one end-to-end flow — from the manifest a developer edits, through resolution and lockfile integrity, to the dual-format artifacts published to a registry.
These stages correspond directly to the topic pages on this site. Manifest design is covered in Understanding package.json Fields, with the dual-output recipe broken out in How to Configure package.json for Dual Modules. Graph mechanics live in Dependency Resolution Explained. Reproducibility is the subject of Lockfile Management Strategies. Multi-package orchestration spans Workspace Configuration Deep Dive and Root-Level vs Package-Level Scripts. Module-format correctness is handled in ESM and CJS Interoperability, and shipping type definitions that survive both formats is the focus of TypeScript Declaration Publishing. Turning that source into the exact artifacts consumers install is covered in Bundling and Build Tooling for Libraries, and keeping a shipped package secure over time is the subject of Dependency Auditing and Automated Updates.
Package Initialization and Manifest Architecture
Initialize every project with explicit engine constraints and package manager declarations to prevent environment drift. Use Corepack to pin the exact package manager version, and declare it via the packageManager field in the root package.json. This guarantees that every developer and CI runner executes the identical resolution algorithm rather than whichever version happens to be globally installed. A mismatched package manager is one of the most common sources of "works on my machine" lockfile churn, because each major version of npm, pnpm, and Yarn ships subtly different hoisting and resolution rules.
Replace legacy main and module fields with conditional exports maps. The Node.js module resolver evaluates exports strictly and in declaration order, prioritizing type definitions, then ESM, then CJS. Configure type: "module" at the package root, set sideEffects: false to enable aggressive tree-shaking, and restrict published artifacts using the files array so internal scaffolding, tests, and source maps never leak into the tarball. The exports map also acts as an encapsulation boundary: any subpath you do not explicitly export becomes unreachable to consumers, which prevents downstream code from depending on internal file layout you may want to refactor later.
{
"name": "@scope/core-lib",
"type": "module",
"sideEffects": false,
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
},
"files": ["dist"],
"engines": { "node": ">=18.0.0" }
}
Validate the manifest before every publish. A misordered exports key or a missing types condition will not throw locally yet will silently break consumers, so treat the manifest as a contract that warrants automated checks. The field-by-field reference, including the publish-blocking private flag and the difference between files and an .npmignore, is laid out in Understanding package.json Fields.
Treat the manifest as the single source of truth that every later stage reads. The resolver consults engines and packageManager before it touches the dependency graph, the bundler reads type and exports to decide output formats, and the registry reads files to build the tarball. Because these fields are evaluated by different tools at different phases, a single inconsistency — an exports path that points at a file the files array excludes, for instance — surfaces only in a consumer's install, never in your own. Validate the whole manifest in CI with a publint-style check so contract violations fail the build rather than a downstream user's.
The order of conditions inside exports is not cosmetic — Node evaluates them top to bottom and stops at the first match, so a misplaced default above import silently short-circuits ESM resolution for every consumer. The reliable ordering is types first (so type checkers resolve declarations before anything else), then the runtime conditions from most specific to least (import, require), and default last as a catch-all. The same rule applies to subpath exports: each subpath key gets its own ordered condition block, and a subpath you do not list is genuinely unreachable, which is the encapsulation benefit — consumers cannot reach into dist/internal/helper.js and couple themselves to a layout you may refactor.
Engine and package-manager pinning deserve equal rigor because they govern reproducibility before a single dependency resolves. Declare a realistic engines.node floor — one you actually test against — and pin the package manager with packageManager so Corepack activates the exact version on every machine. A common and costly mistake is leaving engines aspirational (>=14) while relying on Node 20 features; consumers on Node 14 then install cleanly and crash at runtime. Treat engines as a tested contract, verified in CI against the lowest supported version, not a hopeful annotation.
Two fields quietly prevent whole classes of publish accidents. private: true blocks npm publish entirely, which belongs on every application and internal package that must never reach a registry; removing it is the deliberate act that makes a package publishable. The files allowlist, meanwhile, is the safest way to control the tarball: an allowlist fails safe, so a new build artifact you forget is simply not published, whereas an .npmignore denylist fails open and ships anything you forget to exclude. Prefer files: ["dist"] and treat anything outside it as internal by default.
Dependency Classification and Graph Management
Categorize dependencies rigorously, because the bucket a package lands in determines whether it ships to consumers, whether it duplicates, and whether installs fail. Use dependencies for runtime requirements that must be installed alongside your package, devDependencies for tooling that never reaches production, and peerDependencies for framework integrations that the consumer must provide. Misclassifying a framework plugin as a direct dependency causes duplicate module instantiation, breaking React context providers and inflating bundle sizes — the canonical example being two copies of React in one tree. The decision boundary between these buckets is examined in detail in When to Use peerDependencies vs devDependencies.
Patch vulnerable transitive dependencies using overrides (npm v8.3+), pnpm.overrides (pnpm), or resolutions (Yarn v1). Avoid blanket overrides; they mask architectural misconfigurations and can introduce breaking changes during minor version bumps. pnpm's strict, content-addressed node_modules structure naturally prevents duplicate instantiation by isolating packages unless explicitly hoisted, which is why peer-dependency conflicts surface earlier and more loudly there than under npm's flat hoisting. When a peer conflict does block an install, the resolution steps are documented in Fixing npm ERESOLVE Peer Dependency Conflicts, and the specific case of a doubled framework is covered in Deduplicating Duplicate React Versions. For the full picture of how ranges flatten into an installed tree, see Dependency Resolution Explained.
{
"dependencies": { "lodash-es": "^4.17.21" },
"devDependencies": { "typescript": "^5.7.0", "tsup": "^8.0.0" },
"peerDependencies": { "react": ">=18.0.0" },
"peerDependenciesMeta": { "react": { "optional": true } },
"overrides": { "semver": "^7.5.4" }
}
The cost of misclassification compounds in a monorepo. A plugin declared as a direct dependency inside a shared package is installed independently for every consumer of that package, so a single mislabelled react can fan out into a dozen duplicated trees across apps. Auditing buckets is therefore a periodic hygiene task, not a one-time decision: run npm ls <pkg> (or pnpm why <pkg>) after every significant dependency change to confirm a package resolves to exactly one physical copy where you expect it to.
The peer-dependency contract is the subtlest of the three buckets because it inverts responsibility: your package declares a requirement that the consumer must satisfy, and the package manager only warns — it does not install it for you unless auto-install-peers is enabled. Declaring a framework as a peer with a deliberately wide range (>=18) tells the resolver to use the consumer's single copy, which is what keeps React context, hook dispatchers, and instanceof checks working. Pair every peer with an entry in peerDependenciesMeta marking it optional where the integration is genuinely optional, so a consumer who does not use that adapter is not nagged by a spurious warning.
Overrides are a scalpel, not a default tool. Reaching for overrides (npm/pnpm) or resolutions (Yarn) to force a transitive version is correct when a security patch exists only in a newer sub-dependency, but a blanket override that pins a widely-shared package can mask a real incompatibility and will silently hold that package back long after upstream has moved on. Scope each override as narrowly as the fix allows, document why it exists, and treat it as temporary debt to remove once the direct parent catches up — the discipline covered in depth by the dependency-auditing guides.
Optional peer dependencies are the mechanism for framework adapters that a consumer may or may not use. Declaring the peer and marking it optional in peerDependenciesMeta tells the package manager not to warn when the consumer omits it, while still using the consumer's copy when present. This is how a plugin can integrate with, say, both React and Vue without forcing either on every consumer — the adapter code guards the optional import, and the manifest documents the contract.
Duplication is worth watching because it is silent until it breaks something. Two copies of a stateless utility only waste bytes, but two copies of a stateful package — a framework, a validation library whose schemas are compared by identity, a plugin registry — produce bugs that resist debugging: a provider that does not match its consumer, an instanceof that returns false for an object that visibly is that class. Deduplication with npm dedupe/pnpm dedupe, or an override that forces a single compatible version, collapses those copies; confirming the result with npm ls <pkg> proves the graph resolved to one physical instance where you expected it.
Lockfile Integrity and CI/CD Enforcement
Guarantee reproducible builds by enforcing frozen lockfile installs across all CI pipelines. Never run npm install, pnpm install, or yarn install in CI without strict flags, because an unguarded install will happily mutate the lockfile to satisfy a floating range, producing builds that differ from what was reviewed. Use npm ci --ignore-scripts, pnpm install --frozen-lockfile, or yarn install --immutable to block lockfile mutations and fail fast on drift. The --ignore-scripts flag additionally neutralizes arbitrary postinstall code from transitive dependencies, closing a common supply-chain vector.
Automate conflict resolution by routing lockfile updates through dedicated dependency-bump PRs rather than letting them piggyback on feature branches. A regenerated lockfile is nearly impossible to review by eye, so the review should focus on the manifest diff while CI verifies that the lockfile resolves cleanly. Validate registry integrity using lockfile-lint to restrict allowed hosts and detect typosquatting or unexpected registries injected into the dependency graph. For cross-platform hash validation, merge-conflict recovery, and cache reuse, apply Lockfile Management Strategies; the most common merge failure has a dedicated walkthrough in Fixing pnpm-lock.yaml Merge Conflicts.
jobs:
verify-lockfile:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- run: pnpm install --frozen-lockfile --ignore-scripts
- run: pnpm exec lockfile-lint --path pnpm-lock.yaml --allowed-hosts npm
Reproducibility is only meaningful if the failure mode is loud. A frozen install must fail the job when the lockfile and manifest disagree, not silently reconcile them — that is the entire point of the --frozen-lockfile, --immutable, and npm ci flags. Pair the frozen install with a scheduled, isolated update job that regenerates the lockfile deliberately, opens a dedicated pull request, and lets CI prove the new graph resolves and builds before a human ever reviews the manifest diff.
A lockfile earns its keep only if CI treats a drift between it and package.json as a hard failure. That is precisely what npm ci, pnpm install --frozen-lockfile, and yarn install --immutable do: they refuse to mutate the lockfile and exit non-zero when the manifest and lockfile disagree, so a developer who bumps a dependency without regenerating the lockfile gets a red build instead of a non-reproducible one. Never substitute a plain install in CI to 'make it pass' — that reintroduces exactly the drift the frozen install exists to catch.
Route lockfile changes through their own reviewed pull requests rather than letting them ride along on feature branches. A regenerated lockfile is thousands of lines no human reviews line-by-line, so the review should focus on the manifest diff — which packages moved and why — while CI proves the lockfile resolves and installs cleanly. Layer a lockfile-lint check on top to restrict the hosts packages may resolve from; it catches a typosquatted dependency or an unexpected registry injected into the graph before the poisoned lockfile is ever merged.
Reproducibility extends to the update path, not just the enforcement. A mature setup runs a scheduled, isolated job that regenerates the lockfile deliberately, opens a dedicated pull request, and lets CI prove the new graph resolves and builds before a human reviews the manifest diff. This keeps everyday branches frozen and deterministic while still moving dependencies forward on a predictable cadence, rather than letting a floating range mutate the graph on an unrelated feature branch where nobody is watching for it.
ESM/CJS Module Boundaries and Dual Packaging
Enforce strict ESM/CJS boundaries using conditional export maps and explicit file extensions. Node.js throws ERR_REQUIRE_ESM when a CommonJS environment attempts to require() a file marked as ESM via type: "module", and it throws a "named export not found" SyntaxError when a consumer destructures named bindings from a CJS module that Node only exposes as a default object. Configure bundlers like tsup or Rollup to emit separate .mjs and .cjs outputs, ensuring each receives matching .d.ts declarations so type checkers resolve the correct file under each condition.
Handle dynamic import() versus synchronous require() by isolating runtime evaluation. Use createRequire from node:module when CJS code must load a CJS dependency by path, and prefer dynamic import() when CJS must reach an ESM-only module, since a static require() of ESM cannot work. Avoid top-level await in any code path that may be consumed as CJS. The full compatibility matrix, including interop edge cases and fallback strategies, lives in ESM and CJS Interoperability; the two errors above have targeted fixes in Fixing ERR_REQUIRE_ESM in Node.js and Resolving 'Named Export Not Found' in ESM. When you ship two JavaScript formats you must also ship two sets of declarations, which is the entire subject of Generating Dual CJS/ESM Type Definitions.
The two most common runtime errors at this boundary have the same root cause and opposite directions. ERR_REQUIRE_ESM fires when CommonJS code require()s a module that resolves to ESM, because require is synchronous and ESM evaluates asynchronously; the fix is to convert the caller to ESM, bridge with a dynamic import(), or ask the maintainer for a require condition. The mirror error — a SyntaxError about a missing named export — fires when ESM code destructures named bindings from a CommonJS module that Node only exposes as a single default object; the fix is to import the default and destructure from it, or have the package ship a real ESM build.
Dual packaging carries a hazard that a naive build introduces silently: the dual-package hazard, where a consumer's graph reaches your package through both import and require, and Node instantiates each build as a separate module with its own state. For stateless utilities this is merely wasteful, but any singleton — a registry, a cache, a class checked with instanceof — now exists twice and desynchronizes. The defense is to keep identity-bearing state in a single-format module both builds reference, or to ship ESM-only when you do not need synchronous require support at all.
Node 22 added the ability to require() an ES module that has no top-level await, unflagged on newer lines, which softens the boundary for application code — but it is not portable to Node 18 or 20 and still fails on ESM that uses top-level await. Treat it as a convenience for apps on new runtimes, never as a dependency assumption for a published library, which must still ship a proper require condition so consumers on older LTS lines resolve a real CommonJS artifact.
TypeScript Declarations as a First-Class Artifact
Treat type declarations with the same rigor as your JavaScript output. A .d.ts file that is generated against the ESM build but served to a require consumer will produce a "cannot find module or its corresponding type declarations" error even when the runtime code resolves correctly, because TypeScript follows the same conditional exports resolution and picks the file under the matching condition. Point a types condition inside both the import and require branches at format-appropriate declarations (.d.ts for ESM, .d.cts for CJS) so editors and tsc never cross-contaminate. End-to-end declaration emission, bundling, and verification are covered in TypeScript Declaration Publishing, and the specific consumer-side failure is fixed in Fixing 'Cannot Find Module' Type Declaration Errors.
Declaration correctness is verifiable, so verify it. Add a matrix smoke test that imports the built package from a .mts file and requires it from a .cts file, then runs tsc --noEmit against both under moduleResolution: "bundler" and "node16". If either resolution mode picks the wrong declaration file, the type check fails in your CI instead of in a consumer's editor weeks later.
Under node16/nodenext module resolution, the type checker follows the same conditional exports as the runtime, which means a single shared .d.ts is not enough for a dual-format package. The require branch needs a CommonJS-flavored .d.cts sitting next to its .cjs file, or a consumer importing via require gets any or a wrong-shape type even though the JavaScript resolves correctly. Nest a types condition inside both the import and require export branches so each points at its format-appropriate declaration.
Declaration correctness is verifiable, so make it a CI gate rather than a hope. @arethetypeswrong/cli packs your tarball and resolves the types across every module and resolution combination a consumer might use, failing the build on a mismatch, while publint catches structural exports mistakes. Adding both to the release pipeline turns 'the types are wrong' from a report you receive weeks later, in a consumer's editor, into a red build on the pull request that introduced it.
Bundling declarations is the other half of shipping types well. Emitting one .d.ts per source file leaks your internal module structure into the public type surface, so consumers can deep-import internal types and a refactor becomes a breaking change; bundling the declarations into a single public entry (with api-extractor or a dts bundler) hides the internals and speeds the consumer's type-check. Pair bundling with a declaration-map only when you intend to ship source, since maps that point at absent source files degrade the editor experience rather than improving it.
Workspace Topology and Build Orchestration
Define workspace roots explicitly to enforce strict package boundaries. npm uses the workspaces array in package.json, pnpm relies on pnpm-workspace.yaml, and Yarn Berry uses the same workspaces field but resolves via Plug'n'Play or node_modules hoisting. To prevent accidental cross-package hoisting and phantom dependencies — where a package imports something it never declared simply because a sibling hoisted it — disable broad hoisting patterns and restrict native module builds to an explicit allowlist.
# pnpm-workspace.yaml
packages:
- 'packages/*'
- 'apps/*'
# .npmrc
hoist-pattern[]=
public-hoist-pattern[]=
onlyBuiltDependencies[]=esbuild
onlyBuiltDependencies[]=sharp
Map internal dependencies using the workspace: protocol (e.g., "workspace:*") to guarantee symlink resolution and enforce topological execution order during builds, so a package always builds after the local packages it imports. Deploy workspace-aware task runners to manage caching, parallel execution, and topological dependencies, and separate root-level orchestration scripts from package-specific commands to prevent command collision and simplify CI matrix routing. Use prepublishOnly for final validation and artifact generation, but avoid postinstall for network-dependent operations due to security and reproducibility risks. The protocol, hoisting controls, and filtering mechanics are explored in Workspace Configuration Deep Dive, with smaller setups walked through in Setting Up npm Workspaces for Small Teams and migrations in Migrating from Yarn 1 to pnpm Workspaces. Script layering across the root and individual packages is the focus of Root-Level vs Package-Level Scripts.
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": { "dependsOn": ["^build"], "outputs": ["dist/**"] },
"lint": { "dependsOn": [] },
"test": { "dependsOn": ["build"], "inputs": ["src/**/*.ts", "test/**/*.ts"] }
}
}
The workspace: protocol is what makes a monorepo's internal graph both correct in development and safe at publish time. In development, "@acme/ui": "workspace:^" resolves to a symlink into the local package, so a change is live everywhere immediately with no rebuild-and-relink dance; at publish time, the package manager rewrites that specifier to the real published version, so external consumers get a normal semver range. Getting this wrong — hardcoding a version instead of the protocol — reintroduces the stale-copy problems the protocol exists to eliminate.
Build orchestration is where the topology pays off. A task runner reads the same dependency graph, builds packages in topological order so a package always compiles after the local packages it imports, and caches each task's output keyed on its inputs so unchanged work is replayed rather than recomputed. Separate root-level orchestration scripts (which fan a task across the workspace) from package-level scripts (which do the actual work), so npm run build at the root has an obvious meaning and each package remains responsible for building itself.
Script layering is what keeps a workspace's command surface coherent as it grows. Root-level scripts orchestrate — fanning a task across packages, often through a task runner that adds ordering and caching — while package-level scripts do the actual work of building or testing one package. Keeping the two layers distinct means npm run build at the root has an obvious, single meaning, contributors are not confused about where a task runs, and CI can target the root for whole-repo tasks or a single package for focused ones without command collisions.
Common Implementation Pitfalls
| Mistake | Impact | Resolution |
|---|---|---|
Omitting the packageManager field |
Developers use mismatched package manager versions, causing inconsistent resolution and lockfile corruption. | Add "packageManager": "pnpm@10.4.1" (or equivalent) and enforce via Corepack or a CI version check. |
Using npm install instead of npm ci in CI |
CI modifies lockfiles on every run, introducing non-deterministic builds and deployment drift. | Replace with npm ci --ignore-scripts or pnpm install --frozen-lockfile. |
Relying on implicit main/module fields |
Bundlers resolve incorrect entry points, breaking ESM/CJS interop and causing runtime ERR_REQUIRE_ESM errors. |
Migrate to explicit conditional exports maps with types, import, and require keys. |
Declaring framework plugins as dependencies |
Duplicate framework instances in the dependency graph, broken context providers, larger bundles. | Move framework references to peerDependencies and set peerDependenciesMeta with optional: true where applicable. |
Emitting one .d.ts for both formats |
require consumers hit "cannot find module" or get ESM-shaped types under CJS. |
Emit .d.ts for ESM and .d.cts for CJS, wired through matching exports conditions. |
Verification: proving the workflow end to end
Every stage in this workflow is verifiable, and a mature setup verifies each rather than trusting it. The manifest is checked with publint and @arethetypeswrong/cli; the dependency graph is checked with npm ls/pnpm why to confirm a package resolves to exactly one copy where you expect it; the lockfile is enforced with a frozen install; the module boundary is checked with a smoke test that both requires and imports the built package; and the declarations are checked by type-checking those smoke tests under node16 resolution.
Wiring these into one CI job means a regression in any stage — a reordered exports key, a duplicated dependency, a dropped declaration — fails your build instead of a consumer's install. The payoff is that 'works on my machine' becomes 'works on every machine', because the machine that matters is the one running the frozen, verified pipeline. Treat the checks as the definition of done for a publishable change, not as optional extras.
Build orchestration and script execution pipelines
Between the manifest and the published artifact sits the build, and treating it as a first-class, reproducible pipeline is what separates a reliable package from a fragile one. The pipeline has a fixed shape: clean the output directory, type-check, emit JavaScript in each target format, emit matching declarations, and validate the result before it can be published. Encoding that as ordered scripts — with prepublishOnly running the full validation so a broken build cannot be published by hand — makes the release deterministic rather than dependent on a developer remembering the steps.
Script execution boundaries matter as much as the steps themselves. Reserve postinstall for genuinely local, offline work and never for network-dependent or environment-specific operations, because it runs on every consumer's machine with their privileges — a supply-chain risk and a reproducibility hazard. Keep build-time tooling in devDependencies so it never ships, run CI installs with --ignore-scripts to neutralize arbitrary lifecycle code from the dependency graph, and let the explicit build script — not an implicit install hook — be the single place your artifact is produced.
Frequently Asked Questions
How do I enforce strict lockfile synchronization across a distributed team?
Enable Corepack for version pinning, add --frozen-lockfile (or --immutable for Yarn Berry) to every CI install step, and add a pre-commit hook using lint-staged and husky to block commits that change a lockfile without a matching package.json change.
What is the recommended strategy for publishing dual ESM/CJS packages?
Use a bundler like tsup or Rollup to generate separate .mjs and .cjs outputs, map them via conditional exports, emit .d.ts and .d.cts declarations for each condition, and set "type": "module" at the package root.
When should I use overrides versus resolutions in a monorepo?
Use overrides (npm v8.3+) or pnpm.overrides for explicit, audited dependency patching. Reserve resolutions for Yarn v1 projects. Avoid global overrides unless addressing a critical CVE, as they can mask underlying dependency misconfigurations.
How can I optimize CI/CD pipeline execution for large workspaces?
Adopt a task runner like Turborepo or Nx with remote caching, configure pipeline dependencies so only affected packages rebuild, use incremental inputs for test jobs, and cache node_modules and build artifacts across runs.
Do I have to choose one package manager for the whole organization?
For a single repository, yes — the lockfile format is manager-specific, so mixing them produces conflicting state. Across separate repositories you can differ, but pinning each repo's manager via packageManager and Corepack keeps every checkout deterministic.
Why does the order of keys in exports matter?
Node evaluates conditions top to bottom and stops at the first match. Put types first, then runtime conditions from most specific to least (import, require), and default last. A default placed above import short-circuits ESM resolution for every consumer.
How do I stop consumers from importing my internal files?
Only list the subpaths you intend to be public in exports. Any path you do not export is unreachable to consumers, so internal modules under dist/internal/ cannot be imported — which frees you to refactor the internal layout without a breaking change.
What's the fastest way to catch a broken dual-format publish?
Add publint and @arethetypeswrong/cli to CI, plus a smoke test that requires and imports the built package and type-checks under node16. Together they catch reordered exports, missing .d.cts declarations, and the dual-package hazard before publish.
Where should I start if I'm setting up a new package?
Begin with the manifest — an explicit exports map, pinned engines and packageManager, and a files allowlist — because every later stage reads from it. Then add a frozen-lockfile CI install, a dual-format build if you need CommonJS consumers, and validation with publint and @arethetypeswrong/cli before your first publish.
Related
- Understanding package.json Fields — the field-by-field manifest reference that every other workflow builds on.
- Dependency Resolution Explained — how declared ranges flatten into the installed tree and where conflicts arise.
- ESM and CJS Interoperability — the module-format rules behind dual packaging and the common runtime errors.
- Lockfile Management Strategies — keeping installs reproducible and lockfiles reviewable in CI.
- TypeScript Declaration Publishing — shipping type definitions that resolve correctly under both ESM and CJS.
← Home