Our little content site etch.co was built with Next.js until we recently migrated it to Eleventy. Even though no features were changed or removed the statistics show a dramatically different result:
| Statistic | Next.js | 11ty |
|---|---|---|
| Lighthouse performance score | 76 | 97 |
| Homepage Kilobytes of JavaScript | 2,161KB | 11.3KB |
| Homepage JavaScript file count | 33 | 2 |
| NPM dependency count | 1115 | 13 |
| Overall lines of code | 6,877 | 4,307 |
Use the browser
Permalink to "Use the browser" headingThe numbers clearly don’t favour Next.js, but this isn’t a story about frameworks, quite the opposite.
Eleventy (11ty) doesn’t have some special performance enhancing feature or a magic syntax which massively reduces lines of code.
Instead, it provides the minimalistic set of tools to template some HTML, bundle some CSS and lastly sprinkle JavaScript in the few places it’s needed. Then, it gets completely out the way and lets the browser do the rest.
This leads to, what in our opinion, are 11ty’s biggest strengths:
- Clear separation between build and browser 11ty is build-time-only, we can trust it’s not going to ship extra code to the browser after the build.
- Stability In busy periods where we’re focused on our clients we don’t want to spend what time we do have on our site fixing incoming breaking changes from dependencies. 11ty has only had 3 major version changes since 2018.
We’ve found 11ty’s lightweight feature set ideal for our needs, but it doesn’t always lead to the best developer experience. In some cases, more work was needed up front to achieve parity with Next.js features that are either provided out of the box or via a well-supported plugin.
In the remaining sections of this article we’ll dive into the details covering how we maintained feature parity during the migration and what we learned along the way.
Choosing a templating language
Permalink to "Choosing a templating language" headingSince 11ty is a build-time-only tool, it’s not locked in to a syntax that compiles to browser compatible code like JSX. This freedom means there’s a mind boggling number of possible ways to write templates.
As big fans of web components and HTML, the WebC templating language got our attention — it enables the use of custom HTML elements as a templating language. Here’s how a typical WebC component file is structured:
<script>
// JS in here will be bundled
</script>
<style>
/* CSS will get bundled too */
</style>
<!--
This HTML content gets output where the
custom element tag is placed
-->
<slot>
<!-- Slots are supported too -->
</slot>
WebC did the job and we got it to cover everything JSX did before, but not without some effort. Here are some of the things to keep in mind when migrating from JSX:
- There are a lot of different ways that 11ty can render WebC components which we needed to learn, and even after getting familiar with it, we still end up with unexpected results.
- Props can be added in 3 different ways – build only props are prefixed with
@, dynamic props are prefixed with:and dynamic build only props are prefixed with:@. Unprefixed props will be treated as a standard HTML attributes. - For functions or variables to be made available to templates they need to either be added inside a setup script or globally as a helper function. Variables from front matter can also be used but this is only available within page-level templates.
- WebC is still new, and the community is smaller than what we were used to. When things went wrong it wasn’t always obvious how to fix them.
This was actually one of the most challenging parts of the migration and we’re still settling on a consistent template format, but given the chance, we’d choose WebC all over again. It’s the closest we can get to writing in plain HTML and ultimately that’s the goal.
Bundling styles
Permalink to "Bundling styles" headingWebC has a simple built in bundler which let’s us declare CSS files to include as part of the layout template. Here’s how that looks:
<!--- Bundled base styles --->
<link rel="stylesheet" href="../../node_modules/@etchteam/diamond-ui/diamond-ui.css" webc:bucket="styles">
<link rel="stylesheet" href="../styles/variables.css" webc:bucket="styles">
<link rel="stylesheet" href="../styles/base/font.css" webc:bucket="styles">
<link rel="stylesheet" :href="getBundleFileUrl('css', 'styles')" webc:keep>
<!--- Bundled component styles --->
<link rel="stylesheet" :href="getBundleFileUrl('css')" webc:keep>
This ends up outputting the two <link> tags with webc:keep:
- The core styles added to the styles bucket via
webc:bucket="styles" - Component styles which get automatically bundled via
getBundleFileUrl('css')
To accompany the WebC bundler a basic Lightning CSS transform was added to the 11ty config which sends all the CSS through minification and Browserslist:
async function transformCSS(content) {
if (this.type !== 'css') return content;
const { code } = transform({
code: Buffer.from(content),
minify: true,
targets: browserslistToTargets(
browserslist('> 0.25% and not dead')
),
});
return code;
}
eleventyConfig.addPlugin(webc, {
components: ['src/components/**/*.webc'],
bundlePluginOptions: {
transforms: [transformCSS],
},
});
Adding JavaScript interactivity
Permalink to "Adding JavaScript interactivity" headingConsidering that with Next.js every single file contained JavaScript, there was surprisingly little JavaScript that was actually required on the client-side to add interactivity.
In these cases, we found a combination of WebC and Web Components work well together.
<!--
When JavaScript loads on the browser
`<my-component>` will be registered as a
Web Component
-->
<script>
class MyComponent extends HTMLElement {
// Custom JavaScript to add interactivity
}
if ('customElements' in window) {
customElements.define(
'my-component',
MyComponent
);
}
</script>
<!--
WebC will render this HTML first, before
JavaScript loads
-->
<div class="my-component">
<slot></slot>
</div>
During the static build, 11ty will pull the parts of this file apart:
- The HTML will be output wherever
my-componentis included on the page - The content inside the
scriptwill be bundled with the rest of JavaScript, it will only be included once even ifmy-componentis included multiple times. - Once JavaScript is executed, the web component will be registered
The result: reusable static HTML content is available on the page immediately with custom Web Component logic progressively enhancing the static markup.
Implementing a service worker
Permalink to "Implementing a service worker" headingPreviously we used next-pwa to provide near automatic service worker capabilities out of the box.
We found no equivalent plugin for 11ty, but here’s the thing… the reason dependencies like next-pwa are helpful is because Next.js performs abstracted and wonderfully complex bundling, which makes it hard to predict which files will end up being served by the browser.
We don’t have this problem with 11ty, so we can implement a custom service worker instead.
Workbox was set up to set up a pre-cache file list for all but the blog and images:
injectManifest({
globDirectory: 'dist/',
globPatterns: ['**/*.{html,js,css,woff,woff2,json}'],
globIgnores: ['**/sw.js', '**/workbox-*.js', '**/blog/**'],
swSrc: 'src/sw.js',
swDest: 'dist/sw.js',
});
This replaces self.__WB_MANIFEST in the sw.js file at build time, caching for the images, blog posts and API requests are also included here.
workbox.precaching.precacheAndRoute(self.__WB_MANIFEST);
// Cache strategy for static images
workbox.routing.registerRoute(
({ request }) => request.destination === 'image',
new workbox.strategies.CacheFirst({ /* ... */ }),
);
// Cache strategy for HTML pages not included in precache (blog posts)
workbox.routing.registerRoute(
({ request }) => request.mode === 'navigate',
new workbox.strategies.NetworkFirst({ /* ... */ }),
);
// Cache strategy for external resources (API)
workbox.routing.registerRoute(
({ url }) => url.hostname === 'f.etch.co',
new workbox.strategies.StaleWhileRevalidate({ /* ... */ }),
);
All that’s left is to include the service worker in the layout file:
<script webc:keep webc:if="process.env.NODE_ENV !== 'development'">
if ('serviceWorker' in navigator) {
try {
navigator.serviceWorker.register('/sw.js');
} catch (error) {
console.error('Service Worker registration failed:', error);
}
}
</script>
Final thoughts
Permalink to "Final thoughts" headingSomething important that hasn’t been mentioned yet is that we could have achieved similar performance gains with Next.js, but it would have required gymnastics like running bundle analysers and tweaking routing methods. In choosing 11ty we accepted that Next.js is built for bigger problems than our little static content site has, so we opted for subtraction rather than addition.
The benefits of the switch have been clear, the site noticeably performs better, and that’s not just in the browser – builds are faster, there are fewer dependencies and fewer breaking changes. It feels like the tooling has gotten out of the way, and the website is just a website again ❤️