Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Метадані та SEO в Next.js». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Next.js metadata** - вбудований API для генерації `<head>`-тегів під SEO. Для статичних сторінок - об'єкт `metadata`, для маршрутів із даними - `generateMetadata`. ```tsx // Статика export const metadata: Metadata = { title: 'IT Lead', description: 'Підготовка до співбесід' } // Динаміка export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> { const doc = await getDoc(params.slug) return { title: doc.title, description: doc.description } } ``` **Ключове:** метадані дочірньої сторінки автоматично об'єднуються з батьківськими та перевизначають їх.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**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-блоки) у результатах пошуку.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.