📝 Building a simple blog using SvelteKit and markdown files
Published at Jun 3, 2025
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">← 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>#{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">← 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>#{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 usingconst 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 thecontent
andmetadata
of the post. content
is the rendered Markdown content, andmetadata
contains the post’s metadata like title, date, tags, etc., defined between the---
markers in the Markdown file.
- The
- 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 thediagramElement
. - 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:
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! 🎉