Writing Modern JavaScript without a Bundler

November 27, 2024

2,967 words

Post contents

Note: Some of the embeds in this article are broken

We're currently investigating a bug with our StackBlitz embeds. Sorry for the inconvenience!

Modern web development is awesome. We've never had a period of time where our tools enable us to move faster, ship less bugs, and make great web apps.

But while tools like Vite and Webpack are extremely powerful and can provide a better user experience (UX), they can often feel like they're getting in the way of rapid prototyping.

Let's explore how we can build a website using many of the conveniences of a Vite app while remaining buildless.

In this article, we'll learn how to:

Without further ado, let's dive in!

Setting up Pre-Requisites

Let's first set up the initial bit of tooling required to run a webpage locally. We'll start with a package.json file:

{	"name": "your-name-here",	"private": true,	"version": "0.0.0",	"scripts": {		"start": "http-server -c-1 src"	},	"devDependencies": {		"http-server": "^14.1.1"	}}

We're adding -c-1 to disable caching. This means that you can refresh the page when you've made changes and it'll automatically show the newest results instead of caching them for an hour.

Then create a src folder so we don't mix up our source code files with other files we'll add later:

  • src/
    • index.html
  • package.json

And finally, we'll create our index.html file with a Hello world message:

<!doctype html><html lang="en">	<head>		<meta name="viewport" content="width=device-width" />		<meta charset="utf-8" />		<title>Basic Setup</title>	</head>	<body>		<p>Hello, world!</p>	</body></html>

Now we can npm run start from root and get a basic web server at http://127.0.0.1:8080/.

Remember, when you make changes you'll need to refresh the page to see them loaded. We'll explore how to fix that in a later section of this article.

Import JS files From a Script Tag

Managing multiple files in older vanilla JavaScript projects used to be a pain. Luckily, modern browsers support the import "something.js" syntax that we can now use to manage multiple files.

To use this, we need to denote our script tag (from our HTML file) as type="module":

<script type="module" src="script.js"></script>

Once this is done, we can add import statements in our JS files:

// script.jsimport template from "./template.js";const root = document.getElementById("root");root.innerHTML = template;

Something worth noting is that you need to have the .js identifier at the end of your import statement, otherwise the browser will not know where to look.

Introducing HMR for Vanilla JavaScript Apps

It's neat that we're able to load JavaScript files without a bundler, but if you spend much time in our environment you'll likely yearn for a solution that reloads the page whenever you modify the files in use.

Luckily for us, there's a different web server that can handle this for us: browser-sync.

Let's change it in our package.json:

{	"name": "your-name-here",	"private": true,	"version": "0.0.0",	"scripts": {		"start": "browser-sync start --server \"src\" --watch --no-ui"	},	"devDependencies": {		"browser-sync": "^3.0.3"	}}

And see as the page refreshed while we modify any of the files in src:

Using CDNs to load libraries

Most apps require a fair number of libraries to get up-and-running. Let's load in a date library, Luxon, to handle our dates in a nicer way.

To do this, we can use a CDN like unpkg.com to load in the files required to run the library in our app.

If we go to https://unpkg.com/luxon, we'll see a loaded bit of JavaScript:

TODO: Write

However, this isn't the format we need the library in. Remember, we need to have import and export lines, ideally without any additional import statements for ease-of-setup (more on that later in the article).

To find this, we're looking for files labeled something like ES6 or ESM or BROWSER, like so:

https://unpkg.com/browse/luxon@3.5.0/build/es6/luxon.js

To find these files, you can use the browse feature of Unpkg by adding browse at the start of the URL:

TODO: Write

Not all libraries are bundled to support ESM in this way as a single file. If it does not, you can add compile the dependency to support it, as we'll touch on later.

Once we find the right file, we need to make sure to use the raw file:

https://unpkg.com/luxon@3.5.0/build/es6/luxon.js

Instead of:

https://unpkg.com/browse/luxon@3.5.0/build/es6/luxon.js

Then we can import from this URL like any other import:

