Asset Bundling Workflow for Eleventy with Webpack and Tailwind CSS

In this guide we will walk through how to set up an asset bundling workflow for an Eleventy project with Webpack and Tailwind CSS. At the end of this guide we will have a project with these features:

  • A standard directory layout
  • Static site generation using Eleventy
  • Asset bundling using Webpack
  • ES6 support using Babel
  • CSS utilities using Tailwind CSS
  • CSS processing using PostCSS
  • CSS vendor prefixes using autoprefixer
  • Unused CSS removal using PurgeCSS
  • CSS minification using cssnano
  • Cache busting in production

This guide borrows ideas and code from the excellent Eleventy starter project Elevenpack.

1. Create Eleventy Project

Create a new directory my-eleventy-project and initialize the project by running the following commands in a terminal window.

mkdir my-eleventy-project
cd my-eleventy-project
npm init -y

2. Install Dependencies

Install all the dependencies required for our setup. In the following sections we will configure each of these dependencies and explain their usage.

npm install --save-dev @11ty/eleventy
npm install --save-dev webpack webpack-cli
npm install --save-dev npm-run-all
npm install --save-dev babel-loader @babel/core @babel/preset-env
npm install --save-dev css-loader postcss-loader autoprefixer
npm install --save-dev mini-css-extract-plugin
npm install --save-dev webpack-manifest-plugin
npm install --save-dev tailwindcss
npm install --save-dev @fullhuman/postcss-purgecss
npm install --save-dev cssnano

3. Create Directory Structure

Create this directory structure under my-eleventy-project.

my-eleventy-project
|-- dist
|-- src
|-- assets
|--css
|--js
|-- data
|-- includes
|-- layouts
|-- static

4. Configure Eleventy Directories

Create the .eleventy.js configuration file in my-eleventy-project with the following contents. This configures Eleventy to use the src directory as input and dist as output. The includes, layouts and data files will be in corresponding directories under src.

module.exports = function(eleventyConfig) {
return {
dir: {
input: 'src',
output: 'dist',
includes: 'includes',
layouts: 'layouts',
data: 'data',
}
};
};

5. Configure Static Files Passthrough

Configure Eleventy to copy static files from src/static directly to dist without any processing.

module.exports = function(eleventyConfig) {
// Copy static files directly to output.
eleventyConfig.addPassthroughCopy({ "src/static": "/" });

return {
dir: {
...
},
passthroughFileCopy: true
};
};

6. Create Site Metadata

Create a site.json file under src/data. This file contains the title, description and url of our site for now. Later it can be used to store other metadata such as Google Analytics Tracking Id, Facebook URL, Twitter URL etc. We will refer to this metadata in our templates instead of hardcoding it. This helps keep things DRY and avoid having to repeat this information in multiple places.

{
"title": "My Eleventy Project",
"description": "Eleventy project with Webpack and Tailwind CSS",
"url": "http://example.com"
}

7. Create Base Layout Template

Create a base.njk file under src/layouts. This will be the base layout template that will be used by all pages.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% if title %}{{ title }} | {% endif %}{{ site.title }}</title>
<meta name="description" content="{% if description %}{{ description }}{% else %}{{ site.description }}{% endif %}">
<link rel="canonical" href="{{ site.url }}{{ page.url }}">
</head>
<body>
{{ content | safe }}
</body>
</html>

Also add an alias to the newly created base layout in .eleventy.js. This allows us to refer to the layout by its alias instead of by its file path.

module.exports = function(eleventyConfig) {
// Add layout alias
eleventyConfig.addLayoutAlias("base", "base.njk");

...
};

8. Create Index Page

Create an index.njk file under src. This will serve as the index page of the website.

---
title: My Index Page
description: A sample index page for My Eleventy Project
layout: base
---

<h1>{{ title }}</h1>
<p>Lorem ipsum dolor sit amet...</p>

9. Add Scripts in package.json

Let's add a few scripts in package.json to save ourselves from the tedium of having to repeat commands to serve / build our project. We will use the npm-run-all package to run commands in sequence or in parallel.

The scripts we will add are:

  • dev - runs the dev:assets and dev:site scripts in parallel during development

  • dev:assets - runs the webpack --watch command to continuously watch for changes in JavaScript or CSS assets

  • dev:site - runs the eleventy --serve command to continuously watch for changes in website templates

  • build - runs the clean, build:assets and build:site commands in sequence

  • build:assets - runs the webpack command to build the assets

  • build:site - runs the eleventy command to build the website templates

  • clean - deletes the output dist directory

The scripts section of package.json should look like this:

...

scripts {
"dev": "run-p dev:*",
"dev:assets": "APP_ENV=development webpack --mode production --watch",
"dev:site": "APP_ENV=development eleventy --serve",
"build": "run-s clean build:assets build:site",
"build:assets": "webpack --mode production",
"build:site": "eleventy",
"clean": "rm -rf ./dist"
}

...

10. Create JavaScript and CSS Files

Create two empty files at src/assets/js/index.js and src/assets/css/index.css. These files will be the entry points of our JavaScript and CSS code respectively.

11. Configure Webpack

Create webpack.config.js in my-eleventy-project with the following contents.

const path = require('path');

const MiniCssExtractPlugin = require('mini-css-extract-plugin');

const isDev = process.env.APP_ENV === 'development';

const baseFilename = isDev ? 'index' : 'index.[contenthash]';

module.exports = {
entry: [
path.resolve(__dirname, 'src', 'assets', 'js', 'index.js'),
path.resolve(__dirname, 'src', 'assets', 'css', 'index.css'),
],
output: {
path: path.resolve(__dirname, 'dist', 'assets'),
filename: `${baseFilename}.js`,
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: [
{
loader: 'babel-loader',
options: {
presets: ["@babel/preset-env"],
},
},
],
},
{
test: /\.css$/,
use: [
MiniCssExtractPlugin.loader,
{
loader: 'css-loader',
options: {
importLoaders: 1,
},
},
'postcss-loader',
],
},
],
},
plugins: [
new MiniCssExtractPlugin({ filename: `${baseFilename}.css` }),
],
};

