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)
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":
This component has a rich-text field called "text".
A second component will be an image component "Image", having one 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.
navigation
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:
And add a title (short text) and a relation to page to that component.
permissions
Navigate to settings -> roles and edit the public role:
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]
.
navigation component
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:
On a xperia xz1 compact this is the lighthouse scoring:
image credits
Demo images from the video clip are all from Basil Smith.
Article Image by Basil Smith via unsplash and ghost ♥