Migrating from Sapper to SvelteKit

Sapper is no longer under active development and now SvelteKit is the official tool to create Svelte apps. In this rather code-heavy article, I'd like to share with you some of the main procedures performed, as well as some problems and solutions that came as a surprise to me when I recently migrated our website.

Migrating from Sapper to SvelteKit Hero image

A while ago, the Svelte team announced that they’ll drop Sapper, their framework for building web applications, in favor of SvelteKit, which is now the official tool to create Svelte apps. It’s still based on key parts of the Sapper codebase but has some interesting features like different adapters that let you build your website as static site, SPA or optimized for popular hosters like Netlify or Vercel.

The biggest difference is probably the usage of Vite under the hood. Vite was originally built for Vue but has been framework-agnostic for a while now. It basically replaces your Webpack or Rollup and instead of bundling code after every change you make, it uses native ES modules in the browser to load source files. These are lightning fast, lead to an almost instant server cold start and give you HMR that has no perceptible delay, regardless of the size of your project.

All of this is just for the developer experience at dev time and does not affect production builds. These will, of course, still be bundled and optimized as usual but Vite itself has nothing to do with that. SvelteKit, much like Sapper, uses Rollup as bundler because it’s faster than Webpack and was created by Rich Harris, who also happens to be the inventor of Svelte.

In my opinion, bundlers are a relic of old times and probably the biggest bottleneck that current web frameworks have. I mean, think about it: Whenever you add a dependency using npm or yarn, you are importing an already bundled package that you’ll then bundle again in your own build pipeline to create the infamous vendor.js file. Not to mention that your own code is bundled over and over again, every time you save it.

I can very well imagine a world where native ESM modules and native browser support will make all of this madness obsolete in the future but these features are currently limited to modern browsers. The ingenuity of Vite is that it rightly assumes that at least web developers use modern browsers while website visitors might not.

Anyways, as SvelteKit is now officially in Open Beta, it was time for me to migrate https://shipbit.de away from Sapper. There is an official migration guide but I decided to start with a fresh SvelteKit project instead and take the opportunity to refactor some old and semi-optimal code. Our website was actually my first-ever Svelte project, so I certainly made some mistakes and experienced some issues with Sapper that I could never fully resolve. Also, when I started with Svelte, it didn’t support TypeScript and when this was introduced later, I decided not to refactor everything. This seemed like a good time to finally do it - and so I did.

This post is not intended to be a step-by-step tutorial. Instead, I want to share with you some of the main procedures I performed, as well as some problems and solutions that were unexpected for me.

Configuration is very concise

In SvelteKit, there is no more rollup.config.js, src/client.js or src/server.js. Instead, it’s just a single file called svelte.config.js that’s well-documented and pretty short in length. The final version of our website adds SCSS, Markdown processing with mdsvex (used for our dynamic Blog and Work pages), post-processing with autoprefixer and the aforementioned adapter-static that prerenders our entire site as plain HTML/CSS/JS package that we can simply upload to our FTP server.

import sveltePreprocess from 'svelte-preprocess';
import autoprefixer from 'autoprefixer';
import staticAdapter from '@sveltejs/adapter-static';
import path from 'path';
import { mdsvex } from 'mdsvex';
import mdsvexConfig from './mdsvex.config.cjs';

