Next.js에서 OG 이미지 생성하기 — 동적 og:image API
FastOG의 구독 기반 동적 API를 사용하여 Next.js에서 OG 이미지를 생성합니다. 동적 API 구독(대시보드에서 API 키 생성)과 무료 OG 이미지 테스터를 통해 코드를 작성하기 전에 이미지를 미리 볼 수 있습니다. 이 가이드는 Next.js App Router에서 HMAC 서명 URL을 사용하여 동적 Open Graph 이미지를 구축하는 방법을 보여줍니다.
빠른 시작
1. 서명자 유틸리티 생성
javascript
// lib/og-image-signer.js
import crypto from "crypto";
export function generateSignedOgUrl(params, options = {}) {
const {
baseUrl = "https://fastog.com/api/v1/og",
apiKey = process.env.FASTOG_API_KEY,
hmacSecret = process.env.FASTOG_HMAC_SECRET,
} = options;
if (!apiKey || !hmacSecret) {
throw new Error("Missing FASTOG_API_KEY or FASTOG_HMAC_SECRET");
}
// RFC-3986 percent-encoding: keep A-Za-z0-9-._~
const enc = (s) =>
encodeURIComponent(s).replace(
/[!'()*]/g,
(c) => "%" + c.charCodeAt(0).toString(16).toUpperCase(),
);
// Every query param except the signature, INCLUDING key
const all = { ...params, key: apiKey };
// Canonical query: sorted enc(key)=enc(value) pairs joined with &
const pairs = [];
for (const [k, v] of Object.entries(all)) {
for (const vv of Array.isArray(v) ? v : [v]) {
pairs.push(`${enc(k)}=${enc(vv)}`);
}
}
pairs.sort();
const canonical = pairs.join("&");
// Sign the exact bytes the server will verify
const signature = crypto
.createHmac("sha256", hmacSecret)
.update(`GET/api/v1/og\n${canonical}`)
.digest("hex");
// Build the URL from the same pairs so bytes match the canonical form
const url = new URL(baseUrl);
for (const [k, v] of Object.entries(all)) {
for (const vv of Array.isArray(v) ? v : [v]) {
url.searchParams.append(Array.isArray(v) ? `${k}[]` : k, vv);
}
}
url.searchParams.set("s", signature);
return url.toString();
}
2. 환경 변수 설정
bash
# .env.local
FASTOG_API_KEY=sk_your_full_api_key_here
FASTOG_HMAC_SECRET=your_64_char_hex_secret_here
패턴 1: 메타데이터 API (권장)
typescript
// app/blog/[slug]/page.tsx
import { generateSignedOgUrl } from "@/lib/og-image-signer";
import { getPost } from "@/lib/posts";
interface PageProps {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({ params }: PageProps) {
const { slug } = await params;
const post = await getPost(slug);
const ogImageUrl = generateSignedOgUrl({
template: "blog",
title: post.title,
subtitle: post.excerpt,
author: post.author.name,
});
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [
{
url: ogImageUrl,
width: 1200,
height: 630,
alt: post.title,
},
],
type: "article",
},
twitter: {
card: "summary_large_image",
images: [ogImageUrl],
},
};
}
export default async function BlogPost({ params }: PageProps) {
const { slug } = await params;
const post = await getPost(slug);
return (
<article>
<h1>{post.title}</h1>
<p>{post.excerpt}</p>
</article>
);
}
패턴 2: API 라우트 프록시
OG 이미지를 캐싱하거나 사용자 정의 로직을 추가하려면 이 방법을 사용하세요.
typescript
// app/api/og/route.ts
import { NextRequest, NextResponse } from "next/server";
import { generateSignedOgUrl } from "@/lib/og-image-signer";
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const title = searchParams.get("title");
const template = searchParams.get("template") || "default";
if (!title) {
return NextResponse.json(
{ error: "Missing title parameter" },
{ status: 400 },
);
}
const ogImageUrl = generateSignedOgUrl({
template,
title,
});
// Option 1: Redirect to FastOG
return NextResponse.redirect(ogImageUrl);
// Option 2: Fetch and return image (for caching)
// const response = await fetch(ogImageUrl);
// const imageBuffer = await response.arrayBuffer();
// return new NextResponse(imageBuffer, {
// headers: {
// 'Content-Type': 'image/png',
// 'Cache-Control': 'public, max-age=31536000, immutable',
// },
// });
} catch (error) {
console.error("OG image generation failed:", error);
return NextResponse.json(
{ error: "Failed to generate OG image" },
{ status: 500 },
);
}
}
템플릿에서의 사용법:
typescript
<meta property="og:image" content={`/api/og?title=${encodeURIComponent(post.title)}`} />
패턴 3: 서버 컴포넌트 직접 사용
typescript
// app/blog/[slug]/page.tsx
import { generateSignedOgUrl } from '@/lib/og-image-signer';
export default async function BlogPost({ params }) {
const post = await getPost(params.slug);
const ogImageUrl = generateSignedOgUrl({
template: 'blog',
title: post.title,
});
return (
<article>
<head>
<meta property="og:image" content={ogImageUrl} />
<meta property="og:title" content={post.title} />
</head>
{/* ... post content ... */}
</article>
);
}
전자상거래 예시
typescript
// app/products/[id]/page.tsx
import { generateSignedOgUrl } from "@/lib/og-image-signer";
export async function generateMetadata({ params }) {
const { id } = await params;
const product = await getProduct(id);
const ogImageUrl = generateSignedOgUrl({
template: "ecommerce",
title: product.name,
price: product.price,
image: product.image,
});
return {
openGraph: {
title: product.name,
description: product.description,
images: [{ url: ogImageUrl, width: 1200, height: 630 }],
type: "product",
},
};
}
사용자 프로필 (블로그 템플릿)
FastOG에는 profile 템플릿이 없습니다. 프로필 스타일 카드에는 blog을 사용하고, 사용자 이름은 title로, 소개는 subtitle으로, 이름은 author로 전달하세요.
typescript
// app/users/[username]/page.tsx
import { generateSignedOgUrl } from "@/lib/og-image-signer";
export async function generateMetadata({ params }) {
const { username } = await params;
const user = await getUser(username);
const ogImageUrl = generateSignedOgUrl({
template: "blog",
title: user.name,
subtitle: user.bio,
author: user.name,
});
return {
openGraph: {
title: `${user.name} - Profile`,
description: user.bio,
images: [{ url: ogImageUrl, width: 1200, height: 630 }],
type: "profile",
},
};
}
일반적인 함정
전송하는 바이트와 다른 바이트에 서명하는 경우
서명은 GET/api/v1/og과 개행 문자, 그리고 표준 쿼리(정렬된 enc(key)=enc(value) 쌍)를 포함합니다. 여기서 s을 제외한 모든 매개변수가 포함되며, key도 포함됩니다. 동일한 인코딩된 쌍으로 최종 URL을 구성하지 않으면 서버가 401 invalid_signature을 반환합니다.
클라이언트 코드에 HMAC 비밀 노출
❌ 잘못된 예:
javascript
// In client component
const ogUrl = generateSignedOgUrl(params, {
hmacSecret: process.env.NEXT_PUBLIC_HMAC_SECRET,
});
✅ 올바른 예:
javascript
// In server component or API route (never expose to client)
export async function generateMetadata() {
const ogUrl = generateSignedOgUrl(params);
}