Static Rendering of Svelte Components

Recently I have been working on a Single Page Application (SPA) using Svelte where some pages like Terms of Service were static HTML pages. This had the downside that when the user clicked on a link to one of these pages he would leave the SPA and trigger a page load, potentially loosing application state when hitting the browsers back button. So I decided to convert these static HTML pages to Svelte components. As these pages mainly contain legal content I thought it might be still a good idea to have them available as static HTML pages. But what I clearly did not want to do is to maintain two versions of the same content, one static page and one Svelte component, so I sat down and added static rendering of selected components to the rollup build.

Note that there are full-featured frameworks that use a technique named Server-Side Rendering (SSR) to solve similar problems. The one available for Svelte is Sapper.

Sapper is a great choice if:

  • Your SPA has more than just a few well-known static pages,
  • Search-Engine Optimization (SEO) is important to you,
  • time to first paint matters,
  • or your backend already uses node.js.

None of these applies to our SPA. In our case the backend is written in Rust so requiring a second backend that just serves the purpose of SSR for a dozen pages would have been total overkill.

Rendering a Svelte component to HTML

Let’s say you have a Test component living in ./Test.svelte:

<script>
  export let title;
</script>
<div>{title}</div>

To render this to HTML we use the following script render.js:

require("svelte/register");
console.log(require("./Test.svelte").default.render({title: "Hello world"}).html);

Running node render.js outputs the HTML on the console:

<div>Hello world</div>

As Svelte components can contain component-local CSS, the result of the render() function also contains a css field (and also a head field).

Integration with rollup

I use a very simple template for all my statically generated HTML pages. This is also used to generate the root index.html:

<!-- file: index.template.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>%%TITLE%%</title>
    <link ref="icon" type="image/png" href="/favicon.png" />
    <link rel="stylesheet" href="/css/main.css" />
    %%SCRIPTS%%
  </head>
  <body>
    %%BODY%%
  </body>
</html>

As can be seen below in the excerpt of my rollup.config.js, I use @rollup/plugin-html to create HTML pages from within rollup (note that I create the required directory structure outside of rollup). In the template shown above we want to replace %%BODY%% with the rendered output of the Svelte component. makeTemplate() function below is a little bit more complex than it has to be. This is because I create a bundle-[hash].js file and from within makeTemplate() I do not know the actual value of hash. So instead, I scan through all the entries of bundle and look for any javascript file and inject it into the template at the %%SCRIPTS%% location. If you don’t use a hashed bundle you can simply get rid of the scripts code and replace %%SCRIPTS%% for instance with <script defer src="/bundle.js"></script>.

// file: rollup.config.js
import svelte from "rollup-plugin-svelte";
import html from "@rollup/plugin-html"
import fs from "fs";
require("svelte/register");

function makeTemplate(templateData, svelteComponent) {
  return ({ attributes, bundle, files, publicPath, title }) => {
    const scripts = Object.entries(bundle)
      .filter(([key, value]) => value.isEntry && key.endsWith(".js"))
      .map(([key, value]) => `<script defer src="/${key}"></script>`)
      .join();

    const body = svelteComponent.render({}).html;

    return templateData
      .replace("%%BODY%%", body)
      .replace("%%TITLE%%", title)
      .replace("%%SCRIPTS%%", scripts);
  };
}

export default {
  // ...
  plugins: [
    svelte({
      // ...
      hydratable: true, // This is important!
    }),

    html({
      fileName: "privacy/index.html",
      title: "Privacy Policy",
      template: makeTemplate(
        fs.readFileSync("./index.template.html", "utf8"),
        require("./src/App/components/PrivacyPage.svelte").default
      ),
    }),

    // ... more calls to html({})
  ],
}

The hydratable: true option will tell Svelte to re-render the page using the dynamic Svelte component.

This is all it needs to add static rendering of Svelte components to a project.