Node.js Modules

Split Node.js programs into modules, share code with export and import, understand ES modules versus CommonJS require, use built-in modules with the node: prefix, and get file paths with import.meta.

Lesson 2 of 43Beginner18 min read

As soon as a program grows beyond a few dozen lines, keeping everything in one file becomes painful. Modules let you split code into separate files, each responsible for one job: one for formatting prices, one for talking to the database, one for the web routes. Each module chooses what to export, and other files import exactly what they need.

Node.js supports two module systems. ES modules (ESM) use the import and export keywords, the same standard used in browsers, and they're the modern choice for new projects. CommonJS uses require() and module.exports; it was Node's original system, and you'll still meet it in older code, tutorials and many npm packages. Node can use both, so it's worth recognising each.

Modules also give you access to Node's built-in modules, such as fs for files, path for file paths and os for system information, and to the hundreds of thousands of packages on npm. In this lesson you'll create your own modules, use named and default exports, switch a project to ES modules, read CommonJS code, and learn a few module details that often trip people up.

ES modules: export and import

JavaScript
// prices.mjs
export const VAT = 0.2;
export function withVat(amount) {
  return amount * (1 + VAT);
}
export default function formatGBP(amount) {
  return `£${amount.toFixed(2)}`;
}
JavaScript
// app.mjs
import formatGBP, { withVat, VAT } from './prices.mjs';
console.log(formatGBP(withVat(10)));   // £12.00
  • Named exports (export const, export function) are imported by name inside braces, and you can rename them: import { withVat as addTax }.
  • A module can have one default export, imported without braces under any name you choose.
  • Relative imports need the full file name, including the extension: './prices.mjs' or './prices.js'.
  • import * as prices from './prices.mjs' imports everything as one object.

Turning on ES modules

Node treats a file as an ES module when:

  • its extension is .mjs, or
  • it's a .js file and the nearest package.json contains "type": "module".

Otherwise .js files are treated as CommonJS. Most new projects add "type": "module" to package.json and use .js everywhere. ES modules also allow top-level await, so you can write const data = await readFile('x.json', 'utf8'); at the top of a file.

CommonJS: require and module.exports

JavaScript
// prices.cjs
const VAT = 0.2;
function withVat(amount) { return amount * (1 + VAT); }
module.exports = { VAT, withVat };

// app.cjs
const { withVat } = require('./prices.cjs');
const fs = require('node:fs');
  • require loads a module synchronously and returns whatever it assigned to module.exports.
  • In CommonJS, __dirname and __filename give the current folder and file. In ES modules, use import.meta.dirname and import.meta.filename (Node 20.11 and later).
  • ES modules can import CommonJS packages. Recent Node versions can also require() ES modules, but mixing is easiest to avoid in your own code.

Built-in modules and caching

Import built-in modules with the node: prefix, which makes it obvious they come from Node and not from npm: import fs from 'node:fs', import { join } from 'node:path', import { readFile } from 'node:fs/promises'.

A module's code runs once, the first time it's imported. Later imports get the same, cached module, so a module-level variable is shared by every file that imports it. Dynamic import() loads a module on demand and returns a promise, which is useful for optional features.

Node.js Basics Syntax

Module syntax

// ES modules
export const x = 1;
export function f() {}
export default class Cart {}
import Cart, { x, f as g } from './cart.js';
import * as utils from './utils.js';
import { readFile } from 'node:fs/promises';
const mod = await import('./optional.js');
console.log(import.meta.dirname, import.meta.filename);

// CommonJS
const path = require('node:path');
module.exports = { x, f };

Node.js Basics Summary

  • Modules split code into files; export shares values and import uses them.
  • Named exports use braces when imported; a default export doesn't.
  • .mjs files, or "type": "module" in package.json, turn on ES modules.
  • CommonJS uses require() and module.exports; you'll see it in older code.
  • Use the node: prefix for built-in modules; module code runs once and is cached.

Node.js Basics Examples

Each example shows the code, the output it produces and what each part does.

Example 1

A module with named and default exports

Node.js
// cart.mjs
export const DELIVERY_INCLUDED_FROM = 50;

export function subtotal(items) {
  return items.reduce((sum, item) => sum + item.price * item.qty, 0);
}

export default function summary(items) {
  const total = subtotal(items);
  const delivery = total >= DELIVERY_INCLUDED_FROM ? 0 : 4.99;
  return `Items: £${total.toFixed(2)}, delivery: £${delivery.toFixed(2)}`;
}
Node.js
// app.mjs
import summary, { subtotal, DELIVERY_INCLUDED_FROM } from './cart.mjs';

const basket = [
  { name: 'Mug', price: 8.5, qty: 2 },
  { name: 'Lamp', price: 24, qty: 1 },
];

console.log(subtotal(basket));
console.log(summary(basket));
console.log(`Spend £${DELIVERY_INCLUDED_FROM} and delivery is included`);
Output
41
Items: £41.00, delivery: £4.99
Spend £50 and delivery is included
Line-by-line explanation
  1. Line 2A named export, imported by name in app.mjs.
  2. Lines 4 to 6Exported functions can be used by any module that imports them.
  3. Line 8The default export is imported without braces, under any name.
  4. Line 10Inside the module, other exports are used directly by name.
Example 2

Reading CommonJS code

Node.js
// greet.cjs
const greetings = { en: 'Hello', es: 'Hola', fr: 'Bonjour' };

function greet(name, lang = 'en') {
  return `${greetings[lang] ?? greetings.en}, ${name}!`;
}

module.exports = { greet, languages: Object.keys(greetings) };
Node.js
// main.cjs
const { greet, languages } = require('./greet.cjs');
const path = require('node:path');

console.log(greet('Ada'));
console.log(greet('Luis', 'es'));
console.log(greet('Zoe', 'de'));
console.log(languages.join(', '));
console.log(path.basename(__filename));
Output
Hello, Ada!
Hola, Luis!
Hello, Zoe!
en, es, fr
main.cjs
Line-by-line explanation
  1. Line 2A plain object holds the greetings; it isn't exported, so it stays private to the module.
  2. Line 5?? falls back to English for a language that isn't listed.
  3. Line 8module.exports decides what require returns; Object.keys lists the supported languages.
Example 3

import.meta, dynamic import and caching

Node.js
import { join } from 'node:path';
import { writeFile } from 'node:fs/promises';

const here = import.meta.dirname;
console.log(typeof here, import.meta.filename.endsWith('.mjs'));

const counterFile = join(here, 'counter.mjs');
await writeFile(counterFile, 'export let count = 0;\nexport function bump() { count++; }\n');

const first = await import('./counter.mjs');
const second = await import('./counter.mjs');
first.bump();
first.bump();
console.log(second.count);
console.log(first === second);

const { platform } = await import('node:os');
console.log(['win32', 'darwin', 'linux'].includes(platform()));
Output
string true
2
true
true
Line-by-line explanation
  1. Line 4import.meta.dirname is the folder that contains this module.
  2. Line 8For the demo, the script writes a tiny module to disk; normally it would already exist.
  3. Lines 10 to 11import() loads a module on demand and returns a promise, so it's awaited.
  4. Line 14Both imports share one cached module, so count is 2 through either name.
  5. Line 15The same module object is returned every time.
  6. Line 17Built-in modules can be loaded dynamically too.

Common Node.js Basics Mistakes

Leaving out the file extension

Don't
import { subtotal } from './cart';      // ERR_MODULE_NOT_FOUND in ES modules
Do
import { subtotal } from './cart.js';

Mixing require into an ES module

Don't
// app.mjs
const fs = require('fs');               // ReferenceError: require is not defined in ES module scope
Do
// app.mjs
import fs from 'node:fs';

Using __dirname in an ES module

Don't
const file = __dirname + '/data.json';  // ReferenceError in ES modules
Do
import { join } from 'node:path';
const file = join(import.meta.dirname, 'data.json');

Node.js Basics in Real Projects

Modules in real projects

A typical Node API has folders such as routes/, services/, db/ and utils/, each containing small modules that import one another. Configuration is loaded once in a config.js module and shared through caching. Newer projects use ES modules throughout with "type": "module", while large older codebases and some tools still use CommonJS, so most Node developers read both fluently.

Node.js Basics FAQ

Should I use import or require in a new Node.js project?

Use import (ES modules) for new code. It's the JavaScript standard, works the same way in browsers, supports top-level await, and is what most current documentation and packages assume. You'll still read require in older code, so it's worth recognising, but there's rarely a reason to start a project with it today.

How do I fix "require is not defined in ES module scope"?

The file is being treated as an ES module (because it ends in .mjs or package.json has "type": "module"), and require doesn't exist there. Replace it with import, for example import fs from 'node:fs'. If you really need require for one package, create it with import { createRequire } from 'node:module' and const require = createRequire(import.meta.url).

What is the difference between .js, .mjs and .cjs files?

.mjs files are always ES modules and .cjs files are always CommonJS, whatever package.json says. A plain .js file follows the "type" field of the nearest package.json: "module" means ES modules, and anything else means CommonJS. The explicit extensions are handy when one project needs a few files of the other kind.

How do I import a JSON file in Node.js?

In an ES module, use an import attribute: import config from './config.json' with { type: 'json' };. In CommonJS, require('./config.json') parses it for you. Both are cached like any module, so if the file changes while the program runs, read it with readFile and JSON.parse instead.

Why does a variable in my module keep its value between imports?

Node runs each module's code only once and caches the result, so every file that imports it gets the same module instance. A let count = 0 at the top of a module is therefore shared by everyone who imports it. That's useful for things like a single database connection, but it means you should avoid storing per-request data in module-level variables.

Node.js Basics Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Fill in the blankseasy

Complete the line to share the function with other modules.

 function add(a, b) {
  return a + b;
}
Multiple choiceeasy

How do you import a default export called formatDate from './dates.js'?

Multiple choicemedium

Which setting makes .js files in a project ES modules?

Predict the outputmedium

A module contains console.log('loaded') at the top level. Two other files import it in the same program. How many times is "loaded" printed?

Multiple choicehard

What replaces __dirname in an ES module?

Node.js Basics Quiz

5 questions to check what you learned.

  1. What does the node: prefix in import fs from 'node:fs' show?

    • The module comes from npm
    • The module is a CommonJS module
    • The module is optional
    • The module is built into Node.js
  2. How does CommonJS export values?

    • export default
    • module.exports = { ... }
    • exports default
    • return { ... }
  3. What does dynamic import() return?

    • The module directly
    • A string
    • A promise that resolves to the module
    • Nothing
  4. How many default exports can a module have?

    • None
    • One
    • Two
    • Any number
  5. Which file extension always means an ES module, whatever package.json says?

    • .js
    • .cjs
    • .json
    • .mjs