PROWEB

Building a Blog with Nuxt 3 and Nuxt Content: From Idea to Production

Complete guide to creating a modern blog with Nuxt 3 using Nuxt Content: setup, structure, components, SEO, performance, and deployment.

Muza Neuronova
28 minutes reading
Building a Blog with Nuxt 3 and Nuxt Content: From Idea to Production

Introduction

Nuxt Content is not just a "static site generator", but a neat CMS subsystem built on top of Nuxt 3. It covers 80% of typical blog tasks without an external backend and allows you to move fast without sacrificing quality:

  • SSR/SSG/ISR out of the box — fast TTFB and predictable SEO
  • Type safety and autocomplete for frontmatter
  • Flexible structure: Markdown/MDC, YAML, JSON, Vue components
  • Search and navigation — without heavy external services (for starters)
  • Excellent integration with the Nuxt ecosystem (Nitro, route rules, image)

In this article, I'll put together a working configuration, add the nuances that are most often stumbled upon in production, and show how not to lose performance under real traffic.

Why Nuxt Content for Blog

Against Static Generators (Jekyll/Hugo)

  • HMR and DX at Vite level: iteration speed increases by orders of magnitude
  • Vue components directly in content (MDC) instead of "templates with workarounds"
  • Single stack and build — less context switching and small integrations

Against Headless CMS (Strapi/Sanity)

  • Zero network requests to API on render — fewer failure points and latency
  • Data in repository — version control, PR review, no need for staging API
  • Cost — no subscriptions and paid plugins "just for search"

Against WordPress

  • Modern rendering and dev pipeline
  • Stable response time under load without dancing with PHP-FPM/OpCache
  • Smaller attack surface: no plugin jungle where security is a lottery

What's important: for very large catalogs (5–10k+ articles), I would prefer a separate search engine (Meilisearch/Algolia) and caches (Redis/Cloudflare KV). But up to these scales, Nuxt Content solves the task faster and simpler.

Architecture: SSG, SSR, and ISR Without Surprises

In real traffic, the most practical approach:

  • Articles and listings — SSG/ISR (almost CDN-fast)
  • Rarely changing APIs — ISR with short maxAge
  • Dynamics (comments, likes) — separate API (Fastify/NestJS), Web API, or external widgets

I recommend setting route rules right away:

// nuxt.config.ts (fragment)
export default defineNuxtConfig({
  routeRules: {
    '/': { prerender: true },
    '/blog': { prerender: true, swr: 300 },
    '/blog/**': { swr: 600 }, // ISR: rebuild every 10 min
    '/api/search': { cache: { maxAge: 60, staleMaxAge: 300 }, cors: true }
  }
})
  • Keep simple content as static pages, but maintain the possibility of ISR for updates.
  • Low latency on Vercel/Netlify is achieved without manual caching at CDN levels.

Project Setup

Creating Project

npx nuxi@latest init my-blog
cd my-blog
npm install

Installing Dependencies

npm i @nuxt/content @nuxtjs/tailwindcss @nuxtjs/color-mode @nuxt/image-edge
npm i -D @types/markdown-it @vueuse/core

Nuxt Configuration with Production Nuances

