Zevs @ zevs.gg

Vue 3 SSG: How I Built This Website

Mar 6 · 15min

Every developer eventually builds a personal site. Most of us rebuild it every year or two, chasing the latest framework. I’ve been through Jekyll, Hugo, Gatsby, Next.js, and Nuxt. This iteration is the one that stuck — not because Vue 3 is objectively the best choice, but because the developer experience finally matches how I want to work.

This post is a walkthrough of the entire stack — the architecture, the build pipeline, and the patterns that make writing content feel effortless.

Why Vue 3 + SSG

Static site generation means the site is pre-rendered to HTML at build time. No server, no runtime, no database. The output is a folder of HTML files that can be deployed anywhere — Netlify, Cloudflare Pages, a simple Nginx server, or even a GitHub Pages repo.

Vue 3 might seem like overkill for a personal site, but the combination of Vue’s component model with static generation gives me something that pure static site generators (Hugo, Eleventy) can’t: interactive components embedded directly in markdown content. I can write a blog post in Markdown and drop in a Vue component for a live demo, an interactive chart, or a custom visualization — all without leaving the markdown file.

The framework powering this is vite-ssg, built by Anthony Fu. It takes a standard Vue 3 + Vite application and generates static HTML for every route at build time. The client-side JavaScript then hydrates the HTML, making it interactive. You get the SEO benefits of pre-rendered HTML with the interactivity of a single-page application.

// src/main.ts
import { ViteSSG } from 'vite-ssg'
import { routes } from 'vue-router/auto-routes'
import App from './App.vue'

export const createApp = ViteSSG(
  App,
  { routes },
  ({ router, app, isClient }) => {
    // Plugins, router guards, client-only setup
    app.use(createPinia())

    if (isClient) {
      setupRouterScroller(router, { behavior: 'auto' })

      router.beforeEach(() => NProgress.start())
      router.afterEach(() => NProgress.done())
    }
  },
)

The isClient guard ensures that browser-specific code (scroll behavior, progress bars) only runs in the browser, not during SSG pre-rendering. This pattern appears everywhere when you work with SSG — you have to be mindful that your code runs in two contexts: Node.js at build time and the browser at runtime.

File-Based Routing

Routes are generated automatically from the filesystem using unplugin-vue-router. Every .vue or .md file in the pages/ directory becomes a route:

pages/
├── index.md              → /
├── uses.md               → /uses
├── photos.vue            → /photos
├── bookmarks.vue         → /bookmarks
└── posts/
    ├── index.md          → /posts
    ├── building-smscode.md    → /posts/building-smscode
    ├── terminal-setup-2026.md → /posts/terminal-setup-2026
    └── from-node-to-rust.md   → /posts/from-node-to-rust

No router configuration to maintain. Create a file, get a route. Delete the file, the route disappears. The plugin generates type-safe route definitions, so <RouterLink to="/posts"> gets compile-time validation.

The route generation also handles frontmatter. Each markdown file has YAML frontmatter at the top — title, date, description, duration. This metadata is read at build time using gray-matter and attached to the route’s meta:

// vite.config.ts
VueRouter({
  extensions: ['.vue', '.md'],
  routesFolder: 'pages',
  extendRoute(route) {
    const path = route.components.get('default')
    if (path?.endsWith('.md')) {
      const { data } = matter(fs.readFileSync(path, 'utf-8'))
      route.addToMeta({ frontmatter: data })
    }
  },
})

This is what powers the blog listing page. The ListPosts component reads route metadata from the router directly — no API calls, no file imports, just data that’s already available in the route definitions:

// ListPosts.vue
const routes: Post[] = router.getRoutes()
  .filter(i => i.path.startsWith('/posts')
    && i.meta.frontmatter.date
    && !i.meta.frontmatter.draft)
  .map(i => ({
    path: i.meta.frontmatter.redirect || i.path,
    title: i.meta.frontmatter.title,
    date: i.meta.frontmatter.date,
    lang: i.meta.frontmatter.lang,
    duration: i.meta.frontmatter.duration,
  }))

Posts are sorted by date, grouped by year, and the year headers render as large, semi-transparent text in the background — a subtle but effective visual hierarchy. Draft posts (marked with draft: true in frontmatter) are filtered out of the listing but still accessible via direct URL during development.

Markdown as Vue Components

This is the core trick that makes the whole setup work. unplugin-vue-markdown compiles Markdown files into Vue Single File Components at build time. This means every .md file is a Vue component — it can use other components, receive props, and has full access to Vue’s reactivity system.

---
title: My Blog Post
date: 2026-03-06T00:00:00Z
duration: 10min
---

This is regular markdown with **bold** and `code`.

But I can also use Vue components directly:

<MyCustomChart :data="chartData" />

Or use HTML with UnoCSS utilities:

<div flex gap-4 items-center>
  <span text-2xl>Interactive content in markdown!</span>
</div>

Each markdown file is wrapped in a WrapperPost component that provides the layout — title, date, duration, navigation links, and social sharing buttons. The wrapper reads the frontmatter and renders the chrome around the content:

