How to write a Markdown blog with SvelteKit

Posts are Markdown files in your repo. vela enable blog sets up mdsvex, a post list, tag pages and an RSS feed in one command, so publishing a post means adding a file and pushing.

4 min read blogmarkdownmdsvexrsscontentsveltekit

A blog does not need a database. A folder of Markdown files, versioned with the code, reviewed in pull requests and deployed with the site, is simpler to run and impossible to lose. mdsvex turns each file into a Svelte component, so a post can also embed a chart or a demo when plain text is not enough. What is left is the plumbing: a list page, a post page, tags, a feed, reading times. vela enable blog writes that plumbing once.

Prerequisites

A vela project: npx vela create my-app, or npx vela bless inside an existing SvelteKit project (details).

Run the command

$ vela enable blog
✔ Installed mdsvex
✔ Created 21 files, modified 3
  src/lib/content.ts
  src/lib/content/blog/welcome-to-the-blog.svx
  src/lib/content/blog/writing-posts-with-mdsvex.svx
  src/lib/components/blog/PostCard.svelte
  src/lib/components/blog/AuthorChip.svelte
  src/routes/(public)/blog/+page.svelte
  src/routes/(public)/blog/[slug]/+page.svelte
  src/routes/(public)/blog/tags/[tag]/+page.svelte
  src/routes/(public)/blog/rss.xml/+server.ts
  svelte.config.js

Six example posts come along so the layout has something to show. Delete them when your own posts exist.

Write a post

A post is a .svx file in src/lib/content/blog/. The filename is the URL: this one is served at /blog/hello-world.

src/lib/content/blog/hello-world.svx
---
title: Hello, world
description: The first post on the new blog.
createdDate: 2026-09-03
tags:
  - announcements
author:
  name: Your Name
---

Everything below the frontmatter is Markdown. Headings, lists,
links and fenced code blocks all work.

The frontmatter fields are typed, so a typo in one shows up in the editor rather than as a blank card:

src/lib/content.ts
import type { Component } from "svelte";

export type BlogAuthor = {
  name: string;
  avatar?: string;
};

export type BlogMetadata = {
  title: string;
  description: string;
  createdDate: string;
  updatedDate?: string;
  tags?: string[];
  author?: BlogAuthor;
};

export type BlogModules = Record<
  string,
  { default: Component; metadata: BlogMetadata }
>;

export type BlogRawModules = Record<string, string>;

export type BlogPost = {
  slug: string;
  title: string;
  description: string;
  createdDate: Date;
  updatedDate?: Date;
  tags?: string[];
  author?: BlogAuthor;
  readingTime: number;
  component: Component;
};

export type BlogPostSummary = Omit<BlogPost, "component">;

const WORDS_PER_MINUTE = 220;

function getBlogModules() {
  return import.meta.glob("$lib/content/blog/*.svx", {
    eager: true,
  }) as BlogModules;
}

function getBlogRawModules() {
  return import.meta.glob("$lib/content/blog/*.svx", {
    eager: true,
    query: "?raw",
    import: "default",
  }) as BlogRawModules;
}

function stripFrontmatter(raw: string) {
  if (!raw.startsWith("---")) return raw;
  const end = raw.indexOf("\n---", 3);
  if (end === -1) return raw;
  return raw.slice(end + 4);
}

function computeReadingTime(raw: string) {
  const body = stripFrontmatter(raw);
  const words = body.split(/\s+/).filter(Boolean).length;
  return Math.max(1, Math.ceil(words / WORDS_PER_MINUTE));
}

export function getBlogPosts(): BlogPost[] {
  const modules = getBlogModules();
  const rawModules = getBlogRawModules();

  const posts = Object.entries(modules).map(([path, module]) => {
    const filename = path.split("/").pop() ?? "";
    const slug = filename.replace(".svx", "");
    const raw = rawModules[path] ?? "";

    return {
      slug,
      title: module.metadata.title,
      description: module.metadata.description,
      createdDate: new Date(module.metadata.createdDate),
      updatedDate: module.metadata.updatedDate
        ? new Date(module.metadata.updatedDate)
        : undefined,
      tags: module.metadata.tags,
      author: module.metadata.author,
      readingTime: computeReadingTime(raw),
      component: module.default,
    };
  });

  posts.sort((a, b) => b.createdDate.getTime() - a.createdDate.getTime());
  return posts;
}

