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,otfiwoff— bezwoff2, a właśnie ten format dają większość serwisów z fontami inext/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.tsxalbo.png, Next.js używa go zamiastopenGraph.imageszgenerateMetadata— 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
metadataBasew 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.