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
✔ 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.
---
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:
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:
---
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:
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:
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:
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 }));
};
<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">•</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">•</span>
<span>Updated {formatDate(data.blogPost.updatedDate)}</span>
{/if}
<span aria-hidden="true" class="text-muted-foreground/50">•</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:
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:
import { getBlogPosts } from "$lib/content";
import { site } from "$lib/site";
export const prerender = true;
function escapeXml(value: string) {
return value
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
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
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:
<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:
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.
.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
- Translate the blog: 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 with
vela enable content-negotiation - Let non-developers edit page copy: posts stay as files, marketing copy moves into the CMS
- Pattern page: Blog · Docs: vela enable blog


