Skip to main content
Назад до блогу
5 хв читання

Генерація OG-зображень у Next.js 16 без безсонних ночей

Динамічні OG-зображення роблять посилання професійними в Twitter та Slack. Ось сетап, який працює в продакшні без сюрпризів — і підходи, від яких я відмовився.

next.jsperformance

Коли хтось шерить посилання в 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-сетап, який все одно виглядає професійно:

  1. Один opengraph-image.tsx у директорії [slug] блогу
  2. Темний градієнтний фон, білий текст, URL сайту внизу
  3. Кастомний шрифт (обрізаний, <50КБ)
  4. Обрізка заголовка на 80 символах
  5. 24-годинна ревалідація для динамічних сторінок

Це 40 рядків коду. Покриває кожен пост блогу, кожну сторінку, і робить кожне пошерене посилання навмисним, а не випадковим.


Хочеш, щоб Next.js-сайт виглядав відполіровано скрізь, де його шерять? Напиши — OG-зображення — частина базового технічного SEO, яке я шиплю на кожному проєкті.