// nuxt.config.ts
export default defineNuxtConfig({
  modules: [
    '@nuxt/content',
    '@nuxtjs/tailwindcss',
    '@nuxtjs/color-mode',
    '@nuxt/image-edge'
  ],

  typescript: { strict: true },

  content: {
    // Document-driven mode will simplify navigation
    documentDriven: true,
    highlight: {
      theme: 'github-dark',
      preload: ['diff', 'json', 'js', 'ts', 'css', 'shell', 'html', 'md', 'yaml']
    },
    markdown: {
      // Enable anchors if you have styles for them
      anchorLinks: { depth: 2, exclude: [1] }
    },
    // Table of contents generation
    toc: { depth: 2, searchDepth: 2 }
  },

  colorMode: { classSuffix: '', preference: 'system' },

  image: {
    // Optimization of covers and previews
    format: ['webp', 'avif', 'png'],
    screens: { sm: 640, md: 768, lg: 1024, xl: 1280, '2xl': 1536 }
  },

  // Performance and stability
  experimental: {
    // For SSG, it makes sense to enable payload extraction, for pure SSR — no
    payloadExtraction: true
  },

  nitro: {
    prerender: { routes: ['/sitemap.xml', '/rss.xml', '/robots.txt'] },
    storage: {
      // You can switch to redis in the future
      cache: { driver: 'memory' }
    }
  },

  routeRules: {
    '/': { prerender: true },
    '/blog': { prerender: true, swr: 300 },
    '/blog/**': { swr: 600 },
    '/api/search': { cache: { maxAge: 60, staleMaxAge: 300 }, cors: true }
  },

  runtimeConfig: {
    public: {
      siteUrl: 'https://yoursite.com'
    }
  }
})

Notes from practice:

  • payloadExtraction: true reduces HTML and speeds up TTFB on SSG/ISR. If pure SSR on Vercel — keep false.
  • Enable image module — covers and previews without it hurt LCP badly.
  • routeRules with ISR remove the headache of "how to update sitemap/rss without rebuild".

Content Structure

content/
├─ blog/
│  ├─ getting-started.md
│  ├─ advanced-features.md
│  └─ performance-tips.md
├─ pages/
│  ├─ about.md
│  └─ contact.md
└─ config/
   └─ navigation.yaml

Frontmatter that covers 99% of cases:

---
title: 'Article Title'
description: 'Brief SEO description'
author: 'Author Name'
publishedAt: '2024-01-15'
updatedAt: '2024-01-20'
tags: ['nuxt', 'vue', 'javascript']
category: 'tutorial'
image: '/images/article-cover.jpg'
draft: false
featured: true
---

💡 Tip: normalize fields (e.g., always ISO 8601), otherwise sorting/filtering will "drift".

Components

Article List Component (fixing pagination)

<!-- components/BlogList.vue -->
<template>
  <div class="space-y-6">
    <article
      v-for="article in articles"
      :key="article._path"
      class="border rounded-lg p-6 hover:shadow-lg transition-shadow bg-white dark:bg-neutral-900"
    >
      <div class="flex items-center gap-2 text-sm text-grey-500 dark:text-grey-400 mb-2">
        <time :datetime="article.publishedAt">{{ formatDate(article.publishedAt) }}</time>
        <span aria-hidden="true"></span>
        <span>{{ article.author }}</span>
      </div>

      <h2 class="text-2xl font-bold mb-3">
        <NuxtLink
          :to="article._path"
          class="hover:text-emerald-600 transition-colors"
          prefetch
        >
          {{ article.title }}
        </NuxtLink>
      </h2>

      <p class="text-grey-600 dark:text-grey-300 mb-4 line-clamp-3">{{ article.description }}</p>

      <div class="flex items-center justify-between">
        <div class="flex gap-2 flex-wrap">
          <span
            v-for="tag in article.tags"
            :key="tag"
            class="px-2 py-1 bg-grey-100 dark:bg-neutral-800 rounded text-sm"
          >
            {{ tag }}
          </span>
        </div>

        <NuxtLink
          :to="article._path"
          class="text-emerald-600 hover:text-emerald-800 font-medium"
        >
          Read more →
        </NuxtLink>
      </div>
    </article>
  </div>
</template>

<script setup lang="ts">
interface Article {
  _path: string
  title: string
  description: string
  author: string
  publishedAt: string
  tags: string[]
}

defineProps<{ articles: Article[] }>()

const formatDate = (date: string) =>
  new Date(date).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
</script>

Search Component (adding debounce and request cancellation)

