build a sapper/strapi website

build a sapper/strapi website
Rich Harris stalled sapper development on his talk at the svelte summit 2020 and the svelte@next thing looks really promising. In fact I tried directly to move a sapper page to svelte@next and it seems, that - for now - it is a little early for that.
UPDATE March 02 2021: I removed the "Single" prefix from page component names and in the sapper code.
UPDATE March 21 2021: I updated the example project now including a sapper and a sveltekit codebase.

This post shows my way of building a website where strapi delivers the structure and content and sapper delivers the frontend. You can find the working code example here: https://github.com/djpogo/sapper-strapi. The following video presents the editing workflow of this setup:

strapi setup

page model

Starting with a new collection type "page" with these fields:
* title (short text)
* indexPage (boolean, default false)
* slug (short text, unique)
* fixSlug (boolean, default false)
* description (long text)
* ogImage (single media)
* parentPage (optional parent page)

strapi page model

The slug will be auto generated on save/update of a page. Have a look into the repository to see how. The index page needs the indexPage flag, to get the / slug. With this page setup you can create pages and with a parent page, the page will prefix its slug with the slug of the parent page.
Sapper will query strapi by matching the page.path against the /pages?slug=<page.path>.

page contents

Adding a dynamic zone "contents" to the page, with a first component "Text":

add new component to the page dynamic zone called Text

This component has a rich-text field called "text".

A second component will be an image component "Image", having one image:

add new component to the page dynamic zone called Image

This component has a media field called "image".

I will keep it with this two basic components and hope you can add your components from here.

dynamic zone components when editing a page

Lastly on our strapi setup we create a "navigation" single type, to get in control of our page order. You may add a second "metaNavigation" single type for a footer navigation.

Create a single type called Navigation and add a component as only field:

Navigation - add new component
Navigation - make component repeatable

And add a title (short text) and a relation to page to that component.

Navigation - component fields

permissions

Navigate to settings -> roles and edit the public role:

strapi public permissions setup

Now you are able to consume your strapi api from any client.

Create some pages with content and create a navigation, and we can move on to sapper.

sapper

routing

Our application does not know any route strapi provides and therefore we need a catch-all route on sapper. Unfortunately I did not find a solution to catch the index-route as well as any other route, so I need to have two files in the routes folder consuming strapi:

 src
 + routes
 + + index.svelte
 + + [...slug].svelte
 + + /* other routes */

index.svelte and [...slug].svelte are identical and by the use of sappers spread route feature, every route besides the index route, will use this file.
You can still provide other named routes, for example a login.svelte will be rendered under /login  instead of the [...slug].svelte route.

For server side rendering sapper provides the <script context="module"> pattern, which expects a export async function preload() function.

Because we have duplicate code in the index and [...slug] we create a /src/strapi.js file, having the preload function:

/* /src/strapi.js */
export async function strapiPreload(page, session) {
  const res = await this.fetch(`${apiUrl}/pages?slug=${encodeURIComponent(page.path)}`);
  const data = await res.json();
  if (res.status !== 200) {
    this.error(res.status, data.message);
  }
  // empty array from strapi results in a 404 page
  if (!data || data.length === 0) {
    this.error(404, 'Page not found');
  }
  return {
    pageData: data.shift(),
  };
}

In the .svelte files you use this setup:

<!-- /src/routes/index.svelte | /src/routes/[...slug].svelte -->
<script context="module">
  import { strapiPreload } from '../strapi';
  export async function preload(page, session) {
    const strapi = strapiPreload.bind(this);
    return strapi(page, session);
  }
</script>

<script>
  export let pageData;
</script>

<svelte:head>
  <title>{pageData.title}</title>
</svelte:head>

To minimize the duplicate code, we put the <head> data in its own component:

<!-- /src/components/HtmlHead.svelte -->
<script>
  export let pageData;
</script>

