Skip to main content

Метадані та SEO в Next.js

Next.js metadata - декларативний API для генерації HTML-тегів <head> (title, description, OpenGraph): статично через звичайний об'єкт або динамічно через async-функцію, що виконується на кожен запит.

Теорія

Коротко

  • Статичні метадані - звичайний об'єкт, зчитується під час білду; generateMetadata виконується на кожен запит і може звертатися до БД
  • Аналогія: статика - табличка на вітрині за замовчуванням; динаміка - табличка, що змінюється під кожен товар за даними зі складу
  • metadata в layout.tsx задає дефолти для всього сайту; дочірні сторінки перевизначають окремі поля
  • Статика для фіксованих сторінок, динаміка для маршрутів зі slug (блог, документація, товари)
  • URL для OG-зображень мають бути абсолютними, інакше соціальні краулери їх ігнорують

Швидкий приклад

tsx
// 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 краулер проігнорує, бо не знає твого домену.

tsx
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 дозволяє автоматично додавати назву сайту до кожної дочірньої сторінки без дублювання коду:

tsx
// 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/:

tsx
// 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

tsx
'use client' export const metadata = { title: 'Не спрацює' } // тихо ігнорується, в <head> не потрапить

Метадані обробляються тільки в Server Components. Перенеси експорт у серверний page.tsx або layout.tsx.

Помилка: відносний URL для OG-зображення

tsx
openGraph: { images: '/og.png' } // соціальні краулери не зможуть завантажити

Зустрічав цю помилку в кількох продакшн-проектах. На localhost все виглядає нормально, у LinkedIn - зламане превью. Фікс: ${process.env.NEXT_PUBLIC_SITE_URL}/og.png.

Помилка: відсутній тип Promise<Metadata> у generateMetadata

tsx
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

tsx
// 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

tsx
// 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 для розширених фрагментів

tsx
// 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-блоки) у результатах пошуку.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?