export function getBlogPost(slug: string) {
  const posts = getBlogPosts();
  return posts.find((post) => post.slug === slug) ?? null;
}

export function getBlogPostRaw(slug: string): string | null {
  const rawModules = getBlogRawModules();
  for (const [path, raw] of Object.entries(rawModules)) {
    const filename = path.split("/").pop() ?? "";
    if (filename.replace(".svx", "") === slug) return raw;
  }
  return null;
}

export function getBlogPostsByTag(tag: string) {
  return getBlogPosts().filter((post) => post.tags?.includes(tag));
}

export function getAllTags(): { tag: string; count: number }[] {
  const counts = new Map<string, number>();
  for (const post of getBlogPosts()) {
    for (const tag of post.tags ?? []) {
      counts.set(tag, (counts.get(tag) ?? 0) + 1);
    }
  }
  return [...counts.entries()]
    .map(([tag, count]) => ({ tag, count }))
    .sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
}

export function getAdjacentPosts(slug: string): {
  prev: BlogPost | null;
  next: BlogPost | null;
} {
  const posts = getBlogPosts();
  const index = posts.findIndex((post) => post.slug === slug);
  if (index === -1) return { prev: null, next: null };
  return {
    next: index > 0 ? posts[index - 1] : null,
    prev: index < posts.length - 1 ? posts[index + 1] : null,
  };
}

export function getRelatedPosts(slug: string, limit = 3): BlogPost[] {
  const posts = getBlogPosts();
  const current = posts.find((post) => post.slug === slug);
  if (!current?.tags?.length) return [];

  const currentTags = new Set(current.tags);

  return posts
    .filter((post) => post.slug !== slug && post.tags?.length)
    .map((post) => ({
      post,
      overlap: post.tags!.filter((tag) => currentTags.has(tag)).length,
    }))
    .filter(({ overlap }) => overlap > 0)
    .sort(
      (a, b) =>
        b.overlap - a.overlap ||
        b.post.createdDate.getTime() - a.post.createdDate.getTime(),
    )
    .slice(0, limit)
    .map(({ post }) => post);
}

Only title, description and createdDate are required. Posts are sorted newest first, so backdating a post moves it down the list.

Here is one of the generated examples in full:

src/lib/content/blog/welcome-to-the-blog.svx
---
title: Welcome to the Blog
description: A quick note on what this space is for and what to expect from us.
createdDate: 2026-04-20
tags:
  - announcements
author:
  name: Ada Chen
---

Welcome in. This blog is where we write about the things we build, the
decisions behind them, and what we learn when they go sideways.

You can expect a mix of short notes and longer walkthroughs. No schedule,
no filler. If a post exists it's because something was worth writing down.

A few threads you'll see pop up often:

- Product notes on what we're shipping and why
- Practical tutorials for the tools we use every day
- Design and engineering decisions that took more than five minutes

If you'd rather not check back manually, the [RSS feed](/blog/rss.xml)
has every post. Thanks for reading.

What was generated

mdsvex

svelte.config.js gains the .svx extension and the mdsvex preprocessor. From here on any .svx file in the project is a Svelte component:

svelte.config.js
import adapter from "@sveltejs/adapter-node";
import { mdsvex } from "mdsvex";

/** @type {import('@sveltejs/kit').Config} */
const config = {
  extensions: [".svelte", ".svx"],
  preprocess: [mdsvex()],
  compilerOptions: {
    // Force runes mode for the project, except for libraries. Can be removed in svelte 6.
    runes: ({ filename }) =>
      filename.split(/[/\\]/).includes("node_modules") ? undefined : true,
  },
  kit: {
    adapter: adapter(),
  },
};

export default config;

The content module