<!-- components/BlogSearch.vue -->
<template>
  <div class="relative">
    <input
      v-model="query"
      type="text"
      placeholder="Search articles..."
      class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-transparent dark:bg-neutral-900"
      @input="onInput"
      aria-label="Search articles"
    />

    <div
      v-if="results.length > 0"
      class="absolute top-full left-0 right-0 mt-2 bg-white dark:bg-neutral-900 border rounded-lg shadow-lg z-10"
    >
      <button
        v-for="result in results"
        :key="result._path"
        class="block w-full text-left p-3 hover:bg-grey-50 dark:hover:bg-neutral-800 border-b last:border-b-0"
        @click="navigateTo(result._path)"
      >
        <h3 class="font-medium">{{ result.title }}</h3>
        <p class="text-sm text-grey-600 dark:text-grey-400">{{ result.description }}</p>
      </button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { useDebounceFn } from '@vueuse/core'

const query = ref('')
const results = ref<any[]>([])
let abort: AbortController | null = null

const search = async () => {
  const q = query.value.trim()
  if (q.length < 2) { results.value = []; return }

  // Cancel previous request when typing fast
  abort?.abort()
  abort = new AbortController()

  const res = await $fetch('/api/search', {
    query: { q },
    signal: abort.signal
  }).catch(() => ({ data: [] }))

  results.value = (res as any).data || []
}

const onInput = useDebounceFn(search, 200)
</script>

Tags Component

<!-- components/BlogTags.vue -->
<template>
  <div class="space-y-4">
    <h3 class="text-lg font-semibold">Tags</h3>

    <div class="flex flex-wrap gap-2">
      <button
        v-for="tag in tags"
        :key="tag.name"
        :class="[
          'px-3 py-1 rounded-full text-sm transition-colors',
          selectedTag === tag.name
            ? 'bg-emerald-600 text-white'
            : 'bg-grey-100 dark:bg-neutral-800 text-grey-700 dark:text-grey-300 hover:bg-grey-200 dark:hover:bg-neutral-700'
        ]"
        @click="toggleTag(tag.name)"
      >
        {{ tag.name }} ({{ tag.count }})
      </button>
    </div>
  </div>
</template>

<script setup lang="ts">
interface Tag { name: string; count: number }

const props = defineProps<{ tags: Tag[] }>()
const selectedTag = ref<string | null>(null)
const emit = defineEmits<{ 'tag-change': [tag: string | null] }>()

const toggleTag = (tagName: string) => {
  selectedTag.value = selectedTag.value === tagName ? null : tagName
  emit('tag-change', selectedTag.value)
}
</script>

Pages

Blog Homepage (fixing pagination and sorting)

<!-- pages/blog/index.vue -->
<template>
  <div class="container mx-auto px-4 py-8">
    <div class="max-w-4xl mx-auto">
      <h1 class="text-4xl font-bold mb-8">Blog</h1>

      <div class="mb-8">
        <BlogSearch />
      </div>

      <div class="mb-8 flex gap-4">
        <select v-model="selectedCategory" class="px-4 py-2 border rounded">
          <option value="">All Categories</option>
          <option v-for="category in categories" :key="category" :value="category">{{ category }}</option>
        </select>

        <select v-model="sortBy" class="px-4 py-2 border rounded">
          <option value="publishedAt">By Date</option>
          <option value="title">By Title</option>
        </select>
      </div>

      <!-- Important: use paginated selection, not full list -->
      <BlogList :articles="paginatedArticles" />

      <div class="mt-12 flex justify-center">
        <Pagination
          :current-page="currentPage"
          :total-pages="totalPages"
          @page-change="handlePageChange"
        />
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
definePageMeta({ layout: 'default' })

useHead({
  title: 'Blog',
  meta: [{ name: 'description', content: 'Articles about web development, Nuxt 3, and modern technologies' }]
})

// In production, it's better to make route-based pagination (/blog/page/2) — SSG/ISR at page level.
// For simplicity — client-side pagination.
const { data: articles } = await queryContent('/blog')
  .where({ draft: { $ne: true } })
  .sort({ publishedAt: -1 })
  .find()

const selectedCategory = ref('')
const sortBy = ref<'publishedAt' | 'title'>('publishedAt')
const currentPage = ref(1)
const postsPerPage = 10

