Do not use sapper to build static blogs

Hiring The Tool Before Defining The Job

This year I’ve spend quite a bit of time trying out svelte. Naturally, when I decided to pick up blogging again, I defaulted to sapper. Sapper is the equivalent of the react based next.js framework, but written in svelte. Svelte compiles code together with the parts of the “framework” that are used by the code into plain JS. The resulting bundle should be notably smaller compared to a zeit app, as react and react-dom alone clock in at 38kb.

Defining The Job

My requirements for a blog are simple:

  1. Posts are edited as markdown, and versioned as code.
  2. Pages load quickly, even when loaded on a flaky mobile network. The blog feels fluid while browsing it with a mid-tier smartphone. The blog should feel like a good SPA.
  3. Content should be crawled easily.

Retro-Fitting Sapper – ❤️at 1st Sight

As I mentioned above, I decided for sapper, based on my preference for svelte, without checking against my acceptance criteria. I blindly trusted the listing in Staticgen, “A List of Static Site Generators for JAMstack Sites”. In hindsight, I made the rooky mistake of choosing the tool without regards for the job. Sapper describes itself as:

a framework for building web applications of all sizes

– which does not correspond perfectly to my mostly static blog.

Adapting sapper for markdown editing (1.) seemed straight-forward: Just follow this article. Voila, sapper delivers the blog’s content from markdown files, deployment and blog post editing with an UI are taken care of - thank you, netifly - onwards!

Performance (2.) seems good at first sight - the lighthouse performance score for the blog list is a straight 100. Too good to be true – it’s always dangerous to trust a single score from a test run on a single page – more on that later.

SEO friendliness (3.) comes out of the box: A big selling point of next.js and sapper is that SSR is taken care of by default. Users and crawlers will always receive a fully rendered page in its initial state. Then the page is automatically hydrated from the JS bundle, turning the page into a full-fledged SPA.

As the blog’s CMS is file-based and the files are hosted on Github, there’s no need for an express server and sapper’s server side rendering. All I need to do is to run sapper export. Sapper export then automatically:

  • builds the latest bundle chunks (split by route) from the svelte components.
  • crawls all routes and stores the pre-rendered HTML with links to the required chunks.
  • copies all assets.

Looking at the build input ensures, that indeed the markdown posts now exist as pre-rendered HTML files with the full posts content. No additional requests besides the one to the .html necessary. Nice! 👏

Pitfalls

I popped a bottle of champagne and wanted to write my first post. Then I opened the network tools and realized that:

  1. opening a blog post loaded 80kb of javascript.
  2. hovering over any blog entry from the post list would load the post’s markdown file. And, connected to that:
  3. on slow network connections, it felt sluggish to move from one page to the other. Absolute load times weren’t bad - but the perceived responsiveness was.

Let’s address one point at a time:

Understanding Sapper’s Component Structure

Let’s first look at the blog post’s component [slug].svelte as in the tutorial linked above:

<script context="module">
    // the preload function loads the post's markdown source –
    // its execution blocks the rendering of the svelte component below
    export async function preload({ params }) {
        const res = await this.fetch(`_posts/${params.slug}.md`);

        if (res.status !== 200) {
            this.error(res.status, data.message);
            return;
        }

        return { postMd: await res.text() };
    }
</script>

<script>
    // transform the markdown to JSON meta-data & HTML output
    // note the heavy dependencies
    import fm from 'front-matter'
    import MarkdownIt from 'markdown-it'

    export let postMd

    const md = new MarkdownIt()
    $: frontMatter = fm(postMd)
    $: post = {
        ...frontMatter.attributes,
        html: md.render(frontMatter.body)
    }
<script>

<h1>{post.title}</h1>
<article>
    {@html post.html}
</article>

Use Sapper’s server routes to minimize client side script

As visible above, sapper’s client side components load the markdown, and transforms it to a JSON object of metadata and the HTML with front-matter (~40kb) and markdown-it (~30kb). Metadata and HTML are then injected and rendered on the page. The libraries are part of the client side JS bundle. There’s an easy fix for that: use a server side route to transform the markdown to HTML. It might look like the below [slug].json.js:

import { fs } from 'mz'

import fm from 'front-matter'
import MarkdownIt from 'markdown-it'