// script.jsimport { DateTime } from "https://unpkg.com/luxon@3.5.0/build/es6/luxon.js";const root = document.getElementById("root");const date = DateTime.now()	.setZone("America/New_York")	.minus({ weeks: 1 })	.endOf("day")	.toISO();root.innerText = date;

Aliasing modules

The script.js file above works in-browser, but doesn't look quite right to anyone that's done modern JS. Moreover, if you wanted to use a different version of luxon, you'd have to track all imports from this URL and update them one-by-one.

Let's instead alias that URL to be imported when we import "luxon". To do this, we'll leverage an importmap:

<!doctype html><html lang="en">	<head>		<meta name="viewport" content="width=device-width" />		<meta charset="utf-8" />		<title>Import Map</title>		<script type="importmap">			{				"imports": {					"luxon": "https://unpkg.com/luxon@3.5.0/build/es6/luxon.js"				}			}		</script>	</head>	<body>		<div id="root"></div>		<script type="module" src="/script.js"></script>	</body></html>

And modify our script.js file to import from that path:

// script.jsimport { DateTime } from "luxon";const root = document.getElementById("root");const date = DateTime.now()	.setZone("America/New_York")	.minus({ weeks: 1 })	.endOf("day")	.toISO();root.innerText = date;

Installing Libraries from NPM

While using a CDN can be convenient, it comes with a number of problems:

  • Reliance on someone else's uptime
  • Trusting a third-party's security to not replace modules later
  • IDE and tooling issues

To sidestep this, it would be ideal for us to use the NPM registry to load our modules in our apps manually.

Unfamiliar with NPM?

If you're new to web development, you may not know what NPM is.

Thankfully, we have an article that explains it in detail!

📝 Webdev 101: How to use npm and Yarn

However, if we were to npm install normally, it would place our installs inside of node_modules. Instead, we need our installs to go into src so that our web server is able to reference those files using the public server URL.

As the NPM CLI doesn't allow us to change the directory of node_modules, we'll use PNPM as our NPM install CLI:

npm i -g pnpm

There are other ways to install PNPM; Check their docs for other methods of installing PNPM.

Now we have PNPM installed, we can configure it using a root file of .npmrc:

# Move all dependencies to src/vendor so we can use import maps
modules-dir = src/vendor
# Hoist all dependencies to the top level so to avoid symbolic links, which won't work well with import maps
node-linker = hoisted
# Change the virtual store location so we can symbolically link node_modules to src/vendor
virtual-store-dir = .pnpm

Here, we're telling our package manager to install all dependencies into src/vendor rather than node_modules, avoiding symbolic links, and to move PNPM's internal instances to the .pnpm folder (more on that in a moment).

If you have a .gitignore file, make sure to add these items to it:

# Our custom PNPM settings
.pnpm
# Our custom node_modules path
src/vendor/

Now, we'll want to create a symbolic link from node_modules that points to src/vendor so our IDEs can track our deps better:

mklink /D node_modules src/vendor
ln -s src/vendor node_modules

Now, we'll update our package.json to include the deps we want to use:

{	"name": "your-name-here",	"private": true,	"version": "0.0.0",	"scripts": {		"start": "browser-sync start --server \"src\" --watch --no-ui"	},	"devDependencies": {		"browser-sync": "^3.0.3"	},	"dependencies": {		"luxon": "^3.5.0"	}}

As a helpful tip, we can use devDependencies to track the tools we don't need to ship to the browser and dependencies as the libraries we need in production.

And install them using pnpm:

pnpm install

This finally enables us to reference our modules from our importmap but using a local URL instead of a remote one:

<!doctype html><html lang="en">	<head>		<meta name="viewport" content="width=device-width" />		<meta charset="utf-8" />		<title>Import Map</title>		<script type="importmap">			{				"imports": {					"luxon": "./vendor/luxon/build/es6/luxon.js"				}			}		</script>	</head>	<body>		<div id="root"></div>		<script type="module" src="/script.js"></script>	</body></html>

And without modifying the JavaScript file from before, we should be up-and-running!

Adding support for incompatible modules

While many libraries are properly packaged to be bundled in a single ESM file, others are not. Let's take lodash-es as an example:

pnpm install lodash-es