const categories = computed(() => {
  const set = new Set((articles.value || []).map(a => a.category).filter(Boolean))
  return Array.from(set) as string[]
})

const filteredArticles = computed(() => {
  let arr = (articles.value || []).slice()
  if (selectedCategory.value) {
    arr = arr.filter(a => a.category === selectedCategory.value)
  }
  if (sortBy.value === 'title') {
    arr.sort((a, b) => a.title.localeCompare(b.title))
  }
  return arr
})

const totalPages = computed(() => Math.max(1, Math.ceil(filteredArticles.value.length / postsPerPage)))

const paginatedArticles = computed(() => {
  const start = (currentPage.value - 1) * postsPerPage
  return filteredArticles.value.slice(start, start + postsPerPage)
})

const handlePageChange = (page: number) => {
  currentPage.value = page
  if (process.client) window.scrollTo({ top: 0, behavior: 'smooth' })
}
</script>

For traffic of 10k+ articles, use route-based pagination (pages /blog/page/[n].vue) and limit/skip on serverQueryContent — this will give SSG/ISR and won't bloat the payload.

Article Page

<!-- pages/blog/[slug].vue -->
<template>
  <div class="container mx-auto px-4 py-8">
    <div class="max-w-4xl mx-auto">
      <nav class="mb-8">
        <ol class="flex items-center space-x-2 text-sm">
          <li><NuxtLink to="/" class="text-emerald-600 hover:underline">Home</NuxtLink></li>
          <li class="text-grey-400">/</li>
          <li><NuxtLink to="/blog" class="text-emerald-600 hover:underline">Blog</NuxtLink></li>
          <li class="text-grey-400">/</li>
          <li class="text-grey-600 dark:text-grey-300">{{ article.title }}</li>
        </ol>
      </nav>

      <header class="mb-8">
        <h1 class="text-4xl font-bold mb-4">{{ article.title }}</h1>
        <div class="flex items-center gap-4 text-grey-600 dark:text-grey-400 mb-6">
          <time :datetime="article.publishedAt">{{ formatDate(article.publishedAt) }}</time>
          <span aria-hidden="true"></span>
          <span>{{ article.author }}</span>
          <span aria-hidden="true"></span>
          <span>{{ readingTime }} min read</span>
        </div>

        <div class="flex gap-2 mb-6 flex-wrap">
          <span
            v-for="tag in article.tags"
            :key="tag"
            class="px-3 py-1 bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300 rounded-full text-sm"
          >
            {{ tag }}
          </span>
        </div>
      </header>

      <article class="prose prose-lg dark:prose-invert max-w-none">
        <ContentRenderer :value="article" />
      </article>

      <nav class="mt-12 pt-8 border-t border-grey-200 dark:border-neutral-800">
        <div class="flex justify-between">
          <NuxtLink
            v-if="prevArticle"
            :to="prevArticle._path"
            class="flex items-center gap-2 text-emerald-600 hover:text-emerald-800"
          >
            ← {{ prevArticle.title }}
          </NuxtLink>
          <div v-else></div>

          <NuxtLink
            v-if="nextArticle"
            :to="nextArticle._path"
            class="flex items-center gap-2 text-emerald-600 hover:text-emerald-800"
          >
            {{ nextArticle.title }} →
          </NuxtLink>
        </div>
      </nav>
    </div>
  </div>
</template>

<script setup lang="ts">
const route = useRoute()
const slug = route.params.slug as string

const { data: article } = await queryContent(`/blog/${slug}`).findOne()
if (!article) {
  throw createError({ statusCode: 404, statusMessage: 'Article not found' })
}

const { data: allArticles } = await queryContent('/blog')
  .where({ draft: { $ne: true } })
  .sort({ publishedAt: -1 })
  .only(['_path', 'title'])
  .find()