export async function get(req, res) {
    const { slug } = req.params

    const postMd = (await fs.readFile(`static/_posts/${slug}.md`)).toString()

    const md = new MarkdownIt()
    const frontMatter = fm(postMd)
    const post = {
        ...frontMatter.attributes,
        html: md.render(frontMatter.body)
    }

    res.writeHead(200, {
        'Content-Type': 'application/json'
    })

    res.end(JSON.stringify({ ...post }))
}

The server side route’s output is crawled during sapper export, and exported as .json files with pre-computed HTML. Besides the benefit of reducing the client JS payload by ~70kb, the client also no longer has to compute the transformation of markdown to HTML.

After this measure, 1. is fixed – opening a blog post only loads 12kb of JS, a payload small enough to fit into the first roundtrip.

Sapper’s Focus on SPA

As cited above, Sapper’s goal is to be an application framework. That’s why Sapper’s export is concerned with enabling the full SPA experience alike to running the sapper server. The only case where Sapper dissuades from exporting is in cases where different responses are wanted depending on the client. export sounds like a case specifically built for static page generation suited to blogs, but it’s not.

While sapper export outputs pre-rendered HTML files, they are not meant to be the primary way of consuming the web app. The pre-rendered HTML files are meant to be a solid foundation for search engines and users without JS. While it’s technically a progressive enhancement, the default assumption is that clients will run the web application with JS enabled as SPA.

Once sapper’s javascript runs, sapper takes over the navigation and data loading. And both of those were not built with the static site generation of pages without dynamic behaviors as primary use-case in mind.

If one had static site generation as primary target, one would consider changing the following aspects:

  1. Clients would not take over browser history / navigation.
  2. Link preloading would preload the pre-computed, exported HTML file, instead of calling the preload function of the route. Or at least, preloading would only be blocking during the build process for crawling, but not for clients.

For a static blog there’s no need to do routing on the client side, as it’s the case with sapper.

On hydrated clients, hovering over a link calls the preload function. This means that clients load blog post’s raw data for quick injection on navigation. This makes no sense given that the post’s fully rendered page is also available on the server as HTML and could be preloaded.

As preload is blocking, no page navigation happens in reaction to the user’s navigation interaction until the raw data have finished loading. With slower internet connections, this results in a noticeable delay between clicking on a link and seeing the route change. Users will double-click, firing off another round of network requests and potentially slow down the navigation further.

Sapper blocking data loading demo

One could decide to not use the blocking preload function, if that wouldn’t break sapper export. The export process only is able to pre-render the HTML output because the preload function is blocking. When rolling data loading inside the regular svelte component, the pre-rendered HTML will contain the route’s HTML shell without the content, at which point the fundament of progressive enhancement is no longer given.

Quick Band Aid 🩹

A quick fix was to mask the blocking data preloading. I added a service worker with a cache first strategy for posts. For the post list I had to stay with a network first strategy so that users always receive the current list of posts. Repeat-visits to posts will now be near instant. The user still can run into delays when navigating from a post to the post list on a slow network, and the application also loads the current route’s data twice by loading the HTML first, and then the JS bundle with the duplicated data for hydration.

Learning

Hire the right tool for the job.

Long Term Solution

There’s a host of alternatives with different trade-offs:

  • Use a static site generator like 11ty. It’s been built to export truly static blogs. The reason I didn’t go with this option in the first place was the selfish reason that I wanted to have a code editing experience matching SPA development. I was not that much into learning a new template language, and having to understand how to patch through my custom JS.
  • Move to next.js once ready. There’s a change request to add the feature of data loading that runs specifically during export to build static HTML for routes which require data injection. We’re not quite there yet.
  • Invest time understanding sapper’s build pipeline, and contributing to make it better suited to SSG use-cases. This work could also be merged into SSG, a sapper fork that’s not well-maintained.
  • Spending a weekend to write the trillionth node script that enriches templates from markdown files at build time. To enable dynamic behavior, the HTML templates could import ES modules, which would be the output of building svelte components, or web components. Pre-loading links on hover is trivial with intersection observers, or, for the lazy ones, as dependency. Note of surprise: Turbolinks seems a bit big to just cover link preloading.