Menu
Załóż konto

Obrazki OG w Next.js bez ograniczeń @vercel/og

next/og nie obsłuży CSS grid, woff2 ani paczki ponad 500 KB. Gdy to przeszkadza, użyj prawdziwego Chrome'a: podpisany og:image w generateMetadata.

Opublikowano 4 listopada 2026 · 6 min czytania

next/og (API ImageResponse, zbudowane na @vercel/og) to domyślny sposób na obrazki Open Graph w Next.js i przy tytule na gradiencie sprawdza się świetnie. Nie jest to jednak przeglądarka: zamienia JSX na SVG za pomocą Satori i rasteryzuje wynik przez Resvg. Gdy karta potrzebuje CSS grid, twojego prawdziwego arkusza stylów, fontów woff2 albo więcej niż 500 KB zasobów, rozwiązaniem nie jest walka z Satori — tylko wyrenderowanie karty prawdziwym Chrome'em i wstawienie gotowego adresu do generateMetadata.

Poniżej dokładna lista ograniczeń, sytuacje, w których dają o sobie znać, i zamiennik, który działa w runtime'ach Node.js i Edge.

Ograniczenia według dokumentacji Next.js

Dokumentacja ImageResponse wymienia je wprost:

  • Tylko flexbox i część CSS. „Zaawansowane układy (np. display: grid) nie zadziałają”. Pełna lista obsługiwanych właściwości jest w README Satori.
  • Limit paczki 500 KB, liczony łącznie dla JSX, CSS, fontów, obrazków i innych zasobów.
  • Fonty: tylko ttf, otf i woff — bez woff2, a właśnie ten format dają większość serwisów z fontami i next/font.
  • Domyślny wynik to PNG 1200×630, czyli właściwy rozmiar dla Open Graph.

W praktyce wygląda to tak: dwukolumnowa karta, która potrzebuje gridu; font firmowy dostępny tylko jako woff2 (trzeba go przekonwertować, inaczej po cichu podmieni się na zastępczy); dwie grubości fontu CJK, które same przekraczają 500 KB; logo w SVG z filtrami, których Resvg nie obsługuje; i ogólny koszt przepisywania projektu na style inline, bo arkusz CSS strony jest niedostępny.

Uwaga, jeśli czytasz README samego Satori: nowsze samodzielne wydania Satori dodały CSS grid i WOFF2, ale @vercel/og dołączane do Next.js (1.0.x w Next 16, oparte na starszym Satori) ich nie ma — dla next/og obowiązują ograniczenia z dokumentacji Next.js podane wyżej.

To nie są błędy. Satori zamienia kompletność na to, że działa wszędzie i w milisekundach. Jeśli twoja karta się w nim mieści, zostań przy nim.

Opcja A: zostań przy next/og, ale w jego granicach

Jeśli problemem są tylko fonty i rozmiar, popraw je i zostań przy ImageResponse:

// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';

export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';

// ttf/otf parse fastest; subset the font to Latin to stay far below 500 KB.
const inter = readFile(join(process.cwd(), 'assets/Inter-Bold-latin.ttf'));

export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
  const post = await getPost((await params).slug);

  return new ImageResponse(
    (
      <div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
                    width: '100%', height: '100%', padding: 72, background: '#0b0a09', color: '#fff' }}>
        <div style={{ fontSize: 64, fontWeight: 700, lineHeight: 1.1 }}>{post.title}</div>
        <div style={{ fontSize: 28, opacity: 0.7 }}>yourblog.com</div>
      </div>
    ),
    { ...size, fonts: [{ name: 'Inter', data: await inter, weight: 700, style: 'normal' }] },
  );
}

Okrojenie fontu do potrzebnych znaków (np. narzędziem pyftsubset z pakietu fonttools) to najskuteczniejszy sposób na limit 500 KB. Pamiętaj tylko, że polskie litery poza „ó” (ą, ć, ę, ł, ń, ś, ź, ż) leżą w Unicode w bloku Latin Extended-A, nie w podstawowym zakresie Latin (U+0000–U+00FF) — jeśli zostawisz sam ten zakres, tytuły z polskimi znakami wyrenderują się fontem zastępczym albo pustymi kwadratami.

Opcja B: prawdziwy Chrome, jako adres w generateMetadata

Gdy potrzebujesz gridu, swojego arkusza stylów, woff2, fontów emoji albo złożonych systemów pisma, wyrenderuj kartę z HTML-a i CSS w prawdziwej przeglądarce. Uruchomienie Chrome'a w funkcji serverless jest możliwe (okrojony Chromium, np. @sparticuz/chromium z puppeteer-core), ale zimne starty trwające kilka sekund i limity pamięci czynią to kruchym — a robot podglądu linków nie poczeka.

Lżejsza droga to podpisany adres obrazka: obliczasz go w generateMetadata (bez zapytania sieciowego i bez klucza API na stronie), a serwis renderuje obrazek przy pierwszym żądaniu i potem serwuje gotowy plik z cache. Poniżej format adresu Shareglint z wbudowanym szablonem blog-og — podmień slug na własny szablon HTML, żeby użyć własnego projektu. Dzięki Web Crypto ten sam kod działa w Node.js i Edge:

// lib/og.ts
const enc = (v: string) =>
  encodeURIComponent(v).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());

