JavaScript Modules Explained — import, export and Real-World Patterns
Every serious JavaScript application you've ever used — Gmail, Figma, VS Code's web version — is built from hundreds or thousands of individual files working together. Without a way to connect those files cleanly, every variable and function you write would bleed into every other part of your app, causing naming collisions, impossible-to-trace bugs, and a codebase that nobody wants to touch. Modules are the foundation that makes large-scale JavaScript possible.
Before ES Modules landed in 2015 (ES6), JavaScript had no native module system. Developers stitched files together with script tags in a specific order, prayed nothing clashed, or bolted on third-party systems like CommonJS (Node.js) or AMD (RequireJS). The language finally got a first-class answer: the import and export keywords — a standardised, statically-analysable way to declare exactly what a file exposes and exactly what it needs from others. Bundlers like Webpack and Vite, and modern browsers natively, all speak this language today.
By the end of this article you'll understand why modules exist, when to reach for named exports versus default exports, how barrel files and re-exports keep large projects sane, how dynamic import() unlocks code-splitting, and the exact gotchas that trip up even experienced developers. You'll walk away with patterns you can drop into a real project today.
Named Exports — Sharing Multiple Things from One File
A named export is the most explicit form of sharing in JavaScript. You slap the export keyword in front of any declaration — a function, a class, a const, whatever — and that thing becomes available for other files to import by that exact name.
The key insight is intentionality. Without export, a value is completely private to that file. With it, you're making a deliberate, documented contract: 'this is part of my public API, everything else is an implementation detail.' That's not just tidiness — bundlers like Rollup use this information to tree-shake your code, stripping out anything that was never imported anywhere. Smaller bundles, faster apps.
You can export as many things as you like from a single file, and importers can cherry-pick only what they need. They don't get the whole file dumped into their scope — just the named pieces they asked for. This is why named exports are the default choice for utility libraries and shared logic files. When you import { formatCurrency } from a utils file, you're grabbing one tool from the toolbox, not the whole workshop.
// currencyUtils.js — a shared utility module // Each export is a deliberate part of the public API // Named export: a pure function to format a number as currency export function formatCurrency(amount, currencyCode = 'USD') { // Intl.NumberFormat gives us locale-aware formatting for free return new Intl.NumberFormat('en-US', { style: 'currency', currency: currencyCode, }).format(amount); } // Named export: another utility living in the same file export function calculateTax(subtotal, taxRate) { // taxRate is expected as a decimal, e.g. 0.08 for 8% const taxAmount = subtotal * taxRate; return Math.round(taxAmount * 100) / 100; // round to 2 decimal places } // This helper is NOT exported — it's an internal implementation detail // Nothing outside this file can touch it function _validateAmount(amount) { return typeof amount === 'number' && amount >= 0; } // ───────────────────────────────────────────────── // checkout.js — consuming the named exports // ───────────────────────────────────────────────── // We only import what we actually need — tree-shakers love this import { formatCurrency, calculateTax } from './currencyUtils.js'; const itemSubtotal = 49.99; const stateTaxRate = 0.08; // 8% tax const taxOwed = calculateTax(itemSubtotal, stateTaxRate); const totalAmount = itemSubtotal + taxOwed; console.log(`Subtotal: ${formatCurrency(itemSubtotal)}`); // Subtotal: $49.99 console.log(`Tax (8%): ${formatCurrency(taxOwed)}`); console.log(`Total: ${formatCurrency(totalAmount)}`); // You can also alias an import if a name would clash with something local import { formatCurrency as formatPrice } from './currencyUtils.js'; console.log(formatPrice(19.99, 'EUR')); // €19.99
Tax (8%): $4.00
Total: $53.99
€19.99
Default Exports — One Main Thing Per File
A default export says: 'this file has one primary thing it's about.' A React component file is the classic example — the file exists to export that one component, and everything else in it (helper functions, local constants) supports that main purpose.
The critical difference from named exports: the importer gets to decide the name. When you write import UserProfileCard from './UserProfileCard.js', you could have written import MyFavouriteComponent and it would work identically. The name is chosen at the import site, not locked in at the export site. This feels convenient but has a real trade-off: it makes automated refactoring and grepping harder, because the same thing can appear under different names across a codebase.
A file can have only one default export, but it can have that default export alongside multiple named exports. This is a common and valid pattern — a component file might default-export the component and named-export its TypeScript prop types or a utility hook that's tightly coupled to it.
The general rule most teams settle on: use default exports for the 'main character' of a file (a component, a class, a configuration object). Use named exports for supporting cast members and utility modules that intentionally expose multiple things.
// ShoppingCart.js — a component-style module with one clear purpose // Named export: the cart item shape is useful to callers too export const EMPTY_CART_MESSAGE = 'Your cart is empty. Start shopping!'; // Named export: a helper that callers might want to use independently export function sumCartItems(cartItems) { return cartItems.reduce((total, item) => { return total + item.price * item.quantity; }, 0); } // Default export: the main class this file is really about // Only one default export allowed per file export default class ShoppingCart { constructor() { this.items = []; } addItem(product, quantity = 1) { const existingItem = this.items.find(item => item.id === product.id); if (existingItem) { // Increment quantity instead of adding a duplicate existingItem.quantity += quantity; } else { // Spread to avoid mutating the original product object this.items.push({ ...product, quantity }); } } getTotal() { // Delegate to the named export — shared logic stays DRY return sumCartItems(this.items); } isEmpty() { return this.items.length === 0; } } // ───────────────────────────────────────────────── // storePage.js — consuming both the default and named exports // ───────────────────────────────────────────────── // Default import: name it whatever makes sense in context import ShoppingCart, { EMPTY_CART_MESSAGE, sumCartItems } from './ShoppingCart.js'; const cart = new ShoppingCart(); console.log(cart.isEmpty()); // true console.log(EMPTY_CART_MESSAGE); // Your cart is empty. Start shopping! cart.addItem({ id: 'shoe-42', name: 'Trail Runner', price: 89.99 }, 2); cart.addItem({ id: 'sock-m', name: 'Merino Socks', price: 12.50 }, 3); console.log(`Cart total: $${cart.getTotal().toFixed(2)}`); // Cart total: $217.48 // sumCartItems works independently — useful for a cart preview elsewhere const previewItems = [{ price: 10, quantity: 3 }]; console.log(sumCartItems(previewItems)); // 30
Your cart is empty. Start shopping!
Cart total: $217.48
30
Barrel Files and Re-exports — Keeping Large Projects Clean
Once a project grows past a handful of files, you start hitting a painful import problem. A component deep in your tree needs a utility that lives five directories away, so you write import { formatCurrency } from '../../../../shared/utils/currencyUtils.js'. That path is fragile — move either file and everything breaks. It's also just ugly to read.
Barrel files (usually called index.js) solve this by acting as a curated public API for an entire folder. Instead of every consumer reaching into the internals of your utils/ folder, they import from utils/ and the barrel file decides what to expose. This is the re-export pattern.
The export ... from syntax lets you re-export from another module without importing it into your current scope first — it's a clean pass-through. This matters for tree-shaking: a good bundler can still trace the chain and only include what's used, but you need to be careful with CommonJS interop and wildcard re-exports, which can prevent dead-code elimination.
Barrel files are powerful but not free — they can cause circular dependency issues and, if misused, they pull in everything whether you need it or not. Use them at clear architectural boundaries, not obsessively inside every folder.
// utils/index.js — the barrel file for the entire utils folder // This is the ONLY file that consumers of this folder should import from // It defines the public API; everything else inside utils/ is an internal detail // Re-export specific named exports from each utility module // The 'export { } from' syntax passes through without polluting this scope export { formatCurrency, calculateTax } from './currencyUtils.js'; export { truncateText, capitalise, slugify } from './stringUtils.js'; export { debounce, throttle } from './functionUtils.js'; // You can also re-export a default export as a named export // This normalises everything to named exports at the barrel level (recommended) export { default as DatePicker } from './DatePicker.js'; // ───────────────────────────────────────────────── // stringUtils.js — one of the internal util modules // ───────────────────────────────────────────────── export function truncateText(text, maxLength) { if (text.length <= maxLength) return text; return text.slice(0, maxLength).trimEnd() + '…'; } export function capitalise(word) { if (!word) return ''; return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); } export function slugify(text) { return text .toLowerCase() .trim() .replace(/[^\w\s-]/g, '') // remove non-word chars .replace(/[\s_-]+/g, '-') // replace spaces/underscores with hyphens .replace(/^-+|-+$/g, ''); // strip leading/trailing hyphens } // ───────────────────────────────────────────────── // productCard.js — a consumer anywhere in the project // Clean single import, no fragile relative paths through internal structure // ───────────────────────────────────────────────── import { formatCurrency, truncateText, slugify } from '../utils/index.js'; // Or with path aliases set up in your bundler config: // import { formatCurrency, truncateText, slugify } from '@/utils'; const product = { name: 'Handcrafted Leather Journal ', description: 'A beautifully bound leather journal perfect for daily writing, sketching and planning your next adventure.', price: 34.95, }; const productSlug = slugify(product.name); const shortDescription = truncateText(product.description, 60); const displayPrice = formatCurrency(product.price); console.log(productSlug); // handcrafted-leather-journal console.log(shortDescription); // A beautifully bound leather journal perfect for daily… console.log(displayPrice); // $34.95
A beautifully bound leather journal perfect for daily…
$34.95
Dynamic import() — Loading Code Only When You Actually Need It
Everything we've covered so far is static — the imports are resolved when the module is first parsed, before any code runs. That's great for correctness and tree-shaking, but it means your entire dependency graph loads upfront. On a large app, that's a lot of JavaScript hitting the network before the user sees anything useful.
Dynamic import() is different. It's a function call that returns a Promise, and it tells the JavaScript engine (and your bundler): 'load this module later, only when this code path actually runs.' Bundlers like Vite and Webpack automatically split dynamic imports into separate chunk files, so a feature that only 5% of users ever click doesn't cost 100% of users their load time.
This is the mechanism behind route-based code splitting in React, Vue, and Angular. When a user navigates to /settings, the settings page module loads then — not on the initial page load. It's also the right pattern for heavy third-party libraries (chart libraries, PDF renderers, Markdown parsers) that are only used in specific features.
The async/await syntax makes dynamic imports feel almost identical to static ones once you get used to it — the only difference is that you're inside an async function and the module arrives as a module namespace object.
// reportGenerator.js // Demonstrates dynamic import for a heavy PDF-generation library // The PDF library only loads when the user actually clicks 'Export PDF' // — not on every page visit async function generateSalesReport(salesData) { // Show feedback immediately — don't wait for the module to load console.log('Preparing your report...'); try { // Dynamic import: the PDF module loads NOW, on demand // Bundlers split this into a separate chunk automatically const { createPdfDocument, addTable, saveAsPdf } = await import('./pdfUtils.js'); // From here on it looks just like a normal module usage const document = createPdfDocument({ title: 'Q3 Sales Report', orientation: 'landscape' }); addTable(document, { headers: ['Region', 'Units Sold', 'Revenue'], rows: salesData, }); const filename = `sales-report-${new Date().getFullYear()}.pdf`; await saveAsPdf(document, filename); console.log(`Report saved: ${filename}`); return { success: true, filename }; } catch (loadError) { // This catch handles BOTH module-load failures AND runtime errors // Worth noting: if the chunk fails to load (e.g. offline), you'll end up here console.error('Failed to generate report:', loadError.message); return { success: false, error: loadError.message }; } } // ───────────────────────────────────────────────── // Real-world pattern: lazy-loading a route in a SPA // This is exactly what React.lazy() does under the hood // ───────────────────────────────────────────────── const routes = [ { path: '/dashboard', // Module loads immediately — it's the landing page, it should be fast component: await import('./pages/Dashboard.js').then(m => m.default), }, { path: '/analytics', // Wrapped in a function — only called when the user navigates to /analytics // The bundle for this page never touches the network until then loadComponent: () => import('./pages/Analytics.js').then(m => m.default), }, { path: '/settings', loadComponent: () => import('./pages/Settings.js').then(m => m.default), }, ]; // Simulating navigation to /analytics const analyticsRoute = routes.find(r => r.path === '/analytics'); if (analyticsRoute.loadComponent) { const AnalyticsPage = await analyticsRoute.loadComponent(); console.log('Analytics module loaded on demand:', typeof AnalyticsPage); } console.log( 'Routes registered. Only Dashboard loaded at startup — ' + 'other pages load on navigation.' );
Routes registered. Only Dashboard loaded at startup — other pages load on navigation.
| Aspect | Named Export | Default Export |
|---|---|---|
| Syntax to export | `export function doThing() {}` | `export default function doThing() {}` |
| Syntax to import | `import { doThing } from './mod.js'` | `import doThing from './mod.js'` |
| Import name | Fixed — must match the exported name (or alias) | Flexible — caller chooses any name |
| How many per file | Unlimited | Exactly one |
| Tree-shaking friendliness | Excellent — bundlers know exactly what's used | Good, but aliasing makes static analysis harder |
| Refactoring safety | High — rename in one place, IDE updates imports | Lower — no guaranteed name consistency across files |
| Best used for | Utility modules, multiple related functions, hooks | Components, classes, single-purpose files |
| Mixing with the other | Yes — a file can have both named and one default | Yes — same file, different import syntax |
🎯 Key Takeaways
- Named exports create an explicit, rename-proof public API for your file — everything else is private by default. This intentional boundary is what makes tree-shaking possible.
- Default exports trade naming consistency for flexibility. They're fine for single-purpose files like React components, but named exports are safer in team codebases because they can't be accidentally imported under the wrong name.
- Barrel files (
index.js) flatten deep import paths and define clean module boundaries — but wildcard re-exports (export *) can silently defeat tree-shaking. Use explicit named re-exports inside barrels. - Dynamic
import()returns a Promise and tells your bundler to split that module into a separate chunk. Use it for heavy features, third-party libraries, and every route in a SPA to avoid paying the load-time cost upfront.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Adding
type='module'to a script tag but running the file directly viafile://— The browser throws a CORS error ('Cross-origin request blocked') because ES modules require HTTP headers that thefile://protocol doesn't send. Fix: always serve your files through a local dev server (e.g.npx serve .or Vite) even for quick experiments. - ✕Mistake 2: Forgetting the file extension in import paths in a browser or Node environment — Writing
import { helper } from './utils'works in Webpack (which resolves extensions automatically) but breaks in native browser ES modules and Node's--experimental-vm-modulesmode, both of which require the full path including.js. Fix: always include the extension in module specifiers when not using a bundler, and know which environment your code targets. - ✕Mistake 3: Creating a circular dependency through barrel files — File A imports from the barrel, the barrel re-exports from File B, and File B imports something from File A. You get
undefinedvalues at runtime with no clear error, because the module that was needed hadn't finished evaluating yet. Fix: if you spot a circular dependency warning from your bundler, break the cycle by extracting the shared piece into a third module that neither A nor B imports through the barrel.
Interview Questions on This Topic
- QWhat's the practical difference between a named export and a default export, and does your team have a preference? Why? (Interviewers want to hear you discuss trade-offs like renaming freedom vs refactoring safety, not just syntax.)
- QHow does `import()` differ from `import`, and what problem does it solve in a production web application? Walk me through how you'd use it for route-based code splitting. (They're checking whether you understand bundle size and load performance, not just syntax.)
- QWhat is a circular dependency in the context of ES modules, and how would you detect and fix one? (A tricky follow-up — many candidates know circular deps are bad but can't explain why they silently produce `undefined` values instead of a thrown error.)
Frequently Asked Questions
Can I use ES modules in Node.js without a bundler?
Yes. Save your file with a .mjs extension, or add "type": "module" to your package.json. Node then treats all .js files in that package as ES modules. Remember to include file extensions in your import paths — Node won't resolve them automatically the way Webpack does.
What is the difference between CommonJS require() and ES module import?
require() is synchronous, evaluated at runtime, and returns a copy of the exports object. ES module import is static, resolved before any code runs, and creates live bindings — if the exporting module changes a value, the importer sees the update. ES modules also enable tree-shaking; CommonJS generally doesn't.
Why do I get 'Cannot use import statement outside a module' in Node.js?
Node defaults to CommonJS mode, where import is not valid. Fix it one of two ways: rename your file to .mjs, or add "type": "module" to your nearest package.json. If you're using a bundler like Webpack or a framework like Next.js, it handles this transformation for you automatically.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.