# 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.

Pattern: [Enable blog](https://velastack.dev/patterns/enable-blog) · Docs: https://docs.velastack.dev/enable/blog

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](https://mdsvex.pngwn.io) 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](https://docs.velastack.dev/bless)).

## Run the command

```sh
$ 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**

```md
---
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**

```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**

```md
---
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](https://velastack.dev/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**

```js
import adapter from "@sveltejs/adapter-node";
// [!code highlight:1]
import { mdsvex } from "mdsvex";

/** @type {import('@sveltejs/kit').Config} */
const config = {
  // [!code highlight:2]
  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**

```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**

```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**

```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**

```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**

```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

```sh
$ vela dev
```

Open [localhost:5173/blog](http://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**

```svelte
<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**

```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](https://velastack.dev/tutorials/sveltekit-markdown-for-ai-agents), which serves each post as Markdown to anything that asks for it.

## Going further

- [Translate the blog](https://velastack.dev/tutorials/sveltekit-translate-your-site): with i18n enabled the routes are locale-aware, and the UI strings around each post are extracted for translation
- [Serve posts as Markdown to AI agents](https://velastack.dev/tutorials/sveltekit-markdown-for-ai-agents) with `vela enable content-negotiation`
- [Let non-developers edit page copy](https://velastack.dev/tutorials/sveltekit-let-clients-edit-content): posts stay as files, marketing copy moves into the CMS
- [Pattern page: Blog](https://velastack.dev/patterns/enable-blog) · [Docs: vela enable blog](https://docs.velastack.dev/enable/blog)
