JavaScriptでのOG画像
FastOG APIを使用してJavaScriptでog:imageを生成します。Dynamic APIサブスクリプション(ダッシュボードでAPIキーを作成)、サブスクリプションベース、HMACで保護されています。このガイドでは、HMAC署名付きOG画像URLを作成するための署名ユーティリティ(signer utility)を作成し、動的なOpen Graph画像をその場でレンダリングする方法を説明します。公開前にFree OG Image Testerでプレビューしてください。
目次
- クイックスタート(5分)
- Next.js App Router
- Next.js Pages Router
- Express.js バックエンド
- Vanilla Node.js
- フロントエンド統合パターン
- テストとデバッグ
- よくある落とし穴
クイックスタート
目標: 署名付きOG画像URLを5分で生成する。
ステップ1: 依存関係のインストール
npm install crypto # Built-in, no install needed
ステップ2: 署名ユーティリティの作成
// 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();
}
ステップ3: アプリでの使用
// app/blog/[slug]/page.jsx
import { generateSignedOgUrl } from "@/lib/og-image-signer";
export async function generateMetadata({ params }) {
const post = await getPost(params.slug);
const ogImageUrl = generateSignedOgUrl({
template: "blog",
title: post.title,
subtitle: post.excerpt,
});
return {
openGraph: {
images: [ogImageUrl],
},
};
}
ステップ4: テスト
署名ツールはESモジュール(import/export)であるため、動的なimport()を使用します:
node -e "
import('./lib/og-image-signer.js').then(({ generateSignedOgUrl }) => {
console.log(generateSignedOgUrl({ title: 'Test' }));
});
"
Next.js App Router
パターン1: Metadata API(推奨)
// 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,
// Optional: customize colors
theme: "indigo",
});
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],
},
};
}
パターン2: API Route プロキシ
OG画像をキャッシュしたり、カスタムロジックを追加したい場合はこちらを使用します。
// 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 },
);
}
// Generate signed URL
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 },
);
}
}
使用方法:
// In your component
<meta property="og:image" content={`/api/og?title=${encodeURIComponent(post.title)}`} />
パターン3: サーバーコンポーネント直接
// 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>
);
}
Next.js Pages Router
// pages/blog/[slug].tsx
import { generateSignedOgUrl } from '@/lib/og-image-signer';
import { GetStaticProps, GetStaticPaths } from 'next';
interface BlogPostProps {
post: Post;
ogImageUrl: string;
}
export default function BlogPost({ post, ogImageUrl }: BlogPostProps) {
return (
<>
<Head>
<meta property="og:image" content={ogImageUrl} />
<meta property="og:title" content={post.title} />
<meta property="og:description" content={post.excerpt} />
</Head>
<article>
<h1>{post.title}</h1>
<p>{post.excerpt}</p>
</article>
</>
);
}
export async function getStaticPaths() {
// Generate paths...
}
export async function getStaticProps({ params }) {
const post = await getPost(params.slug);
const ogImageUrl = generateSignedOgUrl({
template: 'blog',
title: post.title,
subtitle: post.excerpt,
});
return {
props: {
post,
ogImageUrl,
},
};
}
Express.js バックエンド
セットアップ
npm install express crypto
実装
// app.js
import express from "express";
import crypto from "crypto";
import { URL } from "url";
const app = express();
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;
// 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();
}
// Blog post route
app.get("/blog/:slug", async (req, res) => {
const post = await getPost(req.params.slug);
const ogImageUrl = generateSignedOgUrl({
template: "blog",
title: post.title,
subtitle: post.excerpt,
});
res.render("blog/post", {
post,
ogImageUrl,
});
});
// API endpoint for dynamic OG images
app.get("/api/og", (req, res) => {
const { title, template = "default" } = req.query;
if (!title) {
return res.status(400).json({ error: "Missing title" });
}
const ogImageUrl = generateSignedOgUrl({
template,
title,
});
res.json({ ogImageUrl });
});
app.listen(3000, () => {
console.log("Server running on port 3000");
});
EJS テンプレート
<!-- views/blog/post.ejs -->
<!DOCTYPE html>
<html>
<head>
<meta property="og:image" content="<%= ogImageUrl %>" />
<meta property="og:title" content="<%= post.title %>" />
<meta property="og:description" content="<%= post.excerpt %>" />
</head>
<body>
<h1><%= post.title %></h1>
</body>
</html>
Vanilla Node.js
// og-signer.js
const crypto = require("crypto");
const { URL } = require("url");
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;
// 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();
}
module.exports = { generateSignedOgUrl };
使用方法:
// index.js
const { generateSignedOgUrl } = require("./og-signer");
const ogUrl = generateSignedOgUrl({
template: "blog",
title: "My Blog Post",
});
console.log("OG Image URL:", ogUrl);
フロントエンド統合パターン
パターン1: 静的サイト生成(SSG)
最適な用途: ブログ記事、商品ページ、頻繁に変更されないコンテンツ。
// Generate at build time
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post) => ({ slug: post.slug }));
}
export async function generateMetadata({ params }) {
const post = await getPost(params.slug);
return {
openGraph: {
images: [generateSignedOgUrl({ title: post.title })],
},
};
}
パターン2: サーバーサイドレンダリング(SSR)
最適な用途: 動的コンテンツ、ユーザー固有のページ。
export default async function Page({ params }) {
const data = await fetchData(params.id);
const ogImageUrl = generateSignedOgUrl({ title: data.title });
return (
<>
<Head>
<meta property="og:image" content={ogImageUrl} />
</Head>
<Content data={data} />
</>
);
}
パターン3: クライアントサイドフォールバック
サーバーサイド生成が不可能な場合に使用します。
'use client';
import { useEffect, useState } from 'react';
export default function ShareButtons({ post }) {
const [ogImageUrl, setOgImageUrl] = useState<string | null>(null);
useEffect(() => {
// Fetch from your API that generates signed URLs
fetch(`/api/og?title=${encodeURIComponent(post.title)}`)
.then(res => res.json())
.then(data => setOgImageUrl(data.ogImageUrl));
}, [post.title]);
const shareUrl = ogImageUrl || '/fallback-og.png';
return (
<div>
<button onClick={() => shareToTwitter(shareUrl)}>
Share on Twitter
</button>
</div>
);
}
⚠️ 警告: クライアントサイド生成では、ソーシャルクローラーが動的なOG画像を認識できません。可能な場合はSSR/SSGを使用してください。
テストとデバッグ
ユニットテスト
// __tests__/og-image-signer.test.js
import { generateSignedOgUrl } from "../lib/og-image-signer";
describe("generateSignedOgUrl", () => {
const mockParams = {
title: "Test Post",
template: "blog",
};
const mockOptions = {
apiKey: "sk_test1234567890",
hmacSecret: "a".repeat(64),
};
it("generates a valid URL", () => {
const url = generateSignedOgUrl(mockParams, mockOptions);
expect(url).toMatch(/^https:\/\/fastog\.com\/api\/v1\/og\?/);
expect(url).toContain("key=sk_test");
expect(url).toContain("s=");
});
it("includes all params", () => {
const url = generateSignedOgUrl(mockParams, mockOptions);
const parsed = new URL(url);
expect(parsed.searchParams.get("title")).toBe("Test Post");
expect(parsed.searchParams.get("template")).toBe("blog");
});
it("throws without API key", () => {
expect(() => generateSignedOgUrl(mockParams, {})).toThrow();
});
});
手動テスト
# 1. Generate URL (ES module — use dynamic import)
node -e "
import('./lib/og-image-signer.js').then(({ generateSignedOgUrl }) => {
const url = generateSignedOgUrl({ title: 'Test' });
console.log(url);
});
"
# 2. Test with curl
curl "https://fastog.com/api/v1/og?title=Test&key=sk_quick_123abc&s=<sig>"
# 3. Check response headers
curl -I "https://fastog.com/api/v1/og?title=Test&key=sk_quick_123abc&s=<sig>"
デバッグモード
function generateSignedOgUrl(params, options = {}) {
const debug = options.debug || process.env.DEBUG_OG === "true";
// ... existing code ...
if (debug) {
console.log("OG URL Debug:", {
baseUrl,
canonical,
apiKey,
signature,
finalUrl: url.toString(),
});
}
return url.toString();
}
よくある落とし穴
1. クライアントコードでのHMACシークレットの露出
❌ 誤り:
// In client component
const ogUrl = generateSignedOgUrl(params, {
hmacSecret: process.env.NEXT_PUBLIC_HMAC_SECRET,
});
✅ 正しい:
// In server component or API route
export async function generateMetadata() {
const ogUrl = generateSignedOgUrl(params);
// ...
}
2. URLエンコーディングの処理漏れ
❌ 誤り:
const url = `${baseUrl}?title=${params.title}&key=${apiKey}`;
✅ 正しい:
const url = new URL(baseUrl);
url.searchParams.set("title", params.title);
url.searchParams.set("key", apiKey);
3. 送信するバイト列と異なるデータへの署名
署名はGET/api/v1/ogと改行、そして正規化されたクエリを対象とします。これはsを除くすべてのパラメータのソート済みenc(key)=enc(value)ペアで、keyを含みます。最終的なURLは、これらの同じエンコード済みペアから構築してください。そうしないと、サーバーは401 invalid_signatureを返します。
環境変数
# .env.local
FASTOG_API_KEY=sk_your_full_api_key_here
FASTOG_HMAC_SECRET=your_64_char_hex_secret_here
FASTOG_BASE_URL=https://fastog.com/api/v1/og
// lib/env.ts
import { z } from "zod";
const envSchema = z.object({
FASTOG_API_KEY: z.string().startsWith("sk_"),
FASTOG_HMAC_SECRET: z.string().length(64),
FASTOG_BASE_URL: z
.string()
.url()
.optional()
.default("https://fastog.com/api/v1/og"),
});
export const env = envSchema.parse(process.env);