Метаданные, sitemap и картинки для соцсетей
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Страница, которую видит человек, и страница, которую забирает робот, — разные документы. document.title, который меняет эффект, правит первый; второй уже уехал, и превью в мессенджере рисуется по нему. В App Router заголовок, описание и картинка для соцсетей — не действие в компоненте, а объект metadata, который Next превращает в теги <head> ещё до отправки страницы.
Телеграм, Slack и WhatsApp скриптов не исполняют вовсе: забирают HTML, ищут в нём og-теги и рисуют карточку по тому, что нашли. Поисковый робот скрипты исполнить умеет, но делает это отдельным заходом и не всегда. Значит, заголовок и описание должны быть в ответе сервера — как данные, а не как результат работы кода на странице.
Поэтому рядом с разметкой в page.tsx или layout.tsx экспортируется объект. Next собирает его на сервере, добирает недостающие поля и печатает теги. Три поля в примере превращаются в семь тегов — прикиньте до запуска, в какие именно.
Next в песочницу не поместится: там нет ни сервера, ни файловой системы. Зато правила, по которым объект превращается в теги, — обычный код, и его мы повторяем целиком.
Половину тегов вы не писали. og:title и og:description Next добирает из обычных title и description, а карточку для Twitter собирает из openGraph — своей писать не нужно, пока не понадобится другой текст. Обратное неверно: если openGraph.title задан явно, обычный title его уже не перебьёт — на этом основана ловушка следующего раздела.
| Поле объекта | Что появится в <head> |
|---|---|
title: "Замыкания" | <title>Замыкания</title> |
description: "…" | <meta name="description" content="…"> |
openGraph: { images: ["/og.png"] } | <meta property="og:image" content="…"> |
alternates: { canonical: "/articles/closures" } | <link rel="canonical" href="…"> |
robots: { index: false } | <meta name="robots" content="noindex"> |
файл app/favicon.ico | <link rel="icon" …> — без единой строки кода |
Экспорт живёт в layout.tsx и page.tsx любого сегмента — и только в серверном компоненте. Файл с 'use client' объект metadata экспортировать не может: он нужен на сервере, до отрисовки, а клиентский модуль к этому моменту ещё не выполнялся. Если заголовок зависит от состояния — это не метаданные, а разметка.
themeColor и colorScheme в metadata не кладут: у них свой экспорт viewport. Положите их сюда — сборка напечатает предупреждение и поле проигнорирует.
Одно поле стоит особняком — metadataBase. В og:image и canonical нужен полный адрес со схемой и доменом: робот читает тег в отрыве от страницы. Писать домен в каждом файле незачем, его задают один раз в корневом layout — metadataBase: new URL("https://java-script.pro"), — и дальше все относительные адреса достраиваются от него.
| Что написано в поле | Что уедет в тег |
|---|---|
"/og/closures.png" | https://java-script.pro/og/closures.png |
"og/closures.png" | https://java-script.pro/og/closures.png |
canonical: "/" | https://java-script.pro — у корня Next обрезает даже слэш |
"https://cdn.example.com/x.png" | остаётся как есть: полный адрес сильнее базы |
Ничего не сломается — и в этом беда. Недостающее начало адреса Next подставит сам: на сборке в лог уйдёт предупреждение, а в карточку — http://localhost:3000/og/closures.png. Относительный canonical при этом так и останется относительным.
Ловится это не в браузере, а в исходнике ответа: curl -s https://ваш-домен/статья | grep og:. Панель элементов показывает документ уже после работы браузера, а робот читает то, что пришло по проводу.
Метаданные собираются по той же цепочке сегментов, что и раскладки: корневой layout.tsx, layout раздела, наконец page.tsx. Слияние поверхностное: одинаковый ключ на нижнем уровне заменяет верхний целиком, внутрь вложенного объекта слияние не заходит.
В layout лежит картинка по умолчанию. Одна страница объявила свой openGraph с заголовком, другая не трогала его вовсе. Что окажется в карточках — предскажите обе строки.
Первая строка — самая дорогая ошибка в метаданных: страница задала openGraph, и весь openGraph из layout исчез вместе с картинкой. Ссылка уходит в чат голым текстом, и никто ничего не замечает — тег ведь не сломан, его просто нет.
Вторая строка — беда наоборот: страница поменяла title, а в карточке остался чужой заголовок. Добирать og:title из title Next не стал: у унаследованного openGraph заголовок уже был задан явно.
Лечится это не хитростью, а привычкой: общие поля карточки держат в отдельном объекте и раскладывают спредом в каждой странице. Слияние вложенных полей делаете вы, а не фреймворк.
| В layout | На странице | Что в карточке |
|---|---|---|
title | title | заголовок страницы |
openGraph.images | ничего | картинка из layout |
openGraph.images | openGraph.title | только заголовок; картинки нет |
openGraph.title | title | заголовок из layout: og-поле уже занято |
description | ничего | og:description из layout |
Хвост «— java-script.pro» нужен в каждом заголовке, а писать его в каждой статье не нужно. В корневом layout заводится шаблон: title: { default: "…", template: "%s — java-script.pro" }. Дальше страницы пишут только свою часть.
Ниже четыре маршрута и один шаблон. Три ответа очевидны, один — нет; напишите прогноз для всех четырёх.
Первая строка удивляет: app/page.tsx лежит в одной папке с app/layout.tsx, а шаблон достаётся только вложенным сегментам. Своей странице layout отдаёт не шаблон, а default — потому default и обязателен, когда объявлен template.
Остальное читается прямо: строка идёт через шаблон, { absolute: … } шаблон игнорирует (так делают для главных страниц разделов, где хвост мешает), а страница без title получает default ближайшего родителя.
| Где объявлено | Что написано | Что в <title> |
|---|---|---|
app/layout.tsx | { default: "Учебник", template: "%s — сайт" } | Учебник — шаблон к себе не применяется |
app/page.tsx | "Главная" | Главная — та же папка, шаблона нет |
app/articles/page.tsx | "Все статьи" | Все статьи — сайт |
app/articles/[slug]/page.tsx | { absolute: "Замыкания" } | Замыкания |
Шаблон — подарок вложенным сегментам, а не себе. Отсюда правило: увидели голый default в разметке — ищите страницу, лежащую в одной папке с layout.
Длина заголовка ограничена не числом символов, а шириной колонки в выдаче — обычно это 55–60 знаков кириллицы вместе с хвостом шаблона. Всё, что длиннее, поисковик обрежет или перепишет по своему усмотрению: заголовок из тега для него подсказка, а не приказ. Практический вывод один — смысл несёт начало строки, а название сайта живёт в шаблоне и стоит в конце.
Статический объект знает всё заранее. Заголовок статьи, которой нет в коде, он не знает: app/articles/[slug]/page.tsx отвечает на сотню адресов. Для этого случая вместо объекта экспортируется функция — с теми же пропсами, что у страницы.
Ничего нового в устройстве нет: params — промис (мы разбирали это в статье «Динамические сегменты и асинхронные params»), функция асинхронная, возвращает тот же объект Metadata. Неизвестный адрес — законный повод вернуть другой заголовок и robots: { index: false }, чтобы страница-заглушка не попала в индекс.
В одном файле экспортируют либо metadata, либо generateMetadata — вместе они не работают. И ещё: данные для метаданных и для самой страницы обычно одни и те же, а функции две. Заворачивайте загрузчик в cache из React — тогда на один заход к странице будет один запрос за данными, а не два.
Опечатка в ключе — самая тихая ошибка из возможных: тег просто не появится, и узнаете вы об этом от коллеги, который прислал в чат ссылку без картинки. Тип ловит её на месте — запустите и прочитайте, что скажет компилятор.
Вторая диагностика даже подсказывает исправление: «Объектный литерал может указывать только известные свойства, но "titel" не существует в типе… Вы хотели записать "title"?». В настоящем проекте тип Metadata приезжает импортом из next и знает все поля до последнего — ради этой проверки его и подписывают явно.
Если generateMetadata ждёт медленные данные, страница из-за этого не стоит: для динамических страниц Next отдаёт разметку сразу, а теги досылает потоком и вставляет в документ, когда функция ответит. Это продолжение того же приёма, что и в статье «Потоковая отдача: страница приходит частями».
Роботам такой фокус не подходит: часть из них читает только первый кусок ответа. Их Next узнаёт по заголовку User-Agent (Twitterbot, Slackbot, Bingbot и прочие из встроенного списка) и для них дожидается метаданных, отдавая страницу целиком. Для заранее собранных страниц вопрос не стоит вовсе: там всё посчитано на сборке.
app/sitemap.ts — не страница. Файл экспортирует функцию, возвращающую массив адресов, а по адресу /sitemap.xml отдаётся XML, который Next собрал сам. Тип массива тоже готовый: MetadataRoute.Sitemap.
Обход в примере повторяет настоящий: порядок тегов, Date через toISOString, пропуск незаполненных полей. Полезно знать, что из этого читают: lastmod роботы учитывают, пока он честный — дата должна меняться при изменении содержимого, а не при каждой сборке. priority и changefreq Google игнорирует открыто, но другие роботы их читают, и вреда от них нет.
Уберите as const — и компилятор остановит сборку. Внутри литерала массива вывод типов до колбэка map не доходит, литерал "monthly" расширяется до string, а в поле ждут одно из семи слов. Знакомое расширение типа из части III, только в новом месте.
Сосед по конвенции — app/robots.ts. Тот же приём: объект вместо текста, а robots.txt печатает Next.
Оба файла — маршруты, а не страницы, и кешируются так же, как обработчики из статьи «Мимо страниц: route.ts и proxy.ts»: пока внутри нет обращения к запросу, результат считается один раз на сборке. Предел одного файла карты — 50 000 адресов; дальше либо sitemap.ts в разных сегментах, либо generateSitemaps, которая нарежет карту на части и отдаст их как /articles/sitemap/0.xml, /articles/sitemap/1.xml и так далее.
Карту сайта не собирают руками по списку файлов: она строится из того же источника, что и сами страницы. Разошлись — значит, дыра в источнике, и это отдельная хорошая новость.
Картинка для карточки бывает двух родов. Готовый файл opengraph-image.png, положенный в папку сегмента, Next подхватит сам: добавит og:image, ширину, высоту и тип, а подпись возьмёт из соседнего opengraph-image.alt.txt. Второй род — картинка, которой ещё нет: заголовок статьи на фирменном фоне, свой для каждого адреса.
Такую рисует файл opengraph-image.tsx: он экспортирует функцию, возвращающую ImageResponse, и JSX превращается в PNG на сервере — по умолчанию один раз на сборке, а если внутри есть работа с запросом, то при обращении. Разметка при этом обычная, её видно глазами: ниже тот же компонент отрисован React'ом прямо в кадре.
В проекте разметка уезжает первым аргументом в new ImageResponse(<OgCard … />, size), картинка отдаётся по адресу вроде /articles/closures/opengraph-image, а теги og:image, og:image:width и og:image:height Next печатает сам. Руками в metadata эту картинку прописывать не надо — файл рядом со страницей сильнее.
Satori, который рисует PNG, — не браузер: он знает флексбокс и не знает grid, требует явный display: flex у любого блока с несколькими детьми и не подтягивает шрифты сам — файл шрифта передают в опциях.
| Экспорт из файла | Во что превращается |
|---|---|
export const alt = "Замыкания" | <meta property="og:image:alt"> |
export const size = { width: 1200, height: 630 } | og:image:width и og:image:height |
export const contentType = "image/png" | <meta property="og:image:type"> |
export default function Image({ params }) | сама картинка; params — такой же промис, как у страницы |
Пять пунктов, каждый из которых хоть раз да ломался: metadataBase задан в корневом layout (иначе картинки уедут на localhost); у страниц есть canonical; на тестовом домене стоит robots: { index: false } — иначе черновик обгонит боевой сайт в выдаче; sitemap.xml и robots.txt открываются по своим адресам; карточка проверена в исходнике ответа, а не в панели элементов.
И отдельная строчка про нервы: соцсети держат карточку в своём кеше. Поменяли картинку — какое-то время будете видеть старую, и это не ошибка сборки. У крупных площадок для такого есть отладчики карточек, которые перечитывают страницу по требованию.
- Метаданные в App Router — экспортируемый объект в серверном
layout.tsxилиpage.tsx, а не код, меняющий документ после загрузки. - Уровни сливаются поверхностно:
openGraphстраницы заменяетopenGraphраскладки целиком — общие поля выносят в переменную и раскладывают спредом. title.templateдостаётся вложенным сегментам, а не своей же странице; ей остаётсяdefault, аabsoluteотменяет шаблон.generateMetadataполучает те жеparams, что и страница, и возвращает тот же объект; общий загрузчик оборачивают вcache, чтобы не ходить за данными дважды.sitemap.tsиrobots.ts— маршруты, возвращающие данные: XML и текст печатает Next, а вы отдаёте массив и объект.opengraph-image.tsxрисует картинку из JSX черезImageResponse: флексбокс, явныйdisplay: flex, свои шрифты — и тегиog:imageпоявляются сами.
1/4В app/layout.tsx объявлено title: { default: "Учебник", template: "%s — сайт" }. В app/page.tsx — title: "Главная". Что окажется в теге title?
2/4В корневом layout задан openGraph с картинкой по умолчанию. Страница объявила openGraph: { title: "Замыкания" }. Что уедет в og:image?
3/4Где можно экспортировать объект metadata?
4/4В sitemap.ts у записи указано lastModified: new Date("2026-08-14"). Что попадёт в готовый sitemap.xml?
Заголовок из данных
Страница статьи отвечает на любой адрес вида /articles/<slug>, а заголовок у неё один на всех. Замените заглушку настоящей функцией: дождитесь params, найдите статью и верните её заголовок с описанием. Для неизвестного адреса — «Статья не найдена» и robots: { index: false }.
Функция асинхронная, потому что params — промис. Всё остальное — обычный поиск по данным.
Теперь строки разные: {"title":"Всплытие","description":"Объявления поднимаются, присваивания нет"} и {"title":"Статья не найдена","robots":{"index":false}}. В настоящем проекте articles.find заменяется загрузчиком данных, обёрнутым в cache из React, — тогда страница и метаданные обойдутся одним запросом.
Карта сайта из тех же данных
Соберите массив для sitemap.ts: главная с priority: 1 и changeFrequency: "daily", страницы частей /parts/<id> и статьи /articles/<slug> с датой из данных. Адреса — полные, от site.
Ручной список тут не нужен: карта строится из тех же массивов, из которых строятся страницы.
as const у changeFrequency не украшение: внутри литерала массива вывод типов до колбэка не доходит, и без него строка расширится до string. Без as const компилятор скажет, что string не подходит для союза из семи слов.
Куда делась картинка
У статьи должен быть свой заголовок карточки и картинка из layout. Сейчас картинки нет. Почините метаданные страницы, не трогая resolve и layout.
Причина не в опечатке: openGraph страницы заменил объект из layout целиком. Общие поля вынесем в переменную рядом со страницами и разложим спредом — так их видно и так их можно менять в одном месте.
Тот же приём годится для любого вложенного поля метаданных: robots, twitter, alternates. Фреймворк сливает только верхний уровень — остальное ваша забота.