ES Modules in the Browser: The Simplest Possible Setup

JavaScript modules have been part of the EcmaScript specification for a while now, and every major browser has shipped support for them. If you’ve been holding off because the transition felt complicated, the basic setup is actually just a matter of adding one attribute to a script tag and running a local server.

Let’s start with the JavaScript file we want to load into the page:

// append-div.js
function appendDiv(message) {
	const div = document.createElement('div')
	div.textContent = message
	document.body.appendChild(div)
}

export { appendDiv }

To load this file in the browser, we create an HTML file that references it with a standard script tag, plus the type="module" attribute:

<script type="module">
	import { appendDiv } from './append-div.js'
	appendDiv('Hello from inline script')
</script>

That attribute tells the browser to treat the code as a module rather than a classic script. Modules run with different scoping and loading behavior than regular scripts, but the key win is that you can now use import and export statements in your browser code.

One practical caveat: you can’t just open the HTML file from your filesystem. Modules must be served over HTTP. If you have Node.js installed, the quickest way to get a local server running is:

npx serve

That starts a server you can reach at http://localhost:5000 (the default port). From there, you should see “Hello from inline script” in the page. That’s your first real EcmaScript module executing in the browser.

Moving the Module Out of the HTML

In practice you won’t want your module nested inside an inline script. Create a separate module file:

// script-src.js
import { appendDiv } from './append-div.js'

appendDiv('Hello from external script')

Then reference it from a new script tag in your HTML:

<script type="module">
	import { appendDiv } from './append-div.js'
	appendDiv('Hello from inline script')
</script>
<script type="module" src="./script-src.js"></script>

Refresh the page and you’ll see “Hello from external script” appear as well. Note that the import path must include the full filename with the .js extension. Unlike Node.js or Babel, the browser doesn’t resolve extensions for you — you have to be explicit about what file you’re requesting.

Dynamic Import Works the Same Way

Modules can also be loaded on demand with a dynamic import(). Start with another file:

// async-script.js
import { appendDiv } from './append-div.js'

function go() {
	appendDiv('Hello from async script')
}

export { go }

Then load it from your script:

// script-src.js
import { appendDiv } from './append-div.js'

appendDiv('Hello from external script')

import('./async-script.js').then(
	(moduleExports) => {
		moduleExports.go()
	},
	(error) => {
		console.error('there was an error loading the script')
		throw error
	},
)

With dynamic imports, the same rule applies: the URL you pass must point directly to a JavaScript resource. What matters isn’t the .js extension itself, but that the server responds with a JavaScript text file that the browser can execute. If you have a URL that returns JavaScript without a .js suffix, that’s fine:

import * as d3 from 'https://unpkg.com/d3?module'

The bottom line: the string inside your import statement must resolve to a JavaScript resource on a server. For example, services like unpkg.com can serve npm packages as browser-ready modules directly.