Урок 02

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.