Метаданные, sitemap и картинки для соцсетей

кеш и прод≈ 15 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Страница, которую видит человек, и страница, которую забирает робот, — разные документы. document.title, который меняет эффект, правит первый; второй уже уехал, и превью в мессенджере рисуется по нему. В App Router заголовок, описание и картинка для соцсетей — не действие в компоненте, а объект metadata, который Next превращает в теги <head> ещё до отправки страницы.

Разделы темы:·

Телеграм, Slack и WhatsApp скриптов не исполняют вовсе: забирают HTML, ищут в нём og-теги и рисуют карточку по тому, что нашли. Поисковый робот скрипты исполнить умеет, но делает это отдельным заходом и не всегда. Значит, заголовок и описание должны быть в ответе сервера — как данные, а не как результат работы кода на странице.

Поэтому рядом с разметкой в page.tsx или layout.tsx экспортируется объект. Next собирает его на сервере, добирает недостающие поля и печатает теги. Три поля в примере превращаются в семь тегов — прикиньте до запуска, в какие именно.

Next в песочницу не поместится: там нет ни сервера, ни файловой системы. Зато правила, по которым объект превращается в теги, — обычный код, и его мы повторяем целиком.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Половину тегов вы не писали. 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 с заголовком, другая не трогала его вовсе. Что окажется в карточках — предскажите обе строки.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Первая строка — самая дорогая ошибка в метаданных: страница задала openGraph, и весь openGraph из layout исчез вместе с картинкой. Ссылка уходит в чат голым текстом, и никто ничего не замечает — тег ведь не сломан, его просто нет.

Вторая строка — беда наоборот: страница поменяла title, а в карточке остался чужой заголовок. Добирать og:title из title Next не стал: у унаследованного openGraph заголовок уже был задан явно.

Лечится это не хитростью, а привычкой: общие поля карточки держат в отдельном объекте и раскладывают спредом в каждой странице. Слияние вложенных полей делаете вы, а не фреймворк.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
В layoutНа страницеЧто в карточке
titletitleзаголовок страницы
openGraph.imagesничегокартинка из layout
openGraph.imagesopenGraph.titleтолько заголовок; картинки нет
openGraph.titletitleзаголовок из layout: og-поле уже занято
descriptionничегоog:description из layout

Хвост «— java-script.pro» нужен в каждом заголовке, а писать его в каждой статье не нужно. В корневом layout заводится шаблон: title: { default: "…", template: "%s — java-script.pro" }. Дальше страницы пишут только свою часть.

Ниже четыре маршрута и один шаблон. Три ответа очевидны, один — нет; напишите прогноз для всех четырёх.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Первая строка удивляет: 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 отвечает на сотню адресов. Для этого случая вместо объекта экспортируется функция — с теми же пропсами, что у страницы.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ничего нового в устройстве нет: params — промис (мы разбирали это в статье «Динамические сегменты и асинхронные params»), функция асинхронная, возвращает тот же объект Metadata. Неизвестный адрес — законный повод вернуть другой заголовок и robots: { index: false }, чтобы страница-заглушка не попала в индекс.

В одном файле экспортируют либо metadata, либо generateMetadata — вместе они не работают. И ещё: данные для метаданных и для самой страницы обычно одни и те же, а функции две. Заворачивайте загрузчик в cache из React — тогда на один заход к странице будет один запрос за данными, а не два.

Опечатка в ключе — самая тихая ошибка из возможных: тег просто не появится, и узнаете вы об этом от коллеги, который прислал в чат ссылку без картинки. Тип ловит её на месте — запустите и прочитайте, что скажет компилятор.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Вторая диагностика даже подсказывает исправление: «Объектный литерал может указывать только известные свойства, но "titel" не существует в типе… Вы хотели записать "title"?». В настоящем проекте тип Metadata приезжает импортом из next и знает все поля до последнего — ради этой проверки его и подписывают явно.

Если generateMetadata ждёт медленные данные, страница из-за этого не стоит: для динамических страниц Next отдаёт разметку сразу, а теги досылает потоком и вставляет в документ, когда функция ответит. Это продолжение того же приёма, что и в статье «Потоковая отдача: страница приходит частями».

Роботам такой фокус не подходит: часть из них читает только первый кусок ответа. Их Next узнаёт по заголовку User-Agent (Twitterbot, Slackbot, Bingbot и прочие из встроенного списка) и для них дожидается метаданных, отдавая страницу целиком. Для заранее собранных страниц вопрос не стоит вовсе: там всё посчитано на сборке.

app/sitemap.ts — не страница. Файл экспортирует функцию, возвращающую массив адресов, а по адресу /sitemap.xml отдаётся XML, который Next собрал сам. Тип массива тоже готовый: MetadataRoute.Sitemap.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Обход в примере повторяет настоящий: порядок тегов, Date через toISOString, пропуск незаполненных полей. Полезно знать, что из этого читают: lastmod роботы учитывают, пока он честный — дата должна меняться при изменении содержимого, а не при каждой сборке. priority и changefreq Google игнорирует открыто, но другие роботы их читают, и вреда от них нет.

Уберите as const — и компилятор остановит сборку. Внутри литерала массива вывод типов до колбэка map не доходит, литерал "monthly" расширяется до string, а в поле ждут одно из семи слов. Знакомое расширение типа из части III, только в новом месте.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Сосед по конвенции — app/robots.ts. Тот же приём: объект вместо текста, а robots.txt печатает Next.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Оба файла — маршруты, а не страницы, и кешируются так же, как обработчики из статьи «Мимо страниц: 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'ом прямо в кадре.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

В проекте разметка уезжает первым аргументом в 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 }.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Функция асинхронная, потому что params — промис. Всё остальное — обычный поиск по данным.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь строки разные: {"title":"Всплытие","description":"Объявления поднимаются, присваивания нет"} и {"title":"Статья не найдена","robots":{"index":false}}. В настоящем проекте articles.find заменяется загрузчиком данных, обёрнутым в cache из React, — тогда страница и метаданные обойдутся одним запросом.

Карта сайта из тех же данных

Соберите массив для sitemap.ts: главная с priority: 1 и changeFrequency: "daily", страницы частей /parts/<id> и статьи /articles/<slug> с датой из данных. Адреса — полные, от site.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ручной список тут не нужен: карта строится из тех же массивов, из которых строятся страницы.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

as const у changeFrequency не украшение: внутри литерала массива вывод типов до колбэка не доходит, и без него строка расширится до string. Без as const компилятор скажет, что string не подходит для союза из семи слов.

Куда делась картинка

У статьи должен быть свой заголовок карточки и картинка из layout. Сейчас картинки нет. Почините метаданные страницы, не трогая resolve и layout.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Причина не в опечатке: openGraph страницы заменил объект из layout целиком. Общие поля вынесем в переменную рядом со страницами и разложим спредом — так их видно и так их можно менять в одном месте.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Тот же приём годится для любого вложенного поля метаданных: robots, twitter, alternates. Фреймворк сливает только верхний уровень — остальное ваша забота.