Метадані та SEO в Next.js
Next.js metadata - декларативний API для генерації HTML-тегів <head> (title, description, OpenGraph): статично через звичайний об'єкт або динамічно через async-функцію, що виконується на кожен запит.
Теорія
Коротко
- Статичні метадані - звичайний об'єкт, зчитується під час білду;
generateMetadataвиконується на кожен запит і може звертатися до БД - Аналогія: статика - табличка на вітрині за замовчуванням; динаміка - табличка, що змінюється під кожен товар за даними зі складу
metadataвlayout.tsxзадає дефолти для всього сайту; дочірні сторінки перевизначають окремі поля- Статика для фіксованих сторінок, динаміка для маршрутів зі slug (блог, документація, товари)
- URL для OG-зображень мають бути абсолютними, інакше соціальні краулери їх ігнорують
Швидкий приклад
// app/layout.tsx - Статика: діє на всі сторінки
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: { template: '%s | IT Lead', default: 'IT Lead' },
description: 'Платформа для підготовки до співбесід з фронтенду'
}
// Результат: <title>IT Lead</title> на головній, "Сторінка | IT Lead" на дочірніх
// app/blog/[slug]/page.tsx - Динаміка: виконується на кожен запит
export async function generateMetadata(
{ params }: { params: { slug: string } }
): Promise<Metadata> {
const post = await fetch(`/api/posts/${params.slug}`).then(r => r.json())
return {
title: post.title, // замінює шаблон з layout
description: post.excerpt
}
}Root layout задає базові значення. Кожен сегмент маршруту може їх перевизначити або розширити. Next.js автоматично об'єднує їх.
Статика vs динаміка
Статичні метадані - звичайний об'єкт, що експортується з layout.tsx або page.tsx. Next.js зчитує його під час білду (або один раз при SSR-рендері) і вставляє теги в <head>. Жодних запитів до бази, жодного очікування.
Динамічні метадані живуть у generateMetadata - async-функції, що отримує params і searchParams. Вона виконується на кожен запит, тому можна підтягнути заголовок статті з CMS або назву товару з бази. Next.js усуває дублювання fetch-запитів: якщо generateMetadata і компонент сторінки викликають однаковий fetch, запит виконується лише раз.
Статику обирай для всього, що не змінюється між запитами. Динаміку - коли вміст <head> залежить від параметрів маршруту або живих даних.
Коли що використовувати
- Фіксований заголовок і опис сайту: статичний
metadataв root layout - Заголовок статті або документа з бази:
generateMetadataв[slug]/page.tsx - Сторінки товарів з OG-зображеннями з CDN: динаміка з
openGraph.images - Адмін-панель або внутрішні сторінки без SEO:
robots: { index: false } - Мультимовні сайти: динаміка з
alternates.languages
Як це працює всередині
App Router сканує кожен сегмент маршруту на наявність metadata-об'єкта або функції generateMetadata під час серверного рендеру. Збирає їх від root layout до поточної сторінки, об'єднує (поля дочірнього сегмента перевизначають батьківські), і вставляє результат у <head> через React Server Components.
Для динамічних метаданих Next.js чекає на Promise до початку стримінгу відповіді. Fetch-виклики всередині generateMetadata використовують той самий кеш даних, що і компонент сторінки - тому дубльовані запити зливаються в один. Кешування контролюється через fetch({ next: { revalidate: 3600 } }) або тегову інвалідацію через revalidateTag.
OpenGraph та Twitter Cards
Соціальні краулери (Facebook, LinkedIn, X) зчитують OG і Twitter meta-теги для побудови превью посилань. Головна пастка: URL для OG-зображень мають бути абсолютними. Відносний шлях /og.png краулер проігнорує, бо не знає твого домену.
export const metadata: Metadata = {
openGraph: {
title: 'Задачі з JavaScript для співбесід',
description: 'Розв\'язуй задачі з реальних інтерв\'ю',
url: 'https://itlead.org/problems',
siteName: 'IT Lead',
images: [
{
url: `${process.env.NEXT_PUBLIC_SITE_URL}/images/ITLeadBanner.png`, // абсолютний URL
width: 1200,
height: 630
}
],
type: 'website'
},
twitter: {
card: 'summary_large_image',
images: [`${process.env.NEXT_PUBLIC_SITE_URL}/images/ITLeadBanner.png`]
}
}Якщо twitter вказано частково, Next.js автоматично підтягує відсутні значення з openGraph.
Шаблони заголовків
Шаблон заголовка в root layout дозволяє автоматично додавати назву сайту до кожної дочірньої сторінки без дублювання коду:
// app/layout.tsx
export const metadata: Metadata = {
title: {
template: '%s | IT Lead',
default: 'IT Lead' // показується, якщо сторінка не задає свій заголовок
}
}
// app/docs/page.tsx
export const metadata: Metadata = {
title: 'База знань'
// Результат: "База знань | IT Lead"
}%s замінюється на заголовок дочірньої сторінки. default відображається на сторінках без власного title.
Sitemap та robots.txt
Next.js генерує обидва файли як маршрути, а не статичні файли. Розміщуються в app/:
// app/sitemap.ts
import { MetadataRoute } from 'next'
export default function sitemap(): MetadataRoute.Sitemap {
const docs = getAllDocs()
return [
{ url: 'https://itlead.org', lastModified: new Date() },
...docs.map(doc => ({
url: `https://itlead.org/docs/${doc.slug}`,
lastModified: doc.updatedAt
}))
]
}
// app/robots.ts
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: '*', allow: '/', disallow: ['/api/', '/auth/'] },
sitemap: 'https://itlead.org/sitemap.xml'
}
}Обидва автоматично доступні за адресами /sitemap.xml і /robots.txt.
Типові помилки
Помилка: експорт metadata з Client Component
'use client'
export const metadata = { title: 'Не спрацює' } // тихо ігнорується, в <head> не потрапитьМетадані обробляються тільки в Server Components. Перенеси експорт у серверний page.tsx або layout.tsx.
Помилка: відносний URL для OG-зображення
openGraph: { images: '/og.png' } // соціальні краулери не зможуть завантажитиЗустрічав цю помилку в кількох продакшн-проектах. На localhost все виглядає нормально, у LinkedIn - зламане превью. Фікс: ${process.env.NEXT_PUBLIC_SITE_URL}/og.png.
Помилка: відсутній тип Promise<Metadata> у generateMetadata
export async function generateMetadata({ params }) { // TypeScript неправильно виводить тип
return { title: 'Погано' }
}Додай явний тип повернення: Promise<Metadata>. Без нього TypeScript не спіймає відсутні або некоректні поля під час компіляції.
Помилка: повільний API у generateMetadata без кешування
Фetch без кешу в generateMetadata затримує всю серверну відповідь. Використовуй fetch({ next: { revalidate: 3600 } }) для ISR або cache: 'force-cache' для даних, що рідко змінюються.
Помилка: відсутній canonical URL у динамічних маршрутах
Без alternates.canonical пошукові системи можуть індексувати /blog/post?ref=twitter і /blog/post як окремі сторінки і розбивати SEO-сигнали між ними.
Де зустрічається в реальних проектах
- nextjs.org: статична база в root layout + динаміка для кожного slug документації через Contentlayer
- Supabase dashboard:
generateMetadataпідтягує назви проектів з бази на кожен запит - Linear.app: сторінки задач з
alternates.canonicalдля консолідації SEO-сигналів - Сторінки товарів в e-commerce: динамічні OG-зображення з CDN з ISR-ревалідацією кожні 5 хвилин
Питання на співбесіді
Q: У чому різниця між статичними метаданими та generateMetadata з точки зору часу виконання?
A: Статичні метадані зчитуються під час білду або один раз при SSR і нічого не коштують під час запиту. generateMetadata виконується на кожен запит і може отримати свіжі дані, але без кешування додає затримку.
Q: Як працює успадкування метаданих між layouts і сторінками?
A: Next.js об'єднує метадані від root layout до поточного сегмента. Поля дочірнього сегмента перевизначають поля батьківського з тим самим ключем. Сторінка, яка задає title, замінює title з layout, але успадковує все інше.
Q: Чому URL для OG-зображень мають бути абсолютними?
A: Соціальні краулери (Facebook, Googlebot, X) не знають твого домену. Вони звертаються до URL як є, тому відносний шлях поза контекстом сервера нічого не повертає.
Q: Як додати структуровані дані (JSON-LD) для розширених фрагментів у пошуку?
A: Вставляй тег <script type="application/ld+json"> безпосередньо в компонент сторінки через dangerouslySetInnerHTML. Metadata API JSON-LD не обробляє.
Q: Як у multi-tenant застосунку генерувати метадані для кожного тенанта без витоку даних між запитами?
A: Зчитуй ідентифікатор тенанта через headers().get('x-tenant') або cookies() всередині generateMetadata. Додай unstable_noStore(), щоб відключити кешування, і fetch({ next: { tags: ['tenant-data'] } }) для точкової інвалідації. Ніколи не зберігай стан тенанта в модульному скоупі - serverless-функції не мають спільного стану між запитами.
Приклади
Статичні метадані в root layout
// app/layout.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: {
template: '%s | IT Lead',
default: 'IT Lead'
},
description: 'Платформа для підготовки до співбесід з фронтенду',
keywords: ['frontend', 'interview', 'javascript', 'react'],
openGraph: {
siteName: 'IT Lead',
type: 'website',
images: [
{
url: 'https://itlead.org/images/ITLeadBanner.png', // обов'язково абсолютний URL
width: 1200,
height: 630
}
]
}
}Виконується один раз. Всі сторінки в застосунку успадковують ці значення, якщо не перевизначають їх.
Динамічні метадані для статті блогу з OpenGraph
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
import { getPost } from '@/lib/posts'
export async function generateMetadata(
{ params }: { params: { slug: string } }
): Promise<Metadata> {
const post = await getPost(params.slug) // кешується Next.js, спільний з компонентом
return {
title: post.title, // стає "Як працюють замикання | IT Lead" через шаблон
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
type: 'article',
publishedTime: post.date,
images: post.image ? [{ url: post.image }] : []
},
twitter: { card: 'summary_large_image' }
}
}
export default async function BlogPost({ params }) {
const post = await getPost(params.slug) // той самий fetch - виконується один раз
return <article>{post.content}</article>
}getPost викликається двічі в коді, але виконується один раз за запит. Next.js дедуплікує fetch-виклики в рамках одного рендер-циклу, тому подвійного мережевого запиту немає.
Структуровані дані з JSON-LD для розширених фрагментів
// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }) {
const post = await getPost(params.slug)
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.title,
description: post.excerpt,
datePublished: post.date,
author: { '@type': 'Organization', name: 'IT Lead' }
}
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<article>{post.content}</article>
</>
)
}JSON-LD розміщується в компоненті сторінки, а не в metadata API. Google зчитує його для розширених фрагментів (карточки статей, хлібні крихти, FAQ-блоки) у результатах пошуку.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.