Here's a quick summary of the Webpack configuration:

  • Use src/assets/js/index.js and src/assets/css/index.css as entry points for the bundling process

  • Use babel-loader to load and process the JavaScript code

  • Use css-loader and postcss-loader to load and process the CSS code

  • Use mini-css-extract-plugin to extract the CSS into a separate file

  • Write the processed JavaScript and CSS code to the dist/assets directory

  • Use a content hash in the output filenames to enable cache busting in production

12. Include Bundled Assets in the Layout

We have configured Webpack to generate a cache-busting hash in the filename in build mode. The file names of the bundled JavaScript and CSS assets will not be known until build time.

The webpack-manifest-plugin generates a manifest.json file that contains a mapping of each Webpack entry point to its corresponding output file. The generated manifest file will look something like this:

{
"main.css": "/assets/index.ef8ec0036bded08d3104.css",
"main.js": "/assets/index.620f9bd09ffda87304fe.js"
}

We will fetch the bundle file names from the manifest file and include them in our layout at build time.

Add the webpack-manifest-plugin in webpack.config.js.

...

const ManifestPlugin = require('webpack-manifest-plugin');

module.exports = {
...

plugins: [
...
new ManifestPlugin({ publicPath: '/assets/' }),
],
};

Configure Eleventy in .eleventy.js to read the manifest.json file and fetch the bundle file names.

const fs = require("fs");
const path = require("path");

const isDev = process.env.APP_ENV === "development";

const manifestPath = path.resolve(__dirname, "dist", "assets", "manifest.json");
const manifest = isDev
? {
"main.js": "/assets/index.js",
"main.css": "/assets/index.css",
}
: JSON.parse(fs.readFileSync(manifestPath, { encoding: "utf8" }));

module.exports = function(eleventyConfig) {
...
};

Add Eleventy shortcodes in .eleventy.js to use the filenames from manifest.json to generate link and script tags. The link and script tags will then be included in our base layout.

...

module.exports = function(eleventyConfig) {
...

// Add a shortcode for bundled CSS.
eleventyConfig.addShortcode("bundledCss", function() {
return manifest["main.css"]
? `<link href="${manifest["main.css"]}" rel="stylesheet" />`
: "";
});

// Add a shortcode for bundled JS.
eleventyConfig.addShortcode("bundledJs", function() {
return manifest["main.js"]
? `<script src="${manifest["main.js"]}"></script>`
: "";
});

...
};

Include the link and script tags in our base layout base.njk.

 <html lang="en">
<head>
...
{% bundledCss %}
</head>
<body>
...
{% bundledJs %}
</body>
</html>

During development we want the browser to refresh the page whenever a JavaScript or CSS file changes. Instead of watching for changes to individual JavaScript and CSS files we will watch for changes to manifest.json as the manifest file is regenerated by the webpack-manifest-plugin whenever a JavaScript or CSS file changes.

...

module.exports = function(eleventyConfig) {
...

// Reload the page every time any JS/CSS files are changed.
eleventyConfig.setBrowserSyncConfig({ files: [manifestPath] });

...
};

13. Configure PostCSS

Create postcss.config.js in my-eleventy-project with the following contents. This configures postcss with the autoprefixer plugin.

const autoprefixer = require('autoprefixer');

const plugins = [
autoprefixer,
];

module.exports = { plugins };

14. Configure Tailwind CSS

To inject Tailwind's base, components and utilities styles into our CSS, edit index.css and add the following contents.

@tailwind base;
@tailwind components;
@tailwind utilities;

Create a tailwind.config.js file in my-eleventy-project with the following contents.

module.exports = {
theme: {
extend: {}
},
variants: {},
plugins: []
}

Add Tailwind as a plugin in the postcss.config.js file.

...

const tailwindcss = require('tailwindcss');

const plugins = [
tailwindcss('tailwind.config.js'),
...
];

...

15. Configure PurgeCSS

Tailwind provides a large number of CSS styles. We will most likely use only a subset of these styles. To keep the bundled CSS file size as small as possible we will use PurgeCSS to remove the unused CSS styles from the output.

Configure PurgeCSS in postcss.config.js as shown below.

...

const isDev = process.env.APP_ENV === "development";

if (!isDev) {
const purgecss = require('@fullhuman/postcss-purgecss');

[].push.apply(plugins, [
purgecss({
content: ['src/**/*.njk', 'src/**/*.md', 'src/**/*.js'],
defaultExtractor: content => content.match(/[\w-/:]+(?<!:)/g) || []
}),
]);
}

...

As recommended by Tailwind we will apply PurgeCSS only to Tailwind's utility classes and not to base styles or component classes. Use PurgeCSS's whitelisting feature to disable PurgeCSS for base and component classes.

/* purgecss start ignore */
@tailwind base;
@tailwind components;
/* purgecss end ignore */

@tailwind utilities;

16. Configure cssnano

As a final step we will optimize and minimize our CSS using cssnano to ensure the output CSS bundle is as small as possible for production.

Configure cssnano in postcss.config.js as shown below.

...

if (!isDev) {
...
const cssnano = require('cssnano');

[].push.apply(plugins, [
...
cssnano({
preset: 'default',
}),
]);
}

...

And we are done! We now have an Eleventy project with a modern asset bundling workflow that can be used as a starting point for any number of static sites.

Source Code

Check out the source code for this guide on Github at eleventy-webpack-tailwind .