Terminal File
Mon, May 04, 10:33 PM
mateux@tars :~$ ~
← Back to posts

Building a simple blog using SvelteKit and Markdown files

Published at Jun 3, 2025 · 9 min read

sveltesveltekitmarkdownmdsvexmermaidblogshiki

I needed a personal blog that was simple, developer-friendly, and capable of rendering code blocks and diagrams. WordPress and Strapi were too heavy, so I built my own using SvelteKit and Markdown.

My requirements

After some research, I defined a few core requirements:

  • Write posts in Markdown
  • Use SvelteKit for the frontend
  • No complex CMS/Backend
  • Easy to deploy and maintain
  • Support for code blocks and syntax highlighting
  • Support for images and Mermaid diagrams

Special thanks

Before I start, I want to give a special thanks to:

  • Matia - Joy of Code for the initial setup on how to use Markdown files in SvelteKit.
  • James A. Joy for providing help on Mermaid integration with SvelteKit (even though I followed a different approach).
  • Terris Linenbach for setting up Mermaid integration with string as input.
  • MDsveX team for creating the MDsveX library that allows us to use Markdown files in SvelteKit without too much hassle.

What is SvelteKit and MDsveX?

SvelteKit is a modern framework for building web apps with Svelte. MDsveX is a preprocessor that lets you write Svelte components in Markdown, making it perfect for blogs.

Setting up the project

After some trial and error, here’s the setup that worked for me.

To get started, if you don’t have a SvelteKit project yet, create one:

bunx sv create my-blog
bunx sv create my-blog

Set it up with your preferred options. Once you have your SvelteKit project ready, add MDsveX and the necessary dependencies:

bun add -D @sveltejs/adapter-auto @sveltejs/kit mdsvex
bun add -D @sveltejs/adapter-auto @sveltejs/kit mdsvex

Then, configure MDsveX in your svelte.config.js:

import { mdsvex } from 'mdsvex';

const config = {
  extensions: ['.svelte', '.svx'],
  preprocess: [mdsvex()],
  // ...existing config...
};
export default config;
import { mdsvex } from 'mdsvex';

const config = {
  extensions: ['.svelte', '.svx'],
  preprocess: [mdsvex()],
  // ...existing config...
};
export default config;

Now we are almost ready to start seeing our markdown files rendered as blog posts.

Basic markdown file structure

Let’s create a sample Markdown file in src/lib/posts called my-first-post.svx:

---
title: My First Post
slug: my-first-post
date: 2025-06-03
description: This is my first post using SvelteKit and Markdown files.
tags: [svelte, markdown, blog]
---
# My First Post
This is my first post using SvelteKit and Markdown files.
---
title: My First Post
slug: my-first-post
date: 2025-06-03
description: This is my first post using SvelteKit and Markdown files.
tags: [svelte, markdown, blog]
---
# My First Post
This is my first post using SvelteKit and Markdown files.

Rendering markdown files

To render markdown files, we need to create a Svelte component that will fetch and display the Markdown content. But first, we need to set up a route to handle the blog posts. Create a new folder structure in src/routes/blog/[slug] and add a +page.ts file. It will serve as the loader for our blog posts, importing the Markdown files dynamically based on the slug. Below is an example of how to set up the loader:

import { error } from '@sveltejs/kit'

export async function load({ params }) {
  try {
    const post = await import(`../../../../lib/posts/${params.slug}.svx`) // Keep an eye on the path, it should match your posts directory structure, if not you will receive some errors

    return {
      content: post.default,
      meta: post.metadata
    }
  } catch (e) {
    error(404, `Could not find ${params.slug}`)
  }
}
import { error } from '@sveltejs/kit'

export async function load({ params }) {
  try {
    const post = await import(`../../../../lib/posts/${params.slug}.svx`) // Keep an eye on the path, it should match your posts directory structure, if not you will receive some errors

    return {
      content: post.default,
      meta: post.metadata
    }
  } catch (e) {
    error(404, `Could not find ${params.slug}`)
  }
}

Now, when we access /blog/posts/my-first-post, it will load the my-first-post.svx file and render its content.

However, visiting this route won’t show anything yet—we still need a component to render the content. Create a +page.svelte file in the same directory (src/routes/blog/[slug]) and add the following code:

<script lang="ts">
	import { formatDate } from '$lib/utils'

	let { data } = $props()
</script>

<svelte:head>
	<title>{data.meta.title}</title>
	<meta property="og:type" content="article" />
	<meta property="og:title" content={data.meta.title} />
	<meta property="og:description" content={data.meta.description} />
