SEO в Next.js: generateMetadata
Что такое generateMetadata
generateMetadata — асинхронная функция для динамических метаданных.
Когда:
- Данные из БД или API.
- Параметры URL (
[slug]). - Условная логика.
- Client Components (при SSR).
Правило: нельзя в Client Components и Server Components одновременно.
Синтаксис
app/tracks/[slug]/page.tsx:
import type { Metadata } from 'next';
type Props = {
params: Promise<{ slug: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const track = await fetch(`https://api.example.com/tracks/${slug}`).then((r) => r.json());
return {
title: track.title,
description: track.description,
openGraph: {
title: track.title,
description: track.description,
images: [{ url: track.image }],
},
alternates: {
canonical: `https://example.com/tracks/${slug}`,
},
};
}
export default async function Page({ params }: Props) {
const { slug } = await params;
const track = await fetch(`https://api.example.com/tracks/${slug}`).then((r) => r.json());
return <h1>{track.title}</h1>;
}
Что важно: params теперь Promise (Next 15+).
Обработка ошибок
Если данных нет:
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
try {
const track = await fetch(`https://api.example.com/tracks/${slug}`).then((r) => r.json());
return {
title: track.title,
description: track.description,
};
} catch {
return {
title: 'Трек не найден',
};
}
}
Правило: всегда try/catch — не ломать всю страницу.
Кэширование
Что важно:
generateMetadataвызывается отдельно.- Fetch кэшируется.
cacheиnextработают.
Пример:
const track = await fetch(url, { next: { revalidate: 3600 } }).then((r) => r.json());
Правило: не дублировать запросы. Использовать один источник (или React cache).
React cache
Проблема: страница и metadata делают один и тот же fetch.
Решение: обернуть в cache:
import { cache } from 'react';
export const getTrack = cache(async (slug: string) => {
return fetch(`https://api.example.com/tracks/${slug}`).then((r) => r.json());
});
Использование:
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const track = await getTrack(slug);
return {
title: track.title,
description: track.description,
};
}
export default async function Page({ params }: Props) {
const { slug } = await params;
const track = await getTrack(slug);
return <h1>{track.title}</h1>;
}
Что даёт: 1 запрос вместо 2.
Динамический title
Для товара:
return {
title: `Купить ${product.name} — ${product.price} ₽`,
description: `${product.description.slice(0, 150)}...`,
};
Для статьи:
return {
title: `${post.title} — ${post.category}`,
description: post.excerpt,
};
Правило: title человеческий, не «ключ в лоб».
Динамический description
Из контента:
const plainText = post.content.replace(/[#*`]/g, '').trim();
const description = plainText.slice(0, 160);
return {
title: post.title,
description,
};
Что важно: обрезка до 160 символов.
Динамический Open Graph
OG-изображение из API:
return {
openGraph: {
title: product.name,
description: product.shortDescription,
images: [
{
url: product.imageUrl,
width: 1200,
height: 630,
alt: product.name,
},
],
},
};
Правило: OG — динамическое для каждого товара.
OG-изображение через ImageResponse
Генерация на лету:
app/tracks/[slug]/opengraph-image.tsx:
import { ImageResponse } from 'next/og';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';
export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const track = await fetch(`https://api.example.com/tracks/${slug}`).then((r) => r.json());
return new ImageResponse(
(
<div
style={{
background: '#c1442e',
color: 'white',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: 80,
fontSize: 60,
}}
>
<div>{track.title}</div>
<div style={{ fontSize: 30, marginTop: 20 }}>SKILLS-DEV.RU</div>
</div>
),
{ ...size },
);
}
Что даёт: автоматическое OG-изображение.
Правило: Next сам подставит в metadata.
Динамический canonical
return {
alternates: {
canonical: `https://example.com/tracks/${slug}`,
},
};
Что важно: абсолютный URL, HTTPS.
Metadata для 404
app/not-found.tsx:
export const metadata: Metadata = {
title: 'Страница не найдена',
robots: { index: false, follow: false },
};
Правило: 404 — не индексировать.
Metadata для dynamic и force-dynamic
Если страница force-dynamic:
export const dynamic = 'force-dynamic';
export async function generateMetadata(...) {
// работает
}
Правило: работает как обычно.
Параллельные запросы
Что важно: generateMetadata и page выполняются параллельно.
Что даёт: быстрее.
⚠️ Используйте cache — иначе двойной fetch.
Практика
1. Создайте [slug]/page.tsx.
2. Реализуйте generateMetadata:
- Title из данных.
- Description из данных.
- OG из данных.
- Canonical.
3. Оберните fetch в cache.
4. Проверьте через просмотр кода.
5. Проверьте через Facebook Debugger.
6. Сгенерируйте OG через ImageResponse.
7. Проверьте 3 разные страницы.
Частые ошибки
1. Забыли await для params:
Params — Promise
Решение: await params.
2. Двойной fetch:
2 запроса к API
Решение: cache.
3. Без try/catch:
Страница ломается
Решение: обработка.
4. Длинный description:
Обрезается
Решение: 150–160.
5. OG image без размеров:
Обрезается
Решение: 1200×630.
6. Ошибка 404 без noindex:
Индексируется
Решение: robots false.
7. generateMetadata в Client Component:
Ошибка
Решение: Server Component.
8. Без canonical:
Дубли
Решение: alternates.
Итог
generateMetadata— динамические метаданные.params— Promise (Next 15+).- try/catch — обязательно.
cache— против дублей.- Динамический title, description.
- OG — динамическое.
- ImageResponse — OG на лету.
- Canonical — обязателен.
- 404 — noindex.
- Параллельно с page.
Дальше — SEO в Next.js: Server Components vs Client Components.