Chris Draycott-Wheatley

Speed up your Eleventy site with a multi-page app shell

App shells as a concept have been around for a while. The idea is usually associated with single-page apps. The app renders a skeleton view while the actual page markup is being generated.

A multi-page app is what you might consider a traditional website. A full-page reload happens on each navigation event.

Multi-page app shells cache shared aspects of each page on your website. Those shells are then combined with page-specific content for each request.

Shells usually consist of things like the header, navigation and footer of your site. Anything which contains shared, static content that's used across your site is a good candidate.

Using Eleventy's Pagination API we can create a multi-page app shell at build time.

Using Service Worker we can stitch together the caches with our page-specific content as a stream. Doing this allows us to serve the most lightweight response possible in a non-blocking way.

To make to most of the app shell model you should aim to inline as much of your critical CSS & JS as possible into your HTML, and thus your app shells. This will reduce the number of blocking network requests required to render your page. Eleventy has some useful quick tips on how to inline & minify CSS & JS.

To begin with we'll create a couple of stub templates to generate our page start and page end shells. These will be empty other than some Front Matter data to tell Eleventy what to do with them.

Create a shells directory and within it create a shell-start.html & shell-end.html file.

shell-start.html

---
layout: 'layouts/shell-start.njk'
permalink: 'shell-start.html'
---

shell-end.html

---
layout: 'layouts/shell-end.njk'
permalink: 'shell-end.html'
---

Because these shells will be static they don't contain any specific content to, only some Front Matter data which Eleventy will use.

We're referencing a couple of new layout templates above so let's create those now. In your layouts directory create shell-start.njk and shell-end.njk.

These layout files will contain all the cacheable markup for our shells. We want to move as much shared markup into these files as possible and reference them where that markup used to be.

For example, if your base template looks something like this:

base.njk

<!doctype html>
<html lang="en">

{% include "head.njk" %}
<body>
<header>
<a href="{{ '/' | url }}">{{ metadata.title }}</a>
{% include "navigation.njk" %}
</header>

{{ layoutContent | safe }}

<footer>
<small>
Built with Eleventy
</small>
</footer>
</body>
</html>

Then you can move everything above {{ layoutContent | safe }} into layouts/shell-start.njk and everything after it into layouts/shell-end.njk.

Giving you something like this:

base.njk

{% include 'layouts/shell-start.njk' %}

{{ layoutContent | safe }}

{% include 'layouts/shell-end.njk' %}

If you run your build command (npx @11ty/eleventy) you'll see your two shell files generated in the output root. Those files should contain your shared markup and are the basis of your app shell. We'll be using these files later in our service worker to stitch together a streamed response.

Next, we'll update our template configuration to generate two HTML files per page. One will be the full page content which will serve any direct requests where the service worker isn't initialised or supported. The other file will contain the page-specific content markup that our service worker will use.

Using Eleventy's Pagination API we can create a pagination object in a directory specific data file. For this example the file is JSON and resides in the posts directory.

To begin with we'll create a pagination object:

"pagination": {
"data": "viewtypes",
"size": 1,
"alias": "viewtype"
}

Here we're creating a reference to viewtypes, which we'll create shortly. With a size of one we're telling Eleventy to paginate for every viewtype if finds.

Next we create our viewtypes array:

"viewtypes": ["full", "content"]

We're creating a full and content viewtype for Eleventy's pagination to iterate over and create files for.

Finally, we need to add some logic to our permalink object to generate different files for different viewtypes:

"permalink": "posts/{{ title | slug }}/index{% if viewtype == 'content' %}.content{% endif %}.html",

The permalink object allows template logic. This gives us the ability to generate either index.html or index.content.html depending on the viewtype.

Once this is all put together it'll look something like this:

posts/posts.json

{
"layout": "layouts/post.njk",
"tags": "post",
"permalink": "posts/{{ title | slug }}/index{% if viewtype == 'content' %}.content{% endif %}.html",
"pagination": {
"data": "viewtypes",
"size": 1,
"alias": "viewtype"
},
"viewtypes": ["full", "content"]
}

If we run our build again now we'll have two files, but they both contain the same content.

We need to tell Eleventy to render only the page content in the index.content.html file. To do this we can wrap our shell includes in base.njk in a condition, like so:

{% if viewtype == 'full' %}
{% include 'layouts/shell-start.njk' %}
{% endif %}

{{ layoutContent | safe }}

{% if viewtype == 'full' %}
{% include 'layouts/shell-end.njk' %}
{% endif %}

