A JavaScript module is a file that exports values other files import by name. ES modules (import/export) are the standard form and run unchanged in modern browsers and Node.js. This tutorial builds a small module with a default export and a named factory function, imports it from another file, tests it with Node’s built-in runner, and loads it in the browser — the same module code in both environments, no bundler.
Prerequisites
- Node.js 18 or later (validated with Node 26.4.0)
- Python 3 for the local static server (
python3ships with the Xcode Command Line Tools on macOS) make(pre-installed on macOS and most Linux distributions)- A Unix-like terminal (macOS, Linux, or WSL on Windows)
No third-party npm packages are used.
Step 1: Create the project and mark it as a module
Make the project folder and add a package.json. The "type": "module" field is what lets Node treat .js files as ES modules.
Create the file
mkdir -p js-module-01
cd js-module-01
touch package.json
Add the code: js-module-01/package.json
{
"name": "js-module-01",
"version": "1.0.0",
"description": "Create a JavaScript module that runs in Node and the browser.",
"type": "module",
"main": "index.js",
"scripts": {
"start": "node index.js",
"test": "node --test"
},
"license": "MIT"
}
Detailed breakdown
- Without
"type": "module", running a file that usesimportthrowsSyntaxError: Cannot use import statement outside a module, because Node defaults.jsto CommonJS. Setting it tomoduleflips that default for the whole package. mainnames the entry file, and thestart/testscripts alias the two commands used below.- There are no
dependencies— modules, imports, and the test runner are all built in.
Step 2: Write the module
A module exposes two kinds of export: one default export per file, and any number of named exports. This file uses both.
Create the file
touch my-mod.js
Add the code: js-module-01/my-mod.js
// File: my-mod.js
// A module with a named factory function and a default export.
// makeObject is a factory: it returns a fresh object each call, and the
// returned hello() closes over the name passed in.
export function makeObject(options = {}) {
const { name = "Robbie" } = options;
function hello() {
console.log(`My name is ${name}`);
}
return { hello };
}
// The default export is a plain object callers can extend at runtime.
export default {};
Detailed breakdown
export function makeObjectis a named export. It is a factory function: each call returns a new object, and the innerhellofunction keeps a reference to that call’snamethrough a closure, so two objects made with different names stay independent.const { name = "Robbie" } = optionsdestructures the options argument with a default, somakeObject()andmakeObject({ name: "B9" })both work.export default {}is the default export — one per module. Exporting a plain object shows a trait of JavaScript: the importer can add properties to it at runtime, which the next step does.
Step 3: Import and run the module
Create the file
touch index.js
Add the code: js-module-01/index.js
// File: index.js
// Import both exports from my-mod.js and exercise them.
import obj, { makeObject } from "./my-mod.js";
// The default export is an object; add a property to it at runtime.
obj.name = "Droid";
console.log("My name is", obj.name);
// The factory returns independent objects.
const robbie = makeObject();
robbie.hello();
const b9 = makeObject({ name: "B9" });
b9.hello();
Detailed breakdown
import obj, { makeObject } from "./my-mod.js"pulls in both exports at once:objbinds to the default export, and the braces name the named export. The./prefix and the.jsextension are both required for ES-module paths.- Assigning
obj.name = "Droid"mutates the imported default object, then logs it. This works because the default export is a shared object reference. - The two
makeObjectcalls produce independent objects whosehello()greets with each one’s name.
Run it:
node index.js
My name is Droid
My name is Robbie
My name is B9
Step 4: Test the module
Node’s built-in test runner (node --test, stable since Node 20) exercises the module with no framework to install. Because my-mod.js only defines exports and runs no code on import, the tests can import it directly without side effects.
Create the file
mkdir -p test
touch test/module.test.js
Add the code: js-module-01/test/module.test.js
// File: test/module.test.js
// Runs with the built-in Node test runner: `node --test`.
import { test } from "node:test";
import assert from "node:assert/strict";
import defaultExport, { makeObject } from "../my-mod.js";
// Capture console.log output produced by a callback.
function captureLog(fn) {
const lines = [];
const original = console.log;
console.log = (...args) => lines.push(args.join(" "));
try {
fn();
} finally {
console.log = original;
}
return lines;
}
test("the default export is an object that can be extended at runtime", () => {
defaultExport.name = "Droid";
assert.equal(defaultExport.name, "Droid");
});
test("makeObject().hello() greets with the default name", () => {
const lines = captureLog(() => makeObject().hello());
assert.equal(lines[0], "My name is Robbie");
});
test("makeObject({ name }) greets with the provided name", () => {
const lines = captureLog(() => makeObject({ name: "B9" }).hello());
assert.equal(lines[0], "My name is B9");
});
test("each makeObject call returns an independent instance", () => {
const a = makeObject({ name: "A" });
const b = makeObject({ name: "B" });
assert.notEqual(a, b);
assert.equal(typeof a.hello, "function");
});
Detailed breakdown
captureLogtemporarily swapsconsole.logfor a collector so a test can assert on whathello()printed, then restores the original in afinallyblock.- The default-export test mutates the shared object and confirms the property stuck — the runtime-extension behavior from Step 3.
- The two
makeObjecttests confirm the default and the supplied name; the last checks that two calls return distinct objects, which is the point of a factory over a shared singleton.
Run the suite:
node --test
✔ the default export is an object that can be extended at runtime
✔ makeObject().hello() greets with the default name
✔ makeObject({ name }) greets with the provided name
✔ each makeObject call returns an independent instance
ℹ tests 4
ℹ pass 4
ℹ fail 0
Step 5: Load the module in the browser
The same module runs in the browser through a <script type="module"> tag. Browsers block module imports over the file:// protocol, so the page is served over HTTP rather than opened directly.
Create the file
touch index.html
Add the code: js-module-01/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>js-module-01</title>
</head>
<body>
<!-- Output appears in the browser DevTools console, not on the page. -->
<script type="module" src="./index.js"></script>
</body>
</html>
Detailed breakdown
type="module"tells the browser to loadindex.jsas an ES module, so itsimportofmy-mod.jsresolves. This is the browser counterpart to"type": "module"inpackage.json.- The page renders nothing visible;
index.jswrites to the console, so open the browser DevTools console to see the three greeting lines.
Serve the folder and open it:
python3 -m http.server 8000
Visit http://localhost:8000/ in a browser, open the DevTools console (right-click, Inspect, Console), and you will see the same three lines Node printed. A 404 for favicon.ico in the console is harmless — the browser is just looking for a tab icon that the project does not include.
Step 6: Drive it with a Makefile
Create the file
touch Makefile
Add the code: js-module-01/Makefile
.DEFAULT_GOAL := help
SHELL := /bin/bash
PORT ?= 8000
.PHONY: help run test serve
help: ## Show this help screen
@echo "Targets:"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| sort \
| awk 'BEGIN {FS = ":.*?## "}; {printf " %-8s %s\n", $$1, $$2}'
run: ## Run index.js with Node
@node index.js
test: ## Run the Node test suite (node --test)
@node --test
serve: ## Serve the page at http://localhost:$(PORT)/ (open DevTools console)
@echo "Serving on http://localhost:$(PORT)/ (Ctrl-C to stop)"
@python3 -m http.server $(PORT)
Detailed breakdown
.DEFAULT_GOAL := helpmakes a baremakeprint the help screen, so the project documents itself.run,test, andservewrap the Node run, the test suite, and the static server from the previous steps.
Confirm the help screen:
make
Targets:
help Show this help screen
run Run index.js with Node
serve Serve the page at http://localhost:$(PORT)/ (open DevTools console)
test Run the Node test suite (node --test)
Recap
You built a module with a default export and a named factory, imported both from another file, and ran the same code in Node and the browser. The "type": "module" field (and the matching type="module" script tag) is what makes import/export work without a build step, and Node’s --test runner covers the exports with zero dependencies. This factory-plus-closure shape is the foundation for the small random helpers in the related articles below.
Related articles
- How to Select a Random Item from a JavaScript Array (Node and Browser)
- How to Create a Random Boolean Function in JavaScript (Node and Browser)
- How to Create a Weighted Random Function in JavaScript (Node and Browser)