Terminal File
Sun, Jun 29, 11:57 PM
Forest with enormeous trees
mateux@tars :~$ ~
    ← Back to posts

    📝 Building a simple blog using SvelteKit and markdown files

    Published at Jun 3, 2025

    sveltemarkdownmdsvexmermaidblog

    I’ve always wanted to create a personal blog a place to share insights and lessons from my journey as a developer. Over time, I experimented with different technologies but never finished anything. Tools like WordPress and Strapi felt too heavy for what I wanted: a simple blog where I could write in Markdown and customize layouts with code.

    📋 My main requirements

    After some research, I defined a few core requirements:

    • Write posts in Markdown (✍️)
    • Use SvelteKit for the frontend (🖥️)
    • No complex CMS/Backend (None if possible 👽)
    • 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

    I already had a SvelteKit project up and running the one you’re reading right now (mateux.dev). But early on, I lacked a clear path to turn my vision into reality. After hitting a few roadblocks and building multiple proofs of concept, I finally found a setup that worked.

    To get started, if you don’t have a SvelteKit project yet, you can create one using the following command:

    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 ftructure

    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.. We need to create a Svelte component to display 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>
    		{#each data.meta.categories as category}
    			<span>&num;{category}</span>
    		{/each}
    	</section>
    
    	<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>
    		{#each data.meta.categories as category}
    			<span>&num;{category}</span>
    		{/each}
    	</section>
    
    	<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.

    🏁 Conclusion

    From here, you can style your blog to match your preferences whether that means custom components, layouts, or a full design system.. You can use Tailwind CSS or any other CSS framework to style your blog. You can also add additional components, such as a sidebar, footer, or navigation menu.

    I hope this guide helps you kickstart your own Markdown-powered blog with SvelteKit and saves you the time I spent figuring it all out. If you have any questions or suggestions, feel free to reach out to me through the contact information provided on my home page mateux.dev.

    You can find the source code for this blog on my GitHub repository: https://github.com/mateuxlucax/mateux-dot-dev. Feel free to contribute or open issues if you encounter any bugs or have suggestions for improvements.

    Happy coding! 🎉