Przejdź do treści

Jak automatycznie generować grafiki Open Graph w Next.js?

Twórz unikalne podglądy social media w locie. Zobacz, jak wykorzystać ImageResponse z next/og, aby każdy wpis automatycznie zyskał profesjonalny obrazek Open Graph.

Maciej Sala

Founder StriveLab

5 min czytaniaAktualizacja

Po co dynamiczne OG Images w Next.js?

Kiedy ktoś udostępnia link do Twojej strony na Facebooku, X, LinkedIn czy Slacku, platforma pobiera obraz i wyświetla go jako preview. Statyczny obrazek jest lepszy niż nic, ale dynamiczny, który jest generowany z tytułem artykułu, imieniem autora i brandingiem, będzie znacznie lepszy. Skuteczniej przyciągnie użytkowników i będzie przy tym bardziej bardziej optymalny, niż tworzenie obrazów ręcznie. Przy stu artykułach to przynajmniej kilka dni, jeśli nie tydzien pracy, dlatego obraz lepiej wygenerować z kodu. ImageResponse z next/og składa go programowo z tytułu i brandingu, w runtime Edge albo Node.js.

Podstawowy setup dynamicznych OG Images

W Next.js App Router generowanie OG image to Route Handler, który zwraca ImageResponse:

Code
// app/api/og/route.tsx
import { ImageResponse } from 'next/og'
 
export const runtime = 'edge'
 
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const title = searchParams.get('title') || 'Firma'
  const subtitle = searchParams.get('subtitle') || 'Tworzenie stron w Next.js'
 
  return new ImageResponse(
    (
      <div
        style={{
          display: 'flex',
          flexDirection: 'column',
          justifyContent: 'center',
          width: '100%',
          height: '100%',
          backgroundColor: '#0f172a',
          padding: '60px',
        }}
      >
        <div
          style={{
            display: 'flex',
            flexDirection: 'column',
            gap: '16px',
          }}
        >
          <span style={{ color: '#3b82f6', fontSize: '24px', fontWeight: 600 }}>
            example.com
          </span>
          <h1
            style={{
              color: '#f8fafc',
              fontSize: '56px',
              fontWeight: 700,
              lineHeight: 1.2,
              margin: 0,
            }}
          >
            {title}
          </h1>
          <p style={{ color: '#94a3b8', fontSize: '24px', margin: 0 }}>
            {subtitle}
          </p>
        </div>
        <div
          style={{
            display: 'flex',
            alignItems: 'center',
            gap: '16px',
            marginTop: '40px',
          }}
        >
          <div
            style={{
              width: '48px',
              height: '48px',
              borderRadius: '50%',
              backgroundColor: '#3b82f6',
              display: 'flex',
              alignItems: 'center',
              justifyContent: 'center',
              color: '#fff',
              fontSize: '20px',
              fontWeight: 700,
            }}
          >
            MS
          </div>
          <span style={{ color: '#e2e8f0', fontSize: '20px' }}>
            Jan Kowalski
          </span>
        </div>
      </div>
    ),
    {
      width: 1200,
      height: 630,
    },
  )
}

URL: https://example.com/api/og?title=Mój artykuł&subtitle=Poradnik Next.js

Linijka export const runtime = 'edge' jest opcjonalna — ImageResponse działa także w runtime Node.js. Wybierz Edge, gdy kod i zależności są z nim zgodne; jeśli generator potrzebuje systemu plików, ORM-u lub SDK niedostępnego na Edge, użyj Node.js i nie wymuszaj tej konfiguracji.

Publiczny generator to wektor nadużyć — ogranicz go

Zanim podepniesz ten endpoint do metadanych, zatrzymaj się przy jednej konsekwencji: /api/og?title=... przyjmuje dowolny tekst od dowolnej osoby i renderuje go z Twoim brandingiem, na Twojej domenie. Nic nie stoi na przeszkodzie, żeby ktoś wygenerował obraz „Twoje konto zostało zablokowane — kliknij tutaj" podpisany TwojaDomena.pl i użył go w phishingu.