src/lib/content.ts finds every post with import.meta.glob, reads its frontmatter and its raw source, and works out a reading time. Everything else in the blog calls these functions:

src/lib/content.ts
import type { Component } from "svelte";

export type BlogAuthor = {
  name: string;
  avatar?: string;
};

export type BlogMetadata = {
  title: string;
  description: string;
  createdDate: string;
  updatedDate?: string;
  tags?: string[];
  author?: BlogAuthor;
};

export type BlogModules = Record<
  string,
  { default: Component; metadata: BlogMetadata }
>;

export type BlogRawModules = Record<string, string>;

export type BlogPost = {
  slug: string;
  title: string;
  description: string;
  createdDate: Date;
  updatedDate?: Date;
  tags?: string[];
  author?: BlogAuthor;
  readingTime: number;
  component: Component;
};

export type BlogPostSummary = Omit<BlogPost, "component">;

const WORDS_PER_MINUTE = 220;

function getBlogModules() {
  return import.meta.glob("$lib/content/blog/*.svx", {
    eager: true,
  }) as BlogModules;
}

function getBlogRawModules() {
  return import.meta.glob("$lib/content/blog/*.svx", {
    eager: true,
    query: "?raw",
    import: "default",
  }) as BlogRawModules;
}

function stripFrontmatter(raw: string) {
  if (!raw.startsWith("---")) return raw;
  const end = raw.indexOf("\n---", 3);
  if (end === -1) return raw;
  return raw.slice(end + 4);
}

function computeReadingTime(raw: string) {
  const body = stripFrontmatter(raw);
  const words = body.split(/\s+/).filter(Boolean).length;
  return Math.max(1, Math.ceil(words / WORDS_PER_MINUTE));
}

export function getBlogPosts(): BlogPost[] {
  const modules = getBlogModules();
  const rawModules = getBlogRawModules();

  const posts = Object.entries(modules).map(([path, module]) => {
    const filename = path.split("/").pop() ?? "";
    const slug = filename.replace(".svx", "");
    const raw = rawModules[path] ?? "";

    return {
      slug,
      title: module.metadata.title,
      description: module.metadata.description,
      createdDate: new Date(module.metadata.createdDate),
      updatedDate: module.metadata.updatedDate
        ? new Date(module.metadata.updatedDate)
        : undefined,
      tags: module.metadata.tags,
      author: module.metadata.author,
      readingTime: computeReadingTime(raw),
      component: module.default,
    };
  });

  posts.sort((a, b) => b.createdDate.getTime() - a.createdDate.getTime());
  return posts;
}

export function getBlogPost(slug: string) {
  const posts = getBlogPosts();
  return posts.find((post) => post.slug === slug) ?? null;
}

export function getBlogPostRaw(slug: string): string | null {
  const rawModules = getBlogRawModules();
  for (const [path, raw] of Object.entries(rawModules)) {
    const filename = path.split("/").pop() ?? "";
    if (filename.replace(".svx", "") === slug) return raw;
  }
  return null;
}

export function getBlogPostsByTag(tag: string) {
  return getBlogPosts().filter((post) => post.tags?.includes(tag));
}

export function getAllTags(): { tag: string; count: number }[] {
  const counts = new Map<string, number>();
  for (const post of getBlogPosts()) {
    for (const tag of post.tags ?? []) {
      counts.set(tag, (counts.get(tag) ?? 0) + 1);
    }
  }
  return [...counts.entries()]
    .map(([tag, count]) => ({ tag, count }))
    .sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
}

export function getAdjacentPosts(slug: string): {
  prev: BlogPost | null;
  next: BlogPost | null;
} {
  const posts = getBlogPosts();
  const index = posts.findIndex((post) => post.slug === slug);
  if (index === -1) return { prev: null, next: null };
  return {
    next: index > 0 ? posts[index - 1] : null,
    prev: index < posts.length - 1 ? posts[index + 1] : null,
  };
}

