돌아가기
#Next.js
#opengraph image
#weblog

Next.js 15에서 동적으로 오픈그래프 이미지 생성 하기

소셜 미디어에서 링크를 공유할 때 보이는 이미지, 즉 오픈그래프(Open Graph) 이미지는 사용자의 클릭률을 크게 좌우하는 중요한 요소이다.

블로그나 콘텐츠 사이트에서 각 포스트마다 고유한 오픈그래프 이미지를 일일이 제작하는 것은 현실적이지 않기 때문에 동적으로 오픈그래프 이미지를 생성하는 것이 효과적이다.

Next.js v13.3.0에서 소개된 opengraph-image 메타데이터 파일을 사용하여 동적으로 오픈그래프 이미지를 생성해보겠다.

다음은 완성본이다.

로딩 중...완성본

디스코드에서 공유할 때 보이는 이미지.

로딩 중...완성본

사용법

공식 문서

기본적인 사용법은 다음과 같다.

tsx
import { ImageResponse } from 'next/og'
 
export default async function OpengraphImage() {
  return new ImageResponse(
    (
      // JSX 형태
      <div>
        Hello world!
      </div>
    ),
  );
}

Props로 전달할 수 있는 옵션은 다음과 같다.

tsx
export default function Image({ params }: { params: { slug: string } }) {
  // ...
}

폰트도 추가하고 싶다면 다음과 같이 추가할 수 있다.

tsx
//...
export default async function Image() {
  const interSemiBold = await readFile(
    join(process.cwd(), 'assets/Inter-SemiBold.ttf')
  )
 
  return new ImageResponse(
    (
      // JSX 형태
      <div>
        Hello world!
      </div>
    ),
    // ImageResponse options
    {
      ...size,
      fonts: [
        {
          name: 'Inter',
          data: interSemiBold,
          style: 'normal',
          weight: 400,
        },
      ],
    }
  )
}

ImageResponse API는 이 문서 에서 자세히 설명하고 있다.

오픈그래프 이미지 jsx로 작성하기

오픈그래프를 생성하는데 사용되는 @vercel/og 패키지는 내부적으로 Satori를 사용하기 때문에 모든 CSS를 지원하지 않는다.1

따라서 이 점을 유의하며 오픈그래프 이미지를 생성하기 위한 JSX 코드를 작성한다.

tsx
import { getMarkdownFile, getMarkdownFileWithFetch } from "@/app/action/markdown";
import { readFileSync } from "fs";
import { ImageResponse } from "next/og";
import { join } from "path";
 
const size = {
  width: 1200,
  height: 630,
};
 
export async function generateImageMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const file = await getMarkdownFile(slug);
 
  return [
    {
      id: slug,
      size,
      contentType: "image/png",
      alt: file?.title || "",
    },
  ];
}
 
async function loadSeouAlrimFont(font: string) {
  const data = await fetch(`${prefixUrl}/fonts/${font}.ttf`).then(
    (res) => res.arrayBuffer()
  );
 
  if (!data) {
    throw new Error(`Font file not found: ${font}.ttf`);
  }
 
  return data;
}
 
export default async function OpengraphImage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const file = await getMarkdownFileWithFetch(slug);
  const { title, tags } = file || { title: "", tags: [] };
 
  const imageOptions: any = {
    ...size,
    fonts: [
        {
            name: "SeoulAlrimTTF-Medium",
            data: await loadSeouAlrimFont("SeoulAlrimTTF-Medium"),
            style: "normal",
            weight: 400,
        },
        {
            name: "SeoulAlrimTTF-Heavy",
            data: await loadSeouAlrimFont("SeoulAlrimTTF-Heavy"),
            style: "normal",
            weight: 700,
        },
    ]
}
 
  return new ImageResponse(
    (
      <div
        style={{
          flexDirection: "column",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          width: "100%",
          height: "100%",
          gap: "24px",
          backgroundColor: "#09090b",
          position: "relative",
        }}
      >
        <div
          style={{
            position: "absolute",
            top: 0,
            left: 0,
            width: "100%",
            height: "100%",
            backgroundImage: `
              linear-gradient(rgba(39, 39, 42, 0.4) 1px, transparent 1px),
              linear-gradient(90deg, rgba(39, 39, 42, 0.4) 1px, transparent 1px)
            `,
            backgroundSize: "40px 40px",
            display: "flex",
          }}
        />
        
        {/* Subtle gradient overlay */}
        <div
          style={{
            position: "absolute",
            top: 0,
            left: 0,
            width: "100%",
            height: "100%",
            background: "radial-gradient(circle at center, rgba(24, 24, 27, 0.7) 0%, rgba(9, 9, 11, 0.9) 70%)",
            display: "flex",
          }}
        />
 
        <div style={{
          position: 'absolute',
          top: '50%',
          left: '50%',
          transform: 'translate(-50%, -50%)',
          width: "1300px",
          height: "1300px",
          borderRadius: '100%',
          border: '10px dashed #3f3f46',
        }} />
 
        <div
          style={{
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            fontSize: "120px",
            borderRadius: "100%",
            padding: "16px",
            width: "160px",
            height: "160px",
          }}
        >

        </div>
        <div
          style={{
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            color: "white",
            fontSize: "64px",
            fontWeight: 500,
            fontFamily: "SeoulAlrimTTF-Heavy",
            flexWrap: "wrap",
            width: "100%",
            textAlign: "center",
            padding: "0 16px",
            textShadow: "0 2px 4px rgba(0, 0, 0, 0.5)",
          }}
        >
          {title}
        </div>
        <div
          style={{
            display: "flex",
            flexDirection: "row",
            flexWrap: "wrap",
            alignItems: "center",
            justifyContent: "center",
            gap: "8px",
          }}
        >
          {tags &&
            tags.slice(0, 5).map((tag: string, index: number) => (
              <div
                key={index}
                style={{
                  display: "flex",
                  alignItems: "center",
                  justifyContent: "center",
                  color: "white",
                  fontSize: "32px",
                  fontWeight: 500,
                  fontFamily: "SeoulAlrimTTF-Heavy",
                  padding: "16px 24px",
                  borderRadius: "100px",
                  backgroundColor: "#27272a",
                  border: "1px solid #3f3f46",
                  boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)",
                }}
              >
                #{tag}
              </div>
            ))}
        </div>
      </div>
    ),
    imageOptions
  );
}

로컬에서 OG 이미지 미리 보기

개발자 도구를 열고 Elements 탭에서 head 태그를 열어보면 meta 태그 중 og:image 속성을 찾을 수 있다.

로딩 중...og:image

content를 복사 한 후 접속하면 이미지를 미리 볼 수 있다.

로딩 중...og:image

추가로

OG Image Playground에서 오픈그래프 이미지를 미리 그려볼 수 있다. 로딩 중...오픈그래프 이미지 테스트

기본적으로 생성된 이미지는 dynamic API나 캐시되지 않은 데이터를 사용하지 않는 한 빌드 타임에 정적으로 최적화되고 캐시된다.2

Footnotes

  1. Satori 문서

  2. next.js opengraph-image