Here we have access to the viewtype variable meaning we can choose to only render our shells for the full viewtype.

If you run your build command again you'll see that your index.content.html file now only includes the page-specific content.

That's the render side all set up, the next step is to use our service worker to stitch together our shells and content.

We're using Workbox to generate our service worker. Let's create a file called service-worker.js in our template directory.

First, we precache our shells:

precacheAndRoute(self.__WB_MANIFEST);

Workbox will replace self.__WB_MANIFEST with the list of precached files we've generated.

Next we create two strategies to tell our service worker what to do with shells and content. We use the CacheFirst strategy to fetch the pre-cached shells from cache and the StaleWhileRevalidate strategy for our content:

const shellStrategy = new CacheFirst({
cacheName: cacheNames.precache,
});
const contentStrategy = new StaleWhileRevalidate({
cacheName: "content",
});

Next, we create a handler to generate a stream of responses which contain our shells and content:

const navigationHandler = strategy([
() =>
shellStrategy.handle({
request: new Request(getCacheKeyForURL("/shell-start.html")),
}),
({ url }) =>
contentStrategy.handle({
request: new Request(url.href + "index.content.html"),
}),
() =>
shellStrategy.handle({
request: new Request(getCacheKeyForURL("/shell-end.html")),
}),
]);

Finally, we register all navigation requests to pass through our handler:

registerRoute(
({ request }) =>
request.mode === "navigate",
navigationHandler
);

Together this looks like so:

import { cacheNames } from "workbox-core";
import { getCacheKeyForURL, precacheAndRoute } from "workbox-precaching";
import { registerRoute } from "workbox-routing";
import { CacheFirst, StaleWhileRevalidate } from "workbox-strategies";
import { strategy } from "workbox-streams";

precacheAndRoute(self.__WB_MANIFEST);

const shellStrategy = new CacheFirst({
cacheName: cacheNames.precache,
});
const contentStrategy = new StaleWhileRevalidate({
cacheName: "content",
});

const navigationHandler = strategy([
() =>
shellStrategy.handle({
request: new Request(getCacheKeyForURL("/shell-start.html")),
}),
({ url }) =>
contentStrategy.handle({
request: new Request(url.href + "index.content.html"),
}),
() =>
shellStrategy.handle({
request: new Request(getCacheKeyForURL("/shell-end.html")),
}),
]);

registerRoute(
({ request }) =>
request.mode === "navigate",
navigationHandler
);

That's our service worker template created. Next, we need to run Workbox build to generate our precached files and compile our service worker. For this we'll use the workbox-build module:

build-sw.js

const workboxBuild = require('workbox-build');

const buildSW = () => {
return workboxBuild.injectManifest({
swSrc: '_includes/service-worker.js',
swDest: '_site/service-worker.js',
globDirectory: '_site',
globPatterns: ["**/shell-*.html"]
}).then(({count, size, warnings}) => {
// Optionally, log any warnings and details.
warnings.forEach(console.warn);
console.log(`${count} files will be precached, totaling ${size} bytes.`);
});
}

buildSW();

We can run the script with node build-sw after each Eleventy build or integrate it into our build pipeline. For example, in your package.json create a build script which runs eleventy && node build-sw. Alternatively, there are other ways to integrate Workbox with your setup, feel free to use whatever method works best for you.

Finally, we need to register the service worker. Add the following to your shell-end.njk template:

<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js');
});
}
</script>

There's one final piece small piece of code to add to ensure our page titles are dynamic. In your base.njk add the following snippet:

<script>document.title = "{{ title }}";</script>

This will ensure your page title is dynamic rather than using the static version in your cached shell-start.html.

With all this now set up, you're able to now build & run your site and see the service worker initialised. You can check the Cache Storage section of the Application tab in Chrome DevTools to see pages being cached as you visit them. It's also worth checking out the Network tab and seeing your service worker responding to requests.

Page refreshes and navigation to pages you've already visited should now be near-instant. This is because everything is being served from cache. In the background we're fetching a new version of the content asynchronously so that any later requests will have the most up to date content.

The composability and flexibility multi-page app shells offer is exciting. Switching mindsets from a page being a single entity to one composed of multiple entities with different priorities and cache profiles opens up a whole new host of possibilities.

The benefit to end-users can't be understated either. An earlier initial paint and less data being transferred ensure a more resilient and reliable experience for all users.


This implementation is inspired by Philip Walton's Smaller HTML Payloads with Service Workers.

Other useful resources include: