In this tutorial we are going to show how to produce a simple static PWA using SvelteKit (Archived) which we make installable by providing a correct manifest.json file and a service worker.

Update 2022-08-24: SvelteKit has now changed the folder and files structure for pages and layouts, so I will have to change (small parts of) this tutorial. Meanwhile you may want to look at what changed.

We start by creating a new SvelteKit project (select a Skeleton project during init).

npm init svelte@next appbuilding-tutorial
cd appbuilding-tutorial && npm install
git init && git add -A . && git commit -m "Initial Commit"

We then need to create the manifest.json file as a static asset in static/manifest.json. The manifest defines how the PWA should behave when installed.

{
    "name": "Forum Application",
    "display": "standalone",
    "id": "forum-application",
    "start_url": "/",
    "icons": [
		{
		    "src": "favicon.png",
		    "type": "image/png",
		    "sizes": "144x144"
		}
    ]
}

These are the very basic information you should provide for your app, and are basically self-explanatory. Check web.dev tutorial (Archived) if you want to add other fields. Please notice that on icons you should have one of at least size 144x144 otherwise your app won't be installable in some browsers.

To convert the default available one to 144x144 you can use the following command on Linux:

convert static/favicon.png -resize 144x144 static/favicon.png

Now we need to add a link to the manifest from every html page of our app, so we add it in src/app.html, inside <head>

<link rel="manifest" href="%svelte.assets%/manifest.json" />

We also need to add a service worker (Archived) in src/service-worker.js. SvelteKit will take care of installing and activating it.

import { build, files, prerendered, version } from '$service-worker'

const worker = self
const FILES = `cache-${version}`

const toCache = [...build, ...files, ...prerendered]
const staticAssets = new Set(toCache)

// On installation cache all files to the new cache
worker.addEventListener("install", event => {
    event.waitUntil(
	caches.open(FILES)
	      .then(cache => cache.addAll(toCache))
	      .then(() => worker.skipWaiting())
    )
})

// On activation delete all older caches
worker.addEventListener("activate", event => {
    event.waitUntil( 
	caches.keys().then(async (keys) => {
	    for (const key of keys)
		if (key !== FILES)
		    await caches.delete(key)
	})
    )
})

async function fetchAndCache(request) {
    const cache = await caches.open(FILES)

    try {
	const response = await fetch(request)
	cache.put(request, response.clone())
	return response;
    } catch (err) {
	const response = await cache.match(request);
	if (response)
	    return response
	throw err
    }
}

worker.addEventListener("fetch", event => {
    // Cache only GET requests
    if (event.request.method !== "GET" || event.request.headers.has("range"))
	return

    const url = new URL(event.request.url)
    const isHttp = url.protocol.startsWith("http")
    const isDevServerRequest = url.hostname === self.location.hostname && url.port !== self.location.port
    const isStaticAsset = url.host === self.location.host && staticAssets.has(url.pathname)
    const skipBecauseUncached = event.request.cache === "only-if-cached" && !isStaticAsset
    
	// Clean the urls from query string and fragments
	// Otherwise we won't have the exact pages in memory
	url.search = ""
	url.fragment = ""
	const cleanRequest = new Request(url)
	
    if (isHttp && !isDevServerRequest && !skipBecauseUncached)
	event.respondWith(
	    (isStaticAsset && caches.match(cleanRequest)) || fetchAndCache(cleanRequest)
	)
})

The last thing we have to modify is svelte.config.js; first we need to install adapter-node, so type

npm install -D @sveltejs/adapter-node
npm uninstall -D @sveltejs/adapter-auto

and then copy the following svelte.config.js

import adapterNode from '@sveltejs/adapter-node'

/** @type {import('@sveltejs/kit').Config} */
const config = {
    kit: {
	adapter: adapterNode({
	    out: "build",
	}),
	prerender: {
	    default: true,
	},
    },
};

export default config;

which enables prerendering on static routes, and specifies the output folder.

Now the PWA should be installable by mobile browsers; unfortunately it is not always clear where to click to install the application. We will make this more evident by adding a simple install button (Archived) inside of the application.

So we add this simple button inside of src/routes/__layout.svelte:

<script>
 import { onMount } from 'svelte'

 let deferredInstallEvent
 
 onMount(() => {
     window.addEventListener("beforeinstallprompt", e => {
	 e.preventDefault()
	 deferredInstallEvent = e
     })
 })

 async function handleInstall() {
     deferredInstallEvent.prompt()
     let choice = await deferredInstallEvent.userChoice
     if (choice.outcome === "accepted") {
	 // User accepted to install the application
     } else {
	 // User dismissed the prompt
     }
     deferredInstallEvent = undefined
 }
</script>

{#if deferredInstallEvent}
    <button class="install-button" on:click={handleInstall}>Install</button>
{/if}

<slot />

<style>
 .install-button {
     position: absolute;
     top: 1px;
     left: 1px;
 }
</style>

Now you should be able to test the installation of your application. Be aware that you can only install an application if it is server either on localhost or under https, and the service worker should be available, which in SvelteKit only happens when running npm run build && npm run preview, and not in the standard dev mode.

In case you want to customize more of the installation experience, I suggest you to look at this web.dev tutorial (Archived).