export function getRelatedPosts(slug: string, limit = 3): BlogPost[] {
  const posts = getBlogPosts();
  const current = posts.find((post) => post.slug === slug);
  if (!current?.tags?.length) return [];

  const currentTags = new Set(current.tags);

  return posts
    .filter((post) => post.slug !== slug && post.tags?.length)
    .map((post) => ({
      post,
      overlap: post.tags!.filter((tag) => currentTags.has(tag)).length,
    }))
    .filter(({ overlap }) => overlap > 0)
    .sort(
      (a, b) =>
        b.overlap - a.overlap ||
        b.post.createdDate.getTime() - a.post.createdDate.getTime(),
    )
    .slice(0, limit)
    .map(({ post }) => post);
}

The post page

The post route looks up the slug, 404s if there is no such file, and renders the compiled component. entries tells SvelteKit which slugs exist, so the whole blog can be prerendered:

src/routes/(public)/blog/[slug]/+page.ts
import { error } from "@sveltejs/kit";
import { definePageMetaTags } from "svelte-meta-tags";
import {
  getAdjacentPosts,
  getBlogPost,
  getBlogPosts,
  getRelatedPosts,
} from "$lib/content";

export const load = async ({ params, parent }) => {
  await parent();

  const blogPost = getBlogPost(params.slug);

  if (blogPost === null) {
    throw error(404, "Page not found");
  }

  const adjacent = getAdjacentPosts(params.slug);
  const related = getRelatedPosts(params.slug);

  const pageMetaTags = definePageMetaTags({
    title: blogPost.title,
    description: blogPost.description,
  });

  const breadcrumbs = [
    { title: "Home", url: "/" },
    { title: "Blog", url: "/blog" },
    { title: blogPost.title, url: `/blog/${blogPost.slug}` },
  ];

  return { blogPost, adjacent, related, breadcrumbs, ...pageMetaTags };
};

export const entries = async () => {
  return getBlogPosts().map((post) => ({ slug: post.slug }));
};
src/routes/(public)/blog/[slug]/+page.svelte
<script lang="ts">
  import { Badge } from "$lib/components/ui/badge";
  import { Separator } from "$lib/components/ui/separator";
  import AuthorChip from "$lib/components/blog/AuthorChip.svelte";
  import PostCard from "$lib/components/blog/PostCard.svelte";
  import { formatDate } from "$lib/utils/date";
  import ArrowLeft from "@lucide/svelte/icons/arrow-left";
  import ArrowRight from "@lucide/svelte/icons/arrow-right";

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

