JavaScript Modules Guide: ES Modules, import/export & Dynamic Imports

Published April 2026 · JavaScript Tutorial · Try JSON Formatter →

JavaScript modules revolutionized how we organize code. Before ES Modules (ESM), JavaScript had no native module system. Developers relied on workarounds like the revealing module pattern, IIFEs, and third-party formats (CommonJS, AMD). ES Modules became the official standard in ES2015 and are now natively supported in all modern browsers and Node.js. They provide clean syntax, strict mode by default, static analysis for tree-shaking, and a clear dependency graph.

This guide covers every aspect of ES Modules — from basic imports and exports to advanced patterns like dynamic imports, barrel files, and project organization.

Enabling ES Modules

In the browser, use the type="module" attribute on your script tag:

<!-- This is a module -->
<script type="module" src="app.js"></script>

<!-- Inline module -->
<script type="module">
  import { greet } from './utils.js';
  greet('World');
</script>

In Node.js, either use the .mjs file extension or set "type": "module" in your package.json.

Named Exports

Named exports let you export multiple values from a module. Each export has a fixed name that must be matched when importing.

// math.js — Export individual values
export const PI = 3.14159;

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

export function multiply(a, b) {
  return a * b;
}

export class Calculator {
  constructor() {
    this.history = [];
  }
  add(a, b) {
    const result = a + b;
    this.history.push(result);
    return result;
  }
}

You can also export all values at the end:

// Export a list at the bottom of the file
const API_URL = 'https://api.example.com';
const TIMEOUT = 5000;

function fetchData(url) { /* ... */ }
function parseData(data) { /* ... */ }

export { API_URL, TIMEOUT, fetchData, parseData };

Default Exports

A module can have one default export. This is typically used for the module's primary functionality.

// http.js — Default export
export default class HttpClient {
  constructor(baseURL) {
    this.baseURL = baseURL;
  }

  async get(path) {
    const response = await fetch(`${this.baseURL}${path}`);
    return response.json();
  }
}

Importing Modules

// Import named exports
import { add, multiply, PI } from './math.js';

// Import with alias (rename to avoid conflicts)
import { add as sum, multiply as product } from './math.js';

// Import default export (any name)
import HttpClient from './http.js';

// Import everything as a namespace object
import * as math from './math.js';
math.add(2, 3);     // 5
math.multiply(4, 5); // 20

// Import default + named in one statement
import HttpClient, { fetchData } from './http.js';

Importing Styles and Non-JS Files

In bundler environments (Vite, Webpack, Rollup), you can import CSS and other asset types directly:

// Works in Vite/Webpack (not native browser)
import './styles.css';
import logo from './logo.svg';

In native browser modules, only JavaScript files can be imported. CSS is loaded via <link> tags.

Dynamic Imports for Code Splitting

The import() function loads a module asynchronously and returns a Promise. This is essential for code splitting — loading heavy features only when the user needs them.

// Basic dynamic import
button.addEventListener('click', async () => {
  const { Chart } = await import('./chart-module.js');
  const chart = new Chart(canvas, data);
});

// With error handling
async function loadEditor() {
  try {
    const { Editor } = await import('./editor.js');
    return new Editor('#editor-container');
  } catch (error) {
    console.error('Failed to load editor:', error);
    return null;
  }
}

Common Dynamic Import Patterns

// Route-based code splitting
const routes = {
  '/dashboard': () => import('./pages/dashboard.js'),
  '/settings':  () => import('./pages/settings.js'),
  '/profile':   () => import('./pages/profile.js'),
};

async function navigate(path) {
  const loader = routes[path];
  if (loader) {
    const module = await loader();
    module.render(document.getElementById('app'));
  }
}

// Feature detection loading
async function initPayment() {
  if ('PaymentRequest' in window) {
    const { NativePayment } = await import('./payment-native.js');
    return new NativePayment();
  } else {
    const { FallbackPayment } = await import('./payment-fallback.js');
    return new FallbackPayment();
  }
}

Module Scoping and Strict Mode

ES Modules automatically run in strict mode. This means:

// This variable is NOT global — it's scoped to this module
const API_KEY = 'secret';

// You MUST use var, let, or const
x = 10; // ReferenceError in modules (implicit global not allowed)

Project Organization Best Practices

Barrel Files (index.js)

Barrel files re-export from multiple modules, providing a single import point:

// components/index.js
export { Button } from './Button.js';
export { Modal } from './Modal.js';
export { Card } from './Card.js';
export { Input } from './Input.js';

// Now consumers can import from one path:
import { Button, Modal, Card } from './components/index.js';
// Or simply:
import { Button, Modal } from './components';

Recommended Folder Structure

src/
├── components/
│   ├── Button.js
│   ├── Card.js
│   └── index.js          # Barrel file
├── utils/
│   ├── format.js
│   ├── validate.js
│   └── index.js
├── services/
│   ├── api.js
│   └── auth.js
├── pages/
│   ├── home.js
│   └── about.js
├── styles/
│   └── main.css
└── app.js                 # Entry point

Separation of Concerns

CommonJS vs ES Modules

If you are working with older Node.js codebases, you will encounter CommonJS (require/module.exports). Key differences:

// CommonJS
const express = require('express');
module.exports = { handler };

// ES Module
import express from 'express';
export { handler };

Top-Level Await

In ES Modules, you can use await at the top level without wrapping it in an async function:

// Load configuration before anything else
const config = await fetch('/config.json').then(r => r.json());
console.log('App initialized with:', config);

This is only available in modules (type="module"), not in regular scripts.

Frequently Asked Questions

What is the difference between default and named exports?

A module can have one default export (imported with any name) and multiple named exports (imported by their exact names). Default exports are best for single-purpose modules; named exports are best for utility libraries.

Do ES Modules work in all browsers?

Yes, all modern browsers (Chrome, Firefox, Safari, Edge) support ES Modules natively. Internet Explorer does not support them.

Can I use import() with await?

Yes, top-level await is supported in ES Modules. You can write const module = await import('./module.js') directly at the top level of a module.

What is a barrel file?

A barrel file (usually index.js) re-exports from multiple modules in a directory, providing a single import point: import { a, b, c } from './components'.

Do I still need a bundler with ES Modules?

For simple projects, no. For production apps, bundlers like Vite or Rollup still offer benefits: tree-shaking, transpilation, reducing many HTTP requests into fewer files, and asset optimization.