参加者が自分の 4S プロフィールを SNS にシェアしたときに出る OGP 画像を per-request で自動生成する。第22章の「経路B: Satori/OG」の実装例であり、next/og の ImageResponse(Satori が server 側で SVG→PNG)で 1200×630 のカードを焼く。新規 npm 依存は無い(next/og は Next 内蔵、外部の画像生成モデルも不要)。
配線は starter に 2 ファイルだけ:
src/app/api/og/[slug]/route.tsx— 画像本体を返す route handlersrc/app/share/[slug]/page.tsx—generateMetadataでog:imageを上記に向ける公開シェアページ
1. slug → 4S プロフィール → EventBrand で描く
route handler は 4S へライブ fetch するため runtime = "nodejs"(@event/fours-sdk が process.env を広く読む)+ dynamic = "force-dynamic"。取得は fetchPublicProfile(第7章)1 本で、失敗時は null を返すのでフォールバック分岐がそのまま事故対策になる。
import { ImageResponse } from "next/og";
import { createFoursConfig, extractSlug, fetchPublicProfile } from "@event/fours-sdk";
import { visualsBrand } from "@/lib/visuals"; // EventBrand(色/書体)の唯一の reskin surface
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export async function GET(_req: Request, ctx: { params: Promise<{ slug: string }> }) {
const raw = (await ctx.params).slug;
const slug = extractSlug(decodeURIComponent(raw)) || raw; // URL を貼られても slug に正規化
let profile = null;
try {
profile = await fetchPublicProfile(createFoursConfig(), slug);
} catch {
profile = null; // ← stub 接続・ネットワーク断でも throw させない
}
const data = {
name: profile?.name ?? slug, // 取れなければ slug をそのまま氏名に
title: profile?.title ?? null,
slug: profile?.slug ?? slug,
avatar: await loadAvatarDataUri(profile?.avatarUrl ?? null),
};
// …Card を描いて ImageResponse で返す(下記)
}
カードの色・書体は すべて visualsBrand(EventBrand)から引く。イベントを差し替えるときに触るのは lib/visuals.ts の 1 ファイルだけ、という第17章の原則を OG でも守る:
function Card({ data }) {
const c = visualsBrand.colors;
return (
<div style={{
width: 1200, height: 630, display: "flex", flexDirection: "column",
justifyContent: "space-between", padding: 72,
background: `linear-gradient(135deg, ${c.bg} 0%, ${c.bg2} 100%)`,
color: c.fg, fontFamily: '"Noto Sans JP"',
}}>
{/* Satori は flexbox サブセットのみ。子が 2 つ以上の div には必ず display:flex を明示する */}
<div style={{ display: "flex", color: c.accent, fontFamily: visualsBrand.fonts.mono }}>
4S · SHARE CARD
</div>
<div style={{ display: "flex", alignItems: "center", gap: 48 }}>
{data.avatar
? <img src={data.avatar} width={220} height={220}
style={{ borderRadius: 9999, objectFit: "cover", border: `4px solid ${c.accent}` }} />
: <div style={{ /* 頭文字プレースホルダ */ }}>{data.name[0]}</div>}
<div style={{ display: "flex", flexDirection: "column" }}>
<div style={{ display: "flex", fontSize: 68, fontWeight: 700 }}>{data.name}</div>
{data.title ? <div style={{ display: "flex", color: c.fgDim }}>{data.title}</div> : null}
</div>
</div>
</div>
);
}
2. フォントとアバターは「落とさない」ように包む
Satori 特有の 2 つの罠を握りつぶす:
- アバターは URL を
<img>に直接渡さない。Satori はレンダ時にリモート fetch し、失敗するとImageResponse全体が throw する。こちら側で先に data URI へ inline 化し、失敗時はnull(頭文字プレースホルダ)に落とす。 - 日本語は既定フォントだと tofu。Satori は
system-uiを解釈せず woff2 も読めない(第22章)。Noto Sans JP を best-effort でランタイム取得して同梱する。取得できなければfontsを渡さず既定フォントで描画継続する。
async function loadAvatarDataUri(url: string | null): Promise<string | null> {
if (!url) return null;
try {
const res = await fetch(url, { cache: "no-store" });
if (!res.ok || !(res.headers.get("content-type") ?? "").startsWith("image/")) return null;
const buf = Buffer.from(await res.arrayBuffer());
return `data:${res.headers.get("content-type")};base64,${buf.toString("base64")}`;
} catch { return null; }
}
// Google Fonts CSS API を旧 UA で叩き truetype/woff の実体 URL を得る(woff2 は Satori 非対応)。
// text= で必要字だけ subset し、全て try/catch で包む。結果は module cache で使い回す。
const fontData = await loadNotoSansJp(`${data.name}${data.title ?? ""}${data.slug}`);
const fonts = fontData
? [{ name: "Noto Sans JP", data: fontData, weight: 700 as const, style: "normal" as const }]
: undefined;
return new ImageResponse(<Card data={data} />, {
width: 1200, height: 630,
...(fonts ? { fonts } : {}), // 取れた時だけ渡す
});
この 3 段(プロフィール取得 / アバター inline / フォント取得)が全部 fallback を持つので、4S 未接続(stub)でも不正 slug でも 500 を返さず、必ず 1 枚のカードが焼ける。
3. シェアページが og:image をこの route に向ける
/share/<slug> の generateMetadata が og:image を同 slug の /api/og/<slug> に向ける。絶対 URL 化は NEXT_PUBLIC_SITE_URL(無ければ VERCEL_URL)から組み、無ければ相対パス + metadataBase 無指定で出す。
export async function generateMetadata({ params }): Promise<Metadata> {
const { slug, profile } = await loadProfile((await params).slug);
const displayName = profile?.name ?? slug;
const imageUrl = `${siteOrigin() ?? ""}/api/og/${encodeURIComponent(slug)}`;
return {
title: `${displayName} — 4S share card`,
description: profile?.title ?? profile?.bio ?? `${displayName} の 4S プロフィール共有カード`,
openGraph: { type: "profile", images: [{ url: imageUrl, width: 1200, height: 630 }] },
twitter: { card: "summary_large_image", images: [imageUrl] },
};
}
ページ本文では同じ /api/og/<slug> を <img> として読み、カードのプレビュー + プロフィール(bio / 現所属 / 4S へのリンク)を並べる。プレビューと実 OG が同一 URL を指すので、SNS のクローラが見る画像と人が見る画像が必ず一致する。
運用メモ
| 項目 | 決定 |
|---|---|
| 実行場所 | runtime = "nodejs"(fours-sdk が env を読むため。edge も可だが node が安全) |
| キャッシュ | route は force-dynamic。fetchPublicProfile 側が 10 分 TTL を持つ(負値もキャッシュ) |
| フォント | Noto Sans JP を best-effort ランタイム取得(同梱 woff を持たない)。失敗は既定フォント |
| フォールバック | プロフィール null → slug を氏名に / アバター無し → 頭文字。500 を返さない |
| feature flag | 不要(公開ページ。event.config に足さない) |