</svelte:head>

<article>
	<header class="mb-8 flex flex-col gap-2">
		<a href="/blog" target="_self" class="mb-6 underline text-blue-500 hover:text-blue-300">&larr; Back to posts</a>
		
		<h1 class="text-3xl font-bold mb-2">{data.meta.title}</h1>
		<p>Published at {formatDate(data.meta.date)}</p>
		<section>
			{#each data.meta.tags as tag}
				<span class="inline-block bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 px-2 py-1 rounded mr-2 mb-2">
					{tag}
				</span>
			{/each}
		</section>
	</header>

	<section class="prose max-w-prose">
		<data.content />
	</section>
</article>

<style>
	.prose {
		max-width: 100%;
	}

	@media (prefers-color-scheme: dark) {
		* {
			--tw-prose-body: var(--color-white);
			--tw-prose-headings: var(--color-white);
			--tw-prose-links: var(--color-blue-300);
			--tw-prose-code: var(--color-gray-200);
		}
	}
</style>
<script lang="ts">
	import { formatDate } from '$lib/utils'

	let { data } = $props()
</script>

<svelte:head>
	<title>{data.meta.title}</title>
	<meta property="og:type" content="article" />
	<meta property="og:title" content={data.meta.title} />
	<meta property="og:description" content={data.meta.description} />
</svelte:head>

<article>
	<header class="mb-8 flex flex-col gap-2">
		<a href="/blog" target="_self" class="mb-6 underline text-blue-500 hover:text-blue-300">&larr; Back to posts</a>
		
		<h1 class="text-3xl font-bold mb-2">{data.meta.title}</h1>
		<p>Published at {formatDate(data.meta.date)}</p>
		<section>
			{#each data.meta.tags as tag}
				<span class="inline-block bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 px-2 py-1 rounded mr-2 mb-2">
					{tag}
				</span>
			{/each}
		</section>
	</header>

	<section class="prose max-w-prose">
		<data.content />
	</section>
</article>

<style>
	.prose {
		max-width: 100%;
	}

	@media (prefers-color-scheme: dark) {
		* {
			--tw-prose-body: var(--color-white);
			--tw-prose-headings: var(--color-white);
			--tw-prose-links: var(--color-blue-300);
			--tw-prose-code: var(--color-gray-200);
		}
	}
</style>

Now let’s break down the code above:

  • We import the formatDate utility function to format the post date. (It simply transforms the date to a more readable format using const formatDate(date: string, dateStyle: DateStyle = 'medium') => new Date(date).toLocaleDateString('en-US', {dateStyle, timeZone: 'UTC'});)
  • We use the $props() function to access the data passed from the loader.
    • The data object contains the content and metadata of the post.
    • content is the rendered Markdown content, and metadata contains the post’s metadata like title, date, tags, etc., defined between the --- markers in the Markdown file.
  • We set the <svelte:head> to define the page title and Open Graph metadata for better SEO.
  • The <article> element contains the post’s content, including the title, date, tags, and the rendered Markdown content.
  • The prose class is used to style the Markdown content, making it look nice and readable. We can use Tailwind CSS variables to customize some of the styles that MDsveX applies to the Markdown content.

Making the SvelteKit static renderer happy

After building the project, you’ll notice that the static renderer isn’t compatible with our setup. It won’t be able to locate and load the Markdown files.

To fix this, we need to add a few extra steps to ensure that the Markdown files are included in the build process. We can do this by creating a +page.server file in the same directory (src/routes/blog/[slug]) and adding the following code:

import { getSvxPosts } from '$lib/utils';
import type { EntryGenerator } from './$types';

export const entries: EntryGenerator = () => {
  const posts = getSvxPosts();

  return posts.map(post => ({
    slug: post.slug,
  }));
};

export const prerender = true;
import { getSvxPosts } from '$lib/utils';
import type { EntryGenerator } from './$types';

export const entries: EntryGenerator = () => {
  const posts = getSvxPosts();

  return posts.map(post => ({
    slug: post.slug,
  }));
};

export const prerender = true;

Whereas getSvxPosts is a utility function that retrieves all the Markdown files from the src/lib/posts directory. You can implement it like this:

export type Post = {
  title: string;
  slug: string;
  description: string;
  date: string;
  tags: string[];
};

export function getSvxPosts(): Post[] {
  let posts: Post[] = []

  const paths = import.meta.glob('/src/lib/posts/*.svx', { eager: true })

  for (const path in paths) {
    const file = paths[path]
    const slug = path.split('/').at(-1)?.replace('.svx', '')

    if (file && typeof file === 'object' && 'metadata' in file && slug) {
      const metadata = file.metadata as Omit<Post, 'slug'>
      const post = { ...metadata, slug } satisfies Post
      posts.push(post)
    }
  }

  posts = posts.sort((first, second) =>
    new Date(second.date).getTime() - new Date(first.date).getTime()
  )

  return posts
}
export type Post = {
  title: string;
  slug: string;
  description: string;
  date: string;
  tags: string[];
};

export function getSvxPosts(): Post[] {
  let posts: Post[] = []

  const paths = import.meta.glob('/src/lib/posts/*.svx', { eager: true })

  for (const path in paths) {
    const file = paths[path]
    const slug = path.split('/').at(-1)?.replace('.svx', '')

    if (file && typeof file === 'object' && 'metadata' in file && slug) {
      const metadata = file.metadata as Omit<Post, 'slug'>
      const post = { ...metadata, slug } satisfies Post
      posts.push(post)
    }
  }

  posts = posts.sort((first, second) =>
    new Date(second.date).getTime() - new Date(first.date).getTime()
  )

  return posts
}

Also, create a +layout.ts file in the src/routes/blog/posts directory to load the posts and pass them to the layout:

export const prerender = true;
export const trailingSlash = 'always';
export const prerender = true;
export const trailingSlash = 'always';

With this setup, the static renderer will be able to find and load the Markdown files during the build process, and we can access our blog posts at /blog/posts/my-first-post after building the project.

Adding support for Mermaid diagrams

If you’re unfamiliar with Mermaid, it’s a simple tool for creating diagrams and visualizations using text. It’s ideal for blogs, as it enables you to create diagrams without external tools. Learn more about Mermaid on their official website.

To add support for Mermaid diagrams, we need to create a Svelte component that will render the Mermaid diagrams and that can be used in our Markdown files (.svx files). Create a new file in src/lib/components/Mermaid.svelte and add the following code:

<script lang="ts">
  import { onMount, tick } from 'svelte';
  import mermaid from 'mermaid';
  import { isDarkMode } from '$lib/utils';

  export let diagram = '';
  let diagramElement: HTMLElement;
  let currentTheme: 'dark' | 'default' = isDarkMode() ? 'dark' : 'default';

  async function renderDiagram() {
    if (!diagramElement) return;

    mermaid.initialize({
      startOnLoad: false,
      wrap: true,
      theme: currentTheme,
    });

    try {
      await mermaid.run({
        nodes: [diagramElement],
        querySelector: '.mermaid',
      });
    } catch (error) {
      console.error('Error rendering mermaid diagram:', error);
    }
  }

  onMount(() => {
    renderDiagram();

    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');

    const handleThemeChange = async (e: MediaQueryListEvent) => {
      await tick();
      currentTheme = e.matches ? 'dark' : 'default';
      renderDiagram();
    };

    mediaQuery.addEventListener('change', handleThemeChange);

    return () => {

      mediaQuery.removeEventListener('change', handleThemeChange);
    };
  });
</script>

<div bind:this={diagramElement} class="mermaid w-full grow flex justify-center items-center">{diagram}</div>
<script lang="ts">
  import { onMount, tick } from 'svelte';
  import mermaid from 'mermaid';
  import { isDarkMode } from '$lib/utils';

  export let diagram = '';
  let diagramElement: HTMLElement;
  let currentTheme: 'dark' | 'default' = isDarkMode() ? 'dark' : 'default';

  async function renderDiagram() {
    if (!diagramElement) return;

    mermaid.initialize({
      startOnLoad: false,
      wrap: true,
      theme: currentTheme,
    });

    try {
      await mermaid.run({
        nodes: [diagramElement],
        querySelector: '.mermaid',
      });
    } catch (error) {
      console.error('Error rendering mermaid diagram:', error);
    }
  }

  onMount(() => {
    renderDiagram();

    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');

    const handleThemeChange = async (e: MediaQueryListEvent) => {
      await tick();
      currentTheme = e.matches ? 'dark' : 'default';
      renderDiagram();
    };

    mediaQuery.addEventListener('change', handleThemeChange);

    return () => {

      mediaQuery.removeEventListener('change', handleThemeChange);
    };
  });
</script>

<div bind:this={diagramElement} class="mermaid w-full grow flex justify-center items-center">{diagram}</div>

Here’s what’s happening in the code above:

  • We import the mermaid library and a utility to detect dark mode.
  • We define a diagram prop that will contain the Mermaid diagram code.
  • We use the onMount lifecycle function to render the diagram when the component is mounted.
  • We create a renderDiagram function that initializes Mermaid and renders the diagram using the diagramElement.
  • We also listen for changes in the user’s preferred color scheme and re-render the diagram accordingly.
  • The diagramElement is bound to the <div> element that will contain the rendered Mermaid diagram.

With this component, we can now use Mermaid diagrams in our Markdown files. To use it, simply add the following code in your .svx file:

<Mermaid
  diagram={`
    graph TD;
        A[Start] --> B{Is it working?};
        B -- Yes --> C[Great!];
        B -- No --> D[Fix it];
        D --> B;
        C --> E[End];
  `}
/>
<Mermaid
  diagram={`
    graph TD;
        A[Start] --> B{Is it working?};
        B -- Yes --> C[Great!];
        B -- No --> D[Fix it];
        D --> B;
        C --> E[End];
  `}
/>

This will render a Mermaid diagram in your blog post. You can customize the diagram code according to your needs.

Here is an example of the sample diagram above rendered in the blog post:

graph TD; A[Start] --> B{Is it working?}; B -- Yes --> C[Great!]; B -- No --> D[Fix it]; D --> B; C --> E[End];

Adding syntax highlighting for code blocks

For code syntax highlighting in your Markdown posts, we’ll use the excellent shiki library.

bun add -D shiki
bun add -D shiki

Then, we need to configure MDsveX to use shiki for syntax highlighting. Open your svelte.config.js file and update the mdsvex configuration:

import adapter from '@sveltejs/adapter-static';
import { escapeSvelte, mdsvex } from 'mdsvex';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
import { createHighlighter } from 'shiki'

const langs = [
  'javascript',
  'typescript',
  'java',
  'python',
  'bash',
  'html',
  'css',
  'json',
  'php',
  'sql',
  'yaml',
  'markdown',
  'svelte'
];

const themes = {
  dark: 'catppuccin-mocha',
  light: 'catppuccin-latte',
}

const mdsvexOptions = {
  extensions: ['.svx'],
  highlight: {
    highlighter: async (code, lang = 'text') => {
      const highlighter = await createHighlighter({
        themes: Object.values(themes),
        langs: langs,
      })
      await highlighter.loadLanguage(...langs)
      const html = escapeSvelte(
        `<div class="shiki-light">${highlighter.codeToHtml(code, { lang, theme: themes.light })}</div>` +
        `<div class="shiki-dark">${highlighter.codeToHtml(code, { lang, theme: themes.dark })}</div>`
      );

      highlighter.dispose();
      return `{@html `${html}` }`
    }
  },
};

const config = {
  extensions: ['.svelte', '.svx'],
  preprocess: [vitePreprocess(), mdsvex(mdsvexOptions)],
  kit: {
    adapter: adapter(),
  },
};

export default config;
import adapter from '@sveltejs/adapter-static';
import { escapeSvelte, mdsvex } from 'mdsvex';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
import { createHighlighter } from 'shiki'

const langs = [
  'javascript',
  'typescript',
  'java',
  'python',
  'bash',
  'html',
  'css',
  'json',
  'php',
  'sql',
  'yaml',
  'markdown',
  'svelte'
];

const themes = {
  dark: 'catppuccin-mocha',
  light: 'catppuccin-latte',
}

const mdsvexOptions = {
  extensions: ['.svx'],
  highlight: {
    highlighter: async (code, lang = 'text') => {
      const highlighter = await createHighlighter({
        themes: Object.values(themes),
        langs: langs,
      })
      await highlighter.loadLanguage(...langs)
      const html = escapeSvelte(
        `<div class="shiki-light">${highlighter.codeToHtml(code, { lang, theme: themes.light })}</div>` +
        `<div class="shiki-dark">${highlighter.codeToHtml(code, { lang, theme: themes.dark })}</div>`
      );

      highlighter.dispose();
      return `{@html `${html}` }`
    }
  },
};

const config = {
  extensions: ['.svelte', '.svx'],
  preprocess: [vitePreprocess(), mdsvex(mdsvexOptions)],
  kit: {
    adapter: adapter(),
  },
};

export default config;

This configuration sets up shiki to use the catppuccin themes for syntax highlighting. You can change the themes to any other supported themes by shiki.

Final thoughts

You can style your blog to match your preferences using Tailwind CSS or any other framework. I hope this guide helps you kickstart your own Markdown-powered blog with SvelteKit.

The source code is available on GitHub: mateux-dot-dev.