NodeJSCreating Local Modules

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

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

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

Text
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

JS
export { hashPassword } from './hashPassword.js'
export { verifyToken } from './verifyToken.js'
// session.js stays private — it is an implementation detail

JS
import { hashPassword, verifyToken } from './lib/auth/index.js'
// or, in CommonJS, simply: require('./lib/auth')
Barrel files: convenient but not free
A re-exporting "barrel" `index.js` gives consumers one clean import path and lets you reshuffle internals freely. The trade-off: importing one name can pull in the *whole* barrel's dependency graph, which hurts startup and tree-shaking in large apps. Keep barrels small, or import deep paths on hot paths.
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 default for a module that is one thing.

  • Group by feature, not by type — auth/ beats scattering auth logic across helpers/, 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

JS
import { readFile } from 'node:fs/promises'

const url = new URL('./template.html', import.meta.url)
const html = await readFile(url, 'utf8')   // relative to THIS file

CommonJS

JS
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

JSON
{
  "imports": {
    "#auth/*": "./lib/auth/*.js",
    "#config": "./config/index.js"
  }
}

JS
import { verifyToken } from '#auth/verifyToken'
import config from '#config'
// no more ../../.. regardless of how deep this file lives
Keep modules pure where you can
Top-level side effects run on import
Any code at a module's top level executes the moment the module is first imported — including network calls, file reads, or `console.log`. Modules that *do* things on import are hard to test and reason about. Prefer exporting functions the caller invokes explicitly; reserve top-level work for genuine one-time initialization (and remember [it is cached](/nodejs/module-caching)).
Next
See how your local files relate to built-ins and npm packages in [Core, Local & Third-Party Modules](/nodejs/core-vs-external).