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 (python3 ships 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 uses import throws SyntaxError: Cannot use import statement outside a module, because Node defaults .js to CommonJS. Setting it to module flips that default for the whole package.
  • main names the entry file, and the start/test scripts 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 makeObject is a named export. It is a factory function: each call returns a new object, and the inner hello function keeps a reference to that call’s name through a closure, so two objects made with different names stay independent.
  • const { name = "Robbie" } = options destructures the options argument with a default, so makeObject() and makeObject({ 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: obj binds to the default export, and the braces name the named export. The ./ prefix and the .js extension 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 makeObject calls produce independent objects whose hello() 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

  • captureLog temporarily swaps console.log for a collector so a test can assert on what hello() printed, then restores the original in a finally block.
  • The default-export test mutates the shared object and confirms the property stuck — the runtime-extension behavior from Step 3.
  • The two makeObject tests 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 load index.js as an ES module, so its import of my-mod.js resolves. This is the browser counterpart to "type": "module" in package.json.
  • The page renders nothing visible; index.js writes 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 := help makes a bare make print the help screen, so the project documents itself.
  • run, test, and serve wrap 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.

References