As your JavaScript projects grow, keeping all code in one file becomes unmanageable. Before JavaScript ES6 Modules, JavaScript had no built-in way to organize code into separate files.
But ES6 (ECMAScript 2015) introduced native module support using export and import, which allows you to split code into multiple files and reuse it cleanly.
By using modules, you can:
- Import functions, variables, or classes from other files.
- Export only what you need.
- Organize and reuse code more effectively.
In this post, you’ll learn how to use JavaScript ES6 Modules with import and export syntax. You’ll explore different ways to export and import code, understand module scope, handle dynamic imports, re-export modules, and apply best practices to write clean, modular, and maintainable JavaScript.
Before we get started, don’t forget to subscribe to my newsletter!
Get the latest tips, tools, and resources to level up your web development skills delivered straight to your inbox. Subscribe here!
Now let’s jump right into it!🚀
What are JavaScript ES6 Modules?
You can think of each JavaScript file like a warehouse. Where you use export
to pack selected tools (functions, variables) into a box and use import
in another file to receive and unpack those tools.
This keeps your code organized, reusable, and modular, just like sending only what you need from one warehouse to another.
Why Use Modules?
- Cleaner code: Break large files into smaller parts.
- Reusability: You can create reusable components that you write once and reuse anywhere.
- Maintainability: The logic stays in separate files that make it easier to debug and update.
- Native support: Works in modern browsers and Node.js.
👉 If you’re new to JavaScript, check out my beginner-friendly tutorials over on Learnify, my curated platform to help you learn JavaScript step by step, with examples and simple explanations.
How to Enable Modules
To enable ES6 modules, you must use a module-aware environment.
In HTML:
Add type="module"
to your script.
<script type="module" src="app.js"></script>
In Node.js:
Add "type": "module"
in package.json
file.
{
"type": "module"
}
Note:
- If
"type": "module"
is set inpackage.json
, use.js
extension file for your modules. - If not using that, you must use
.mjs
extension file for your modules.
Exporting from a Module
JavaScript provides the following ways to export from a module.
Named Export
By using named export, you can export multiple values from a module.
// math.js
export const PI = 3.14;
export function add(a, b) {
return a + b;
}
export const subtract = (a, b) => a - b;
Here,
-
export const PI = 3.14;
:-
export
makes this variable available to be imported in another file. -
const PI = 3.14;
declares a constant namedPI
with a value of3.14
.
-
-
export function add(a, b) { return a + b;}
:-
export
makes the functionadd
available outside this file. -
function add(a, b)
declares a function that takes two arguments. -
return a + b;
returns the sum of a and b.
-
-
export const subtract = (a, b) => a - b;
:-
export
makessubtract
available to be imported. -
const subtract = (a, b) => a - b;
declares a function using arrow syntax that returns the result ofa - b
.
-
You can also group them like this:
const PI = 3.14;
function add(a, b) {
return a + b;
}
const subtract = (a, b) => a - b;
export { PI, add, subtract };
Here, instead of exporting the variable (PI
) and functions (add
and subtract
) as you declare them, you export them all together at the bottom using export { ... }
.
Default Export
By using the default export, you can export one main thing per file.
Each module can have one default export, which can be a function, class, object, or primitive value.
// logger.js
export default function log(msg) {
console.log(msg);
}
// Or with an object
// config.js
export default {
apiUrl: 'https://api.example.com',
timeout: 5000,
retries: 3
};
Here,
-
export default function log(msg) {console.log(msg);}
, exports the functionlog
as the default export and you can rename it freely when importing. -
export default {...};
exports a default object with config values.
Importing from a Module
Importing Named Exports
import { PI, add, subtract } from "./math.js";
console.log(PI); // Output: 3.14
console.log(add(5, 3)); // Output: 8
console.log(subtract(7, 7)); // Output: 0
Here, import { PI, add, subtract } from "./math.js";
import PI
, add
, and subtract
from math.js
. You can use these imported functions and constants wherever you need.
Importing Default Exports
You can import default exports with any name.
import log from "./logger.js";
import config from "./config.js";
log("App started");
console.log(config.apiUrl);
Here,
-
import log from "./logger.js";
imports the default export fromlogger.js
and names itlog
. -
import config from "./config.js";
imports the default export fromconfig.js
asconfig
.
Importing Everything
import * as MathUtils from "./math.js";
console.log(MathUtils.PI); // Output: 3.14
console.log(MathUtils.add(5, 3)); // Output: 8
console.log(MathUtils.subtract(7, 7)); // Output: 0
Here,
-
import * as MathUtils from "./math.js";
imports all named exports frommath.js
under the namespaceMathUtils
. - You can access the exports via
MathUtils
prefix, like,console.log(MathUtils.PI);
.
Selective Imports
You can import only what you need like this:
import { add } from "./math.js"; // Only imports add function
This only imports the add
function from math.js
.
Module Scope
In ES6 modules, variables, functions, and classes declared in a file are scoped to that module only. They’re not added to the global scope like traditional <script>
tags.
// math.js
const secret = 42;
export const add = (a, b) => a + b;
// app.js
import { add } from "./math.js";
console.log(secret); // ReferenceError: secret is not defined
Only the things you explicitly export can be accessed in another file.
Renaming Exports and Imports
You can change the name when exporting like this:
function add(a, b) {
return a + b;
}
export { add as sum };
Here, export { add as sum };
exports the function add
as sum
.
You can change the name when importing like this:
import { PI as pi } from "./math.js";
console.log(pi); // Output: 3.14
Here, you’re importing PI
but renaming it to pi
in this file.
Default vs Named: What’s the Difference?
- You can think of named exports like putting labels on individual items in your box. Then, when importing, you must use the same names.
- You can think of default export like marking one item as the main thing in the box. Then, when importing, you can use any name.
- You can have many named exports.
- You can only have one default export per file.
- You can rename default exports freely when importing.
When to use default and named?
- Use named exports when exporting multiple values.
- Use default export when the file has one main thing to export.
Mixed Exports and Imports
Combining Named and Default Exports
You can also combine named and default exports in a file like this:
// config.js
export default {
apiUrl: "https://api.example.com",
timeout: 5000,
retries: 3,
};
export const HTTP_METHODS = {
GET: "GET",
POST: "POST",
PUT: "PUT",
DELETE: "DELETE",
};
Importing Mixed Exports
You can import mixed exports like this:
import config, { HTTP_METHODS } from "./config.js";
Alternative Mixed Import Syntax
import config, * as ApiUtils from "./config.js";
console.log(config.apiUrl); // Output: https://api.example.com
console.log(ApiUtils.HTTP_METHODS.POST); // Output: POST
Here, config
holds default and ApiUtils
holds all named exports.
Dynamic Imports
Dynamic imports allow you to load modules only when you need them (conditionally and asynchronously at runtime).
Basic dynamic import:
// app.js
async function loadMathModule() {
const mathModule = await import("./math.js");
console.log(mathModule.add(5, 3));
}
// Or with .then()
import("./math.js")
.then((mathModule) => {
console.log(mathModule.PI);
})
.catch((error) => {
console.error("Failed to load module:", error);
});
Here,
-
async function loadMathModule() { ... }
: Defines an asynchronous function so you can use await. -
const mathModule = await import("./math.js");
: Dynamically imports the modulemath.js
at runtime and waits until it loads. -
mathModule.add(5, 3)
: Accesses the add function exported frommath.js
and calls it with arguments 5 and 3. -
console.log(...)
: Logs the result (8) to the console. - In the second snippet, dynamic import is used with
.then()
instead ofawait
. -
import("./math.js")
returns aPromise
that resolves to the module object. -
.then((mathModule) => {...})
runs when the module loads successfully. -
mathModule.PI
accesses the exported constantPI
frommath.js
. -
.catch(...)
handles any errors if the module fails to load.
Conditional module loading:
// app.js
async function loadFeature(featureName) {
if (featureName === "advanced") {
const { AdvancedCalculator } = await import("./advanced-calculator.js");
return new AdvancedCalculator();
} else {
const BasicCalculator = await import("./basic-calculator.js");
return new BasicCalculator.default();
}
}
Here,
-
loadFeature
loads a different module based onfeatureName
. - If
featureName
is „advanced“, it importsAdvancedCalculator
from"./advanced-calculator.js"
using destructuring. - It then creates and returns a new instance of
AdvancedCalculator
. - Otherwise, it imports the default export from
"./basic-calculator.js"
, which is accessed via.default
. - Returns a new instance of
BasicCalculator
. - This shows how dynamic import lets you conditionally load code only when needed.
Dynamic import with destructuring:
// app.js
async function performCalculation() {
const { PI, add, subtract } = await import("./math.js");
const result = add(subtract(2, PI), 5);
return result;
}
Here,
- Dynamically imports
math.js
and uses destructuring to directly getPI
,add
, andsubtract
. - Then calls
subtract(2, PI)
, subtractingPI
from 2. - Passes that result to
add(..., 5)
to add 5. - Returns the final computed result.
- This shows how you can cleanly access multiple named exports from a dynamic import.
Code splitting with dynamic imports:
// app.js
document.getElementById("load-chart").addEventListener("click", async () => {
const { Chart } = await import("./chart-library.js");
const chart = new Chart("#chart-container");
chart.render();
});
Here,
- Adds a click event listener on an element with id „load-chart“.
- When clicked, dynamically imports the
Chart
class from"./chart-library.js"
. - Creates a new
Chart
instance targeting the DOM element#chart-container
. - Calls
chart.render()
to display the chart. - This defers loading the potentially large chart library until the user actually needs it, improving initial load performance.
Re-Exporting Modules
Re-exporting allows you to gather exports from other files and share them from a single entry point, like combining tools from different toolboxes into one central box.
Simple Re-export:
// index.js (barrel export)
export { add, subtract } from "./math.js";
export { default as Logger } from "./logger.js";
Here,
-
export { add, subtract } from "./math.js";
re-exports the named exportsadd
andsubtract
frommath.js
throughindex.js
. - So anyone can now import them like,
import { add } from './index.js';
-
export { default as Logger } from "./logger.js";
re-exports the default export fromlogger.js
under the nameLogger
. - It allows import like,
import { Logger } from './index.js';
Re-export with renaming:
// index.js
export { add as sum } from "./math.js";
export { default as Calculator } from "./calculator.js";
Here,
-
export { add as sum } from "./math.js";
renames theadd
function frommath.js
tosum
during re-export. - You can use it like,
import { sum } from './index.js';
-
export { default as Calculator } from "./calculator.js";
re-exports the default export fromcalculator.js
under the nameCalculator
.
Re-export all named exports:
// index.js
export * from "./math.js"; // Re-exports all named exports
export * from "./utilities.js"; // Re-exports all named exports
Note: This does not re-export default exports. You still need to handle default exports explicitly.
Re-export all as namespace:
// index.js
export * as MathUtils from "./math.js";
export * as StringUtils from "./string-utils.js";
// Usage:
// import { MathUtils, StringUtils } from './index.js';
Here,
-
export * as MathUtils from "./math.js";
bundles all exports frommath.js
into an object calledMathUtils
. -
export * as StringUtils from "./string-utils.js";
bundles all exports fromstring-utils.js
into an object calledStringUtils
.
Complex re-export pattern:
// modules/index.js
export { default as Calculator } from "./calculator.js";
export { default as Logger } from "./logger.js";
export * from "./math.js";
export * from "./string-utils.js";
export { ApiClient as Client, HTTP_METHODS as Methods } from "./config.js";
Here,
-
export { default as Calculator } from "./calculator.js";
re-exports default export fromcalculator.js
asCalculator
. -
export { default as Logger } from "./logger.js";
re-exports default export fromlogger.js
asLogger
. -
export * from "./math.js";
re-exports all named exports frommath.js
. -
export * from "./string-utils.js";
re-exports all named exports fromstring-utils.js
. -
export { ApiClient as Client, HTTP_METHODS as Methods } from "./config.js";
renames and re-exportsApiClient
asClient
andHTTP_METHODS
asMethods
fromconfig.js
. - This gives you a single entry point (
modules/index.js
) for many exports across your app, a common and clean practice for large codebases.
Module Loading Considerations
How Module Paths Work
// Relative imports:
import { add } from "./math.js"; // Same directory (./ means the same folder)
import { User } from "../models/user.js"; // Parent directory (../ means go up one folder)
import { config } from "./config/app.js"; // Subdirectory (Inside config/ folder)
// Absolute imports (with build tools):
// This kind of path works only if you're using a build tool (like Vite, Webpack, etc.) that supports absolute paths from a root folder (like /src).
import { Component } from "/src/components/component.js";
// Node modules:
// This imports built-in or installed modules (like express or fs) in Node.js.
// No need for ./ because they’re from node_modules or the standard library.
import express from "express";
import { readFile } from "fs/promises";
Which Modules Run First? (Execution Order)
// Modules execute in dependency order
// main.js
import "./setup.js"; // Executes first
import "./app.js"; // Executes second
import "./cleanup.js"; // Executes last
console.log("Main module loaded");
- ES6 modules are executed in the order they’re imported.
- Even if you don’t import any variables, the file runs for side effects (like setup or logging).
- This is useful for initializing settings or running startup code.
Code That Runs Automatically (Side Effects)
// analytics.js
console.log("Analytics module loaded");
let pageViews = 0;
export function trackPageView() {
pageViews++;
console.log(`Page views: ${pageViews}`);
}
// This runs when module is first imported
window.addEventListener("load", () => {
console.log("Page loaded - analytics ready");
});
-
console.log("Analytics module loaded");
runs immediately when the file is imported, not when a function is called. - This is a side effect, useful for logging, setup, or global listeners.
- The event listener also runs when the page loads, which is useful for initialization logic.
When Files Import Each Other (Circular Dependencies)
// user.js
import { Post } from "./post.js";
export class User {
constructor(name) {
this.name = name;
this.posts = [];
}
addPost(content) {
this.posts.push(new Post(content, this));
}
}
// post.js
import { User } from "./user.js";
export class Post {
constructor(content, author) {
this.content = content;
this.author = author; // User instance
}
}
-
user.js
importsPost
frompost.js
, and at the same timepost.js
importsUser
fromuser.js
. - This is called a circular dependency.
- It works if only class definitions are involved, but can cause errors if you try to run functions or access variables immediately during the import.
- In complex apps, avoid circular dependencies or use dynamic imports to fix them.
Best Practices
- Prefer named exports (Helps with Tree Shaking):
Tree shaking means removing unused code during the build to make your app smaller and faster.
Named exports help tools like Webpack or Vite remove what you don’t use. Because with named exports, bundlers know exactly what you’re using, and can skip what you’re not. This makes your final bundle smaller.
// Good: Named exports are more explicit
export function validateEmail(email) {
/* ... */
}
export function validatePassword(password) {
/* ... */
}
// Avoid: Default exports can be ambiguous
export default {
validateEmail,
validatePassword,
};
- Use descriptive names:
// Good: Clear naming
import { validateEmail, validatePassword } from "./validators.js";
// Avoid: Generic names
import { check1, check2 } from "./validators.js";
- Group related imports:
Group related modules into folders (e.g., /utils
, /components
) to keep your codebase organized.
// Good: Organized imports
import React, { useState, useEffect } from "react";
import { Router, Route, Switch } from "react-router-dom";
import { ApiClient } from "./api/client.js";
import { validateForm } from "./utils/validation.js";
import { logger } from "./utils/logger.js";
import "./styles/app.css";
- Avoid deep import paths:
// Good: Use barrel exports
import { UserService, PostService } from "./services/index.js";
// Avoid: Deep nesting
import { UserService } from "./services/user/user-service.js";
import { PostService } from "./services/post/post-service.js";
- Use dynamic imports for code splitting:
// Good: Lazy load heavy modules
const loadChartLibrary = () => import("./chart-library.js");
// Avoid: Loading everything upfront
import ChartLibrary from "./chart-library.js";
Common Errors and Solutions
SyntaxError: Cannot use import statement outside a module
// Problem: Missing type="module" in HTML
<script src="app.js"></script>
// Solution: Add type="module"
<script type="module" src="app.js"></script>
Module not found errors:
// Problem: Incorrect path
import { add } from "./math"; // Missing .js extension
// Solution: Include file extension
import { add } from "./math.js";
Cannot access before initialization:
// Problem: Circular dependency
// user.js
import { Post } from "./post.js";
export class User {
createPost() {
return new Post(); // Error if Post tries to import User
}
}
// Solution: Use dynamic imports or restructure
export class User {
async createPost() {
const { Post } = await import("./post.js");
return new Post();
}
}
Mixed module systems:
// Problem: Mixing CommonJS and ES modules
const fs = require("fs"); // CommonJS
import path from "path"; // ES modules
// Solution: Use only ES modules
import fs from "fs";
import path from "path";
Debugging Tips
- Check Network Tab: Open DevTools → Network → See if your JS files are loading.
- Use Console: Add console.log statements to see if your code is running as expected.
-
Check File Extensions: Always include
.js
in your import paths. -
Check Paths: Make sure the file paths (
./, ../
) are correct. - Modules Are Cached: Once a module is loaded, it won’t run again if imported elsewhere, it’s reused.
Performance Tips
- Tree Shaking: Use named exports so unused code can be removed when building the project.
- Code Splitting: Use dynamic imports for lazy loading (load code only when needed).
- Keep It Small: Avoid importing entire libraries when you only need one small function from them.
- Avoid Circular Imports: Try not to have files importing each other, it can cause bugs and make code harder to optimize.
Wrapping Up
That’s all for today!
I hope this post helps you.
For paid collaboration connect with me at : connect@shefali.dev
If you found this post helpful, here’s how you can support my work:
☕ Buy me a coffee – Every little contribution keeps me motivated!
📩 Subscribe to my newsletter – Get the latest tech tips, tools & resources.
𝕏 Follow me on X (Twitter) – I share daily web development tips & insights.
Keep coding & happy learning!