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.
  • showToc and TocOpen: control whether a table of contents appears and its initial state.
  • cover: optional featured image with image URL and alt text 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:

  1. import.meta.glob eagerly imports all .md files under blog/posts.
  2. gray-matter (matter) parses front-matter and raw content.
  3. I split out an excerpt at <!--more--> or fall back to the first 150 characters.
  4. Reading time is a simple words-per-minute calculation (225 wpm).
  5. marked converts Markdown to HTML, stripping the “more” marker.
  6. 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 showToc is 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: true so BlogPostView receives slug directly.
  • A global beforeEach hook 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:

  • useHead from @vueuse/head dynamically 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 date from 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 spinner placeholder displays until the load event 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:

  • preconnect establishes early TCP/TLS to key domains.
  • preload fetches 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 while AsyncBlogPost fetches & 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!