I’ve always believed that sharing the “why” and “how” behind a product is just as important as building it. When I decided to add a blog to ClarityBox a gentle journaling tool for processing conversations and emotions—I wanted to ensure every piece of content was structured, performant, and discoverable. In this post, I’ll walk you through the end-to-end process: from writing Markdown posts to deploying a Vue.js-driven static site, complete with SEO and performance optimizations.
Technical Architecture
1. Content Structure
I store every post as a Markdown file with front-matter metadata. Here’s the template I use:
---
title: "Why I'm Building ClarityBox"
date: "2025-04-15"
author: "Alex Chen"
tags: ["personal story", "mental health", "product journey"]
showToc: true
TocOpen: false
cover:
image: "/images/blog/why-im-building-claritybox.jpg"
alt: "Person journaling with a cup of tea nearby"
description: "My journey from emotional overload to creating a tool for quieter minds"
---
Explanation: The YAML between
---defines key properties to render each post.
title,date,author,tags: basic metadata shown in the header.showTocandTocOpen: control whether a table of contents appears and its initial state.cover: optional featured image withimageURL andalttext for accessibility.description: used for SEO and preview cards.
2. Loading and Processing Posts
To load posts at build time, I wrote a utility in TypeScript:
// src/utils/blogUtils.ts
import { marked } from 'marked';
import matter from 'gray-matter';
export interface BlogPost { /* … */ }
export async function loadBlogPosts(): Promise<BlogPost[]> {
const context = import.meta.glob('../blog/posts/*.md', { eager: true });
const posts: BlogPost[] = [];
for (const path in context) {
const file = context[path] as any;
const { data, content: raw } = matter(file.default);
// Extract excerpt up to <!--more-->
const excerptEnd = raw.indexOf('<!--more-->');
const excerpt = excerptEnd > 0
? raw.slice(0, excerptEnd).trim()
: raw.slice(0, 150).trim() + '...';
// Estimate reading time
const words = raw.split(/\s+/).length;
const readingTime = Math.ceil(words / 225);
// Convert Markdown to HTML (strip <!--more--> marker)
const html = marked(raw.replace('<!--more-->', ''));
const slug = path.split('/').pop()!.replace('.md', '');
posts.push({ slug, title: data.title, date: data.date, author: data.author, tags: data.tags || [], content: html, excerpt, readingTime, showToc: data.showToc, tocOpen: data.TocOpen, cover: data.cover, description: data.description });
}
return posts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}
Explanation:
import.meta.globeagerly imports all.mdfiles underblog/posts.gray-matter(matter) parses front-matter and raw content.- I split out an excerpt at
<!--more-->or fall back to the first 150 characters.- Reading time is a simple words-per-minute calculation (225 wpm).
markedconverts Markdown to HTML, stripping the “more” marker.- I derive a slug from the filename and then sort posts newest-first.
3. Rendering a Post
My BlogPost.vue component renders each article:
<template>
<article>
<header>
<h1>{{ post.title }}</h1>
<div class="meta">
<span>{{ formatDate(post.date) }}</span>
<span>by {{ post.author }}</span>
<span>{{ post.readingTime }} min read</span>
</div>
<img v-if="post.cover" :src="post.cover.image" :alt="post.cover.alt" loading="lazy" />
</header>
<div v-if="post.showToc" class="toc" :class="{ open: post.tocOpen }">
<h2>Table of Contents</h2>
<div v-html="generateToc(post.content)"></div>
</div>
<section v-html="post.content"></section>
<footer>
<span v-for="tag in post.tags" :key="tag">#{{ tag }}</span>
</footer>
</article>
</template>
<script setup lang="ts">
import { BlogPost } from '../utils/blogUtils';
const props = defineProps<{ post: BlogPost }>();
function formatDate(d: string) {
return new Date(d).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
}
function generateToc(html: string) {
const headings = html.match(/<h[23][^>]*>(.*?)<\/h[23]>/g) || [];
return headings.map(h => {
const level = h.startsWith('<h2') ? 2 : 3;
const text = h.replace(/<\/?h[23][^>]*>/g, '');
const id = text.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '');
return `<div class="toc-level-${level}"><a href="#${id}">${text}</a></div>`;
}).join('');
}
</script>
Explanation:
- I render metadata (date, author, read time) and lazy-load the cover image.
- If
showTocis true, I inject a generated TOC by scanning for<h2>/<h3>tags.- Finally, I output the HTML content and display tags in the footer.
Routing & Navigation
I configure Vue Router to serve the blog list and detail pages:
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router';
import Home from '../views/Home.vue';
import BlogList from '../views/BlogList.vue';
import BlogPostView from '../views/BlogPostView.vue';
const routes = [
{ path: '/', component: Home, meta: { title: 'ClarityBox' } },
{ path: '/blog', component: BlogList, meta: { title: 'Blog – ClarityBox' } },
{ path: '/blog/:slug', component: BlogPostView, props: true }
];
const router = createRouter({ history: createWebHistory(), routes });
router.beforeEach((to, from, next) => {
document.title = to.meta.title || 'ClarityBox';
next();
});
export default router;
Explanation:
- The blog list lives at
/blog, individual posts at/blog/:slug.- I use
props: truesoBlogPostViewreceivesslugdirectly.- A global
beforeEachhook updates the document title per route.
SEO & Sitemap
Dynamic Meta Tags
In my main App, I watch route changes and inject Open Graph/Twitter meta:
<script setup>
import { watch } from 'vue';
import { useRoute } from 'vue-router';
import { useHead } from '@vueuse/head';
const route = useRoute();
watch(() => route.meta, (meta) => {
useHead({
title: meta.title,
meta: [
{ name: 'description', content: meta.description },
{ property: 'og:title', content: meta.title },
{ property: 'og:description', content: meta.description },
{ name: 'twitter:card', content: 'summary_large_image' },
// …other tags…
]
});
}, { immediate: true });
</script>
Explanation:
useHeadfrom@vueuse/headdynamically injects head tags when the route meta changes.- This ensures each post has its own SEO metadata and social-card configuration.
Generating a Sitemap
I wrote a small Node.js script:
// scripts/generate-sitemap.js
const fs = require('fs');
const path = require('path');
const matter = require('gray-matter');
const BASE = 'https://claritybox.app';
const POSTS = path.join(__dirname, '../src/blog/posts');
const OUT = path.join(__dirname, '../public/sitemap.xml');
const staticRoutes = [ '/', '/about', '/pricing', '/blog' ];
const posts = fs.readdirSync(POSTS).filter(f => f.endsWith('.md')).map(f => {
const { data } = matter(fs.readFileSync(path.join(POSTS, f), 'utf8'));
return { url: `/blog/${f.replace('.md','')}`, lastmod: data.date };
});
let xml = `<?xml version="1.0"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n`;
staticRoutes.forEach(u => xml += `<url><loc>${BASE}${u}</loc></url>\n`);
posts.forEach(p => xml += `<url><loc>${BASE}${p.url}</loc><lastmod>${new Date(p.lastmod).toISOString()}</lastmod></url>\n`);
xml += `</urlset>`;
fs.writeFileSync(OUT, xml);
Explanation:
- I read every Markdown filename, parse its
datefrom front-matter, and emit<url>entries.- Static routes get a simple entry; blog posts include
<lastmod>for freshness.
Performance Optimizations
Lazy-Loading Images
In BlogImage.vue, I trigger a placeholder until the image is fully loaded:
<template>
<figure :class="{ loaded: isLoaded }">
<div v-if="!isLoaded" class="spinner"></div>
<img :src="src" :alt="alt" loading="lazy" @load="isLoaded = true" v-show="isLoaded" />
<figcaption v-if="caption">{{ caption }}</figcaption>
</figure>
</template>
<script setup>
import { ref } from 'vue';
const props = defineProps({ src: String, alt: String, caption: String });
const isLoaded = ref(false);
</script>
Explanation:
- A
spinnerplaceholder displays until theloadevent fires.loading="lazy"defers off-screen images for faster initial paint.
Preconnect & Preload
In public/index.html I added critical hints:
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preload" href="/css/critical.css" as="style">
Explanation:
preconnectestablishes early TCP/TLS to key domains.preloadfetches essential CSS before it’s discovered by the parser.
Suspense for Async Loading
Finally, in BlogPostView.vue I wrap the post component in <Suspense>:
<template>
<Suspense>
<template #default><AsyncBlogPost :slug="slug" /></template>
<template #fallback><LoadingSpinner /> Loading…</template>
</Suspense>
</template>
Explanation:
<Suspense>lets me show a spinner whileAsyncBlogPostfetches & renders.- This improves perceived performance on slower networks.
Conclusion
Building the ClarityBox blog taught me the value of combining clear content structure with robust tooling. From Markdown front-matter to Vue rendering, SEO meta tags to performance hints, every layer ensures our posts load quickly, rank well, and guide readers through a smooth experience.
I hope this walkthrough helps you construct your own static-first blog with Vue.js. If you have any questions or suggestions, drop a comment below—I’d love to hear your thoughts!