Trzy poziomy obrony, od najprostszego:

  • Ogranicz wejście. Utnij długość parametrów (title.slice(0, 100)) i waliduj ich dozwolony format. Nagłówek Referer może nie wystąpić lub zostać sfałszowany, więc nie traktuj go jako kontroli dostępu.
  • Podpisuj URL-e. Generuj linki z HMAC (&sig=...) liczonym z parametrów i sekretu; endpoint odrzuca kombinacje, których sam nie wystawił.
  • Zrezygnuj z query params konwencja opengraph-image.tsx (sekcja niżej) bierze dane z Twojego CMS-a po slugu, więc nie ma czego podrabiać. Dla własnego bloga to najlepszy wybór, a endpoint z parametrami zostaw na przypadki, gdy obrazy generujesz dla treści spoza aplikacji.

Podłączenie OG Image do metadanych strony

Code
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
 
export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>
}): Promise<Metadata> {
  const { slug } = await params
  const post = await getPost(slug)
 
  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: 'article',
      publishedTime: post.date,
      authors: ['Jan Kowalski'],
      images: [
        {
          url: `https://example.com/api/og?title=${encodeURIComponent(post.title)}&subtitle=${encodeURIComponent(post.excerpt)}`,
          width: 1200,
          height: 630,
          alt: post.title,
        },
      ],
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.excerpt,
      images: [
        `https://example.com/api/og?title=${encodeURIComponent(post.title)}`,
      ],
    },
  }
}

Natywny plik opengraph-image.tsx w Next.js

Next.js App Router obsługuje konwencję pliku opengraph-image.tsx, obraz generowany automatycznie dla każdej strony bez konfiguracji metadanych:

Code
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
 
export const runtime = 'edge'
export const alt = 'Blog post cover'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
 
export default async function Image({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const post = await fetch(`https://api.example.com/posts/${slug}`).then((r) =>
    r.json(),
  )
 
  return new ImageResponse(
    (
      <div
        style={{
          display: 'flex',
          flexDirection: 'column',
          justifyContent: 'flex-end',
          width: '100%',
          height: '100%',
          background: 'linear-gradient(135deg, #0f172a 0%, #1e3a5f 100%)',
          padding: '60px',
        }}
      >
        <div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
          <div
            style={{
              display: 'flex',
              gap: '8px',
            }}
          >
            {post.tags?.map((tag: string) => (
              <span
                key={tag}
                style={{
                  backgroundColor: 'rgba(59, 130, 246, 0.3)',
                  color: '#93c5fd',
                  padding: '4px 12px',
                  borderRadius: '16px',
                  fontSize: '16px',
                }}
              >
                {tag}
              </span>
            ))}
          </div>
          <h1
            style={{
              color: '#f8fafc',
              fontSize: post.title.length > 60 ? '40px' : '52px',
              fontWeight: 700,
              lineHeight: 1.2,
              margin: 0,
            }}
          >
            {post.title}
          </h1>
          <div
            style={{
              display: 'flex',
              alignItems: 'center',
              gap: '12px',
              marginTop: '20px',
            }}
          >
            <span style={{ color: '#94a3b8', fontSize: '20px' }}>
              example.com
            </span>
            <span style={{ color: '#475569' }}>•</span>
            <span style={{ color: '#94a3b8', fontSize: '20px' }}>
              Jan Kowalski
            </span>
          </div>
        </div>
      </div>
    ),
    { ...size },
  )
}

Next.js automatycznie dodaje ten obraz do metadanych OpenGraph strony, więc nie trzeba nic konfigurować w generateMetadata.

Custom fonty, polskie znaki i emoji

Custom font to w polskim internecie nie kosmetyka, tylko konieczność: jeśli font używany do renderowania nie obejmuje polskich znaków, „ą", „ę" czy „ł" w tytule mogą wyrenderować się jako kwadraty. Dołącz font w wersji z zestawem latin-ext (np. Inter pobrany jako TTF z kompletem polskich glifów) i problem znika:

Code
// app/api/og/route.tsx
import { ImageResponse } from 'next/og'
 
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
 
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const title = searchParams.get('title') || 'Firma'
  const interBold = await readFile(join(process.cwd(), 'assets/Inter-Bold.ttf'))
 
  return new ImageResponse(
    (
      <div
        style={{
          display: 'flex',
          justifyContent: 'center',
          alignItems: 'center',
          width: '100%',
          height: '100%',
          backgroundColor: '#0f172a',
          fontFamily: 'Inter',
        }}
      >
        <h1 style={{ color: '#fff', fontSize: '64px' }}>{title}</h1>
      </div>
    ),
    {
      width: 1200,
      height: 630,
      fonts: [
        {
          name: 'Inter',
          data: interBold,
          style: 'normal',
          weight: 700,
        },
      ],
    },
  )
}

