Перейти к содержимому

11. Metadata и SEO

SEO — это не только контент, но и правильные мета-теги. Nuxt 3 предоставляет мощные инструменты для управления <head> каждой страницы: useSeoMeta, useHead, Open Graph, Twitter Cards и многое другое.


В обычном SPA (Angular, Vue без Nuxt) страницы рендерятся JavaScript — поисковые роботы не всегда их правильно индексируют. Nuxt с SSR рендерит HTML на сервере, включая все мета-теги, что делает сайт идеальным для SEO.


useSeoMeta — наиболее предпочтительный способ. TypeScript-типизированный, предотвращает ошибки:

pages/blog/[slug].vue
<script setup lang="ts">
const { data: post } = await useFetch(\`/api/posts/\${route.params.slug}\`)
useSeoMeta({
// Основные теги
title: () => post.value?.title || 'Блог',
description: () => post.value?.description,
// Open Graph (Facebook, Telegram, VK...)
ogTitle: () => post.value?.title,
ogDescription: () => post.value?.description,
ogImage: () => post.value?.coverImage,
ogUrl: () => \`https://myapp.com/blog/\${post.value?.slug}\`,
ogType: 'article',
ogSiteName: 'My Blog',
ogLocale: 'ru_RU',
// Twitter Cards
twitterCard: 'summary_large_image',
twitterTitle: () => post.value?.title,
twitterDescription: () => post.value?.description,
twitterImage: () => post.value?.coverImage,
twitterCreator: '@myblog',
// Канонический URL (важно для SEO!)
canonical: () => \`https://myapp.com/blog/\${post.value?.slug}\`,
// Robots
robots: 'index, follow',
})
</script>

<script setup>
useHead({
title: 'Страница о нас',
// Шаблон заголовка
titleTemplate: (titleChunk) => {
return titleChunk ? \`\${titleChunk} — My Site\` : 'My Site'
},
// Мета-теги
meta: [
{ name: 'description', content: 'Описание страницы' },
{ name: 'keywords', content: 'nuxt, vue, seo' },
{ name: 'author', content: 'John Doe' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ name: 'theme-color', content: '#00dc82' },
// Open Graph
{ property: 'og:title', content: 'Заголовок для соцсетей' },
{ property: 'og:type', content: 'website' },
// Верификация поисковиков
{ name: 'google-site-verification', content: 'token123' },
{ name: 'yandex-verification', content: 'yandex-token' },
],
// Ссылки
link: [
{ rel: 'canonical', href: 'https://myapp.com/about' },
{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
{ rel: 'apple-touch-icon', href: '/apple-touch-icon.png' },
{
rel: 'alternate',
hreflang: 'en',
href: 'https://myapp.com/en/about'
},
{
rel: 'preconnect',
href: 'https://fonts.googleapis.com'
},
],
// Скрипты
script: [
{
// JSON-LD структурированные данные для Google
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'My Site',
url: 'https://myapp.com',
})
}
],
// HTML атрибуты
htmlAttrs: {
lang: 'ru',
dir: 'ltr',
},
// Body атрибуты
bodyAttrs: {
class: 'dark-theme'
},
})
</script>

nuxt.config.ts
export default defineNuxtConfig({
app: {
head: {
// Шаблон для всех страниц
titleTemplate: '%s | My Awesome Site',
title: 'My Awesome Site', // Дефолтный заголовок
htmlAttrs: {
lang: 'ru',
},
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ name: 'format-detection', content: 'telephone=no' },
// Open Graph дефолты
{ property: 'og:site_name', content: 'My Awesome Site' },
{ property: 'og:type', content: 'website' },
{ property: 'og:locale', content: 'ru_RU' },
// Twitter
{ name: 'twitter:card', content: 'summary' },
{ name: 'twitter:site', content: '@mysite' },
],
link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
],
}
}
})

<script setup>
definePageMeta({
// Отключить для страниц без SEO ценности
robots: false, // Не индексировать
// Для admin страниц
layout: 'admin',
})
// Динамически устанавливаем SEO после загрузки
const { data } = await useFetch('/api/product/1')
useSeoMeta({
title: data.value?.name,
description: data.value?.description,
ogImage: data.value?.imageUrl,
})
</script>

server/routes/robots.txt.ts
export default defineEventHandler((event) => {
const config = useRuntimeConfig()
const isProduction = process.env.NODE_ENV === 'production'
const robots = isProduction
? \`User-agent: *
Allow: /
Disallow: /admin/
Disallow: /api/
Disallow: /_nuxt/
# Карта сайта
Sitemap: \${config.public.siteUrl}/sitemap.xml\`
: \`User-agent: *
Disallow: /\` // В разработке — запрещаем всё
setHeader(event, 'Content-Type', 'text/plain; charset=utf-8')
setHeader(event, 'Cache-Control', 'public, max-age=86400')
return robots
})

Окно терминала
npm install @nuxtjs/sitemap
nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxtjs/sitemap'],
site: {
url: 'https://myapp.com',
name: 'My Awesome Site',
},
sitemap: {
// Статические URL
urls: [
{ loc: '/', priority: 1.0, changefreq: 'daily' },
{ loc: '/about', priority: 0.8 },
{ loc: '/contact', priority: 0.5 },
],
// Динамические URL из API
sources: [
'/api/__sitemap__/urls', // Твой API endpoint
],
// Настройки
exclude: ['/admin/**', '/private/**'],
defaults: {
changefreq: 'weekly',
priority: 0.5,
},
}
})
server/api/__sitemap__/urls.ts
export default defineSitemapEventHandler(async (event) => {
const posts = await db.posts.findMany({ where: { published: true } })
return posts.map(post => ({
loc: \`/blog/\${post.slug}\`,
lastmod: post.updatedAt,
priority: 0.8,
changefreq: 'monthly',
}))
})

pages/blog/[slug].vue
<script setup>
const { data: post } = await useFetch(\`/api/posts/\${route.params.slug}\`)
// Структурированные данные для Google Rich Results
useHead({
script: [
{
type: 'application/ld+json',
innerHTML: computed(() => JSON.stringify({
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.value?.title,
description: post.value?.description,
image: post.value?.coverImage,
datePublished: post.value?.publishedAt,
dateModified: post.value?.updatedAt,
author: {
'@type': 'Person',
name: post.value?.author?.name,
},
publisher: {
'@type': 'Organization',
name: 'My Blog',
logo: {
'@type': 'ImageObject',
url: 'https://myapp.com/logo.png'
}
},
}))
}
]
})
</script>

composables/useSeo.ts
export const useSeo = (options: {
title: string
description: string
image?: string
type?: string
noindex?: boolean
}) => {
const config = useRuntimeConfig()
const route = useRoute()
const siteUrl = config.public.siteUrl as string
const fullUrl = \`\${siteUrl}\${route.path}\`
useSeoMeta({
title: options.title,
description: options.description,
ogTitle: options.title,
ogDescription: options.description,
ogImage: options.image || \`\${siteUrl}/og-default.png\`,
ogUrl: fullUrl,
ogType: options.type || 'website',
twitterCard: options.image ? 'summary_large_image' : 'summary',
twitterTitle: options.title,
twitterDescription: options.description,
twitterImage: options.image,
canonical: fullUrl,
robots: options.noindex ? 'noindex, nofollow' : 'index, follow',
})
}
pages/about.vue
<script setup>
useSeo({
title: 'О компании — My Site',
description: 'Узнайте больше о нашей компании и команде',
image: '/images/about-og.jpg',
})
</script>