export async function ogImageUrl(template: string, fields: Record<string, string>) {
  const path = `/i/${process.env.OG_ACCOUNT_ID}/${template}.png`;
  const query = Object.keys(fields).sort().map((k) => `${enc(k)}=${enc(fields[k])}`).join('&');

  const key = await crypto.subtle.importKey(
    'raw', new TextEncoder().encode(process.env.OG_SIGNING_SECRET!),
    { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
  );
  const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${path}?${query}`));
  const s = Array.from(new Uint8Array(mac), (b) => b.toString(16).padStart(2, '0')).join('').slice(0, 32);

  return `https://shareglint.com${path}?${query}&s=${s}`;
}
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';
import { ogImageUrl } from '@/lib/og';

export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
  const post = await getPost((await params).slug);
  const image = await ogImageUrl('blog-og', { title: post.title, author: post.author, site: 'yourblog.com' });

  return {
    title: post.title,
    openGraph: { images: [{ url: image, width: 1200, height: 630, alt: post.title }] },
    twitter: { card: 'summary_large_image', images: [image] },
  };
}

encodeURIComponent nie koduje znaków ! ' ( ) *, a RFC 3986 tak — replace to poprawia, żeby podpis się zgadzał. Polskie znaki w tytule encodeURIComponent koduje jako UTF-8, więc podpis obejmuje je bez dodatkowej pracy. Sprawdziliśmy tę funkcję na wektorze testowym z dokumentacji API. OG_SIGNING_SECRET nie ma przedrostka NEXT_PUBLIC_, więc Next.js nigdy nie wstawi go do paczek klienta; w HTML-u ląduje tylko podpis.

Dwie rzeczy w Next.js, o których trzeba wiedzieć:

  • Pliki wygrywają. Jeśli segment trasy ma opengraph-image.tsx albo .png, Next.js używa go zamiast openGraph.images z generateMetadata — według dokumentacji generateMetadata metadane z plików „mają wyższy priorytet i nadpiszą” konfigurację. Po przejściu na adres usuń plik.
  • Adresy bezwzględne. Podpisany adres już jest bezwzględny. Jeśli zostawiasz jakieś względne ścieżki obrazków, ustaw metadataBase w głównym layoucie, inaczej Next.js nie zbuduje pełnego adresu.

Opcja C: renderowanie przy buildzie dla eksportu statycznego

Przy output: 'export' w czasie żądania nie ma serwera. Podpisany adres i tak działa — to napis obliczany podczas buildu. Jeśli wolisz wdrażać gotowe pliki, wywołaj API ze skryptu budującego i zapisz zwrócone adresy:

// scripts/og-images.ts — run before `next build`
const res = await fetch('https://shareglint.com/v1/images?wait=true', {
  method: 'POST',
  headers: { Authorization: `Bearer ${process.env.OG_API_KEY}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ template: 'blog-og', modifications: { title: post.title, site: 'yourblog.com' } }),
});
const { url } = await res.json();   // same fields again = same file, no extra render

Te same pola dają ten sam plik, więc ponowny build nie zużywa limitu na karty, które już istnieją. Ceny: plan Free to 100 obrazków miesięcznie, płatne plany od $9 miesięcznie (cennik). Gotowe przykłady dla tego frameworka są też na stronie generator obrazków OG dla Next.js.

Co wybrać

Potrzebujesz Użyj
Tytuł, logo, gradient; fonty poniżej 500 KB next/og
CSS grid, własny arkusz stylów, woff2, złożone systemy pisma Prawdziwy Chrome — podpisany adres (B)
Zero serwera (output: 'export') Podpisany adres obliczony przy buildzie albo skrypt (C)
Jedna ogólna karta dla wszystkich stron Statyczny opengraph-image.png

Cokolwiek wybierzesz, sprawdź wynik tam, gdzie będzie oglądany: wklej produkcyjny adres do Post Inspectora LinkedIna i Sharing Debuggera Facebooka — adresów localhost żaden z nich nie pobierze. Jeśli podgląd mimo to się nie pojawia, przejdź przez poradnik o niedziałającym podglądzie linku.

FAQ

Jakie ograniczenia ma next/og (ImageResponse)?

Według dokumentacji Next.js: tylko flexbox i część CSS (bez display: grid), limit paczki 500 KB łącznie z JSX, CSS, fontami i obrazkami oraz fonty wyłącznie ttf, otf i woff. Renderuje Satori i Resvg, nie przeglądarka.

Czy w obrazku OG mogę użyć Tailwinda albo moich komponentów React?

W next/og tylko częściowo: Satori przyjmuje style inline i atrybut tw, ale nie twój arkusz stylów, CSS grid ani dowolne komponenty. Renderer oparty na prawdziwym Chrome przyjmuje zwykły HTML i CSS, więc karta może korzystać z twojego design systemu.

Czy opengraph-image.tsx nadpisuje generateMetadata?

Tak. Next.js daje metadanym z plików wyższy priorytet niż metadanym z konfiguracji, więc plik opengraph-image w segmencie trasy zastępuje openGraph.images zwracane z generateMetadata.

Czy do obrazków OG potrzebuję runtime'u Edge?

Nie. ImageResponse działa w runtime'ach Node.js i Edge, a podpisany adres obrazka to po prostu napis, który obliczasz — dzięki Web Crypto ten sam kod działa w obu.