ImageResponse domyślnie używa Twemoji, ale w opcjach konfiguracji możesz wybrać także blobmoji, noto lub openmoji. Pamiętaj o optymalizacji: JSX, CSS oraz wszystkie zasoby wliczają się do limitu 500 KB dla całego bundle’a. Aby go nie przekroczyć, dołączaj wyłącznie te kroje i grubości fontów, które są faktycznie wykorzystywane w grafice.

Cache dynamicznych OG Images

OG images nie zmieniają się często — cachuj je agresywnie:

Code
export async function GET(request: Request) {
  // ...
 
  return new ImageResponse(jsx, {
    width: 1200,
    height: 630,
    headers: {
      'Cache-Control':
        'public, max-age=86400, s-maxage=604800, stale-while-revalidate=86400',
    },
  })
}

Tydzień cache na CDN (s-maxage=604800) z jednodniowym stale-while-revalidate.

Nagłówki dotyczą endpointu /api/og. Konwencja opengraph-image.tsx jest domyślnie optymalizowana statycznie: Next.js generuje obraz podczas budowania projektu i buforuje go, o ile nie stosujesz API czasu żądania lub niecache'owanych danych. Konfiguracja fetch lub parametry segmentu trasy mogą zmienić ten tryb działania, dlatego zawsze weryfikuj zachowanie pod kątem swojego źródła danych. To kolejny argument przemawiający za korzystaniem z konwencji plikowej zamiast endpointów z parametrami.

Ograniczenia @vercel/og i zasady projektowania

(silnik renderujący pod spodem ImageResponse) obsługuje podzbiór CSS, a nie pełny CSS:

  • Obsługiwane: flexbox, kolory, fonty, padding, margin, border-radius, gradienty, opacity.

  • Nie obsługiwane lub inne podejście: CSS Grid, media queries oraz animacje (Satori nie jest pełnym silnikiem przeglądarki).

  • Dobrą praktyką jest stosowanie jawne display: 'flex' na kontenerach. Choć jest to wartość domyślna w Satori, poprawia to czytelność kodu.

Rozmiar rekomendowany: 1200×630 px (standard OG image dla większości platform).

Testowanie social media preview i OG Images

Najszybsza pętla developerska jest lokalna, więc przy konwencji plikowej otwórz w przeglądarce http://localhost:3000/blog/twoj-slug/opengraph-image, a przy endpoincie poprzez /api/og?title=Test. Zmieniasz JSX, odświeżasz kartę i widzisz obraz. Dopiero gotowy efekt weryfikuj narzędziami platform:

  • Facebook Sharing Debugger, czyli developers.facebook.com/tools/debug, który pokazuje preview i pełną listę odczytanych tagów OG.

  • LinkedIn Post Inspector, czyli linkedin.com/post-inspector.

  • Slack / Discord / WhatsApp. Wklejenie linku na testowym kanale to najlepszy test, bo komunikatory bywają najbardziej wybredne.

  • Dawny Twitter Card Validator nie renderuje już podglądu i na X po prostu sprawdź link w oknie tworzenia posta.

Platformy cache'ują pobrany podgląd po swojej stronie, więc jeśli poprawiłeś obraz, a LinkedIn czy Facebook dalej pokazują starą wersję, wymuś ponowny scrape w ich debuggerze („Scrape Again" u Facebooka).

Audyt techniczny i optymalizacja pod kątem SEO i GEO.
Audyt techniczny SEO

Często zadawane pytania

Czy OG images wpływają na SEO?

Nie bezpośrednio na ranking. Ich główna wartość to wyższy CTR z social media, komunikatorów i link preview. W Google liczą się przede wszystkim poprawne metadane i treść strony, a OG image działa na klikalność udostępnionego linku.

Czy mogę użyć zdjęć wewnątrz OG image?