const currentIndex = allArticles.value.findIndex(a => a._path === article._path)
const prevArticle = currentIndex > 0 ? allArticles.value[currentIndex - 1] : null
const nextArticle = currentIndex < allArticles.value.length - 1 ? allArticles.value[currentIndex + 1] : null

const readingTime = computed(() => {
  const wordsPerMinute = 200
  const plain = typeof article.body?.raw === 'string'
    ? article.body.raw
    : JSON.stringify(article.body || '')
  const wordCount = (plain.match(/\w+/g) || []).length
  return Math.ceil(wordCount / wordsPerMinute)
})

const formatDate = (date: string) =>
  new Date(date).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })

// Canonical/OG/Twitter
const site = useRuntimeConfig().public.siteUrl
useHead({
  title: article.title,
  link: [{ rel: 'canonical', href: `${site}${article._path}` }],
  meta: [
    { name: 'description', content: article.description },
    { property: 'og:title', content: article.title },
    { property: 'og:description', content: article.description },
    { property: 'og:image', content: article.image || `${site}/default-og-image.jpg` },
    { property: 'og:type', content: 'article' },
    { name: 'twitter:card', content: 'summary_large_image' },
    { name: 'twitter:title', content: article.title },
    { name: 'twitter:description', content: article.description }
  ]
})
</script>

Search API: Realistic Approach

Honest moment: "full-text search by body" on bare Nuxt Content is limited. For 10–200 articles, "by title/description" with simple scoring is enough. For thousands — connect Meilisearch/Algolia. Server handler with cache:

// server/api/search.get.ts
import { serverQueryContent } from '#content/server'

export default cachedEventHandler(async (event) => {
  const q = String((getQuery(event).q || '')).trim().toLowerCase()
  if (q.length < 2) return { data: [] }

  const docs = await serverQueryContent(event, 'blog')
    .where({ draft: { $ne: true } })
    .only(['_path', 'title', 'description', 'tags'])
    .find()

  // Primitive scoring: first exact matches in title, then in description
  const scored = docs
    .map(d => {
      const t = (d.title || '').toLowerCase()
      const desc = (d.description || '').toLowerCase()
      const score = (t.includes(q) ? 2 : 0) + (desc.includes(q) ? 1 : 0)
      return { ...d, _score: score }
    })
    .filter(d => d._score > 0)
    .sort((a, b) => b._score - a._score)
    .slice(0, 10)

  return { data: scored }
}, {
  swr: true,
  maxAge: 60 // 1 min cache — balance of freshness/cost
})

If you need real full-text:

  • Minimum — Fuse.js with pre-indexing in Nitro storage (update index in nitro.hooks on build).
  • At large volumes — Meilisearch/Algolia; BullMQ queue for index rebuild on commits.

SEO and Performance

Sitemap with RuntimeConfig and ISR

// server/api/sitemap.xml.get.ts
export default defineEventHandler(async (event) => {
  const site = useRuntimeConfig(event).public.siteUrl
  const { data: articles } = await queryContent('/blog')
    .where({ draft: { $ne: true } })
    .only(['_path', 'updatedAt', 'publishedAt'])
    .find()

  const urls = [
    { loc: `${site}`, changefreq: 'daily', priority: '1.0' },
    { loc: `${site}/blog`, changefreq: 'daily', priority: '0.8' },
    ...articles.map(a => ({
      loc: `${site}${a._path}`,
      lastmod: new Date(a.updatedAt || a.publishedAt).toISOString(),
      changefreq: 'weekly',
      priority: '0.6'
    }))
  ]

  const body =
`<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls.map(u => `
  <url>
    <loc>${u.loc}</loc>
    ${u.lastmod ? `<lastmod>${u.lastmod}</lastmod>` : ''}
    <changefreq>${u.changefreq}</changefreq>
    <priority>${u.priority}</priority>
  </url>`).join('')}
</urlset>`

  setHeader(event, 'Content-Type', 'application/xml; charset=utf-8')
  return body
})

RSS Feed