<svelte:head>
  <title>{pageData.title}</title>
  {#if pageData.description}<meta name="description" content={pageData.description}>{/if}
  {#if pageData.ogImage}
  <meta property="og:image" content={`http://localhost:1337${pageData.ogImage.url}`}>
  {/if}
</svelte:head>

As soon as you extend your page meta data, only this component needs to be updated and not index.svelte or [...slug.svelte].

Only /src/routes/**/* files are able to preload data, for the navigation we go to the /src/_layout.svelte file:

<!-- /src/routes/_layout.svelte -->
<script context="module">
import { navPreload } from '../strapi';
export async function preload(page, session) {
    const strapi = navPreload.bind(this);
    return strapi(page, session);
}
</script>

<script>
  import Nav from '../components/Nav.svelte';

  export let segment;
  export let navPages;
</script>

<Nav {navPages} />

<main>
	<slot></slot>
</main>

As you see there is a new strapi function arrived:

/* /src/strapi.js */
export async function navPreload(page, session) {
  const res = await this.fetch(`http://localhost:1337/navigation`);
  const data = await res.json();
  if (res.status !== 200) {
    this.error(res.status, data.message);
  }
  return {
    navPages: data.Navigation.map((page) => ({
      title: page.title,
      url: page.page.slug,
    })),
  };
}

The .map(...) of the Navigation data intends to get a smaller object without the complete page data, hopefully to keep the memory footprint small in the browser.
Let us reuse the default Nav.svelte file, for our strapi navigation:

<!-- /src/components/Nav.svelte -->
<script>
  export let navPages;
  import { stores } from '@sapper/app';
  const { page } = stores();

  page.subscribe(({ path }) => {
    navPages = navPages.map((page) => ({
      ...page,
      active: activePage(page.url, path),
    }));
  });

  function activePage(slug, path) {
    if (path === undefined && slug === '/') {
      return 'page'
    }
    if (path === slug) {
      return 'page';
    }
    return undefined;
  }
</script>

<style>...</style>

<nav>
	<ul>
    {#each navPages as page}
    <li><a aria-current={page.active} href={page.url}>{page.title}</a></li>
    {/each}
	</ul>
</nav>

strapi content component

For our strapi components we create a Strapi.svelte container component and for every content component from strapi a stand-alone component file:

src
+ components
+ + strapi
+ + + Strapi.svelte
+ + + Text.svelte
+ + + Image.svelte

You need to add Strapi component to index.svelte and [...slug].svelte:

<!-- /src/routes/index.svelte || /src/routes/[..slug].svelte -->
<script context="module">
  import { strapiPreload } from '../strapi';
  export async function preload(page, session) {
    const strapi = strapiPreload.bind(this);
    return strapi(page, session);
  }
</script>

<script>
  import HtmlHead from '../components/HtmlHead.svelte';
  import Strapi from '../components/strapi/Strapi.svelte';

  export let pageData;
</script>

<HtmlHead {pageData} />

<Strapi contents={pageData.contents} />

Strapi.svelte will walk through all pageData.contents entries and bring the corresponding svelte component into the page.

<!-- /src/components/strapi/Strapi.svelte -->
<script>
  import Text from './Text.svelte';
  import Image from './Image.svelte';

  const componentMap = {
    'page.text': Text,
    'page.image': Image,
  };

  export let contents = [];
</script>

{#each contents as content}
<svelte:component
  this={componentMap[content.__component]}
  {content}
/>
{/each}

This file uses svelte dynamic component instancing to display all strapi components. With every new strapi content component, you need to create the svelte component, import it and add it to the componentMap.
Every strapi components starts with export let content; to get its strapi content.

Text.svelte needs a markdown processor since strapi rich-text editor stores markdown in the db. In this project I use snarkdown for markdown processing, because it is very small in filesize. If you need a different markdown processor I my advise is to have a look at bundlephobia, how big your chosen markdown processor is. This data will land in your client app too, and a showdown (23.6 kb) or a markdown-it (31.8 kb) might decrease your app performance.

<!-- /src/copmponents/strapi/Text.svelte -->
<script>
  import snarkdown from 'snarkdown';
  export let content;
</script>

{@html snarkdown(content.text)}

Image.svelte displays a stand-alone img tag with the image you upload to strapi:

<!-- /src/components/strapi/Image.svelte -->
<script>
  export let content;
</script>

<img
  src={`http://localhost:1337${content.image.url}`}
  alt={content.image.alternativeText}
>

<style>
  img {
    width: 100%;
    height: auto;
  }
</style>

strapi url

Til now the strapi url is hardcoded in our app. This needs to be changed before we go on to production. Let us see where we need to address strapi in our app:
* querying strapi api (/pages, /navigation)
* strapi uploads (ogImage, Image, tba)
To enable the use of environments variables we use the sapper-environment package and follow the instructions to extend the rollup.config.js:

/* /rollup.config.js */
...
import sapperEnv from 'sapper-environment';

export default {
  client: {
    ...,
    replace({
       ...sapperEnv(),
       ...
       }),
  server: {
    ...,
    replace({
       ...sapperEnv(),
       ...
       }),
  servicework: {
    ...,
    replace({
       ...sapperEnv(),
       ...
       }),

Next, create a .env file with a SAPPER_APP_API_URL=http://localhost:1337 value and use this value in our strapi.js file:

/* /src/strapi.js */
const apiUrl = process.env.SAPPER_APP_API_URL;

export async function strapiPreload(page, session) {
  const res = await this.fetch(`${apiUrl}/pages?slug=${encodeURIComponent(page.path)}`);
  const data = await res.json();
  if (res.status !== 200) {
    this.error(res.status, data.message);
  }
  // empty array from strapi results in a 404 page
  if (!data || data.length === 0) {
    this.error(404, 'Page not found');
  }
  return {
    pageData: data.shift(),
  };
}

export async function navPreload(page, session) {
  const res = await this.fetch(`${apiUrl}/navigation`);
  const data = await res.json();
  if (res.status !== 200) {
    this.error(res.status, data.message);
  }
  return {
    navPages: data.Navigation.map((page) => ({
      title: page.title,
      url: page.page.slug,
    })),
  };
}

And everywhere where we use user uploads from strapi:

<!-- /src/components/HtmlHead.svelte -->
<script>
  const apiUrl = process.env.SAPPER_APP_API_URL;
  export let pageData;
</script>

<svelte:head>
  <title>{pageData.title}</title>
  {#if pageData.description}<meta name="description" content={pageData.description}>{/if}
  {#if pageData.ogImage}
  <meta property="og:image" content={`${apiUrl}${pageData.ogImage.url}`}>
  {/if}
</svelte:head>
<!-- /src/components/strapi/Image.svelte -->
<script>
  const apiUrl = process.env.SAPPER_APP_API_URL;
  export let content;
</script>

<img
  src={`${apiUrl}${content.image.url}`}
  alt={content.image.alternativeText}
>

<style>
  img {
    width: 100%;
    height: auto;
  }
</style>

conclusion

Strapi really is a time saving tool. Giving you a what-you-see-is-what-you-get data modelling interface and the whole under-laying framework power (koa.js) too. In this blog post it is the slug creation, in my other blog post I show how to create a custom route or how to import data into strapi by using the api.

Sapper - even it will never become version 1.0 - is a lightweight framework, making the client side rehydration quicker and this way your visitor more happy. svelte@next looks really promising, and when it comes out, I hope that this blog post will be adoptable to svelte@next code.

This example hopefully gives you a basis to start a strapi/sapper project to create websites which do not need a login/authentication/user upload thing. I might extend this repository to give that functionality too, in the future.

I did intentionally add no more complex components, for example a slider or something else, to keep this project pretty much greenfield for you. I made one decision for you, to use snarkdown because of its tempting file size. But it has some limitations (no tables, no HTML sanitizing) which may be a showstopper on your project. I hope this is a good starting point for your next idea.

performance statistics

This setup comes in production on a very small JavaScript footprint of 12.2 kb gzipped and 27.4 kb unzipped:

JavaScript footprint

On a xperia xz1 compact this is the lighthouse scoring:

xperia xz1 compact lighthouse scoring

image credits

Demo images from the video clip are all from Basil Smith.

Article Image by Basil Smith via unsplash and ghost