Skip to content

Instantly share code, notes, and snippets.

@panphora
Created June 1, 2025 19:30
Show Gist options
  • Save panphora/b5f3756d7879515e4ce1ec6981c2aefc to your computer and use it in GitHub Desktop.
Save panphora/b5f3756d7879515e4ce1ec6981c2aefc to your computer and use it in GitHub Desktop.

Local ES Modules Without a Server

Why Use This?

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

How It Works

We use a custom script type (hash-module) to mark our modules, then:

  1. Load each module file using XMLHttpRequest
  2. Convert them to blob URLs
  3. Create an import map that maps hash-prefixed IDs to these blob URLs
  4. Use dynamic import() to load the main module

Example

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>

Module Files

Create these files in the same directory as your HTML:

math.js

// 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);
}

utils.js

// 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.js

// 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 = '';
}

Usage Notes

  • 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

Limitations

  • 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!​​​​​​​​​​​​​​​​

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment