A common way to demonstrate proficiency with a framework is to build something with it. This post covers building a personal blog using Svelte 5, leveraging async UI rendering, server functions, and the prerender function for static generation of dynamic content. The rendering logic demonstrated here can be adapted to any markdown-based application—documentation sites, wikis, or content management systems.
We will also implement modern blog features like syntax highlighting via Shiki, and estimate the time needed to read a entry. Lastly, we will show you how to implement your own components you might need later on.
This website you are on now uses the following code to produce its markdown. You can view the source here.
Prerequisites
You will need the following installed on your computer:
Scaffolding
Let’s begin by scaffolding a new SvelteKit application:
bun x sv create blogIn the dialog, choose a minimal template with TypeScript support. When prompted for add-ons, select:
- tailwindcss with the typograhpy plugin (optional if you want to use your own styling solution)
- mdsvex
And finally install with whatever package manager you are using.
Now, enter your project and add the shiki package:
cd blog
bun add shikiConfiguring Svelte
Inside your svelte.config.js configuration file, add the following:
import adapter from "@sveltejs/adapter-auto";
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
import { mdsvex, escapeSvelte } from "mdsvex";
import { codeToHtml } from "shiki";
/** @type {import("mdsvex").MdsvexOptions} */
const mdsvex_config = {
extensions: [".md"],
highlight: {
highlighter: async (code, lang = "text") => {
const highlighted = await codeToHtml(code, {
lang,
themes: {
light: "github-light",
dark: "github-dark",
},
defaultColor: false,
});
return `<Code>${escapeSvelte(highlighted)}</Code>`;
},
},
};
/** @type {import("@sveltejs/kit").Config} */
const config = {
preprocess: [vitePreprocess(), mdsvex(mdsvex_config)],
kit: {
adapter: adapter(),
experimental: {
remoteFunctions: true,
},
},
compilerOptions: {
experimental: {
async: true,
},
},
extensions: [".svelte", ".md"],
};
export default config;This will enable asynchronous UI rendering, remote functions, and configure mdsvex. If you for some reason want to change the extension of your markdown files, change the extensions property and replace .md with whatever you want. Inside this configuration file you can also customize the themes for Shiki to render with. Shiki allows any theme as long as it follows their configuration format. For a list of already established themes, you can visit Themes. Lastly, the return statement is what gets generated when a code block is reached. If you want to customize the string generated, you can do it here.
Data
For now, create a markdown file in src/posts called hello-world.md. You should have a file in src/posts/hello-world.md.
You can enter the following test data:
---
title: "Hello World"
date: "200-01-01"
---
<script>
import Code from "$lib/components/code.svelte";
</script>
# Introduction
A paragraph.
```ts
console.log("A code snippet!");
```The script up top is omitted during rendering, but is required for mdsvex to know where to know where your code component is stored.
Rendering
Create the following path: src/routes/post/[slug]/+page.svelte. Inside the [slug] folder, create another file called post.remote.ts. This TypeScript file will contain all the remote functions to render your content.
Utilies
Create the following file in: src/lib/types.d.ts, inside this declaration file, add the following:
export type Error = {
status: number;
code: string;
message: string;
};
export type Post = {
default: any;
metadata: {
title: string;
date: string;
reading_time: number;
};
};These are types we will use later on. Feel free to add other metadata fields you might want.
Single-post rendering
Inside code.remote.ts, add the following:
import { prerender } from "$app/server";
import { type } from "arktype";
import { error } from "@sveltejs/kit";
import type { Error, Post } from "$lib/types";
import { render } from "svelte/server";
const post_not_found_error: Error = {
status: 404,
code: "POST_NOT_FOUND",
message: "Post not found.",
}
const construct_path = (slug: string) => `../../../posts/${slug}.md`;
const posts = import.meta.glob(`../../../posts/*.md`, { eager: true });
export const get_post_by_slug = prerender(
type("string"),
async (slug) => {
console.log(posts)
/** import markdown file and render it to html */
if (!(construct_path(slug) in posts)) {
const { status, ...details } = post_not_found_error;
error(status, details);
}
const { default: component, metadata } = posts[construct_path(slug)] as Post;
const rendered = render(component).body;
/** calculate rough reading time in seconds */
const reading_time = Math.ceil(
rendered.replace(/<[^>]+>/g, "").split(/\s+/).length
/ 200
);
return {
html: rendered,
metadata: { ...metadata, reading_time },
};
}
);
Most of this code should be self-documenting, but to leave no doubts let’s go through each section:
Top-level code
The error declared as
post_not_found_errorstores information for the code to return to the client if the function should error. It can only error if the slug doesnt exist.The utility function
construct_pathremoves redundancy to write the relative path for the markdown file path each time.the global
postsvariable stores a record of posts locations.
Function
Because of how Vite handles imports, we have to defensively program to make sure that we are certain the file exists, otherwise the program will crash.
Since
postsis a record, we can just index the post we want with the slug likeposts[construct_path(slug)], because we are certain it exists. While not optimal, we manually cast it to thePosttype we created earlier.the destructured
componentproperty is a Svelte component, and can not be transmitted over the wire, therefore we use therender()function provided by Svelte to serialize it to an HTML string.We can calculate a rough estimate of the time required to read the entry, stripping any HTML syntax from it to not pollute the word count:
Finally, we return an object with the serialized HTML string and metadata for the client to use.