13. Metadata и SEO
🔍 Metadata и SEO: делаем сайт видимым
Заголовок раздела «🔍 Metadata и SEO: делаем сайт видимым»Metadata — это информация о странице, которую видят поисковики, социальные сети и браузеры, но не обычный пользователь. Представь, что каждая страница — это посылка 📦: metadata — это наклейки на ней с адресом, именем получателя и содержимым. Без правильных наклеек посылку не найдут! Next.js сделал работу с metadata элегантной и типобезопасной.
📋 Статическая Metadata: объект export
Заголовок раздела «📋 Статическая Metadata: объект export»Самый простой способ — экспортировать объект metadata из page.tsx или layout.tsx:
import type { Metadata } from 'next';
export const metadata: Metadata = { // Базовые title: 'Мой Awesome Сайт', description: 'Лучший сайт в интернете — знаем как делать сайты!', keywords: ['Next.js', 'React', 'TypeScript', 'разработка'],
// Авторы authors: [{ name: 'Яша Иванов', url: 'https://yasha.dev' }], creator: 'Яша Иванов', publisher: 'AwesomeCorp',
// Canonical URL alternates: { canonical: 'https://mysite.com', languages: { 'ru-RU': 'https://mysite.com/ru', 'en-US': 'https://mysite.com/en', }, },
// Robots robots: { index: true, follow: true, googleBot: { index: true, follow: true, 'max-video-preview': -1, 'max-image-preview': 'large', 'max-snippet': -1, }, },
// Категория для магазинов приложений category: 'technology',};🎯 Title Template: умные заголовки
Заголовок раздела «🎯 Title Template: умные заголовки»Вместо дублирования названия сайта в каждой странице используй title.template:
// app/layout.tsx — ROOT layoutimport type { Metadata } from 'next';
export const metadata: Metadata = { title: { template: '%s | МойСайт', // %s = заголовок страницы default: 'МойСайт', // если страница не задала заголовок absolute: 'МойСайт — Главная', // игнорирует template }, description: 'Описание по умолчанию для всего сайта',};
// app/blog/page.tsxexport const metadata: Metadata = { title: 'Блог', // → "Блог | МойСайт"};
// app/blog/[slug]/page.tsxexport const metadata: Metadata = { title: { absolute: 'Супер статья — только она важна!', // absolute игнорирует template из layout },};// Результат в <title>: "Супер статья — только она важна!"
// app/about/page.tsxexport const metadata: Metadata = { title: 'О нас', // → "О нас | МойСайт"};⚡ generateMetadata: динамическая генерация
Заголовок раздела «⚡ generateMetadata: динамическая генерация»Для динамических страниц (посты блога, товары) используй функцию generateMetadata:
import type { Metadata, ResolvingMetadata } from 'next';import { notFound } from 'next/navigation';
interface Props { params: Promise<{ slug: string }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }>;}
// Вызывается на сервере при рендерингеexport async function generateMetadata( { params, searchParams }: Props, parent: ResolvingMetadata // metadata родительского layout): Promise<Metadata> { const { slug } = await params;
// Получаем данные (автоматически кэшируется — один запрос) const post = await fetch(`https://api.example.com/posts/${slug}`, { next: { revalidate: 3600 } }).then(r => r.json());
if (!post) return { title: 'Статья не найдена' };
// Получаем родительские данные const previousImages = (await parent).openGraph?.images ?? [];
return { title: post.title, description: post.excerpt, keywords: post.tags,
openGraph: { title: post.title, description: post.excerpt, type: 'article', publishedTime: post.publishedAt, modifiedTime: post.updatedAt, authors: [post.author.url], url: `https://mysite.com/blog/${slug}`, images: [ { url: post.coverImage ?? '/default-og.png', width: 1200, height: 630, alt: post.title, }, ...previousImages, // + изображения из родителя ], },
twitter: { card: 'summary_large_image', title: post.title, description: post.excerpt, images: [post.coverImage ?? '/default-og.png'], },
alternates: { canonical: `https://mysite.com/blog/${slug}`, }, };}
export default async function BlogPostPage({ params }: Props) { const { slug } = await params; const post = await getPost(slug); if (!post) notFound();
return <article>{/* ... */}</article>;}🌐 Open Graph: красивые превью в соцсетях
Заголовок раздела «🌐 Open Graph: красивые превью в соцсетях»Open Graph — стандарт для красивых карточек при шаринге в Telegram, VK, Twitter:
export async function generateMetadata({ params }: Props): Promise<Metadata> { const { id } = await params; const product = await getProduct(id);
return { openGraph: { type: 'website', // 'website' | 'article' | 'book' | 'profile' | 'music.*' | 'video.*' url: `https://shop.com/products/${id}`, title: product.name, description: product.description, siteName: 'МойМагазин', locale: 'ru_RU', images: [ { url: product.image, // абсолютный URL! secureUrl: product.image, // HTTPS версия width: 1200, height: 630, alt: product.name, type: 'image/jpeg', }, ], }, };}Для генерации OG-изображения на лету используй app/opengraph-image.tsx:
import { ImageResponse } from 'next/og';
export const size = { width: 1200, height: 630 };export const contentType = 'image/png';
interface Props { params: { slug: string };}
export default async function OGImage({ params }: Props) { const post = await getPost(params.slug);
return new ImageResponse( ( <div style={{ background: '#0f172a', width: '100%', height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'flex-start', justifyContent: 'flex-end', padding: '60px 80px', }} > <div style={{ color: '#4ade80', fontSize: 24, marginBottom: 16 }}> yasha.dev </div> <div style={{ color: 'white', fontSize: 56, fontWeight: 700, lineHeight: 1.1 }}> {post.title} </div> <div style={{ color: '#94a3b8', fontSize: 28, marginTop: 20 }}> {post.author} · {new Date(post.date).toLocaleDateString('ru-RU')} </div> </div> ), { ...size } );}🐦 Twitter Cards
Заголовок раздела «🐦 Twitter Cards»export const metadata: Metadata = { twitter: { card: 'summary_large_image', // 'summary' | 'summary_large_image' | 'app' | 'player' site: '@mysite', // Twitter аккаунт сайта creator: '@yasha_dev', // Twitter автора title: 'Заголовок статьи', description: 'Краткое описание до 200 символов', images: { url: 'https://mysite.com/og/article.png', alt: 'Превью изображения', }, },};🤖 robots.txt
Заголовок раздела «🤖 robots.txt»import type { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots { return { rules: [ { userAgent: '*', allow: '/', disallow: [ '/admin/', '/api/', '/private/', '/*.json$', ], }, { userAgent: 'Googlebot', allow: '/', disallow: '/admin/', crawlDelay: 5, }, ], sitemap: 'https://mysite.com/sitemap.xml', host: 'https://mysite.com', };}// Создаёт /robots.txt автоматически!🗺️ sitemap.xml
Заголовок раздела «🗺️ sitemap.xml»import type { MetadataRoute } from 'next';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> { // Получаем все посты из БД const posts = await db.post.findMany({ select: { slug: true, updatedAt: true }, where: { published: true }, });
const postUrls = posts.map(post => ({ url: `https://mysite.com/blog/${post.slug}`, lastModified: post.updatedAt, changeFrequency: 'weekly' as const, priority: 0.8, }));
return [ // Статические страницы { url: 'https://mysite.com', lastModified: new Date(), changeFrequency: 'daily', priority: 1, }, { url: 'https://mysite.com/blog', lastModified: new Date(), changeFrequency: 'daily', priority: 0.9, }, { url: 'https://mysite.com/about', lastModified: new Date(), changeFrequency: 'monthly', priority: 0.5, }, // Динамические страницы ...postUrls, ];}// Создаёт /sitemap.xml автоматически!📊 JSON-LD: структурированные данные
Заголовок раздела «📊 JSON-LD: структурированные данные»JSON-LD помогает Google понять контент страницы (богатые сниппеты — звёзды, рецепты, события):
interface Post { title: string; excerpt: string; author: { name: string; url: string }; publishedAt: string; updatedAt: string; image: string; slug: string;}
function BlogPostJsonLd({ post }: { post: Post }) { const jsonLd = { '@context': 'https://schema.org', '@type': 'BlogPosting', headline: post.title, description: post.excerpt, image: post.image, datePublished: post.publishedAt, dateModified: post.updatedAt, author: { '@type': 'Person', name: post.author.name, url: post.author.url, }, publisher: { '@type': 'Organization', name: 'МойСайт', logo: { '@type': 'ImageObject', url: 'https://mysite.com/logo.png', }, }, mainEntityOfPage: { '@type': 'WebPage', '@id': `https://mysite.com/blog/${post.slug}`, }, };
return ( <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} /> );}
// Для продуктов (e-commerce):const productJsonLd = { '@context': 'https://schema.org', '@type': 'Product', name: 'Next.js Tutorial', description: 'Полный курс по Next.js', image: 'https://mysite.com/products/nextjs-course.jpg', offers: { '@type': 'Offer', price: '4990', priceCurrency: 'RUB', availability: 'https://schema.org/InStock', }, aggregateRating: { '@type': 'AggregateRating', ratingValue: '4.9', reviewCount: '243', },};📱 Viewport и PWA настройки
Заголовок раздела «📱 Viewport и PWA настройки»import type { Metadata, Viewport } from 'next';
// Viewport вынесен отдельно (Next.js 14+)export const viewport: Viewport = { width: 'device-width', initialScale: 1, maximumScale: 5, userScalable: true, themeColor: [ { media: '(prefers-color-scheme: light)', color: '#ffffff' }, { media: '(prefers-color-scheme: dark)', color: '#0f172a' }, ], colorScheme: 'dark light',};
export const metadata: Metadata = { // Иконки icons: { icon: [ { url: '/favicon.ico' }, { url: '/icon-16.png', sizes: '16x16', type: 'image/png' }, { url: '/icon-32.png', sizes: '32x32', type: 'image/png' }, ], apple: [ { url: '/apple-icon.png' }, { url: '/apple-icon-180.png', sizes: '180x180', type: 'image/png' }, ], other: [ { rel: 'mask-icon', url: '/safari-pinned-tab.svg', color: '#0f172a' }, ], },
// PWA манифест manifest: '/manifest.json',
// Apple Web App appleWebApp: { capable: true, statusBarStyle: 'black-translucent', title: 'МойСайт', },
// Microsoft Tiles other: { 'msapplication-TileColor': '#0f172a', 'msapplication-config': '/browserconfig.xml', },};🏗️ Metadata в layout vs page
Заголовок раздела «🏗️ Metadata в layout vs page»RootLayout metadata: { title.template: '%s | MySite', description: '...' } ├── / page metadata: { title: 'Главная' } → "Главная | MySite" ├── /blog page metadata: { title: 'Блог' } → "Блог | MySite" │ └── /blog/[slug] page metadata: { title: post.title } → "Пост | MySite" └── /admin layout metadata: { robots: { index: false } } └── /admin/... наследует robots: no-indexMetadata мерджится от корня к странице — дочерние значения перезаписывают родительские. Массивы (images, keywords) полностью заменяются, не мерджатся!