/** @type {import('@sveltejs/kit').Config} */
const config = {
	extensions: ['.svelte', ...mdsvexConfig.extensions],
	preprocess: [
		// see https://github.com/sveltejs/svelte-preprocess
			defaults: {
				style: 'scss'
			scss: {
				includePaths: ['src'],
				outputStyle: 'compressed'
			postcss: {
				plugins: [autoprefixer]
	kit: {
		// hydrate the <div id="svelte"> element in src/app.html
		target: '#svelte',
		adapter: staticAdapter(),
		// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
		vite: () => ({
			resolve: {
				alias: {
					$data: path.resolve('./src/data'),
					$misc: path.resolve('./src/misc')
		prerender: {
			enabled: true

export default config;

If you ask me, that’s pretty concise and easy to understand or edit. Also note the alias entries which let you define variables (or better: placeholders) to use in import statements, for example import { fadeIn } from '$misc/animations'. I quickly realized that I also had to add these aliases to my tsconfig.ts:

    "compilerOptions": {
        "paths": {
            // This was already there and is a default alias defined by SvelteKit.
            // src/lib is where your components should go.
            "$lib/*": ["src/lib/*"],
            // These are the our custom aliases as defined in svelte.config.js:
            "$data/*": ["src/data/*"],
            "$misc/*": ["src/misc/*"]
        // While at it, I also had to extend this to fix some VSCode issues in .svelte files.
        "lib": ["es2020", "dom", "dom.iterable"],

Whenever I changed the tsconfig.js file, I also had to restart my dev process or otherwise VSCode would complain that imports using a new alias were invalid besides not giving me proper Intellisense.

No service worker (yet)

I also realized that there is no service-worker.js in the SvelteKit starter because they simply didn’t provide one yet. The service worker template was a nice feature in Sapper but also kind of hard to use and modify. I read in Discord that this is on the SvelteKit roadmap, so we’ll probably get one in the future.

Component and static page migration is trivial

The first thing I took over from the old project (apart from migrating __layout.svelte and app.html as described in the official migration guide), were bare components like Button.svelte. Really all I had to do was to append the new attribute lang="ts" to the existing <script> tags to enable TypeScript. Then I added appropriate type definitions to all my functions and variables. Believe it or not, I immediately spotted one or two minor binding issues that went undetected before.

To correctly type my components and especially data models (like BlogPost or TeamMember), I created very basic TypeScript interfaces. To import these in a .svelte component, you have to use a syntax that was previously unknown to me: import type { BlogPost } from '$data/models/blog-post';. This is because TypeScript interfaces are not compiled to JavaScript (instead they just “vanish”) and are therefore no importable ESM modules. So import type obviously tells Vite not to try to load this as any other dependency.

Loading Markdown files is much easier now

Next, I went on to our dynamic pages, namely /blog and /work. We write these pages in Markdown and when the website is built, the whole Jamstack/SSG mechanism kicks in, traverses through all of the documents and parses them so that we can finally use the data in the appropriate .svelte page components.

The old way in Sapper

In Sapper, I would first create a file called _work.js to process the Markdown files like this:

const fs = require('fs');
const frontMatter = require('front-matter');
const marked = require('marked');
import orderBy from 'lodash/orderBy';
import renderer from '../../marked-renderer';

// iterate through all /work Markdown files
let work = fs.readdirSync('./src/work').map((workFilename, index, elements) => {
	const workContent = fs.readFileSync(`./src/work/${workFilename}`, { encoding: 'utf8' });
	// We need the "next" work page in line for the "More from our blog" section at the bottom of every post
	const nextWork = elements[(index + 1) % elements.length];
	const nextWorkContent = fs.readFileSync(`./src/work/${nextWork}`, { encoding: 'utf8' });

	const workFrontMatter = frontMatter(workContent);
	const nextWorkFrontMatter = frontMatter(nextWorkContent);

	return {
		slug: workFrontMatter.attributes.slug,
		title: nextWorkFrontMatter.attributes.title,
		// note that we use the "marked" lib here to convert Markdown to HTML
		html: marked(workFrontMatter.body, { renderer }),
		// ... more props
		next: {
			slug: nextWorkFrontMatter.attributes.slug,
			title: nextWorkFrontMatter.attributes.title
			// .. more props

work = orderBy(work, ['year'], ['desc']);

export default work;

To make the list of all work pages available in the index page, I’d have to “wrap” this data source in the context of a simple request defined in routes/work/index.html.js, returning the result as JSON:

import work from './_work.js';

const contents = JSON.stringify(
	work.map((work) => ({
		slug: work.slug,
		title: work.title
		// ... more props

export function get(req, res) {
	res.writeHead(200, {
		'Content-Type': 'application/json'

This would finally allow me to preload this data in routes/work/index.html.js when building the website:

<script context="module">
  export function preload({ params, query }) {
    return this.fetch(`work.json`)
      .then((r) => r.json())
      .then((workItems) => ({ workItems }));

  export let workItems; // phew, finally here!

The new way in SvelteKit

Now comes my favorite part of the entire migration: In SvelteKit, I can do all of this directly in my Svelte component:

<script context="module" lang="ts">
	import orderBy from 'lodash/orderBy.js';

	export async function load(): Promise<LoadOutput> {
		const imports = importWorkMarkdown();
		return {
			props: {
				workItems: orderBy(imports.metadata, ['year'], ['desc'])

<script lang="ts">
	import type { WorkItem } from '$data/models/work-item';
	import { importWorkMarkdown } from '$misc/read-markdown';

	export let workItems: WorkItem[] = []; // yay, quick&easy!

I need access to the work and blog post Markdown files in several pages, for example to display the latest blog post on our home page. Therefore, I decided to make this functionality reusable and to apply some TypeScript magic letting me type out the frontmatter metadata. So read-markdown.ts is where the real magic happens:

import type { BlogPost } from '$data/models/blog-post';
import type { WorkItem } from '$data/models/work-item';

export interface DynamicImport<T> {
	modules: { [key: string]: any };
	metadata: T[];

export function importAll<T>(imports: Record<string, { [key: string]: any }>): DynamicImport<T> {
	// mdsvex generates a real JS module out of each Markdown file.
	// The .default of such a module is a Svelte component(!) which contains the actual Markdown content rendered as HTML.
	const modules = Object.values(imports);
	// This is the frontmatter in the Markdown file, parsed by mdsvex automagically as 'metadata'
	const metadata = modules.map((wi) => wi.metadata as T);
	return { modules, metadata };

export function importWorkMarkdown(): DynamicImport<WorkItem> {
	// Much cooler than fs.readdirSync() before, thanks to Vite and dynamic imports!
	const imports = import.meta.globEager('../data/work/*.{svx,md}');
	// You could also load a single file with: const natttModule = (await import('../data/work/nattt.md')).default;
	return importAll<WorkItem>(imports);

export function importBlogPostMarkdown(): DynamicImport<BlogPost> {
	const imports = import.meta.globEager('../data/posts/*.{svx,md}');
	return importAll<BlogPost>(imports);

If you’d inline the core functionality of this helper in the component, you could write what was previously split into three files in less than 30 lines of code.

Moving from marked to mdsvex

Besides the dynamic imports provided by Vite, the real star in the above refactoring is clearly mdsvex which replaces both the marked and frontmatter dependency in the legacy version. Interestingly, I’m not even calling mdsvex anywhere in my code. Instead, this is handled internally by SvelteKit, where we registered mdvex both as extension and as preprocessor in svelte.config.js at the very beginning of this article.

This configuration tells SvelteKit to pass Markdown files to mdsvex which will then convert them to real JS modules. In each module, mdsvex parses the frontmatter as metadata, renders the content as HTML and wraps it in a Svelte component that you can use in your template. This makes it very easy to display a single work page in /routes/work/[slug].svelte:

<script context="module" lang="ts">
	import orderBy from 'lodash/orderBy.js';

	export async function load({ page }: LoadInput): Promise<LoadOutput> {
		const imports = importWorkMarkdown();
		const currentModule = imports.modules.find((m) => m.metadata.slug === page.params.slug);

		if (!currentModule) {
			return {
				status: 404,
				error: 'Oh no! The requested page could not be found.'

		const orderedMetadata = orderBy(imports.metadata, ['year'], ['desc']);
		const currentIndex = orderedMetadata.findIndex((wi) => wi.slug === page.params.slug);
		const nextItemMetadata = orderedMetadata[(currentIndex + 1) % orderedMetadata.length];

		return {
			props: {
				WorkComponent: currentModule.default,
				work: {
					next: {
						slug: nextItemMetadata.slug,
						title: nextItemMetadata.title

<script lang="ts">
	import type { LoadInput, LoadOutput } from '@sveltejs/kit';
	import type { WorkItem } from '$data/models/work-item';
	import { importWorkMarkdown } from '$misc/read-markdown';

	export let WorkComponent: any;
	export let work: WorkItem;

	<!-- Typed frontmatter data <3 -->
	<!-- Actual MD content -->
	<svelte:component this="{WorkComponent}" />
	<!-- You could also just write:
           <WorkComponent />
         but it wouldn't update its content when navigating between work pages -->

Again, I find this solution very elegant. Everything is written exactly where you need it: In your page component. Again, TypeScript comes in very handy because now we even have type definitions of the frontmatter in our Svelte components.

What I still don’t really get is when to import modules in the upper or lower <script> tags. As far as I understand, the one with context="module" runs once before the Svelte component is created and I always imagine it like: “runs once when I build the website”. The other one is executed and always available in the client at runtime and might contain client-side logic like onClick handlers and alike. I’m pretty sure that that the difference is not that it’s “executed on the server” vs. “executed in the client”, or otherwise you probably couldn’t mix & match the imports between tags whatsoever. However, most of the times, VSCode automatically puts import statements in the “normal” <script> tag but sometimes it doesn’t and other times you have to move them up manually.

Interface imports always seem to be inserted in the latter tag. Sometimes Vite didn’t pick up changes made to imports, so that I had to restart the dev process. Well, at least this is almost instant now. If you can explain to me what’s really happening here, I’d love to hear from you via Twitter or mail!

Custom Markdown plugins: marked vs. remark/rehype

While the Markdown to HTML rendering is already nice as-is, there are some elements that need to be transformed to render them on our website:

  • use highlight.js to style code snippets in blog posts
  • wrap images in a styled container <div> and render the <img> tag to be used with CloudImage Responsive
  • add a custom class to list items so that they’ll have our neat ShipBit logo as bullet point
  • mark links as external

I implemented a custom marked renderer plugin in Sapper that would do all of this very easily:

const marked = require('marked');
const hljs = require('highlight.js');
const hljs_svelte = require('highlightjs-svelte');
import { ciSizes } from './actions/cloudimage';


// see https://marked.js.org/#/USING_PRO.md#renderer
const renderer = new marked.Renderer();

renderer.code = (code, language, _escaped) => {
	const { value: highlighted } = language
		? hljs.highlight(code, { language })
		: hljs.highlightAuto(code);
	return `<pre><code class="hljs ${language}">${highlighted}</code></pre>`;

renderer.codespan = (code) => {
	const { value: highlighted } = hljs.highlightAuto(code);
	return `<code class="hljs">${highlighted}</code>`;

renderer.image = (href, _title, text) => `
  <div class="rounded-image-container">
    <img class="img-fluid" src="" alt="${text}" data-ci-src="${href}" ci-sizes="${ciSizes}" data-ci-ratio="1.5" />

renderer.listitem = (text, _task, _checked) => `<li class="shipbit">${text}</li>`;

renderer.link = (href, title, text) =>
	`<a class="link" href="${href}" rel="noopener nofollow">${text}</a>`;

export default renderer;

The first point is dropped with SvelteKit because mdsvex already renders code blocks in Markdown with the help of PrismJS. All I had to do here, is to find a fitting theme and load it in my app.html file.

The other plugins were a bit trickier to rebuild because mdsvex uses remark and rehype under the hood instead of marked. Ryan Filler wrote an excellent blog post explaining what the difference between remark and rehype is and how it can be extended. I highly recommend you to read his article as it helped me a lot. I won’t repeat his findings here and instead show you how to register your custom plugins in mdsvex.config.cjs:

module.exports = {
	extensions: ['.svx', '.md'],
	smartypants: {
		dashes: 'oldschool'
	remarkPlugins: [
		// these are my custom plugins:
	rehypePlugins: [
		// I found these while browsing.
		// They transform headings to anchor links that you can use to scroll/jump to specific sections.
				behavior: 'wrap',
				properties: {
					class: 'silent'

The simple plugin to attach a custom class to list items looks like this:

const visit = require('unist-util-visit');

function transformer(ast) {
	visit(ast, 'listItem', visitor);

	function visitor(node) {
		const data = node.data || (node.data = {});
		const props = data.hProperties || (data.hProperties = {});
		props.class = 'shipbit';

function listItems() {
	return transformer;

module.exports = listItems;

As Ryan explains, this gets more complicated when you try to restructure or wrap your processed element instead of just setting attributes on it. Here’s what my image plugin now looks like:

const visit = require('unist-util-visit');

function transformer(ast) {
	visit(ast, 'image', visitor);

	function visitor(node) {
		let src = node.url;
		const alt = node.alt;
		const ciSizes = `{
            xs: { w: 300 },
            sm: { w: 400 },
            md: { w: 600 },
            l: { w: 800 },
            xl: { w: 1000 }
			.replace(/{/g, '&#123;')
			.replace(/}/g, '&#125;');

		let newNode = {
			type: 'html',
			value: `<img class="img-fluid rounded-image" src="" alt="${alt}" data-ci-src="${src}" ci-sizes="${ciSizes}" data-ci-ratio="1.5" />`
		Object.assign(node, newNode);

function images() {
	return transformer;

module.exports = images;

Obviously, the legacy version is easier to write and understand. I personally like the marked API a lot.

One of the killer features of mdsvex is that it can not only render Markdown in Svelte components but it can also render Svelte components that are written in Markdown. This is undoubtedly a very powerful feature and I could have just put a <CloudImage /> in my Markdown instead of hijacking the Markdown rendering pipeline just to create the same HTML output as my Svelte component.

I like to write my blog posts in external editors like Typora or Bear though and of course, these can’t preview Svelte components that I put in my document. I also prefer to use default Markdown syntax and rather write [Link text](url) than importing my component first and then write <Link href="url" text="link text" />. While it might be viable in our scenario where only developers write blog posts, it’s generally a good idea not to force content editors to have a deeper understanding of your developer components.

All things considered, the markdown plugins are probably the only thing that I personally liked more in the old version.

Adapters are really beta

The team makes no secret of the fact that SvelteKit is still in Beta. I can’t complain and experienced very few hickups up until this point but then I encountered a problem with adapter-static that I could not resolve on my end: Apparently, the adapter crawls all images used on your pages and in case of my aforementioned <CloudImage />, it tried to parse the data-ci-src instead of the src attribute which wouldn’t work either, because it’s well.. empty at the point of building the website. I created an issue in their repository, made an educated guess what could cause this and after that, a contributor was able to fix this for me within one day. Thanks again and you gotta love the Svelte community!

I’m currently working on another project where I’m using SvelteKit with prismic.io and adapter-netlify and I also found some oddities there but that’s a story for another day.


There was recently a thread on Twitter where a fellow developer asked if migrating from Sapper to SvelteKit was “worth it”. He didn’t want to refactor anything and simply considered a technical migration to be up2date. In his case, I advised him against it.

In true ShipBit tradition, we should always consider the user experience first. Let’s be perfectly clear here: Your end users won’t profit from a migration (yet). This might change in the future when SvelteKit gains new capabilities but right now, the static output is pretty much the same as it was before with Sapper.

For me, migrating made perfect sense though. This refactoring was one for the developer experience - so for me and for the future. The ShipBit website is my personal baby and I want it to be state-of-the-art and ready for future expansions. I also learned a lot more about SvelteKIt and other libraries I used in the process. This was the entire purpose of building the website in Svelte without prior knowledge in the first place and I have no regrets.

Get in touch with us!