Next.js에서 포스트별 OG 이미지 자동 생성하기
OG 이미지가 필요한 이유
블로그 링크를 슬랙이나 카카오톡에 공유하면 제목과 설명만 덜렁 나온다.
OG 이미지가 없으면 그렇다.
썸네일 하나 있고 없고의 클릭률 차이는 크다.
근데 매번 글마다 이미지를 직접 만들기는 귀찮다.
자동화하자.
전략
- 글에 이미지가 있으면 → 첫 번째 이미지를 OG 이미지로 사용
- 이미지가 없으면 → 제목 + 카테고리 + 날짜로 텍스트 기반 이미지 자동 생성
Next.js의 opengraph-image.tsx 파일 컨벤션과 next/og의 ImageResponse를 사용한다.
별도 라이브러리 설치 없이 Next.js 내장 기능만으로 충분하다.
1단계: MDX에서 첫 번째 이미지 추출
Velite로 MDX를 빌드하면 body가 컴파일된 JS 문자열이 된다.
이미지는 이 문자열 안에서 img,{src:"..."} 패턴으로 들어간다.
정규식으로 src를 뽑으면 된다.
// velite.config.ts
function extractFirstImage(body: string): string | null {
const match = /img,\{src:"([^"]+)"/.exec(body);
return match ? match[1] : null;
}기존에 TOC를 추출할 때 쓰던 방식과 동일하다.
컴파일된 MDX body에서 패턴 매칭으로 필요한 정보를 꺼내는 거다.
transform에서 thumbnail 필드로 저장한다:
// velite.config.ts
.transform((data) => {
const slugAsParams = data.slug.split("/").slice(1).join("/");
const toc = extractToc(data.body);
const thumbnail = extractFirstImage(data.body);
return {
...data,
slug: slugAsParams,
permalink: `/posts/${slugAsParams}`,
toc,
thumbnail,
};
}),빌드 타임에 한 번만 실행되니 런타임 비용은 제로다.
2단계: opengraph-image.tsx 생성
Next.js App Router에는 opengraph-image.tsx라는 파일 컨벤션이 있다.
이 파일을 라우트 세그먼트에 두면 해당 경로의 OG 이미지를 자동으로 생성하고, <meta property="og:image"> 태그도 자동 주입한다.
직접 메타태그에 이미지 URL을 넣을 필요가 없다.
// src/app/posts/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
import { posts } from "#site/content";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";size와 contentType을 export하면 Next.js가 이 값을 메타태그에 반영한다.
1200x630은 OG 이미지의 표준 사이즈다.
한글 폰트 처리
ImageResponse는 Satori 기반이라 시스템 폰트를 못 쓴다.
폰트를 직접 로드해야 한다.
Google Fonts에서 Noto Sans KR을 fetch한다:
async function loadFont(): Promise<ArrayBuffer> {
const res = await fetch(
"https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@700&display=swap"
);
const css = await res.text();
const fontUrl = css.match(
/src:\s*url\(([^)]+)\)\s*format\('(woff2|truetype|opentype)'\)/
)?.[1];
if (!fontUrl) throw new Error("Font URL not found");
const fontRes = await fetch(fontUrl);
return fontRes.arrayBuffer();
}CSS 응답에서 실제 폰트 파일 URL을 정규식으로 추출하는 방식이다.
빌드 타임에만 실행되니 런타임 네트워크 요청은 없다.
이미지가 있을 때
포스트에 썸네일이 있으면 public/ 디렉토리에서 이미지를 읽어서 base64로 변환한 뒤 렌더링한다.
하단에 그라데이션 오버레이를 깔고 제목과 카테고리를 표시한다:
if (post.thumbnail) {
const imagePath = join(process.cwd(), "public", post.thumbnail);
const imageData = await readFile(imagePath);
const base64 = imageData.toString("base64");
const ext = post.thumbnail.split(".").pop() ?? "png";
const mime = mimeMap[ext] ?? "image/png";
const dataUrl = `data:${mime};base64,${base64}`;
return new ImageResponse(
(
<div style={{ width: "100%", height: "100%", display: "flex", position: "relative" }}>
<img src={dataUrl} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
<div style={{
position: "absolute", bottom: 0, left: 0, right: 0, height: "40%",
background: "linear-gradient(to top, rgba(0,0,0,0.8), transparent)",
display: "flex", flexDirection: "column", justifyContent: "flex-end",
padding: "40px 50px",
}}>
<div style={{ fontSize: 48, fontWeight: 700, color: "#fff" }}>{post.title}</div>
</div>
</div>
),
{ ...size, fonts: [{ name: "Noto Sans KR", data: fontData, weight: 700 }] }
);
}ImageResponse는 JSX를 받아서 PNG로 렌더링한다.
CSS-in-JS 스타일 객체만 지원하고, Tailwind는 못 쓴다. Satori의 제약이다.
fs.readFile로 로컬 이미지를 직접 읽는 이유는 ImageResponse 내부에서 외부 URL fetch가 불안정하기 때문이다.
base64 data URL로 변환하면 확실하다.
이미지가 없을 때
텍스트 기반 폴백 디자인이다. 어두운 배경에 제목을 크게 넣고, 카테고리와 날짜를 하단에 배치한다:
return new ImageResponse(
(
<div style={{
width: "100%", height: "100%", display: "flex", flexDirection: "column",
justifyContent: "space-between", backgroundColor: "#0a0a0a", padding: "60px 50px",
}}>
<div style={{ display: "flex", flexDirection: "column", flex: 1, justifyContent: "center" }}>
<div style={{ fontSize: 56, fontWeight: 700, color: "#ededed", lineHeight: 1.3 }}>
{post.title}
</div>
<div style={{ fontSize: 28, color: "rgba(255,255,255,0.5)", marginTop: 24, display: "flex", gap: 20 }}>
<span>{post.category}</span>
<span>·</span>
<span>{new Date(post.date).toLocaleDateString("ko-KR", {
year: "numeric", month: "long", day: "numeric",
})}</span>
</div>
</div>
<div style={{ display: "flex", justifyContent: "flex-end", fontSize: 28, color: "rgba(255,255,255,0.35)" }}>
easthxxn
</div>
</div>
),
{ ...size, fonts: [{ name: "Noto Sans KR", data: fontData, weight: 700 }] }
);이미지 없는 글도 공유할 때 깔끔한 카드가 나온다.
정적 생성
generateStaticParams를 export하면 빌드 타임에 모든 포스트의 OG 이미지가 미리 생성된다:
export function generateStaticParams() {
return posts
.filter((post) => post.published)
.map((post) => ({ slug: post.slug }));
}런타임에 이미지를 그리는 게 아니라 빌드 결과물에 PNG가 포함되는 거다.
서버 부하 제로.
3단계: 메타데이터 보강
page.tsx의 generateMetadata에 openGraph 필드를 추가한다:
export async function generateMetadata({ params }: PostPageProps): Promise<Metadata> {
const { slug } = await params;
const post = getPostBySlug(slug);
if (!post) return {};
return {
title: post.title,
description: post.description,
openGraph: {
title: post.title,
description: post.description,
type: "article",
},
};
}images 필드는 안 넣어도 된다.
opengraph-image.tsx가 같은 라우트 세그먼트에 있으면 Next.js가 알아서 og:image 메타태그를 주입한다.
이게 파일 컨벤션의 장점이다.
빌드 결과
Route (app)
├ ● /posts/[slug]
│ ├ /posts/2026-01-31-claude-code-shortcuts
│ ├ /posts/2026-01-31-hello-dev
│ └ /posts/2026-01-31-us-news
├ ● /posts/[slug]/opengraph-image
│ ├ /posts/2026-01-31-claude-code-shortcuts/opengraph-image
│ ├ /posts/2026-01-31-hello-dev/opengraph-image
│ └ /posts/2026-01-31-us-news/opengraph-image
포스트별로 /opengraph-image 경로가 자동으로 생긴다.
이 URL이 og:image 메타태그에 들어가고, 크롤러가 이 경로를 요청하면 PNG가 응답된다.
정리
수정한 파일은 세 개다:
- velite.config.ts —
extractFirstImage로 MDX body에서 첫 번째 이미지 경로 추출 - opengraph-image.tsx —
ImageResponse로 1200x630 PNG 생성 (이미지 있으면 썸네일, 없으면 텍스트) - page.tsx —
generateMetadata에openGraph필드 추가
외부 라이브러리 없이 Next.js 내장 기능만으로 구현했다.
opengraph-image.tsx 파일 컨벤션이 메타태그 주입까지 알아서 해주니까, 신경 쓸 게 별로 없다.
댓글
불러오는 중...