<!-- WrapperPost.vue -->
<template>
  <div v-if="frontmatter.title" class="prose m-auto mb-8">
    <h1 class="mb-0">
      {{ frontmatter.title }}
    </h1>
    <p v-if="frontmatter.date" class="opacity-50 !-mt-6">
      {{ formatDate(frontmatter.date) }}
      <span v-if="frontmatter.duration">· {{ frontmatter.duration }}</span>
    </p>
  </div>
  <article>
    <slot />
  </article>
</template>

The markdown pipeline itself is heavily customized with markdown-it plugins:

  • Shiki — Syntax highlighting with the Vitesse dark/light themes. Supports diff notation (// [!code ++]), line highlighting, and even TypeScript type annotations via TwoSlash.
  • Anchor links — Every heading gets a permalink anchor for direct linking.
  • Table of contents — The [[toc]] directive generates a floating TOC from headings.
  • External links — All external links automatically get target="_blank" and rel="noopener".
  • Magic links — Brand names like SMSCode auto-link to their URLs with custom icons. This is configured in vite.config.ts:
md.use(MarkdownItMagicLink, {
  linksMap: {
    SMSCode: 'https://smscode.gg',
    UNDRCTRL: 'https://undrctrl.id',
    FYP: 'https://fyp.id',
  },
  imageOverrides: [
    ['https://smscode.gg', '/icons/smscode.svg'],
    ['https://undrctrl.id', '/icons/undrctrl.svg'],
  ],
})
  • GitHub Alerts — Markdown > [!NOTE] and > [!WARNING] blocks render as styled callouts.

UnoCSS: Utility-First, Zero Config

UnoCSS replaces Tailwind CSS. It’s an on-demand atomic CSS engine — it only generates the CSS for utilities you actually use, and it’s significantly faster than Tailwind’s JIT compiler because it skips the PostCSS step entirely.

The key feature I rely on is attributify mode. Instead of stuffing all utility classes into a class attribute, you write them directly as HTML attributes:

<!-- Traditional utility classes -->
<div class="flex gap-4 items-center opacity-50 mt-4">
  <!-- Attributify mode -->
  <div flex gap-4 items-center op50 mt-4></div>
</div>

It reads cleaner and is easier to scan visually. The Vue compiler and UnoCSS work together to transform these attributes into actual CSS at build time.

The design itself is intentionally minimal. Dark mode is toggled via VueUse’s useDark with class-based switching on the <html> element. The color palette is sparse — mostly grays with opacity variations. Content width is constrained by a .prose class that sets max-width and typographic defaults.

The CSS entry point is clean:

:root {
  --c-bg: #fff;
  --c-scrollbar: #eee;
}

html.dark {
  --c-bg: #050505;
  --c-scrollbar: #111;
}

A slide-enter animation plays when navigating between pages, giving each page load a subtle staggered reveal. Each child element in the content area enters with an increasing delay, creating a cascading effect that makes the page feel alive without being distracting.

Auto-Imports Everywhere

One of the best DX features in this stack is that almost nothing needs to be explicitly imported. Three unplugin plugins handle this:

unplugin-auto-import — Vue APIs (ref, computed, watch, onMounted), Vue Router APIs (useRoute, useRouter), and VueUse composables (useDark, useEventListener, useLocalStorage) are all available globally without import statements.

// vite.config.ts
AutoImport({
  imports: ['vue', VueRouterAutoImports, '@vueuse/core'],
})

unplugin-vue-components — Vue components in src/components/ are auto-registered. If I create MyWidget.vue, I can use <MyWidget /> in any template or markdown file without importing it.

unplugin-icons — Icons from the entire Iconify collection (150,000+ icons) are available as components. No icon fonts, no SVG imports — just use an HTML attribute:

<div i-ri-github-fill />
<div i-ri-article-line />
<div i-carbon-arrow-up-right />

The icon is resolved at build time, tree-shaken to only include the icons you actually use, and inlined as an SVG. Zero runtime cost.

The combined effect is significant. A typical .vue file in this project has zero import statements. Everything is available implicitly. This sounds like it would hurt readability, but in practice, the APIs are so well-known (Vue, VueUse) that explicit imports just add noise.

OG Image Generation

Every blog post automatically gets an Open Graph image for social media previews. The generation happens at build time using sharp — an SVG template is filled with the post title and rendered to a PNG:

// vite.config.ts — frontmatterPreprocess
const route = basename(id, '.md')
const path = `og/${route}.png`
generateOg(frontmatter.title, `public/${path}`)
frontmatter.image = `https://zevs.gg/${path}`

The generated image URL is set as the frontmatter image, which the head meta tags pick up automatically. When someone shares a post on Twitter, Telegram, or LinkedIn, they see a card with the post title — no manual image creation needed.

The SVG template uses a simple layout — title text on a solid background. The title is split into lines at word boundaries every 30 characters, then rendered with sharp:

async function generateOg(title: string, output: string) {
  const lines = title.trim().split(/(.{0,30})(?:\s|$)/g).filter(Boolean)

  const svg = ogTemplate.replace(
    /\{\{([^}]+)\}\}/g,
    (_, name) => ({ line1: lines[0], line2: lines[1], line3: lines[2] })[name] || ''
  )

  await sharp(Buffer.from(svg))
    .resize(1200 * 1.1, 630 * 1.1)
    .png()
    .toFile(output)
}

