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.hookson 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 generateand 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.