<div class="flex flex-col gap-6">
  <header class="flex flex-col gap-5 border-b border-border pb-8">
    <div class="flex flex-col gap-3">
      <h1 class="text-3xl font-semibold tracking-tight sm:text-4xl">
        {data.blogPost.title}
      </h1>
      <p class="text-muted-foreground text-lg">
        {data.blogPost.description}
      </p>
    </div>

    <div
      class="text-muted-foreground flex flex-wrap items-center gap-x-1 gap-y-2 text-sm"
    >
      {#if data.blogPost.author}
        <AuthorChip author={data.blogPost.author} />
        <span aria-hidden="true" class="text-muted-foreground/50">&bull;</span>
      {/if}
      <time datetime={data.blogPost.createdDate.toISOString()}>
        {formatDate(data.blogPost.createdDate)}
      </time>
      {#if data.blogPost.updatedDate}
        <span aria-hidden="true" class="text-muted-foreground/50">&bull;</span>
        <span>Updated {formatDate(data.blogPost.updatedDate)}</span>
      {/if}
      <span aria-hidden="true" class="text-muted-foreground/50">&bull;</span>
      <span>{data.blogPost.readingTime} min read</span>
    </div>

    {#if data.blogPost.tags?.length}
      <div class="flex flex-wrap gap-1.5">
        {#each data.blogPost.tags as tag (tag)}
          <Badge href={`/blog/tags/${tag}`} variant="secondary">{tag}</Badge>
        {/each}
      </div>
    {/if}
  </header>

  <article
    class="prose dark:prose-invert max-w-prose prose-headings:mb-0 prose-headings:mt-4"
  >
    <data.blogPost.component />
  </article>

  <Separator />

  <nav class="grid gap-3 sm:grid-cols-2" aria-label="Post navigation">
    {#if data.adjacent.prev}
      <a
        href={`/blog/${data.adjacent.prev.slug}`}
        class="ring-foreground/10 hover:ring-foreground/20 bg-card text-card-foreground group flex flex-col gap-1 rounded-xl p-4 ring-1 transition-shadow"
      >
        <span
          class="text-muted-foreground inline-flex items-center gap-1 text-xs font-medium uppercase tracking-wide"
        >
          <ArrowLeft class="size-3.5" />
          Previous
        </span>
        <span class="font-medium group-hover:underline"
          >{data.adjacent.prev.title}</span
        >
      </a>
    {:else}
      <div></div>
    {/if}

    {#if data.adjacent.next}
      <a
        href={`/blog/${data.adjacent.next.slug}`}
        class="ring-foreground/10 hover:ring-foreground/20 bg-card text-card-foreground group flex flex-col gap-1 rounded-xl p-4 text-right ring-1 transition-shadow sm:col-start-2"
      >
        <span
          class="text-muted-foreground inline-flex items-center justify-end gap-1 text-xs font-medium uppercase tracking-wide"
        >
          Next
          <ArrowRight class="size-3.5" />
        </span>
        <span class="font-medium group-hover:underline"
          >{data.adjacent.next.title}</span
        >
      </a>
    {/if}
  </nav>

  {#if data.related.length}
    <section class="flex flex-col gap-4">
      <Separator />
      <h2 class="text-xl font-semibold tracking-tight">Related posts</h2>
      <ul class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
        {#each data.related as post (post.slug)}
          <li class="flex">
            <PostCard {post} />
          </li>
        {/each}
      </ul>
    </section>
  {/if}
</div>

Under the article: previous and next links, and related posts picked by shared tags.

Tags

Every tag in the frontmatter gets a page at /blog/tags/<tag>, and /blog/tags lists them with counts. An unknown tag is a 404, not an empty page:

src/routes/(public)/blog/tags/[tag]/+page.ts
import { error } from "@sveltejs/kit";
import { definePageMetaTags } from "svelte-meta-tags";
import { getAllTags, getBlogPostsByTag } from "$lib/content";

export const load = async ({ params, parent }) => {
  await parent();

  const blogPosts = getBlogPostsByTag(params.tag);

  if (blogPosts.length === 0) {
    throw error(404, "Tag not found");
  }

  const pageMetaTags = definePageMetaTags({
    title: `Posts tagged "${params.tag}"`,
    description: `Blog posts tagged "${params.tag}".`,
  });

  const breadcrumbs = [
    { title: "Home", url: "/" },
    { title: "Blog", url: "/blog" },
    { title: "Tags", url: "/blog/tags" },
    { title: params.tag, url: `/blog/tags/${params.tag}` },
  ];

  return { blogPosts, tag: params.tag, breadcrumbs, ...pageMetaTags };
};

export const entries = async () => {
  return getAllTags().map(({ tag }) => ({ tag }));
};

RSS

/blog/rss.xml is prerendered at build time. Links in the feed use the build’s own origin, because a feed item’s URL is permanent once a reader has fetched it:

src/routes/(public)/blog/rss.xml/+server.ts
import { getBlogPosts } from "$lib/content";
import { site } from "$lib/site";

export const prerender = true;

function escapeXml(value: string) {
  return value
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&apos;");
}

export const GET = async () => {
  const posts = getBlogPosts();
  // `site.url`, not `url.origin`: this feed is prerendered, where the origin
  // is SvelteKit's placeholder unless the build was told otherwise. Every
  // `<link>` and `<guid>` below is permanent once published, so set `url` in
  // `src/lib/site.ts` before the first deploy.
  const siteURL = site.url.replace(/\/$/, "");
  const feedURL = `${siteURL}/blog/rss.xml`;
  const lastBuild = (
    posts[0]?.updatedDate ??
    posts[0]?.createdDate ??
    new Date()
  ).toUTCString();

  const items = posts
    .map((post) => {
      const link = `${siteURL}/blog/${post.slug}`;
      const pubDate = post.createdDate.toUTCString();
      const categories = (post.tags ?? [])
        .map((tag) => `    <category>${escapeXml(tag)}</category>`)
        .join("\n");
      const creator = post.author
        ? `    <dc:creator>${escapeXml(post.author.name)}</dc:creator>`
        : "";

      return [
        "  <item>",
        `    <title>${escapeXml(post.title)}</title>`,
        `    <link>${escapeXml(link)}</link>`,
        `    <guid isPermaLink="true">${escapeXml(link)}</guid>`,
        `    <description>${escapeXml(post.description)}</description>`,
        `    <pubDate>${pubDate}</pubDate>`,
        creator,
        categories,
        "  </item>",
      ]
        .filter(Boolean)
        .join("\n");
    })
    .join("\n");

  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
  <title>${escapeXml(site.name)}</title>
  <link>${escapeXml(`${siteURL}/blog`)}</link>
  <atom:link href="${escapeXml(feedURL)}" rel="self" type="application/rss+xml" />
  <description>${escapeXml(`Latest posts from ${site.name}.`)}</description>
  <language>en</language>
  <lastBuildDate>${lastBuild}</lastBuildDate>
${items}
</channel>
</rss>
`;

  return new Response(xml, {
    headers: {
      "Content-Type": "application/xml; charset=utf-8",
      "Cache-Control": "max-age=0, s-maxage=3600",
    },
  });
};

The navbar

A Blog link is added to the public layout, and the syntax highlighting theme for code blocks is imported in the root layout.

Try it

$ vela dev

Open localhost:5173/blog. Add hello-world.svx and the list updates without a restart. Click a tag, open the RSS link in the header.

Svelte inside a post

Because a post is a component, it can import components. A newsletter box, a live demo or a chart sits in the Markdown where you want it:

src/lib/content/blog/launch.svx
<script>
	import Signup from '$lib/components/signup.svelte';
</script>

We are live. Here is what shipped this week.

<Signup list="launch" />

The example post writing-posts-with-mdsvex.svx walks through this with code blocks and images.

Make it yours

The blog title and description live in the index route’s load:

src/routes/(public)/blog/+page.ts
import { definePageMetaTags } from "svelte-meta-tags";
import { getAllTags, getBlogPosts } from "$lib/content";

export const load = async ({ parent }) => {
  await parent();

  const blogPosts = getBlogPosts();
  const allTags = getAllTags();

  const pageMetaTags = definePageMetaTags({
    title: "Blog",
    description: "Product notes, tutorials, and engineering posts.",
  });

  const breadcrumbs = [
    { title: "Home", url: "/" },
    { title: "Blog", url: "/blog" },
  ];

  return { blogPosts, allTags, breadcrumbs, ...pageMetaTags };
};

Author chips take an optional avatar URL next to name. The card and post layouts are plain Svelte in PostCard.svelte and [slug]/+page.svelte, styled with the same shadcn-svelte components as the rest of the app.

Tip
The tutorials you are reading run on this exact shape: a folder of .svx files, a content module with reading times and related posts, and an RSS feed. The only addition is content negotiation, which serves each post as Markdown to anything that asks for it.

Going further

Related tutorials

How to let clients edit a SvelteKit site themselves

An admin bar on your own site where editors change copy in place, upload images and publish, with no redeploy. vela enable cms hosts the CMS inside your app, or points a static site at a hosted one, and you mark up which text is editable.

4 min read

How to make SvelteKit pages readable by AI agents

Serve the same page as HTML to browsers and as Markdown or JSON to anything that asks with an Accept header or a .md extension. vela enable content-negotiation wires sveltekit-negotiate into your hooks, and each page decides what its Markdown looks like.

3 min read

How to give users API keys for a SvelteKit app

Open your app's data as a REST API and let users create and revoke their own keys. vela enable api serves PocketBase's API under /api; vela enable api-keys adds the keys page, hashed secrets and Bearer-token access, with collection rules still in charge.

3 min read