Tak, przez <img> z pełnym, publicznie dostępnym URL (https://...). Pamiętaj, że obraz musi być osiągalny w momencie generowania OG image, inaczej wyrenderuje się bez niego.

Jak szybko generuje się OG image?

To zależy od runtime'u, platformy, fontów, obrazów i źródła danych. Nie zakładaj stałego czasu: zmierz endpoint w swoim środowisku. Dla obrazów, które nie zmieniają się często, ustaw cache CDN albo wybierz opengraph-image.tsx, które Next.js może wygenerować podczas builda.

Dlaczego mój layout w OG image się rozjeżdża?

Bo Satori (silnik pod ImageResponse) obsługuje tylko podzbiór CSS. Nie działa CSS Grid, a animacje i media queries nie mają tu zastosowania. Layout buduj na flexboksie; Satori używa go domyślnie, ale jawne display: flex ułatwia czytanie i przewidywanie układu.

Dlaczego polskie znaki wyświetlają się jako kwadraty?

Bo użyty font nie zawiera glifów ą, ę, ł, ś, ż. Dodaj przez pole fonts krój z zestawem latin-ext, np. Inter w formacie TTF. ImageResponse używa Twemoji domyślnie; opcją emoji wybierzesz inny obsługiwany zestaw emoji.

Czy endpoint /api/og trzeba jakoś zabezpieczyć?

Warto. Publiczny generator przyjmujący dowolny tekst z URL pozwala każdemu tworzyć obrazy z Twoim brandingiem i dowolną treścią. Najprościej zrezygnować z query params na rzecz konwencji opengraph-image.tsx (dane z CMS, a nie z URL-a). Jeśli endpoint musi zostać, ogranicz długość parametrów i rozważ podpisywanie URL-i (HMAC), żeby przyjmować tylko wygenerowane przez Ciebie kombinacje.

Czym ImageResponse różni się od @vercel/og?

ImageResponse to API Next.js importowane z next/og. Pod spodem korzysta z @vercel/og, Satori i Resvg. W aplikacji Next.js używaj next/og; pakiet @vercel/og pozostaje przydatny poza Next.js.

O autorze

Maciej Sala

Maciej Sala — konsultant technologiczny produktów cyfrowych i web developer z bogatym doświadczeniem w marketingu internetowym oraz SEO. Na co dzień pracuje z Reactem, Next.js i TypeScriptem, a ostatnio także z Astro i narzędziami do automatyzacji procesów AI. Sprawnie łączy perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat był związany z branżą gier wideo jako project manager i game designer. Absolwent historii na Uniwersytecie Jagiellońskim oraz studiów podyplomowych z marketingu internetowego na AGH w Krakowie. Po godzinach trenuje na siłowni, maluje figurki i rozwija własne projekty.

Pomagam przekładać takie tematy na konkretne wdrożenia w frontendzie, SEO, analityce i procesie produktowym.

Skontaktuj się ze mną
LH.pl – Hosting Mango

Biblioteka wiedzy na temat Next.js

Czytaj dalej

Zobacz więcej wpisów
next/image vs srcset vs CDN: optymalizacja obrazów w Next.js

Obrazy często odpowiadają za dużą część transferu i mogą stać się elementem LCP. Zbyt późne odkrycie obrazu hero wydłuża ładowanie, pobranie wariantu większego niż layout marnuje transfer, a brak zarezerwowanych proporcji powoduje CLS. Porównajmy trzy rozwiązania: next/image , natywny srcset oraz dedykowany image CDN.

Maciej Sala

Maciej Sala

Founder StriveLab

Edge Runtime w Next.js — kiedy Edge, a kiedy Node?

Edge Runtime obiecuje błyskawiczne działanie kodu tuż przy użytkowniku. To ultralekkie środowisko, oparte na V8 Isolates i na Vercel rozproszone globalnie. Startuje szybko, bo nie wymaga uruchamiania typowego kontenera funkcji, lecz sama bliskość użytkownika nie gwarantuje krótszej odpowiedzi. Jeśli baza stoi po drugiej stronie oceanu, Edge jedynie przenosi opóźnienie na kolejne połączenie. W Next.js 16 automatyczny wybór Edge może więc okazać się strategicznym błędem.

Maciej Sala

Maciej Sala

Founder StriveLab

Kiedy Next.js daje przewagę w SEO nad React SPA

Next.js to framework React, który ułatwia uniknięcie głównego ograniczenia aplikacji SPA renderowanych wyłącznie w przeglądarce: kluczowa treść może znaleźć się już w początkowym HTML. Google nie musi wtedy czekać na wykonanie JavaScriptu, żeby ją odkryć. Framework dodaje też Metadata API, optymalizację obrazów i podział kodu, ale korzyść dla Core Web Vitals zależy od sposobu implementacji, ilości kodu klienckiego i infrastruktury.

Maciej Sala

Maciej Sala

Founder StriveLab

LH.pl – Cloud Server 1C4G