Building an Eleventy Starter Template: JavaScript

07-27-22 Dustin Whisman

In the last article of our Eleventy Starter Repo series, we taught you how to create flexible CSS support with SCSS. Today, you'll learn how to add support for JavaScript with a modern bundling process, linting, and testing.

In the previous part of this series, we added CSS support to our template repository. This time, we will be adding support for JavaScript, including a modern bundling process, linting, and testing.

If you just want to see the code (or use the starter template), you can find the repo at You can also see a snapshot of what the codebase looks like after following the steps in this article by going to the js-support branch.

Setting up the Modern JS Build Process

First, let’s add some sample JS files. These are placeholders meant to be replaced by more relevant code after you’ve created a repository from the template. For our purposes, we’ll write a minimal amount of code that will let us verify that our build process is working how we want it to.

We’ll follow the convention that any JS file at the src/js level will be an entry point, and any supporting JS that will be imported will go into subfolders, so let’s create src/js and src/js/utilities folders and add the following files to them.

// src/js/utilities/add.js
export const add = (a, b) => a + b;

// src/js/utilities/subtract.js
export const subtract = (a, b) => a - b;

// src/js/index.js
import { add } from './utilities/add';
import { subtract } from './utilities/subtract';

console.log(`2 + 2 = ${add(2, 2)}`);
console.log(`2 - 2 = ${subtract(2, 2)}`);

We now have an entry point at src/js/index.js that imports the functions from the other two files. We’ve chosen simple math operations—since those will be easy to test—and we’re logging the results to the console so we’ll be able to easily verify that the code is working as expected in the browser.

We’re also using some modern syntax (ES Modules, string interpolation, and arrow functions) that won’t work in older browsers, which will let us confirm that our legacy bundle works. But we’ll get to that later. First, let’s get the primary build working!

Modern Bundles with esbuild

For our bundler, we will be using esbuild. Why esbuild instead of something like webpack or rollup?

  1. It’s fast
  2. We can use its CLI for what we need without any configuration files
  3. It supports TypeScript out of the box (we’ll get to TypeScript support later)

Let’s install esbuild, then set up a script in package.json to build our example JS.

npm install --save-dev esbuild
"scripts": {
  "js": "esbuild src/js/*.js --format=esm --bundle=true --splitting=true --outdir=dist"

When we run npm run js, we should see a new file in dist called index.js which contains our lightly-processed JS. However, we’ll want to treat our JS build similar to our SCSS build, where we include source maps and watch for changes in development mode, and we minify the production build.

Let’s replace that js script with js:dev and js:build scripts and incorporate those into our existing start and build scripts.

"scripts": {
  "start": "run-p eleventy:dev sass:dev js:dev",
  "build": "run-s eleventy:build sass:build js:build",
  "js:dev": "esbuild src/js/*.js --format=esm --bundle=true --splitting=true --outdir=dist --watch --sourcemap=inline",
  "js:build": "esbuild src/js/*.js --format=esm --bundle=true --splitting=true --outdir=dist --minify=true"

Now when we run npm run build, minified JS files will be written to the dist folder, and npm start will watch our JS files for changes. We will also need to update .eleventy.js to tell Eleventy to watch those files to get it to reload the page automatically when JS files change.

module.exports = function (eleventyConfig) {

  return {
    dir: {
    input: 'src/pages',
    output: 'dist',
    includes: '../partials',

Loading Modern JS in our HTML

Now that we have a modern JS build process working, let’s put a script tag in our layout.njk partial just before the closing </body> tag.

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

If we run npm start and visit localhost:8080, we’ll be able to see our messages logged to the console. Note that since we’re using modern syntax (import statements, string interpolation, and arrow functions), we’ll need to use the type="module" attribute. This will work in all modern browsers, but older browsers won’t even attempt to download the file, let alone run it.

Following the death of IE11, a modern-only approach may be sufficient for your audience, but there are still browsers in the wild that won’t support modern JS. So we’ll eventually want to support a legacy JS build as well. We’ll get to that in the next part of the series, but let’s wrap up our basic JS support first, starting with linting.

Linting Our JS

We want to ensure that the JS that we write is high quality and written consistently. For this, we’ll use eslint, and we’ll go with the popular eslint-config-airbnb configuration. Let’s start by installing some dependencies.

npm install --save-dev eslint eslint-config-airbnb

We’ll need to do some configuration to let eslint know what rules to use. Let’s create a file in our root directory called .eslintrc.json.

  "root": true,
  "extends": [
  "rules": {
    "import/prefer-default-export": 0

This will use the rules that eslint recommends, as well as the rules that the airbnb-base config uses. To demonstrate how to override a rule, I have disabled the import/prefer-default-exports rule, since the JS we have set up already is using named exports.

We don’t want eslint evaluating our dependencies or production output, so let’s add another file called .eslintignore. This works the same way as .gitignore. Any files or directories added to it will be ignored by eslint.


Finally, let’s add to our linting scripts in package.json. We’ll want a standalone script for just linting JS, and we’ll want to add that to our catch-all lint script.

"scripts": {
  "lint": "run-s lint:html lint:css lint:js",
  "lint:js": "eslint . --ext .js"

The command tells eslint to evaluate all files starting at the root directory that use the .js extension. If we run npm run lint:js now, we should see some warnings about our console.log statements, but no errors.

Now that we have linting to catch code style problems, let’s add some support for testing so that we can confirm that our code does what we want it to do.

Testing our JS

We’ll use Jest as our test runner. Let’s start by installing some dependencies.

npm install --save-dev jest jest-environment-jsdom babel-jest @babel/core @babel/preset-env

Let’s break down what each of these are and why we need them:

  • jest is the test runner
  • jest-environment-jsdom lets us specify that our JS is meant to run in browsers and lets us use DOM-specific methods in our tests
  • babel-jest and @babel/core are necessary for jest to be able to understand ESM-style imports (annoyingly)
  • babel-preset-env is needed to specify the target environment for babel to transform our code to for testing

We need to do some configuration here, so let’s create a file called jest.config.js.

module.exports = {
  testEnvironment: 'jsdom',
  transform: {
    '^.+\\.js$': 'babel-jest',

All we need now is a test script in our package.json.

"scripts": {
  "test": "jest"

Running npm run test (or just npm test) will run our tests, which should succeed if all goes well.

To Be Continued…

We’re nearly done with JS support, but we can go further. We will probably want a legacy build process to support older browsers, and we might want to use TypeScript instead of, or in addition to, regular old JavaScript. We’ll cover that next time, so see you then!

Related Content

UnConference: A Maturity Model for Design Systems



If you’re planning, attempting, building, evolving, or perfecting a design system, join Ben Callahan at this event!

More Details