Building a Rich Content System with MDsveX
How I built a flexible content authoring system for my portfolio using MDsveX, custom Svelte components, and Shiki syntax highlighting.
Building a Rich Content System with MDsveX
When building this portfolio, I wanted a content system that was:
- Easy to author - Write in Markdown, not a CMS
- Flexible - Embed interactive components when needed
- Git-tracked - Version control for content
- Fast - No runtime content fetching
MDsveX gave me all of this by letting me write Markdown files that can import and use Svelte components.
The Basic Setup
MDsveX is configured in svelte.config.js:
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
import { mdsvex } from 'mdsvex';
import { createHighlighter } from 'shiki';
// Create highlighter instance at the top level
const highlighter = await createHighlighter({
themes: ['night-owl'],
langs: ['javascript', 'typescript', 'svelte', 'bash', 'json']
});
const config = {
extensions: ['.svelte', '.md'],
preprocess: [
vitePreprocess(),
mdsvex({
extensions: ['.md'],
highlight: {
highlighter: async (code, lang) => {
// Special handling for Mermaid diagrams
if (lang === 'mermaid') {
return `
<script>
import Mermaid from '$lib/components/Mermaid.svelte';
</script>
<Mermaid code={\`${code.replace(/`/g, '\\`')}\`} />
`;
}
const html = highlighter.codeToHtml(code, {
lang: lang || 'plaintext',
theme: 'night-owl'
});
// Escape curly braces for Svelte
const escaped = html.replace(/[{}]/g, (match) => `{'${match}'}`);
return `<div class="code-block-wrapper">${escaped}</div>`;
}
}
})
],
kit: {
adapter: adapter()
}
};
export default config;This lets me:
- Write
.mdfiles that become Svelte components - Use Shiki with the Night Owl theme for syntax highlighting
- Import components directly in Markdown
- Use Mermaid diagrams with fenced code blocks
Content Structure
All content lives in src/content/:
src/content/
projects/
portfolio-website.md
oscar-data-analytics-app.md
blog/
mdsvex-content-system.md
automating-og-images.mdEach file has frontmatter for metadata:
---
title: "Building a Rich Content System with MDsveX"
slug: "mdsvex-content-system"
description: "How I built a flexible content authoring system..."
date: "2026-01-28"
published: true
tags: ["SvelteKit", "MDsveX", "Markdown"]
project: "portfolio-website"
---Loading Content
A utility function loads all content at build time:
// src/lib/content/index.ts
import type { Project, BlogPost, ProjectModule, BlogPostModule } from '$lib/types/content';
export async function getProjects(): Promise<Project[]> {
const projectFiles = import.meta.glob<ProjectModule>('/src/content/projects/*.md', {
eager: true
});
const projects = Object.entries(projectFiles)
.map(([, module]) => module.metadata)
.filter((project) => project.published !== false);
return projects.sort((a, b) =>
new Date(b.date).getTime() - new Date(a.date).getTime()
);
}
export async function getBlogPosts(): Promise<BlogPost[]> {
const blogFiles = import.meta.glob<BlogPostModule>('/src/content/blog/*.md', {
eager: true
});
// Also import raw content for reading time calculation
const rawFiles = import.meta.glob('/src/content/blog/*.md', {
eager: true,
query: '?raw',
import: 'default'
}) as Record<string, string>;
const posts = Object.entries(blogFiles)
.map(([path, module]) => {
const rawContent = rawFiles[path] || '';
return {
...module.metadata,
readingTime: calculateReadingTime(rawContent)
};
})
.filter((post) => post.published);
return posts.sort((a, b) =>
new Date(b.date).getTime() - new Date(a.date).getTime()
);
}Since SvelteKit prerenders these pages, content is fetched once at build time and baked into static HTML.
Custom Components in Markdown
The real power comes from embedding Svelte components. In any .md file:
<script>
import Callout from '$lib/components/Callout.svelte';
import FeatureCard from '$lib/components/FeatureCard.svelte';
</script>
Regular markdown content here...
<Callout type="tip" title="Pro Tip">
This callout is a Svelte component rendered inside Markdown.
</Callout>Components I Built
Callout - For tips, warnings, and notes:
<Callout type="warning" title="Important">
Watch out for this common pitfall.
</Callout>FeatureCard - For showcasing features in a grid:
<div class="grid grid-cols-2 gap-4">
<FeatureCard
title="Fast"
icon="lucide:zap"
description="Built for speed"
/>
<FeatureCard
title="Flexible"
icon="lucide:layers"
description="Adapts to your needs"
/>
</div>Mermaid - For diagrams:
<Mermaid code={`
flowchart LR
A[Markdown] --> B[MDsveX]
B --> C[Svelte Component]
C --> D[Static HTML]
`} />Terminal - For animated command demonstrations:
<Terminal lines={[
{ type: "command", text: "npm run build" },
{ type: "output", text: "Build complete!" }
]} />MDsveX Gotchas
No Blank Lines in Components
MDsveX parses blank lines as paragraph breaks. This fails:
<Callout type="info" title="Note">
This breaks because of blank lines.
</Callout>This works:
<Callout type="info" title="Note">
This works with no blank lines around content.
</Callout>Escaping Svelte Syntax
Since MDsveX processes Svelte syntax, literal ${variable} text gets interpreted as JavaScript. Escape it with:
${"${variable}"}Component Props Must Be Expressions
String props work directly, but objects need curly braces:
<!-- String prop -->
<Component title="Hello" />
<!-- Object/array prop -->
<Component items={["one", "two", "three"]} />Type Safety
TypeScript interfaces ensure content consistency:
// src/lib/types/content.ts
export interface Project {
title: string;
slug: string;
description: string;
date: string;
featured: boolean;
technologies: string[];
github?: string;
demo?: string;
appStore?: string;
website?: string;
image?: string;
status?: 'active' | 'in-development' | 'coming-soon' | 'archived';
published?: boolean;
}
export interface BlogPost {
title: string;
slug: string;
description: string;
date: string;
published: boolean;
tags: string[];
readingTime?: number;
project?: string; // Optional slug of related project
}
// Module types for the glob imports
export interface ProjectModule {
metadata: Project;
default: ConstructorOfATypedSvelteComponent;
}
export interface BlogPostModule {
metadata: BlogPost;
default: ConstructorOfATypedSvelteComponent;
}The content loader validates against these types at build time.
Benefits of This Approach
- Version controlled - Content changes are tracked in git
- No CMS overhead - No external service to maintain
- Full flexibility - Any Svelte component works in content
- Fast builds - Content is static, no runtime fetching
- Great DX - Write Markdown in your editor with syntax highlighting
When to Use MDsveX
Good for:
- Developer portfolios and blogs
- Documentation sites
- Marketing pages with dynamic elements
- Any site where devs are the content authors
Consider alternatives for:
- Non-technical content editors (use a headless CMS)
- Frequently updated content (git workflow may be slow)
- User-generated content (needs a database)
Conclusion
MDsveX hits a sweet spot for developer content. You get Markdown simplicity with Svelte’s component model. For this portfolio, it means I can write blog posts quickly while still embedding interactive demos when needed.
The initial setup takes some work, but the authoring experience is worth it.
Related
Jack O'Shea
DevOps Engineer and Software Developer building reliable systems with clean code.