Коли хтось шерить посилання в 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, яке я шиплю на кожному проєкті.