Введение
Nuxt Content — не просто «генератор статических страниц», а аккуратная CMS-подсистема поверх Nuxt 3. Она закрывает 80% типовых задач блога без внешнего бэкенда и позволяет двигаться быстро, не жертвуя качеством:
- SSR/SSG/ISR из коробки — быстрый TTFB и предсказуемое SEO
- Типобезопасность и автокомплит по frontmatter
- Гибкая структура: Markdown/MDC, YAML, JSON, Vue-компоненты
- Поиск и навигация — без тяжёлых внешних сервисов (для старта)
- Отличная интеграция с экосистемой Nuxt (Nitro, route rules, image)
В статье соберу рабочую конфигурацию, добавлю те нюансы, о которые чаще всего спотыкаются в проде, и покажу, как не потерять производительность на реальном трафике.
Почему Nuxt Content для блога
Против статических генераторов (Jekyll/Hugo)
- HMR и DX уровня Vite: скорость итераций растёт в разы
- Vue-компоненты прямо в контенте (MDC) вместо «шаблонов с костылями»
- Единый стек и сборка — меньше контекст-свитчинга и мелких интеграций
Против headless CMS (Strapi/Sanity)
- Ноль сетевых запросов к API на рендере — меньше точек отказа и латентности
- Данные в репозитории — контроль версий, PR-ревью, не нужен staging API
- Стоимость — нет подписок и платных плагинов «ради поиска»
Против WordPress
- Современный рендеринг и dev-пайплайн
- Стабильное время ответа под нагрузкой без плясок с PHP-FPM/OpCache
- Меньше поверхность атаки: нет плагин-джунглей, где безопасность — лотерея
Что важно: на очень больших каталогах (5–10k+ статей) я предпочту отдельный поисковый движок (Meilisearch/Algolia) и кеши (Redis/Cloudflare KV). Но до этих масштабов Nuxt Content закрывает задачу быстрее и проще.
Архитектура: SSG, SSR и ISR без сюрпризов
На реальном трафике самое практичное:
- Статьи и листинги — SSG/ISR (почти CDN-быстрые)
- Редко меняющиеся API — ISR с коротким maxAge
- Динамика (комментарии, лайки) — отдельный API (Fastify/NestJS), Web API или внешние виджеты
Рекомендую задать route rules сразу:
// nuxt.config.ts (фрагмент)
export default defineNuxtConfig({
routeRules: {
'/': { prerender: true },
'/blog': { prerender: true, swr: 300 },
'/blog/**': { swr: 600 }, // ISR: пересборка раз в 10 мин
'/api/search': { cache: { maxAge: 60, staleMaxAge: 300 }, cors: true }
}
})
- Простой контент сохраняйте как статические страницы, но держите возможность ISR для обновлений.
- Низкая латентность на Vercel/Netlify достигается без ручного кеширования на CDN уровнях.
Настройка проекта
Создание проекта
npx nuxi@latest init my-blog
cd my-blog
npm install
Установка зависимостей
npm i @nuxt/content @nuxtjs/tailwindcss @nuxtjs/color-mode @nuxt/image-edge
npm i -D @types/markdown-it @vueuse/core
Конфигурация Nuxt с прод-нюансами
// nuxt.config.ts
export default defineNuxtConfig({
modules: [
'@nuxt/content',
'@nuxtjs/tailwindcss',
'@nuxtjs/color-mode',
'@nuxt/image-edge'
],
typescript: { strict: true },
content: {
// Документа-ориентированный режим упростит навигацию
documentDriven: true,
highlight: {
theme: 'github-dark',
preload: ['diff', 'json', 'js', 'ts', 'css', 'shell', 'html', 'md', 'yaml']
},
markdown: {
// Якоря включайте, если у вас есть стили под них
anchorLinks: { depth: 2, exclude: [1] }
},
// Генерация оглавления
toc: { depth: 2, searchDepth: 2 }
},
colorMode: { classSuffix: '', preference: 'system' },
image: {
// Оптимизация обложек и превью
format: ['webp', 'avif', 'png'],
screens: { sm: 640, md: 768, lg: 1024, xl: 1280, '2xl': 1536 }
},
// Производительность и стабильность
experimental: {
// Для SSG имеет смысл включать payload extraction, для чистого SSR — нет
payloadExtraction: true
},
nitro: {
prerender: { routes: ['/sitemap.xml', '/rss.xml', '/robots.txt'] },
storage: {
// Можете переключить на redis в будущем
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'
}
}
})
Замечания из практики:
- payloadExtraction: true уменьшит HTML и ускорит TTFB на SSG/ISR. Если чистый SSR на Vercel — оставляйте false.
- Включайте image module — обложки и превью без него больно бьют по LCP.
- routeRules с ISR снимают головную боль с «а как обновлять sitemap/rss без пересборки».
Структура контента
content/
├─ blog/
│ ├─ getting-started.md
│ ├─ advanced-features.md
│ └─ performance-tips.md
├─ pages/
│ ├─ about.md
│ └─ contact.md
└─ config/
└─ navigation.yaml
Frontmatter, которого хватает 99% кейсов:
---
title: 'Заголовок статьи'
description: 'Краткое SEO-описание'
author: 'Имя автора'
publishedAt: '2024-01-15'
updatedAt: '2024-01-20'
tags: ['nuxt', 'vue', 'javascript']
category: 'tutorial'
image: '/images/article-cover.jpg'
draft: false
featured: true
---
| Совет: нормализуйте поля (например, всегда ISO 8601), иначе сортировка/фильтрация будут «плыть».
Компоненты
Компонент списка статей (исправляем пагинацию)
<!-- 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-gray-500 dark:text-gray-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-gray-600 dark:text-gray-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-gray-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"
>
Читать далее →
</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('ru-RU', { year: 'numeric', month: 'long', day: 'numeric' })
</script>
Компонент поиска (добавим debounce и отмену запросов)
<!-- components/BlogSearch.vue -->
<template>
<div class="relative">
<input
v-model="query"
type="text"
placeholder="Поиск по статьям..."
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="Поиск по статьям"
/>
<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-gray-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-gray-600 dark:text-gray-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 }
// Отмена предыдущего запроса при быстрой печати
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>
Компонент тегов
<!-- components/BlogTags.vue -->
<template>
<div class="space-y-4">
<h3 class="text-lg font-semibold">Теги</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-gray-100 dark:bg-neutral-800 text-gray-700 dark:text-gray-300 hover:bg-gray-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/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">Блог</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="">Все категории</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">По дате</option>
<option value="title">По названию</option>
</select>
</div>
<!-- Важно: используем страницу выборки, а не полный список -->
<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: 'Блог',
meta: [{ name: 'description', content: 'Статьи о веб-разработке, Nuxt 3 и современных технологиях' }]
})
// В продакшене лучше сделать route-based пагинацию (/blog/page/2) — SSG/ISR на уровне страниц.
// Для простоты — клиентская пагинация.
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>
На трафике 10k+ статей используйте route-based пагинацию (страницы /blog/page/[n].vue
) и limit/skip
на serverQueryContent
— это даст SSG/ISR и не раздует payload.
Страница статьи
<!-- 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">Главная</NuxtLink></li>
<li class="text-gray-400">/</li>
<li><NuxtLink to="/blog" class="text-emerald-600 hover:underline">Блог</NuxtLink></li>
<li class="text-gray-400">/</li>
<li class="text-gray-600 dark:text-gray-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-gray-600 dark:text-gray-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 }} мин чтения</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-gray-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: 'Статья не найдена' })
}
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('ru-RU', { year: 'numeric', month: 'long', day: 'numeric' })
// Canonical/OG/Твиттер
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>
API для поиска: реалистичный подход
Честный момент: «полнотекстовый поиск по body» на голом Nuxt Content — ограничен. Для 10–200 статей хватает «по заголовку/описанию» с простым скорингом. Для тысяч — подключайте Meilisearch/Algolia. Серверный хэндлер с кешем:
// 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()
// Примитивный скоринг: сначала точные совпадения в title, потом в 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 мин кеша — баланс свежести/стоимости
})
Если нужен настоящий полнотекст:
- Минимум — Fuse.js с прединдексированием в Nitro storage (обновляйте индекс в
nitro.hooks
на билд). - На больших объёмах — Meilisearch/Algolia; очередь BullMQ для пересборки индекса при коммитах.
SEO и производительность
Sitemap с учётом runtimeConfig и 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>Мой блог</title>
<description>Статьи о веб-разработке</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
`
})
Микроразметка
Вставляйте JSON-LD для статей (Article/BlogPosting) через useHead — это ощутимо помогает сниппетам.
Изображения и LCP
- @nuxt/image-edge + WebP/AVIF + размеры через sizes/srcset — реальный прирост LCP.
- Не кидайте 2–3MB PNG на обложки. Даже на CDN это больно.
Деплой и пайплайн
Vercel (SSR/ISR — рекомендую)
Лучшее — вообще без custom vercel.json, Vercel сам определит Nuxt 3.
- Node 18/20, переменные окружения в Dashboard,
NITRO_PRESET=vercel
. - Для SSG можно
nuxi generate
и раздавать статику, но тогда без SSR/ISR. - Мониторинг: Vercel Analytics + Sentry SDK (если нужен трейсинг ошибок).
Пример GitHub Actions (без лишнего экшена — используйте официальный 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
- Для SSR:
NITRO_PRESET=netlify
. Не перекидывайте всё на/index.html
— это ломает SSR. - Для SSG:
publish = ".output/public"
корректно, но без SSR/ISR.
Реальные подводные камни и best practices
- Контент растёт — payload тоже. Включайте payloadExtraction на SSG, на SSR — нет смысла.
- Хардкод домена в sitemap/rss — забудете поменять на проде. Держите в runtimeConfig.public.siteUrl.
- Поиск «по всему» на Content — соблазнителен, но упирается в AST. Или упрощайте критерии, или сразу ставьте Meilisearch.
- Темизация: color-mode без SSR «мигает». Добавьте цветовую схему в критический CSS и classSuffix: '' — корректно.
- Навигация/хлебные крошки: documentDriven экономит часы верстки, особенно с оглавлением и автонумерацией.
- Обновления статей: используйте updatedAt для sitemap, а для «похожие статьи» — cosine similarity по тегам/категориям (простая эвристика — уже ок).
- Логирование и наблюдаемость: подключайте Sentry/Logtail; 500ки Nitro без логов искать неприятно.
- Кеш: cachedEventHandler на «тяжёлых» API; дальше — Redis (Nitro storage driver) или Cloudflare KV.
- Если добавите комментарии/активность — вынесите это в отдельный сервис на Fastify/NestJS + Prisma/TypeORM и Redis. Очереди (BullMQ) пригодятся для индексации и генерации OG-изображений.
Заключение
Nuxt 3 + Content — это быстрый путь к производственному блогу:
- SSG/ISR для скорости и стабильного SEO
- Типизированный контент и компоненты прямо в Markdown/MDC
- Поиск, карта сайта, RSS без тяжёлой CMS
- Предсказуемая производительность и понятный путь масштабирования
А дальше — по потребностям: вынос поиска на Meilisearch, кеши на Redis, очереди на BullMQ, аналитика и A/B. Главное — база у нас уже крепкая и не стреляет в ногу при росте трафика.