Creating Local Modules
A local module is simply a file in your own project that you import elsewhere — the everyday act of splitting a program into focused, reusable pieces. There is no ceremony: any .js file that exports something is a module. This page covers the practical patterns and conventions for organizing them well.
A single-responsibility file
lib/currency.js
// Each function does one thing; only the public ones are exported.
const RATE_TO_USD = { EUR: 1.08, GBP: 1.27 }
export function toUSD(amount, currency) {
const rate = RATE_TO_USD[currency]
if (!rate) throw new Error(`Unknown currency: ${currency}`)
return amount * rate
}
export function format(amount) {
return `$${amount.toFixed(2)}`
}app.js
import { toUSD, format } from './lib/currency.js'
console.log(format(toUSD(100, 'EUR'))) // $108.00$108.00
Folder modules and the index file
Group related files in a folder and give it an entry point. Importing the folder resolves to its index.js (CJS) or the "exports"/explicit file (ESM) — a single front door for a feature:
A feature folder
lib/auth/ ├─ index.js ← the public entry; re-exports the rest ├─ hashPassword.js ├─ verifyToken.js └─ session.js ← internal helper, not re-exported
lib/auth/index.js — the barrel
export { hashPassword } from './hashPassword.js'
export { verifyToken } from './verifyToken.js'
// session.js stays private — it is an implementation detailimport { hashPassword, verifyToken } from './lib/auth/index.js'
// or, in CommonJS, simply: require('./lib/auth')Choosing what to export
Export the minimum public surface — every export is a promise you must keep.
Keep helpers private (unexported) so you can refactor them without breaking callers.
Prefer named exports for collections; reserve
defaultfor a module that is one thing.Group by feature, not by type —
auth/beats scattering auth logic acrosshelpers/,utils/,models/.
Resolve paths from the module, not the cwd
When a local module reads a bundled file (a template, a JSON fixture), anchor the path to the module's own location so it works no matter where node was launched:
ESM
import { readFile } from 'node:fs/promises'
const url = new URL('./template.html', import.meta.url)
const html = await readFile(url, 'utf8') // relative to THIS fileCommonJS
const path = require('node:path')
const file = path.join(__dirname, 'template.html')Why this matters is covered in The Module Wrapper Function.
Path aliases for deep trees
Long relative chains like ../../../lib/auth are brittle. Node supports subpath imports via the "imports" field (keys must start with #), giving you stable internal aliases:
package.json
{
"imports": {
"#auth/*": "./lib/auth/*.js",
"#config": "./config/index.js"
}
}import { verifyToken } from '#auth/verifyToken'
import config from '#config'
// no more ../../.. regardless of how deep this file lives