Создание блога на Nuxt 3 и Nuxt Content: от идеи до продакшена

Ларцев Дмитрий

Полное руководство по созданию современного блога на Nuxt 3 с использованием Nuxt Content: настройка, структура, компоненты, SEO, производительность и деплой.

Создание блога на Nuxt 3 и Nuxt Content: от идеи до продакшена

Введение

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. Главное — база у нас уже крепкая и не стреляет в ногу при росте трафика.