Modules Best Practices
Module boundaries shape deployability and refactors - standardize on ESM, explicit exports, and honest package.json dependencies unless an ADR documents a dual-module exception.
How to Use This List
- Apply to every new package in a monorepo and every publishable library.
- During CJS migration, check only the items marked relevant - do not dual-publish without a sunset date.
- Pair with Knip or dependency-cruiser in CI for phantom dependency detection.
- Revisit when TypeScript or Node changes module resolution defaults.
A - Module System Choice
- Set
"type": "module"on new application packages. CJS only for.cjsconfigs and legacy shims. - One module system per deployable package when possible. Dual CJS/ESM only for published libraries with documented interop tests.
- Use
.mjs/.cjsextensions when mixing systems in one package. Never rely on ambiguous.jsparsing. - Document dual-module exceptions in an ADR with sunset date. Dual mode is migration, not architecture.
- Prefer
node:prefix on built-in imports. Avoid shadowing and clarify intent.
B - package.json Hygiene
- Define
"exports"for libraries and shared monorepo packages. Block deep imports intosrc/. - Declare every bare specifier import in
dependencies. Do not rely on hoisted phantom deps. - Point
exportsat compileddist/, not TypeScript source. Consumers run JS matching Node resolution. - Export
"./package.json"only when tooling requires it. Keep surface minimal otherwise. - Use workspace protocol (
workspace:*) consistently in monorepos. Lock versions on publish.
C - Import Style
- Prefer named exports over default for libraries. Better refactors and tree shaking clarity.
- Use
import typefor type-only imports. EnablesverbatimModuleSyntaxand cleaner bundles. - Include
.jsextensions in relative ESM import paths. Matches NodeNext emit and runtime. - Limit dynamic
import()to plugins and optional code paths. Keep core graph static and analyzable. - Avoid circular dependencies between barrels. Import from leaf modules directly.
D - TypeScript Alignment
- Use
"module": "NodeNext"and"moduleResolution": "NodeNext". Matches Node 24 ESM resolver. - Do not assume
tsconfigpaths fix runtime. Emit correct paths or use build tooling. - Enable
verbatimModuleSyntaxfor new projects. Separates type imports explicitly. - Test
node dist/main.jsin CI, not onlytsx src. Catches extension and exports mistakes. - Ship
.d.tsnext to emitted JS with matchingexportstypes condition.
E - Monorepo and Publishing
- Run
npm pack --dry-runbefore publishing. Verifyfilesandexportsinclude only intended artifacts. - Integration-test both
importandrequireif dual publishing. Prevent dual package hazard regressions. - Pin internal workspace deps to compatible versions on release. Avoid symlink-only assumptions in production tarballs.
- Use
#internalimport maps for package-private modules. Never expose#paths viaexports. - Scan for
ERR_PACKAGE_PATH_NOT_EXPORTEDin consumer apps after tightening exports. Communicate breaking changes.
FAQs
Should our API service ship dual CJS/ESM?
No - applications deploy one module system. Dual publishing is for libraries with external CJS consumers.
What is the biggest monorepo mistake?
Phantom dependencies from hoisting - import works until a clean npm ci in Docker.
When is default export acceptable?
CLI entry with single function, or framework convention - not for shared domain libraries.
Do we need path aliases?
Rarely at runtime - prefer exports subpaths for public API. Aliases are OK inside apps if build enforces them.
How do we enforce .js extensions?
ESLint import/extensions or TypeScript moduleResolution: NodeNext compile errors.
Can NestJS 11 apps be ESM-only?
Yes with modern compiler settings - verify third-party Nest modules support ESM.
What about JSON config modules?
readFile + Zod validation is explicit - or JSON import attributes per Node 24 docs.
Should barrel files export everything?
No - export curated public API only. Large barrels harm compile times and circular deps.
How do we migrate CJS to ESM?
Leaf utilities first, createRequire bridges temporarily, .cjs only for unmigrated edges.
Does pnpm change these rules?
Same principles - pnpm is stricter about declared deps, which helps enforce this list.
What tools detect violations?
Knip, dependency-cruiser, and npm ls <pkg> in CI for undeclared imports.
Where is interop documented in depth?
CJS ↔ ESM Interop for bridges and dual-package hazards.
Related
- package.json type & exports - exports configuration
- Module Resolution Algorithm - how Node resolves imports
- CJS ↔ ESM Interop - migration bridges
- TypeScript in Node Basics - tsconfig defaults
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.