Sometimes you just want to write modular JavaScript without the overhead of:
- Starting a development server
- Setting up a build process
- Dealing with CORS issues on
file://
protocol - Installing Node.js or any tooling
This technique lets you use ES6 modules (import
/export
) in a plain HTML file that you can open directly in your browser. Perfect for:
- Quick prototypes
- Educational examples
- Offline development
- Sharing self-contained demos
We use a custom script type (hash-module
) to mark our modules, then:
- Load each module file using XMLHttpRequest
- Convert them to blob URLs
- Create an import map that maps hash-prefixed IDs to these blob URLs
- Use dynamic
import()
to load the main module
Save this as index.html
and open it directly in your browser:
<!DOCTYPE html>
<html>
<head>
<title>Local ES Modules Demo</title>
</head>
<body>
<h1>Local ES Modules Demo</h1>
<div id="output"></div>
<!-- Module loader -->
<script>
(async () => {
const imports = {};
const moduleScripts = document.querySelectorAll('script[type="hash-module"]');
for (const script of moduleScripts) {
const id = script.id;
const src = script.getAttribute('src');
if (!id) continue;
let codeText;
if (src) {
// Load external module file
codeText = await new Promise((res, rej) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', src);
xhr.onload = () => res(xhr.responseText);
xhr.onerror = () => rej(new Error("Failed to load " + src));
xhr.send();
});
} else {
// Inline module
codeText = script.textContent;
}
const blobUrl = URL.createObjectURL(new Blob([codeText], { type: 'application/javascript' }));
imports['#' + id] = blobUrl;
}
// Create import map
const importMap = document.createElement('script');
importMap.type = 'importmap';
importMap.textContent = JSON.stringify({ imports }, null, 2);
document.head.appendChild(importMap);
// Load main module
if (imports['#main']) {
import('#main').catch(err => console.error("Import failed:", err));
}
})();
</script>
<!-- External modules -->
<script type="hash-module" id="math" src="math.js"></script>
<script type="hash-module" id="utils" src="utils.js"></script>
<script type="hash-module" id="dom" src="dom.js"></script>
<!-- Main module (inline) -->
<script type="hash-module" id="main">
import { sum, multiply, divide } from '#math';
import { formatNumber, formatDate } from '#utils';
import { addToOutput } from '#dom';
// Use the imported functions
const a = 10;
const b = 5;
addToOutput(`Sum of ${a} + ${b} = ${formatNumber(sum(a, b))}`);
addToOutput(`Product of ${a} × ${b} = ${formatNumber(multiply(a, b))}`);
addToOutput(`Division of ${a} ÷ ${b} = ${formatNumber(divide(a, b))}`);
addToOutput(`Current date: ${formatDate(new Date())}`);
</script>
</body>
</html>
Create these files in the same directory as your HTML:
// Math utility functions
export function sum(a, b) {
return a + b;
}
export function multiply(a, b) {
return a * b;
}
export function divide(a, b) {
if (b === 0) throw new Error('Division by zero');
return a / b;
}
export function power(base, exponent) {
return Math.pow(base, exponent);
}
// Formatting utilities
export function formatNumber(num) {
return num.toLocaleString('en-US', { maximumFractionDigits: 2 });
}
export function formatDate(date) {
return date.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
}
export function capitalize(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
// DOM manipulation utilities
import { capitalize } from '#utils';
export function addToOutput(text) {
const output = document.getElementById('output');
const p = document.createElement('p');
p.textContent = capitalize(text);
output.appendChild(p);
}
export function clearOutput() {
const output = document.getElementById('output');
output.innerHTML = '';
}
- Modules are referenced by their ID with a
#
prefix:import { sum } from '#math'
- External modules use
src
attribute:<script type="hash-module" id="math" src="math.js"></script>
- Inline modules work too - just put the code inside the script tag
- The module with ID
main
is automatically loaded as the entry point - Modules can import from other modules using the same
#id
syntax
- Import maps are immutable once set, so all modules must be declared before the loader runs
- No support for bare module specifiers (e.g.,
import React from 'react'
) - Hot module replacement isn't possible
- Browser dev tools might show blob URLs instead of file names
Despite these limitations, this approach is perfect for quick experiments and self-contained demos that "just work" without any setup!