// server/api/rss.xml.get.ts
export default defineEventHandler(async (event) => {
  const site = useRuntimeConfig(event).public.siteUrl
  const { data: articles } = await queryContent('/blog')
    .where({ draft: { $ne: true } })
    .sort({ publishedAt: -1 })
    .limit(20)
    .find()

  const body =
`<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>My Blog</title>
    <description>Articles about web development</description>
    <link>${site}</link>
    <atom:link href="${site}/api/rss.xml" rel="self" type="application/rss+xml"/>
    ${articles.map(a => `
    <item>
      <title>${a.title}</title>
      <description><![CDATA[${a.description}]]></description>
      <link>${site}${a._path}</link>
      <guid>${site}${a._path}</guid>
      <pubDate>${new Date(a.publishedAt).toUTCString()}</pubDate>
    </item>`).join('')}
  </channel>
</rss>`

  setHeader(event, 'Content-Type', 'application/rss+xml; charset=utf-8')
  return body
})

Robots

// server/api/robots.txt.get.ts
export default defineEventHandler((event) => {
  const site = useRuntimeConfig(event).public.siteUrl
  setHeader(event, 'Content-Type', 'text/plain; charset=utf-8')
  return `User-agent: *
Allow: /
Sitemap: ${site}/sitemap.xml
`
})

Microdata

Insert JSON-LD for articles (Article/BlogPosting) via useHead — this noticeably helps snippets.

Images and LCP

  • @nuxt/image-edge + WebP/AVIF + sizes via sizes/srcset — real LCP improvement.
  • Don't throw 2–3MB PNGs on covers. Even on CDN it hurts.

Deployment and Pipeline

Vercel (SSR/ISR — recommended)

Best — without custom vercel.json at all, Vercel will detect Nuxt 3 itself.

  • Node 18/20, environment variables in Dashboard, NITRO_PRESET=vercel.
  • For SSG you can nuxi generate and serve static files, but then without SSR/ISR.
  • Monitoring: Vercel Analytics + Sentry SDK (if error tracing is needed).

GitHub Actions example (without unnecessary action — use official Vercel CLI):

name: Deploy to Vercel
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run build
      - name: Deploy
        run: npx vercel --prod --token ${{ secrets.VERCEL_TOKEN }}

Netlify

  • For SSR: NITRO_PRESET=netlify. Don't redirect everything to /index.html — this breaks SSR.
  • For SSG: publish = ".output/public" is correct, but without SSR/ISR.

Real Pitfalls and Best Practices

  • Content grows — payload too. Enable payloadExtraction on SSG, on SSR — no point.
  • Hardcoding domain in sitemap/rss — you'll forget to change in production. Keep in runtimeConfig.public.siteUrl.
  • Search "everywhere" on Content — tempting, but hits AST limits. Either simplify criteria, or immediately set up Meilisearch.
  • Theming: color-mode without SSR "flashes". Add color scheme to critical CSS and classSuffix: '' — correct.
  • Navigation/breadcrumbs: documentDriven saves hours of layout, especially with table of contents and auto-numbering.
  • Article updates: use updatedAt for sitemap, and for "similar articles" — cosine similarity by tags/categories (simple heuristic — already ok).
  • Logging and observability: connect Sentry/Logtail; 500s from Nitro without logs are unpleasant to debug.
  • Cache: cachedEventHandler on "heavy" APIs; further — Redis (Nitro storage driver) or Cloudflare KV.
  • If you add comments/activity — move this to a separate service on Fastify/NestJS + Prisma/TypeORM and Redis. Queues (BullMQ) will be useful for indexing and OG image generation.

Conclusion

Nuxt 3 + Content is a fast path to a production blog:

  • SSG/ISR for speed and stable SEO
  • Typed content and components directly in Markdown/MDC
  • Search, sitemap, RSS without heavy CMS
  • Predictable performance and clear scaling path

And further — as needed: moving search to Meilisearch, caches to Redis, queues to BullMQ, analytics and A/B. The main thing — our base is already solid and won't shoot us in the foot when traffic grows.