This gives us a src/vendor/lodash-es folder that looks like this:

  • add.js
  • ...
  • lodash.js
  • ...
  • zipWith.js
  • package.json
  • README.md

With every single method as a dedicated file that imports from other individual files.

This becomes tricky because, while importmaps support relative links:

// src/vendor/lodash-es/lodash.jsexport { default as add } from './add.js';// ...
<script type="importmap">  {    "imports": {      "lodash": "/vendor/lodash-es/lodash.js",      "./add.js": "/vendor/lodash-es/attempt.js",    }  }</script>

The lodash-es main import file has hundreds of these relative imports, making this nearly impossible to manage long-term.


To solve for this, we can do something a bit silly: We can bundle our dependencies that have this problem.

We'll use esbuild for this solution, since it's lightweight and can be script-able using a straightforward API:

pnpm i -D esbuild

To do this, let's add a scripts/bundle-deps.js file:

import esbuild from "esbuild";Promise.all([	esbuild.build({		format: "esm",		entryPoints: ["./src/vendor/lodash-es/lodash.js"],		bundle: true,		platform: "browser",		outfile: "./src/vendor_bundled/lodash-es.js",	}),  // Add other `esbuild.build` commands here]).catch(console.error);

And run the script from pnpm's postinstall step, so that it autogenerates after each pnpm i usage.

{	"name": "your-name-here",	"private": true,	"version": "0.0.0",	"type": "module",	"scripts": {		"start": "browser-sync start --server \"src\" --watch --no-ui",		"postinstall": "node scripts/bundle-deps.js"	},	"devDependencies": {		"browser-sync": "^3.0.3",		"esbuild": "^0.24.0"	},	"dependencies": {		"lodash-es": "^4.17.21"	}}

If you get the error:

import esbuild from "esbuild";
^^^^^^

SyntaxError: Cannot use import statement outside a module

Make sure to add the "type": "module" to your package.json file.

Now when you pnpm i you'll see the src/vendor_bundled/lodash-es.js generated and containing a single list of exports at the bottom of the file:

// ...export {  add_default as add,  // ...  zipWith_default as zipWith};

This enables us to update our importmap:

<script type="importmap">  {    "imports": {      "lodash-es": "./vendor_bundled/lodash-es.js"    }  }</script>

And use the new module in script.js:

import { add } from "lodash-es";const root = document.getElementById("root");const val = add(1, 2);root.innerText = val;

Don't forget to add src/vendor_bundled to your .gitignore!

Picking the right framework

While its possible to avoid a framework and still have a good website, it's undeniably become a part of modern web development, so I wanted to touch on that here.

Fortunately, for some tools it's as easy to use a buildless version as the built version.

Unfortunately, it's impossible to use other tools without a build step.

Let's explore and see which is which:

While it's technically possible to use React without JSX:

React.createElement(Element, propsObject, childrenArray)

It's not a pretty API at scale; using React without a bundler is practically infeasible.

Vue comes with a few limitations to use it without a build step:

  • Cannot use SFCs
  • Must add components via components: {} property

However, outside of this limitation, Vue is actually quite nice to use bundle-less:

// script.jsimport { createApp, ref } from "vue";const OtherComponent = {	template: `<div>{{ message }}</div>`,	setup() {		const message = ref("Hello vue!");		return {			message,		};	},};const App = {	components: {		OtherComponent,	},	template: `		<div>			<OtherComponent />		</div>	`,};createApp(App).mount("#app");

To use Lit in a bundle-less scenario, you:

  • Must bundle Lit to avoid many relative imports in your importmap
  • Cannot use decorators, must be replaced with static get properties
  • Must call customElements.define manually

Let's see it in action:

import { html, css, LitElement } from "lit";export class SimpleGreeting extends LitElement {	static get styles() {		return css`			p {				color: blue;			}		`;	}	static get properties() {		return {			name: { type: String },		};	}	constructor() {		super();		this.name = "Somebody";	}	render() {		return html`<p>Hello, ${this.name}!</p>`;	}}customElements.define("simple-greeting", SimpleGreeting);

Using Prettier, ESLint, and TypeScript

Using Prettier and ESLint in a buildless system have nearly identical setup processes as they would in a bundled situation.

You can use ESLint's CLI to setup ESLint:

npm init @eslint/config@latest

When prompted, these should be your answers:

✔ How would you like to use ESLint? · problems
✔ What type of modules does your project use? · esm
✔ Which framework does your project use? · none
✔ Does your project use TypeScript? · javascript
✔ Where does your code run? · browser
The config that you've selected requires the following dependencies:

eslint, globals, @eslint/js
✔ Would you like to install them now? · No / Yes
✔ Which package manager do you want to use? · pnpm

And even pnpm install -D prettier to add Prettier, only needing a single script configuration in your package.json to run:

{	"name": "@playfulprogramming/eslint",	"private": true,	"version": "0.0.0",	"type": "module",	"scripts": {		"start": "browser-sync start --server \"src\" --watch --no-ui",		"lint": "eslint .",		"format": "prettier --write ."	},	"devDependencies": {		"@eslint/js": "^9.14.0",		"browser-sync": "^3.0.3",		"eslint": "^9.14.0",		"globals": "^15.12.0",		"prettier": "^3.3.3"	}}

The only hiccup is that you'll need to:

  • Add src/vendor and src/vendor_bundled as ignored paths to ESLint
  • Add pnpm-lock.yaml as an ignored path to Prettier

By changing your eslint.config.mjs file to:

import globals from "globals";import pluginJs from "@eslint/js";/** @type {import('eslint').Linter.Config[]} */export default [	{ languageOptions: { globals: globals.browser } },	pluginJs.configs.recommended,	{		ignores: ["src/vendor", "src/vendor_bundled"],	},];

And your .prettierignore file to include:

pnpm-lock.yaml

Now you can pnpm format and pnpm lint to your heart's content!

// script.js// This code is broken, but would be caught by a linter like ESLintfor (let i = 0; i < 10; i--) {	console.log(i);}
> eslint .

/src/script.js
  2:1  error  The update clause in this loop moves the variable in the wrong direction  for-direction

✖ 1 problem (1 error, 0 warnings)

Note

If you get errors like this:

> eslint .Oops! Something went wrong! :(ESLint: 9.14.0Error: Cannot find module 'ajv/lib/refs/json-schema-draft-04.json'

It's because you've forgotten to alias node_modules using a symbolic link. ESLint doesn't know how to import from src/vendor and instead looks to node_modules for the ajv internal package.

TypeScript

While ESLint and Prettier don't really require different usages in a buildless system, TypeScript most certainly does.

See, in most TypeScript usages, you have .ts and .d.ts (and maybe even .tsx) files that compile into .js files during a build pipeline:

TODO: Write

But this goes against what we're trying to do; we want to eliminate the need for a build pipeline and ship what we've written directly.

Luckily for us, this is where TypeScript's JSDoc support comes in. JSDoc is a markup extension to JavaScript where you can add metadata about your code through JavaScript comments:

/** * Add two numbers * @param {number} a * @param {number} b * @returns {number} */function add(a, b) {	return a + b;}

These comments then can:

  • Highlight usage comments to your IDE
  • Inform your IDE and tooling about the metadata associated with the code

For example, we can run TypeScript over this add function's usage:

add(1, "2");

And get the expected error of:

Argument type string is not assignable to parameter type number

To setup TypeScript with our JSDoc setup we will install the typescript package:

pnpm i -D typescript

Then configure it to check JavaScript files by modifying our tsconfig.json file to:

{	"compilerOptions": {		"target": "es2022",		"allowJs": true,		"checkJs": true,		"noEmit": true,		"strict": true,		"skipLibCheck": true	},	"exclude": ["src/vendor", "src/vendor_bundled"]}

Now if we run tsc we get:

↳ pnpm tsc
src/script.js:11:8 - error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.

11 add(1, "2");
          ~~~


Found 1 error in src/script.js:11

Subscribe to our newsletter!

Subscribe to our newsletter to get updates on new content we create, events we have coming up, and more! We'll make sure not to spam you and provide good insights to the content we have.