SEO-чеклист для Nuxt-проекта
Большинство SEO-регрессий в Nuxt-приложении вызваны не стратегией, а кодом: noindex, переживший релиз, canonical, собранный от неправильного базового URL, дерево заголовков с пропущенным уровнем, sitemap, в котором всё ещё лежит удалённый роут. Этот чеклист проходит по двенадцати областям, которые Google Search Central описывает как технические требования, показывает код на Nuxt 4 для каждой из них и называет правила ranklint, которые валят сборку, когда код расходится с ними. У Яндекса набор требований почти тот же, но отчёты и подтверждение прав живут в Яндекс Вебмастере, а не в Search Console, и расхождения отмечены прямо в соответствующих блоках.
Все сниппеты ниже используют API, которые поставляются с Nuxt 4, @nuxtjs/i18n, @nuxt/image, @nuxtjs/robots или самим модулем @ranklint/nuxt. Идентификаторы правил ведут в полный справочник: Все правила.
Индексируемость
Индексируемая страница обязана одинаково отвечать на три вопроса: что говорит мета-тег robots, что говорит заголовок X-Robots-Tag и что разрешает robots.txt. Google Search Central прямо пишет, что закрытую в robots.txt страницу прочитать нельзя, поэтому noindex внутри неё никто не увидит, а URL всё равно может попасть в выдачу без сниппета. Яндекс читает те же директивы, но дополнительно учитывает Clean-param: она схлопывает дубли по параметрам запроса, а Googlebot её игнорирует, так что в общем robots.txt её стоит держать отдельно от правил обхода, важных для Google.
За вторую половину индексируемости отвечает серверный рендеринг. Если основной контент появляется только после гидратации, первый проход краулера видит пустую оболочку. В Nuxt это обычно означает страницу с ssr: false или загрузку данных в onMounted вместо useAsyncData.
// app/pages/admin/[id].vue
useHead({
meta: [{ name: 'robots', content: 'noindex, follow' }],
})
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxtjs/robots', '@ranklint/nuxt'],
robots: {
disallow: ['/admin', '/checkout'],
sitemap: ['https://example.com/sitemap.xml'],
},
ranklint: {
site: { url: 'https://example.com', name: 'Example' },
robots: { mode: 'external' },
},
})
Владеть /robots.txt может только один модуль. Если в стеке есть @nuxtjs/robots, файл пишет он, поэтому @ranklint/nuxt запускается с robots: { mode: 'external' } (или robots: false) и не регистрирует конкурирующий обработчик роута. Обратный вариант тоже рабочий: выкинуть @nuxtjs/robots, оставить дефолтный mode: 'owner', и модуль ranklint отдаст robots.txt с учётом окружения, закрыв непродакшн-хосты и объявив sitemap.
Как это проверяет ranklint
indexability:ssr-contenthttp:x-robots-consistentrobots:env-policyrobots:zone-not-blockedrobots:expected-disallowsitemap:no-noindex
Canonical и дубли
Google считает rel=canonical сильной подсказкой, а не командой, и при расхождении сигналов выбирает канонический URL сам. Практическое правило из Search Central: каждая индексируемая страница объявляет абсолютный canonical на саму себя, а дубли указывают на ту версию, которую вы действительно хотите видеть в выдаче. Яндекс реагирует на тот же тег, но сходится медленнее, и на больших каталогах стабильный canonical от этого только ценнее.
В Nuxt-проектах доминируют два сценария поломки. Первый: относительный canonical без origin, собранный из одного useRoute().fullPath. Второй: цепочка, в которой страница A указывает на B, а B указывает на C. Собирайте значение из useRequestURL(), тогда origin будет корректным и на localhost, и на превью-деплоях, и в продакшене.
// app/pages/products/[slug].vue
const url = useRequestURL()
const canonical = `${url.origin}${url.pathname}`
useHead({
link: [{ rel: 'canonical', href: canonical }],
})
Как это проверяет ranklint
canonical:requiredcanonical:validcanonical:no-chainmeta:no-duplicate-titlemeta:no-duplicate-description
Title и description
Search Central просит уникальный описательный <title> на каждую страницу и мета-описание, которое пересказывает страницу, а не повторяет заголовок. Ни то, ни другое само по себе не фактор ранжирования, но оба управляют сниппетом, а отсутствие описания означает, что Google напишет его сам из тела страницы. Open Graph и Twitter-теги вообще не поисковые сигналы, они решают, как расшаренная ссылка выглядит в мессенджерах и соцсетях, и поэтому живут в том же блоке кода.
useSeoMeta даёт плоский типизированный способ описать всё это в Nuxt 4. Держите title в районе 30-60 символов, а description в районе 70-160: именно такие дефолты у ranklint.
// app/pages/products/[slug].vue
const route = useRoute()
const { data: product } = await useAsyncData(() => $fetch(`/api/products/${route.params.slug}`))
useSeoMeta({
title: () => `${product.value?.name} price and specs`,
description: () => product.value?.summary,
ogTitle: () => product.value?.name,
ogDescription: () => product.value?.summary,
ogImage: () => product.value?.image,
ogType: 'website',
twitterCard: 'summary_large_image',
})
Как это проверяет ranklint
meta:title-requiredmeta:title-lengthmeta:description-requiredmeta:description-lengthmeta:og-requiredmeta:twitter-card
Заголовки
Google Search Central описывает заголовки как способ выразить структуру документа, а не как инструмент оформления. Один h1 на страницу, без пропуска уровней, без пустых заголовков, оставленных компонентом, который рендерит слот с названием по условию. Скринридеры читают то же дерево, так что выигрыш в доступности достаётся бесплатно.
Специфичная для Nuxt ловушка прячется в layout. Если layout рендерит имя сайта внутри h1, а страница добавляет свой, на каждом роуте выходит два заголовка первого уровня и одинаковый текст h1 по всему сайту. Оставьте в layout тег div или p, а единственный h1 отдайте странице.
<!-- app/pages/products/[slug].vue -->
<template>
<article>
<h1>{{ product.name }}</h1>
<h2>Specifications</h2>
<h3>Dimensions</h3>
<h2>Reviews</h2>
</article>
</template>
Как это проверяет ranklint
hreflang и i18n
Документация по hreflang на Search Central формулирует одно жёсткое требование: аннотации должны быть взаимными. Если английская страница перечисляет немецкую, немецкая обязана перечислить английскую в ответ, и в каждом наборе должен быть x-default для языкового переключателя или запасной локали. Нарушенная взаимность молча игнорируется, поэтому теги выглядят проставленными в исходнике страницы и при этом ничего не делают. Яндекс тоже читает эти аннотации.
В Nuxt @nuxtjs/i18n генерирует весь набор из конфига локалей, если у каждой локали есть тег language в формате BCP 47, а baseUrl абсолютный. Композабл возвращает ref, поэтому передавайте его в useHead геттером: одно разворачивание .value в setup заморозит аннотации на первом отрендеренном роуте.
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxtjs/i18n'],
i18n: {
baseUrl: 'https://example.com',
defaultLocale: 'en',
strategy: 'prefix_except_default',
locales: [
{ code: 'en', language: 'en-US' },
{ code: 'de', language: 'de-DE' },
],
},
})
// app/app.vue
const head = useLocaleHead({ seo: true })
useHead(() => ({
htmlAttrs: head.value.htmlAttrs,
link: head.value.link,
meta: head.value.meta,
}))
Как это проверяет ranklint
JSON-LD
Расширенным сниппетам нужна структурированная разметка, и Search Central предпочитает JSON-LD микроданным, потому что он не переплетается с вёрсткой. Яндекс читает JSON-LD наравне с Google. Ломаются в продакшене обычно две тривиальные вещи: JSON не парсится, чаще всего после того как в скрипт подставили строку с неэкранированной кавычкой, либо у выбранного типа не хватает обязательного свойства, и тогда тип отбрасывается целиком, а не понижается.
Модуль @ranklint/nuxt поставляет useJsonLd(type, data), который рендерит скрипт через useHead с проставленными @context и @type и валидирует payload через Zod только в dev. Без модуля эквивалентом служит обычный useHead с сырым скриптом.
// app/pages/products/[slug].vue
useJsonLd('Product', {
name: product.value?.name,
description: product.value?.summary,
offers: { price: product.value?.price, priceCurrency: 'EUR' },
})
// without the module
useHead({
script: [{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'Example',
url: 'https://example.com',
}),
}],
})
Как это проверяет ranklint
Хлебные крошки
Расширенный сниппет с хлебными крошками заменяет в выдаче Google голый URL на читаемый путь, и Search Central требует тип BreadcrumbList с упорядоченным itemListElement, где у каждого элемента есть position, name и item. Яндекс рисует свою навигационную цепочку по той же разметке. Список обязан повторять то, что видит пользователь: невидимые крошки, существующие только в JSON-LD, нарушают правила структурированных данных.
Рендерьте видимую цепочку через NuxtLink, чтобы внутренняя навигация оставалась клиентской, а краулер всё равно получал настоящие href, и отдавайте соответствующий JSON-LD из того же массива.
<!-- app/components/Breadcrumbs.vue -->
<script setup lang="ts">
const props = defineProps<{ product: { name: string, slug: string } }>()
const crumbs = [
{ name: 'Home', path: '/' },
{ name: 'Products', path: '/products' },
{ name: props.product.name, path: `/products/${props.product.slug}` },
]
useJsonLd('BreadcrumbList', {
itemListElement: crumbs.map((crumb, index) => ({
'@type': 'ListItem',
position: index + 1,
name: crumb.name,
item: `https://example.com${crumb.path}`,
})),
})
</script>
<template>
<nav aria-label="Breadcrumb">
<NuxtLink v-for="crumb in crumbs" :key="crumb.path" :to="crumb.path">
{{ crumb.name }}
</NuxtLink>
</nav>
</template>
Как это проверяет ranklint
Sitemap
Sitemap помогает обнаружить страницы, но не гарантирует индексацию. Search Central ясно говорит, что в нём должно лежать: только канонические, индексируемые URL, отвечающие 200. Страница с noindex в sitemap даёт противоречивый сигнал, а 404 в sitemap тратит краулинговый бюджет на каждом запросе. Сам файл обязан быть объявлен в robots.txt директивой Sitemap:, и именно так его находит Яндекс, когда хост не подтверждён в панели вебмастера. Директива Host в том же robots.txt давно устарела и Яндексом не учитывается, главное зеркало определяется 301-редиректом.
Модуль @ranklint/nuxt отдаёт /sitemap.xml без всякой конфигурации: собирает статические роуты из app/pages, приводит каждый loc к абсолютному виду относительно site.url и экранирует XML. Динамические роуты приходят из явных источников.
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@ranklint/nuxt'],
ranklint: {
site: { url: 'https://example.com', name: 'Example' },
sitemap: {
path: '/sitemap.xml',
autoRoutes: true,
cacheTtl: 3600,
sources: [
'/api/seo/product-urls',
{ loc: '/pricing', changefreq: 'weekly', priority: 0.8 },
],
},
},
})
Как это проверяет ranklint
Изображения
Документация Google Картинок просит описательный alt у смысловых изображений и пустой alt у декоративных. Стабильность вёрстки важна не меньше: собственные width и height позволяют браузеру зарезервировать место и удержать Cumulative Layout Shift низким, а CLS входит в Core Web Vitals.
Третье правило про loading="lazy". Ленивая загрузка ниже первого экрана даёт выигрыш, а ленивая загрузка hero-изображения откладывает Largest Contentful Paint на каждом визите. NuxtImg не добавляет loading сам: атрибут просто пробрасывается в img, поэтому ставьте loading="lazy" явно на картинки ниже первого экрана, а hero-изображению вместо этого проставляйте проп preload.
<template>
<NuxtImg
src="/img/hero.jpg"
alt="Blue widget on a workbench"
width="1200"
height="630"
sizes="100vw md:1200px"
format="webp"
preload
/>
<NuxtImg src="/img/gallery-1.jpg" alt="Widget from the side" width="600" height="400" loading="lazy" />
</template>
Как это проверяет ranklint
Внутренние ссылки
Search Central описывает краулинг как обнаружение по ссылкам: страница, на которую никто не ссылается, находится только через sitemap, и её важность считывается как низкая. Внутренним ссылкам нужен настоящий href в серверном HTML, и NuxtLink его выдаёт, в отличие от div с обработчиком @click, который делает переход из кода.
Не менее важна консистентность. Выберите одно соглашение по завершающему слешу на весь сайт и держите на нём каждую внутреннюю ссылку, иначе каждый роут существует как два URL и сигналы дробятся. По-настоящему внешние ссылки помечайте external, чтобы Nuxt не пытался разрешить их через роутер.
<template>
<NuxtLink to="/products/blue-widget">Blue widget</NuxtLink>
<NuxtLink to="https://status.example.com" external target="_blank" rel="noopener">
Status page
</NuxtLink>
</template>
Как это проверяет ranklint
HTTP и редиректы
Переехавший URL получает 301, и Search Central утверждает, что постоянный редирект остаётся самым сильным сигналом склейки, тогда как 302 говорит краулеру сохранить старый URL. Вторая проблема в цепочках: каждый лишний переход стоит краулингового бюджета и размывает сигнал, поэтому ведите редирект сразу на конечный адрес, а не через два промежуточных роута. Яндекс следует тем же правилам, но склеивает 301 медленнее, часто за несколько циклов обхода.
Остальная часть этого блока про гигиену, которая видна только в ответе: смешанный контент на HTTPS-странице, удалённый товар, который отдаёт 200 с пустым телом вместо 404, отсутствующий мета-тег viewport при mobile-first индексации и время до первого байта, которое растёт до тех пор, пока не упадёт скорость обхода.
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
'/old-pricing': { redirect: { to: '/pricing', statusCode: 301 } },
'/blog/**': { swr: 3600 },
},
app: {
head: {
meta: [{ name: 'viewport', content: 'width=device-width, initial-scale=1' }],
},
},
})
Как это проверяет ranklint
links:permanent-redirectslinks:no-redirect-chainhttp:no-mixed-contenthttp:no-soft-404http:ttfb-budgetmobile:viewport
Запуск локально и в CI
Всё перечисленное можно проверить до деплоя. Запустите приложение, направьте на него ranklint, и краулер обойдёт сайт, прогонит все 43 правила и выйдет с кодом 1, когда найдётся проблема уровня error. Без конфига аудит идёт по минимальному конфигу, выведенному из origin переданного URL, и для первого прохода этого достаточно.
npm run dev
npx ranklint audit --url http://localhost:3000
npx ranklint audit --url https://example.com --reporter markdown --json-output report.json
В пайплайне полезно связать аудит и diff: аудит валит джобу на новых error-проблемах, а diff сравнивает текущий отчёт с отчётом базовой ветки, чтобы в ревью было видно только то, что сломало это изменение. Настройка для GitHub Actions и GitLab CI, включая закреплённый комментарий в пулл-реквесте и загрузку SARIF, лежит в Diff и CI.
Как это проверяет ranklint
- Полный справочник правил с дефолтными severity: Все правила
- Обход, репортеры и флаги: ranklint audit
- Базовые отчёты, артефакты и пресеты для пайплайнов: Diff и CI