Creating A Static Blog With SvelteKit

My strategy for making a static markdown blog with svelte
Thursday, Jun 1, 2023 svelte

Introduction

I am attempting to make a svelte blog that can dynamicly load the pages, but have no backend code and still allow me to use svelte components.


This blog uses skeleton.dev with tailwind so you may need to swap out some of the components used in this post to get working for your own project.


If you are starting fresh and want to use skeleton, you can run

bash
npm create skeleton-app@latest my-blog
cd my-blog

to get started.

Configuring Svelte For Static Rendering

First we need to swap out the adapter for svelte to enable static site generation.


Install adapter-static, svelte-preprocess and mdsvex into your project as dev depentancies

bash
npm i -D @sveltejs/adapter-static
npm install -D svelte-preprocess
npm i -D mdsvex

And then switch from svelte’s default adapter to adapter-static in svelte.config.js, and add the svelte-preprocess and mdsvex processor.

javascript
/* svelte.config.js */
import { vitePreprocess } from '@sveltejs/kit/vite';
import adapter from '@sveltejs/adapter-static';
import preprocess from "svelte-preprocess";
import { mdsvex } from "mdsvex";

const config = {
	extensions: [".svelte", ".svx", ".md"],
	preprocess: [
		preprocess(),
		vitePreprocess(),
		mdsvex()
	],
	kit: {
		adapter: adapter({
			entries: ['/'],
			fallback: '404.html',
		}),
	}
};
export default config;

Now set svelte to pre-render everything (or most things) in the top level +layout.ts

typescript
/* src/routes/+layout.ts */
export const ssr = true;
export const csr = true; // csr is required for some js operated components, such as the table of contents on this blog.

export const prerender = true;

Gathering The Posts

The first step is to create a blog page to display. Im storing my pages in $lib/posts.


svelte
<!-- $lib/posts/hello-world.svx -->
<script context="module"> 
	export const title = 'Hello, World';
	export const sub_title = 'A new blog post';
	export const created = 'May 20, 2023';
	export const tags = ['example', 'blog'];
</script>

Hello from our new blog post!

And now add the logic that is going to read each page and pull its metadata for easy access. Im going to put this logic in $lib/data/posts.ts.


This will read each page in the `$lib/posts` directory, pull the metadada set at the top of the post, and then store them all in an object with the key as the filename withouth the extention, witch we will be using for the slug.
typescript
/*  $lib/data/posts.ts */

import type {Component} from "@svelte/kit";

export interface PostInfo {
	title: string,
	sub_title: string,
	created: Date,
	tags: string[]
}

export interface Post {
	slug: string,
	component: Component,
	info: PostInfo
}

const GetPostInfo = (post) : PostInfo => ({
	title: post.title,
	sub_title: post.sub_title,
	created: new Date(post.created),
	tags: post.tags,
});

export const posts : {string, Post} 
		= Object.fromEntries(
			Object.entries(
				import.meta.glob('../posts/*.*', { eager: true })
			).map(([filepath, post]) => {
				const filename = filepath.replace(/^.*[\/]/, '')
				const [slug,_ext] = filename.split(".", 2);
				return [slug, {slug, info: GetPostInfo(post), component: post.default}]
			}).sort(([_a_slug,a],[_b_slug,b]) => b.info.created - a.info.created)
		);

Rendering A Post

My blog renders posts at /post/[slug], but you can use whatever you want.


Because we are using dynamic routing, we need to tell the svelte static adapter how to deal with our dynamic [slug]. To do that, create a +page.ts in the route you want to render the posts.

In my case, that would be at src/routes/post/[slug]/+page.ts.

typescript
/* src/routes/post/[slug]/+page.ts */

import { posts } from "$lib/data/posts";
import { error } from '@sveltejs/kit';

export function entries() {
	const prerender 
		= Object.keys(posts)
			.map(slug => { 
				return {
					slug
				}
			});
	return prerender;
}

export async function load({params}) {
 	const post = posts[params.slug];

	if(!post) {
		 throw error(404);
	}

	return post;
}

the entries function tells the svelte static adapter what values to substitute [slug] with when it is rendering. Because we want to render all the post slugs, we just use the keys from the posts object we created earlier. It expects the return value to be an array of {slug: string}.


The load fucntion is used to set the data that will be used for rendering the contense. If the post does not exist, 404.


Now we are going to setup the template for each of our posts.

svelte
<!-- src/routes/post/[slug]/+page.svelte -->

<script lang="ts">import { TableOfContents } from "@skeletonlabs/skeleton";
export let data;
const post = data;
const info = post.info;
</script>

