Upgrading Eleventy 2 to 3 With ESM Configuration
Eleventy 3 brought first-class ES modules, a smaller dependency tree, and a configuration API that the newer plugins assume. Upgrading is mostly painless, but "mostly" hides a handful of failures that are easy to misread: a plugin throwing require is not defined, a data file silently returning undefined, a Nunjucks filter that formats dates one day off. The safest way through is two separate steps — upgrade the package with the existing CommonJS config, verify the output, then convert the config and data files to ESM and verify again.
This guide upgrades a 2,300-page documentation and blog site from Eleventy 2.0.1 to 3.0, converts it to ESM, and checks the result with a full output diff. It belongs to Astro vs Eleventy for Documentation Sites, where Eleventy's upgrade path is part of its long-term maintenance cost.
Prerequisites
- Node.js 18 or later (Eleventy 3 requires it; the site used Node 20 LTS).
- The site building cleanly on Eleventy 2 with no warnings you have been ignoring.
- A clean git working tree, so each step can be committed and reverted independently.
Step 1: Take a Baseline Build
Build the site on Eleventy 2 and keep the output for comparison, along with timing:
rm -rf _site && npx @11ty/eleventy --quiet && mv _site /tmp/site-v2
hyperfine --warmup 1 --runs 5 'rm -rf _site && npx @11ty/eleventy --quiet'
The baseline was 2,341 files and a full build of 48.2 s ± 1.1 on the CI runner.
Step 2: Upgrade With the Upgrade Helper
Install Eleventy 3 and the upgrade helper plugin, and add the helper as the last plugin in the existing CommonJS config:
npm install @11ty/eleventy@3 @11ty/eleventy-upgrade-help@3
// .eleventy.js (still CommonJS)
module.exports = async function (eleventyConfig) {
// ...existing config...
const { default: UpgradeHelper } = await import('@11ty/eleventy-upgrade-help');
eleventyConfig.addPlugin(UpgradeHelper);
};
The helper prints a report on the first build. On this site it flagged three things: the eleventyConfig.addPlugin call for an RSS plugin pinned to a version that did not support Eleventy 3, a deprecated eleventyConfig.addWatchTarget glob syntax, and a reminder that Eleventy 3 no longer deep-merges eleventyComputed arrays.
Step 3: Fix Plugins and Diff the Output
The RSS plugin needed upgrading to @11ty/eleventy-plugin-rss@2, whose API changed from filters to a virtual template. Two other plugins worked unchanged. Then the output diff:
rm -rf _site && npx @11ty/eleventy --quiet
diff -r -q /tmp/site-v2 _site | wc -l # files that differ
diff -r /tmp/site-v2 _site | head -50 # what differs
Fourteen files differed. Twelve were the RSS feeds, where the new plugin produced equivalent XML with different attribute order — verified by parsing both and comparing items. Two blog posts showed dates one day earlier. The cause was a custom readableDate filter that called new Date(value) on a front matter string, which Eleventy 3's updated date handling now passes through as a Date already in UTC. Making the filter format in UTC explicitly fixed both.
Step 4: Convert the Config to ESM
With output matching, convert. Set the package type and rename nothing — .eleventy.js is read as ESM once "type": "module" is in package.json, though renaming to eleventy.config.js matches the current docs:
{ "type": "module" }
// eleventy.config.js
import pluginRss from '@11ty/eleventy-plugin-rss';
import syntaxHighlight from '@11ty/eleventy-plugin-syntaxhighlight';
import markdownIt from 'markdown-it';
import markdownItAnchor from 'markdown-it-anchor';
import { readableDate, excerpt } from './config/filters.js';
export default function (eleventyConfig) {
eleventyConfig.addPlugin(pluginRss);
eleventyConfig.addPlugin(syntaxHighlight);
eleventyConfig.setLibrary('md', markdownIt({ html: true }).use(markdownItAnchor));
eleventyConfig.addFilter('readableDate', readableDate);
eleventyConfig.addFilter('excerpt', excerpt);
}
export const config = { dir: { input: 'src', output: '_site' } };
The dir settings move to a named config export, which Eleventy 3 reads before running the config function.
Step 5: Convert Data Files, Filters and Shortcodes
"type": "module" changes how every .js file in the project is loaded, which catches data files and helpers that still use module.exports. A grep finds them:
grep -rlE 'module\.exports|require\(' src config | grep -v node_modules
Nineteen files. Each needed module.exports = changed to export default, require to import, and __dirname replaced:
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
JSON imports in data files use import attributes: import authors from './authors.json' with { type: 'json' };. Files that could not be converted quickly — one third-party script vendored into the repo — were renamed to .cjs, which Node always treats as CommonJS.
Step 6: Diff Again and Remove the Helper
A second full diff against the Eleventy 2 baseline showed only the twelve RSS files, already verified as equivalent. The upgrade helper was removed, since it is meant only for the transition. Final timings:
hyperfine --warmup 1 --runs 5 'rm -rf _site && npx @11ty/eleventy --quiet'
Updating CI and Local Tooling
Three things outside the Eleventy config needed attention. The CI workflow pinned Node 16 in actions/setup-node, which Eleventy 3 does not support; it moved to Node 20 with node-version-file: .nvmrc so local and CI versions stay in step. A pre-commit hook ran a small script with node scripts/check-frontmatter.js, which used require and failed once the package type changed; it was converted alongside the data files. And the editor setup for writers, which used a VS Code extension to preview Nunjucks, needed no change at all.
Third-party tools that load the config file are worth checking too. The site's link checker imported .eleventy.js to read the output directory, which broke when the file became ESM and moved its dir settings to a named export. Changing the checker to read _site from an environment variable removed the coupling, and is simpler anyway.
What Did Not Need to Change
Templates were untouched. Nunjucks, Liquid and Markdown templates rendered identically apart from the date filter, collections behaved as before, pagination worked unchanged, and permalinks were identical across all 2,341 files. Eleventy's own documentation describes most of the upgrade as configuration-level, and on this site that was accurate: the work was in the JavaScript around the templates, not in the templates.
Measured Impact
| Measure | Eleventy 2.0.1 | Eleventy 3.0 (ESM) | Tool |
|---|---|---|---|
| Full build | 48.2 s ± 1.1 | 41.0 s ± 0.8 | hyperfine, 5 runs |
| Peak memory | 1.31 GB | 1.15 GB | /usr/bin/time -v |
node_modules size | 214 MB | 158 MB | du -sh |
| Direct dependencies | 14 | 14 | package.json |
| Output files differing (after fixes) | — | 12 RSS, equivalent | diff -r |
Pitfalls & Rollback
- Upgrading and converting in one commit. If the output changes, you cannot tell which step caused it.
- Skipping the output diff. Date handling and plugin changes show up only in the output.
- Forgetting data files.
"type": "module"changes how every.jsfile loads, not just the config. - Unmaintained plugins. Check each plugin's Eleventy 3 support before upgrading; replace or vendor the ones without it.
- Rollback: revert the ESM commit to return to CommonJS on Eleventy 3, or revert both commits to return to Eleventy 2 — the baseline output in
/tmp/site-v2confirms the rollback is exact.
Conclusion
The Eleventy 3 upgrade is safest as two verified steps: the version first, with CommonJS config and the upgrade helper, then the ESM conversion of config, data and helpers. A full output diff after each step turned up the only real regression — a date filter off by a day — before it shipped, and the site ended up 15% faster to build with a quarter less in node_modules.
FAQ
Do I have to convert my Eleventy config to ESM to use Eleventy 3?
No. Eleventy 3 still supports CommonJS configuration files, so a site can upgrade first and convert later. Converting is worth doing because ESM-only packages can then be imported directly, and new Eleventy features and plugin examples assume ESM. Doing the two steps separately makes each easier to verify.
How do I check that the upgrade did not change the output?
Build the site with Eleventy 2 and again with Eleventy 3 into separate directories, then diff them. Normalise whitespace and known intentional differences first. Any remaining difference is either a bug introduced by the upgrade or a behaviour change worth understanding before deploying.
What breaks most often when upgrading to Eleventy 3?
Plugins that have not been updated, custom filters that relied on CommonJS globals such as require or __dirname, data files using module.exports in a project now set to type module, and differences in Liquid or Nunjucks behaviour from updated template engine versions. The upgrade helper plugin reports many of these during the first build.
Is Eleventy 3 faster than Eleventy 2?
On the site in this guide, a full build went from 48 to 41 seconds and peak memory fell by about 12 percent. Results vary by site, so measure both versions on your own content with a benchmarking tool such as hyperfine before and after.
Related
- Parent: Astro vs Eleventy for Documentation Sites — upgrade cost as a selection factor.
- Fixing Eleventy Out-of-Memory Errors on Large Collections — memory behaviour after the upgrade.
- Fixing Duplicate Permalink Errors in Eleventy — another common Eleventy build failure.
- Snapshot Testing Build Output — making the output diff a permanent check.
- Scoring SSG Long-Term Maintenance Risk — how upgrade effort feeds into maintenance risk.