RSS Feed

The RSS feed is generated by a post-build script. It reads all markdown files, parses them with gray-matter and markdown-it, and outputs RSS, Atom, and JSON feeds:

// scripts/rss.ts
const files = await fg('pages/posts/*.md')
const posts = await Promise.all(
  files.map(async (i) => {
    const { data, content } = matter(await fs.readFile(i, 'utf-8'))
    const html = markdown.render(content)
    return { ...data, content: html, date: new Date(data.date) }
  })
)

posts.sort((a, b) => +new Date(b.date) - +new Date(a.date))

const feed = new Feed(options)
posts.forEach(item => feed.addItem(item))

await fs.writeFile('./dist/feed.xml', feed.rss2())
await fs.writeFile('./dist/feed.atom', feed.atom1())
await fs.writeFile('./dist/feed.json', feed.json1())

The full build pipeline runs sequentially:

# package.json build script
vite-ssg build          # 1. SSG pre-render all routes
tsx scripts/copy-fonts  # 2. Copy font files to dist
tsx scripts/rss.ts      # 3. Generate RSS/Atom/JSON feeds
cp _dist_redirects dist/_redirects  # 4. Copy redirect rules

Deployment

The site deploys to Netlify on every push. The netlify.toml configuration is minimal:

[build]
publish = "dist"
command = "pnpm run build"

[build.environment]
NODE_VERSION = "22"

[[headers]]
for = "/assets/*"

[headers.values]
Cache-Control = "public, max-age=31536000, immutable"

Static assets (JS, CSS, images) get year-long cache headers with immutable — since Vite hashes filenames, the content at any given URL never changes. HTML files use Netlify’s default caching, which revalidates on each request.

The SPA fallback redirect ensures that client-side navigation works:

[[redirects]]
from = "/*"
to = "/index.html"
status = 200

This catches routes that don’t have pre-rendered HTML (unlikely with SSG, but good as a safety net) and serves the SPA shell instead.

Code Quality

Pre-commit hooks keep the codebase consistent:

{
  "simple-git-hooks": {
    "pre-commit": "npx lint-staged"
  },
  "lint-staged": {
    "*": "eslint --fix"
  }
}

Every staged file — JS, TS, Vue, Markdown, JSON — runs through ESLint with @antfu/eslint-config before commit. This config includes formatting rules (replacing Prettier), Vue-specific rules, TypeScript checks, and UnoCSS attribute ordering. No separate Prettier config, no formatting debates — one tool handles everything.

The Full Architecture

Here’s how everything connects:

pages/*.md / pages/*.vue          (Content & Routes)

    ├─ unplugin-vue-router        (File-based routing)
    ├─ unplugin-vue-markdown      (Markdown → Vue SFC)
    │   ├─ gray-matter            (YAML frontmatter)
    │   ├─ Shiki                  (Syntax highlighting)
    │   ├─ markdown-it-anchor     (Heading permalinks)
    │   ├─ markdown-it-toc        (Table of contents)
    │   └─ markdown-it-magic-link (Brand auto-linking)

    ├─ unplugin-auto-import       (Vue/VueUse auto-imports)
    ├─ unplugin-vue-components    (Component auto-registration)
    ├─ unplugin-icons             (Iconify → SVG components)
    ├─ UnoCSS                     (Atomic CSS, attributify)

    └─ vite-ssg                   (Static site generation)
        ├─ sharp                  (OG image generation)
        ├─ feed                   (RSS/Atom/JSON feeds)
        └─ dist/                  → Netlify

The entire build takes about 15 seconds. The development server starts in under 2 seconds with full HMR — editing a markdown file reflects instantly in the browser.

What I’d Change

Markdown limitations. The markdown-to-Vue compilation is powerful, but debugging errors inside markdown files is painful. If a Vue component inside markdown has a syntax error, the error message points to the compiled output, not the source. Better source maps would help.

Image optimization. I don’t have an automated image pipeline yet. Images are manually compressed before commit. An on-the-fly optimization step (like vite-imagetools or a custom sharp pipeline) would be worthwhile.

Search. There’s no search functionality. For a blog with a growing number of posts, this will eventually matter. A client-side search index built at SSG time (like FlexSearch) would fit the static architecture well.

Wrap Up

The core principle behind this stack is that content should be easy to create and hard to break. Writing a new blog post is: create a .md file, add frontmatter, write content, push. The build system handles routing, OG images, RSS feeds, syntax highlighting, and deployment automatically.

If you’re building a personal site and value developer experience over simplicity, Vue 3 + Vite + SSG is a stack worth considering. The plugin ecosystem (all those unplugin-* packages) does most of the heavy lifting, and the result is a site that’s fast to build, fast to load, and — most importantly — fast to write for.

Thanks for reading!

> comment on bluesky / mastodon / twitter
>