<div class="container mx-auto flex justify-center items-center max-w-5xl">
	<div class="py-7 w-10/12 flex flex-col">
		<div class="card">
			<header class="card-header">
				<h2 class="h2 flex justify-center items-center" data-toc-ignore>
					{info.title}
				</h2>
			</header>
			<section class="p-4 block">
				<span class="flex mt-1 justify-center items-center" data-toc-ignore>
					{info.sub_title}
				</span>
				<hr class="!border-t-4 my-5" />
				<div class="flex justify-end">
					<span class="justify-self-start justify-start mr-auto">
						{info.created.toLocaleDateString('en-us', {
							weekday: 'long',
							year: 'numeric',
							month: 'short',
							day: 'numeric'
						})}
					</span>
					<span>
						{#each info.tags as tag}
							<a class=" mx-1 chip variant-filled" href={`/tag/${tag}`}>{tag}</a>
						{/each}
					</span>
				</div>
				<hr class="!border-t-4 my-5" />
			</section>
			<footer class="card-footer">
				<TableOfContents target="#page-content" />
			</footer>
		</div>
	</div>
</div>

<article class="card mb-10 p-4 w-10/12 items-center mx-auto max-w-5xl" id="page-content">
	<svelte:component this={post.component} />
</article>

This will add the title, subtitle, creation date, and tags to the top of each post like (at the time of writing this) is on this post.

If you are not using skeleton, you will need to remove the TableOfContents.


Now if you run

bash
npm run build
ls build/post

You should see that svelte generated a ststic html version of the post!

bash
┬─[smc@dev-machine:~/src/blog.s-mc.io]─[01:40:46 PM]─[master 7be4386 ✱ ✈]
╰─>$ ls -l build/post
.rw-rw-r-- 1.4k smc  2 Jun 13:39 hello-world.html

Listing Posts On The Main Page

Let's make a component that shows us the mtadata of a post so we can use it for the blog index and for when we search for tags.
svelte
<!-- $lib/components/post-preview.svelte -->

<script>
	import type { Post } from "$lib/data/posts";
	export let post : Post;
	export let include_in_toc = false;
	const info = post.info;

	const date_display = 
			info.created.toLocaleDateString('en-us', {
				weekday: 'long',
				year: 'numeric',
				month: 'short',
				day: 'numeric'
			});
</script>
<div class="card">
	<a href={`/post/${post.slug}`}>
		<header class="card-header">
			<h2 data-toc-ignore={!include_in_toc}>{info.title}</h2>
		</header>
		<section class="p-4">{info.sub_title}</section>
		<hr class="!border-t-4 m-3" />
	</a>
	<footer class="flex card-footer justify-end">
		<span class="justify-self-start justify-start mr-auto">
			{date_display}
		</span>

		<span>
			{#each info.tags as tag}
				<a class=" mx-1 chip variant-filled" href={`/tag/${tag}`}>{tag}</a>
			{/each}
		</span>
	</footer>
</div>

This will give a component that looks something like this:



Now lets use this component to display our posts.


Again, we need to create a +page.ts to fetch the data we want to use, in this case, our posts

typescript
/* src/routes/+page.ts */

import { posts } from '$lib/data/posts';
import type { Post } from '$lib/data/posts';

export function load() : Post[] {
	return { posts: Object.values(posts) };
}

And now the atcual index page:

svelte
<!-- src/routes/+index.svelte -->

<script lang="ts">import PostPreview from "$lib/components/post-preview.svelte";
export let data;
const post_entries = data.posts;
</script>

<div class="container mx-auto flex justify-center items-center">
	<div class="space-y-10 w-10/12 flex flex-col">
		<h2 class="h2 flex py-7 justify-center items-center">
			Welcome to my blog.
		</h2>
		{#each post_entries as post}
			<PostPreview {post} />
		{/each}
	</div>
</div>

Filtering By Tag

This is very similar to how we render the pages with the dynamic slug.


In our +page.ts, we use load to tell the static adapter what needs to be rendered, in this case all the tags that are used in any post. And we also use the load function again to find all the posts with the specified tag.

typescript
/* src/routes/tag/[slug]/+page.ts */

import { posts } from '$lib/data/posts';
import type { Post } from '$lib/data/posts'; 

export function entries() {
	return Object.values(posts)
		.flatMap(p => 
				p.info.tags.map(t => { 
					return {slug: t}
				})
			);
}

export function load({params}) {
	const post_entries = Object.values(posts);

	const filtered_posts: [Post] 
		= post_entries
			.filter(post => post.info.tags.includes(params.slug));
	
	 return {
		 posts: filtered_posts,
		slug: params.slug,
	};
}

And then we list the filtered posts like we did with our index page

svelte
<!-- src/routes/tag/[slug]/+page.svelte -->

<script>
	export let data;
	import PostPreview from '$lib/components/post-preview.svelte';
</script>

<div class="container mx-auto flex justify-center items-center">
	<div class="space-y-10 w-10/12 flex flex-col">
		<h2 class="h2 flex py-7 justify-center items-center">
			Posts tagged with "{data.slug}"
		</h2>
		{#each data.posts as post}
			<PostPreview {post} />
		{/each}
	</div>
</div>

Conclusion

Im happy with how my blog turned out. The biggest pain with this was figuring out how to render the dynamic [slug]s correctly. Finding the documentation for the entries function was a pain in the ***. I couldent even find it for linking in this blog post and I know what im looking for! I think I found it on a github issue somewhere. Maybe I should open a PR to document it better.


Thanks for reading! I hope it healped with your blogging journey!