Когда кто-то шерит ссылку в Twitter, Slack или LinkedIn — OG-изображение первое, что видят. Общий фоллбэк говорит «мне было лень». Хорошо сгенерированное динамическое изображение говорит «это реальный продукт, который поддерживает кто-то, кому не всё равно».
Next.js имеет встроенную генерацию OG-изображений через next/og. Работает. Но дефолтные туториалы пропускают продакшн-подводные камни. Вот сетап, который я использую после того, как потратил время на три разных подхода.
Подход, который работает: next/og с ImageResponse
Next.js предоставляет ImageResponse из next/og, который рендерит JSX в PNG по запросу. Работает на Edge-рантайме и использует Satori (движок лейаутов, который конвертирует подмножество HTML/CSS в SVG, потом в PNG).
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
export const runtime = 'edge'
export const alt = 'Заголовок поста'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
export default async function Image({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug)
return new ImageResponse(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
padding: '60px',
background: 'linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 100%)',
color: 'white',
fontFamily: 'Inter',
}}
>
<div style={{ fontSize: 48, fontWeight: 700, lineHeight: 1.2 }}>
{post.title}
</div>
<div style={{ fontSize: 24, opacity: 0.7, marginTop: 20 }}>
yoursite.com
</div>
</div>,
{ ...size }
)
}
Почему работает: Next.js автоматически обнаруживает файлы opengraph-image.tsx и добавляет правильный <meta property="og:image"> тег. Никакой ручной привязки. Изображение генерируется при сборке для статических страниц (SSG) или по запросу для динамических.
Кастомные шрифты
Дефолтный системный шрифт выглядит дженериково. Кастомный шрифт делает OG-изображения соответствующими бренду. Нюанс: Satori не читает .woff2. Нужен .ttf или .otf.
export default async function Image({ params }) {
const fontData = await fetch(
new URL('../../assets/Inter-Bold.ttf', import.meta.url)
).then((res) => res.arrayBuffer())
return new ImageResponse(
(/* JSX */),
{
...size,
fonts: [
{
name: 'Inter',
data: fontData,
style: 'normal',
weight: 700,
},
],
}
)
}
Подводный камень: fetch с относительным URL работает в разработке, но может упасть в продакшне, если файл не включён в edge-бандл. Положи файл шрифта в ту же директорию или используй абсолютный URL на CDN-копию.
Размер имеет значение: TTF-файл в 500КБ загружается при каждом запросе OG-изображения. Обрежь шрифт до нужных символов (латиница + кириллица, если поддерживаешь эти локали). Я использую pyftsubset из fonttools — обычно режет файл с 500КБ до 50КБ.
Подмножество CSS
Satori поддерживает подмножество CSS. Вот что ловит людей:
Поддерживается: display: flex, flexDirection, justifyContent, alignItems, padding, margin, border, borderRadius, background, color, fontSize, fontWeight, lineHeight, opacity, position, top/left/right/bottom, overflow: hidden, линейные градиенты.
Не поддерживается: grid, gap (используй margin), box-shadow, CSS-переменные, transform, анимации, text-overflow: ellipsis (обрезай в JS).
Если попробуешь неподдерживаемый CSS — Satori молча игнорирует. Изображение рендерится без стиля — никакой ошибки, просто лейаут выглядит неправильно.
Мой подход: держи лейауты OG-изображений максимально простыми. Один flex столбец, текст, может логотип или акцентная полоса. Если борешься с CSS-поддержкой Satori — дизайн слишком сложный для превью 1200×630.
Обрезка текста
Длинные заголовки ломают лейаут. Satori не поддерживает text-overflow: ellipsis, поэтому обрезай в коде:
function truncate(str: string, maxLength: number) {
if (str.length <= maxLength) return str
return str.slice(0, maxLength - 1) + '…'
}
// В JSX:
;<div style={{ fontSize: 48 }}>{truncate(post.title, 80)}</div>
80 символов — безопасный максимум для шрифта 48px при ширине 1200px с отступами 60px с каждой стороны. Тестируй со своим шрифтом — ширина символов отличается.
Кэширование
OG-изображения дорого генерировать (50–200мс на запрос). Кэшируй агрессивно.
Для статических страниц (SSG) Next.js генерирует изображение при сборке. Нулевая стоимость рантайма.
Для динамических страниц добавь заголовки кэша:
export const revalidate = 86400 // Кэш на 24 часа
Или если используешь generateStaticParams — OG-изображения для этих путей генерируются при сборке автоматически.
Ловушка кэша Slack: Slack агрессивно кэширует OG-изображения. Если обновишь изображение и перешеришь ссылку — Slack покажет старое. Нет способа форсировать обновление с твоей стороны — Slack перезапрашивает по своему расписанию. Не паникуй, когда обновлённое изображение не появляется сразу.
Подходы, от которых я отказался
Скриншоты Puppeteer/Playwright: Поднять headless-браузер, загрузить HTML-страницу, сделать скриншот. Работает, но медленно (2–5 секунд на изображение), требует бинарник браузера в деплое, и проблемы с памятью на масштабе. Использовал на двух проектах до появления next/og. Не вернусь.
Внешние сервисы (Cloudinary, Bannerbear): Работают хорошо, но добавляют зависимость и стоимость за изображение. Для портфолио или маленького SaaS с сотнями страниц next/og бесплатен и держит всё в кодовой базе.
Статический фоллбэк: Одно дженериковое изображение для всех страниц. Лучше, чем ничего, но динамическое изображение с заголовком страницы получает значительно больше кликов. Настройка next/og — 30 минут, улучшение CTR — перманентное.
Минимальный сетап
Если хочешь максимально простой OG-сетап, который всё равно выглядит профессионально:
- Один
opengraph-image.tsxв директории[slug]блога - Тёмный градиентный фон, белый текст, URL сайта внизу
- Кастомный шрифт (обрезанный, <50КБ)
- Обрезка заголовка на 80 символах
- 24-часовая ревалидация для динамических страниц
Это 40 строк кода. Покрывает каждый пост блога, каждую страницу, и делает каждую пошеренную ссылку намеренной, а не случайной.
Хочешь, чтобы Next.js-сайт выглядел отполированно везде, где его шерят? Напиши — OG-изображения — часть базового технического SEO, которое я шиплю на каждом проекте.