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

13. Metadata и SEO

Metadata — это информация о странице, которую видят поисковики, социальные сети и браузеры, но не обычный пользователь. Представь, что каждая страница — это посылка 📦: metadata — это наклейки на ней с адресом, именем получателя и содержимым. Без правильных наклеек посылку не найдут! Next.js сделал работу с metadata элегантной и типобезопасной.


Самый простой способ — экспортировать объект metadata из page.tsx или layout.tsx:

app/page.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:

// app/layout.tsx — ROOT layout
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: {
template: '%s | МойСайт', // %s = заголовок страницы
default: 'МойСайт', // если страница не задала заголовок
absolute: 'МойСайт — Главная', // игнорирует template
},
description: 'Описание по умолчанию для всего сайта',
};
// app/blog/page.tsx
export const metadata: Metadata = {
title: 'Блог', // → "Блог | МойСайт"
};
// app/blog/[slug]/page.tsx
export const metadata: Metadata = {
title: {
absolute: 'Супер статья — только она важна!',
// absolute игнорирует template из layout
},
};
// Результат в <title>: "Супер статья — только она важна!"
// app/about/page.tsx
export const metadata: Metadata = {
title: 'О нас', // → "О нас | МойСайт"
};

Для динамических страниц (посты блога, товары) используй функцию generateMetadata:

app/blog/[slug]/page.tsx
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 — стандарт для красивых карточек при шаринге в Telegram, VK, Twitter:

app/products/[id]/page.tsx
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:

app/blog/[slug]/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 }
);
}

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: 'Превью изображения',
},
},
};

app/robots.ts
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 автоматически!

app/sitemap.ts
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 помогает Google понять контент страницы (богатые сниппеты — звёзды, рецепты, события):

app/blog/[slug]/page.tsx
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',
},
};

app/layout.tsx
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',
},
};

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-index

Metadata мерджится от корня к странице — дочерние значения перезаписывают родительские. Массивы (images, keywords) полностью заменяются, не мерджатся!