このレシピは 4S user slug からパーソナル OG 共有カードを自動生成する経路(第24章 / 経路B: Satori/OG)を配線する完全手順。 GET /api/og/<slug> が 4S 公開プロフィールをライブ取得して 1200×630 の PNG を焼き、 公開ページ /share/<slug> の generateMetadata がそれを og:image に張る。 対象 app を apps/<app>(例では apps/starter)とする。上から順に実行すれば完了する。
feature flag は不要(公開ページのみで admin nav も env も持たないため、features / FEATURES descriptor への追加なし)。
1. 依存
- next/og は Next.js 内蔵 —
import { ImageResponse } from "next/og"はそのまま使える。依存追加は不要。 @event/fours-sdkは new-event 時点で配線済み。- カードの色 / 書体は
@event/visualsの EventBrand トークンに従う。add-visuals 済みならこの手順はスキップ。
未導入なら apps/<app>/package.json の dependencies に:
"@event/visuals": "*"
apps/<app>/next.config.ts の transpilePackages 配列に "@event/visuals" を足し、root で yarn install。
2. ブランドトークン(src/lib/visuals.ts)
add-visuals 済みなら既存の visualsBrand をそのまま使う(スキップ)。無ければ最小版を作る:
apps/<app>/src/lib/visuals.ts:
import { resolveBrand, type EventBrand } from "@event/visuals";
/** イベントのブランドトークン(DEFAULT_BRAND への部分上書き)。 */
export const visualsBrand: EventBrand = resolveBrand({
name: "starter",
colors: {
bg: "#0b0e13",
bg2: "#141a24",
accent: "#5ec8c0",
accentDeep: "#2e6f6a",
accentAlt: "#e0653c",
},
});
resolveBrand は DEFAULT_BRAND(中立ダーク)に部分定義を浅くマージするので、カードが参照する 未指定トークン(fg / fgDim / fgFaint / hair / fonts.mono)も全て既定で埋まる。色を差し替えるだけで カードが reskin される(フォーマットレジストリ等の完全版は add-visuals / starter の同ファイル参照)。
3. OG 画像 route(/api/og/[slug]/route.tsx)
apps/<app>/src/app/api/og/[slug]/route.tsx(JSX を含むので拡張子は .tsx):
import { ImageResponse } from "next/og";
import {
createFoursConfig,
extractSlug,
fetchPublicProfile,
type PublicProfile,
} from "@event/fours-sdk";
import { visualsBrand } from "@/lib/visuals";
// 4S へ per-request でライブ fetch する(build 時 prerender しない)。
export const dynamic = "force-dynamic";
// fours-sdk が process.env を広く読むため node runtime を明示する。
export const runtime = "nodejs";
const WIDTH = 1200;
const HEIGHT = 630;
/* ─────────── アバター inline 化(失敗は null) ─────────── */
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) return null;
const ctype = res.headers.get("content-type") ?? "image/jpeg";
if (!ctype.startsWith("image/")) return null;
const buf = Buffer.from(await res.arrayBuffer());
return `data:${ctype};base64,${buf.toString("base64")}`;
} catch {
return null;
}
}
/* ─────────── Noto Sans JP を best-effort でランタイム取得 ───────────
Satori は system-ui を解釈せず woff2 も読めない。Google Fonts の CSS API を
旧 UA で叩いて truetype/woff の実体 URL を得てから fetch する。全て try/catch で
包み、失敗時は空配列(= 既定フォントで描画継続)。text= で必要字だけ subset。 */
const fontCache = new Map<string, ArrayBuffer | null>();
async function loadNotoSansJp(subset: string): Promise<ArrayBuffer | null> {
const key = subset || "_";
const cached = fontCache.get(key);
if (cached !== undefined) return cached;
let data: ArrayBuffer | null = null;
try {
const cssUrl =
"https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@700&display=swap&text=" +
encodeURIComponent(subset);
const cssRes = await fetch(cssUrl, {
headers: {
// 旧 UA を装って woff2 ではなく truetype/woff の URL を得る(Satori は woff2 非対応)。
"User-Agent":
"Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)",
},
cache: "no-store",
});
if (cssRes.ok) {
const css = await cssRes.text();
const m = css.match(/src:\s*url\(([^)]+)\)\s*format\(['"]?(?:truetype|opentype|woff)['"]?\)/);
const fontUrl = m?.[1];
if (fontUrl) {
const fontRes = await fetch(fontUrl, { cache: "no-store" });
if (fontRes.ok) data = await fontRes.arrayBuffer();
}
}
} catch {
data = null;
}
fontCache.set(key, data);
return data;
}
/* ─────────── カード JSX(Satori: flex サブセットのみ) ─────────── */
type CardData = {
name: string;
title: string | null;
org: string | null;
slug: string;
avatar: string | null;
};
function primaryOrg(profile: PublicProfile): string | null {
const current = profile.orgs.find((o) => o.current);
return (current ?? profile.orgs[0])?.orgName ?? null;
}
function Card({ data }: { data: CardData }) {
const b = visualsBrand;
const c = b.colors;
return (
<div
style={{
width: WIDTH,
height: HEIGHT,
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"',
position: "relative",
}}
>
{/* 左端アクセントバー */}
<div
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 12,
background: c.accent,
display: "flex",
}}
/>
{/* ヘッダ: mono ラベル + イベント名 */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div
style={{
display: "flex",
fontFamily: b.fonts.mono,
fontSize: 22,
letterSpacing: 6,
textTransform: "uppercase",
color: c.accent,
}}
>
4S · SHARE CARD
</div>
<div style={{ display: "flex", fontSize: 22, letterSpacing: 3, color: c.fgFaint }}>
{b.name.toUpperCase()}
</div>
</div>
{/* 本体: アバター + 氏名 / 肩書 / 所属 */}
<div style={{ display: "flex", alignItems: "center", gap: 48 }}>
{data.avatar ? (
<img
src={data.avatar}
width={220}
height={220}
style={{
width: 220,
height: 220,
borderRadius: 9999,
objectFit: "cover",
border: `4px solid ${c.accent}`,
}}
/>
) : (
<div
style={{
width: 220,
height: 220,
borderRadius: 9999,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: c.accentDeep,
color: c.fg,
fontSize: 96,
fontWeight: 700,
border: `4px solid ${c.accent}`,
}}
>
{(data.name.trim()[0] ?? "?").toUpperCase()}
</div>
)}
<div style={{ display: "flex", flexDirection: "column", maxWidth: 700 }}>
<div style={{ display: "flex", fontSize: 68, fontWeight: 700, lineHeight: 1.1 }}>
{data.name}
</div>
{data.title ? (
<div style={{ display: "flex", fontSize: 32, marginTop: 18, color: c.fgDim }}>
{data.title}
</div>
) : null}
{data.org ? (
<div style={{ display: "flex", fontSize: 28, marginTop: 8, color: c.fgFaint }}>
{data.org}
</div>
) : null}
</div>
</div>
{/* フッタ: slug ハンドル + hairline */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
borderTop: `1px solid ${c.hair}`,
paddingTop: 24,
}}
>
<div style={{ display: "flex", fontFamily: b.fonts.mono, fontSize: 26, color: c.fgDim }}>
@{data.slug}
</div>
<div style={{ display: "flex", fontFamily: b.fonts.mono, fontSize: 22, color: c.accentAlt }}>
4s.link
</div>
</div>
</div>
);
}
/* ─────────── GET ハンドラ ─────────── */
export async function GET(_req: Request, ctx: { params: Promise<{ slug: string }> }) {
const raw = (await ctx.params).slug;
const slug = extractSlug(decodeURIComponent(raw)) || raw;
// 取得は全て best-effort。失敗しても汎用カードにフォールバックし 500 にしない。
let profile: PublicProfile | null = null;
try {
profile = await fetchPublicProfile(createFoursConfig(), slug);
} catch {
profile = null;
}
const data: CardData = {
name: profile?.name ?? slug,
title: profile?.title ?? null,
org: profile ? primaryOrg(profile) : null,
slug: profile?.slug ?? slug,
avatar: await loadAvatarDataUri(profile?.avatarUrl ?? null),
};
const subset = `${data.name}${data.title ?? ""}${data.org ?? ""}${data.slug}`;
const fontData = await loadNotoSansJp(subset);
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: WIDTH,
height: HEIGHT,
...(fonts ? { fonts } : {}),
});
}
Satori は CSS の flex サブセットしか解釈しないので、複数子を持つ div には必ず display: "flex" を書く (無いと ImageResponse が throw する)。extractSlug は URL 形式(https://4s.link/ja/asao)も raw slug(asao)も受けて slug に正規化する。
4. 公開シェアページ(/share/[slug]/page.tsx)
generateMetadata が og:image を同 slug の /api/og/<slug> に向ける。OGP クローラは相対 URL を 解決しないので、NEXT_PUBLIC_SITE_URL(明示)→ VERCEL_URL(Vercel が自動注入)→ 相対パス (dev フォールバック)の順で絶対 URL に解決する。
apps/<app>/src/app/share/[slug]/page.tsx:
import type { Metadata } from "next";
import {
createFoursConfig,
extractSlug,
fetchPublicProfile,
type PublicProfile,
} from "@event/fours-sdk";
// 4S へライブ fetch するため build 時 prerender を避ける。
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ slug: string }> };
/** 絶対 OG URL 用の origin(未設定なら相対パスで返す)。 */
function siteOrigin(): string | null {
const explicit = process.env.NEXT_PUBLIC_SITE_URL?.trim();
if (explicit) return explicit.replace(/\/+$/, "");
const vercel = process.env.VERCEL_URL?.trim();
if (vercel) return `https://${vercel.replace(/\/+$/, "")}`;
return null;
}
async function loadProfile(rawSlug: string): Promise<{ slug: string; profile: PublicProfile | null }> {
const slug = extractSlug(decodeURIComponent(rawSlug)) || rawSlug;
try {
return { slug, profile: await fetchPublicProfile(createFoursConfig(), slug) };
} catch {
return { slug, profile: null };
}
}
export async function generateMetadata({ params }: Params): Promise<Metadata> {
const { slug, profile } = await loadProfile((await params).slug);
const displayName = profile?.name ?? slug;
const title = `${displayName} — 4S share card`;
const description =
profile?.title?.trim() ||
profile?.bio?.trim() ||
`${displayName} の 4S プロフィール共有カード`;
const origin = siteOrigin();
const ogPath = `/api/og/${encodeURIComponent(slug)}`;
const imageUrl = origin ? `${origin}${ogPath}` : ogPath;
const pageUrl = origin ? `${origin}/share/${encodeURIComponent(slug)}` : undefined;
return {
...(origin ? { metadataBase: new URL(origin) } : {}),
title,
description,
openGraph: {
type: "profile",
title,
description,
...(pageUrl ? { url: pageUrl } : {}),
images: [{ url: imageUrl, width: 1200, height: 630, alt: title }],
},
twitter: {
card: "summary_large_image",
title,
description,
images: [imageUrl],
},
};
}
const label: React.CSSProperties = {
fontFamily: "var(--mono)",
fontSize: 11,
letterSpacing: "0.15em",
textTransform: "uppercase",
color: "var(--accent)",
margin: 0,
};
export default async function SharePage({ params }: Params) {
const { slug, profile } = await loadProfile((await params).slug);
const displayName = profile?.name ?? slug;
const config = createFoursConfig();
const fourSUrl = `${config.webBase.replace(/\/+$/, "")}/${encodeURIComponent(slug)}`;
const ogSrc = `/api/og/${encodeURIComponent(slug)}`;
const currentOrgs = (profile?.orgs ?? []).filter((o) => o.current);
return (
<main style={{ maxWidth: 720, margin: "0 auto", padding: "48px 24px" }}>
<p style={label}>4S · share card</p>
<h1 style={{ fontSize: 28, margin: "6px 0 4px" }}>{displayName}</h1>
{profile?.title ? (
<p style={{ fontSize: 14, color: "var(--ink-2)", margin: "0 0 4px" }}>{profile.title}</p>
) : null}
{!profile ? (
<p style={{ fontSize: 13, color: "var(--ink-3)", margin: "0 0 4px", maxWidth: "70ch" }}>
このハンドルの 4S 公開プロフィールを取得できませんでした(未接続 / 非公開 / 不明な slug)。
下のカードは汎用フォールバックで生成しています。
</p>
) : null}
{/* OG カードのプレビュー(/api/og/<slug> をそのまま画像として読む) */}
<div
style={{
border: "1px solid var(--line)",
background: "var(--surface)",
borderRadius: 8,
overflow: "hidden",
margin: "20px 0 24px",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={ogSrc}
alt={`${displayName} の OG カード`}
width={1200}
height={630}
style={{ display: "block", width: "100%", height: "auto" }}
/>
</div>
{profile?.bio ? (
<section
style={{
border: "1px solid var(--line)",
background: "var(--surface)",
padding: "16px 20px",
marginBottom: 20,
}}
>
<p style={{ fontSize: 14, margin: 0, whiteSpace: "pre-wrap", lineHeight: 1.9 }}>
{profile.bio}
</p>
</section>
) : null}
{currentOrgs.length > 0 ? (
<section
style={{
border: "1px solid var(--line)",
background: "var(--surface)",
padding: "16px 20px",
marginBottom: 20,
}}
>
<h2 style={{ fontSize: 13, margin: "0 0 8px" }}>現所属</h2>
<ul style={{ margin: 0, paddingLeft: 20, fontSize: 14 }}>
{currentOrgs.map((o) => (
<li key={o.orgId} style={{ marginBottom: 4 }}>
<b>{o.orgName}</b>
{o.title ? (
<span style={{ color: "var(--ink-3)", fontSize: 12, marginLeft: 8 }}>{o.title}</span>
) : null}
</li>
))}
</ul>
</section>
) : null}
<p style={{ fontSize: 13, margin: 0 }}>
<a href={fourSUrl} rel="noopener noreferrer" target="_blank" style={{ color: "var(--accent)" }}>
4S でプロフィール全体を見る →
</a>
</p>
</main>
);
}
本番デプロイでは NEXT_PUBLIC_SITE_URL=https://<本番ドメイン> を設定する (カスタムドメイン時、VERCEL_URL は *.vercel.app のデプロイ URL を指すため明示が確実)。
5. 三段フォールバック(絶対に 500 を返さない設計)
| 段 | 失敗するもの | フォールバック |
|---|---|---|
| プロフィール | 4S 未接続 / 不明 slug / 非公開(fetchPublicProfile は失敗時 null を返す) | slug を名前にした汎用カード。null 分岐 = フォールバックで 500 にしない |
| アバター | avatarUrl の fetch 失敗 / content-type が非画像 | イニシャル 1 文字の丸(accentDeep 地)。URL を Satori に渡さず data URI に inline 化してから描くのが要点 — Satori 内のリモート fetch 失敗は ImageResponse 全体を throw させる |
| フォント | Google Fonts CSS API / フォント実体の取得失敗 | 既定フォントで描画継続(日本語は tofu になりうるが 200 は返す)。取得は旧 UA で truetype URL を得る方式(Satori は woff2 非対応)+ text= subset + Map キャッシュ |
6. 型チェックと検証
yarn workspace <app> typecheck # 対象 app にスコープ
dev で確認(4S 未接続でも動く):
yarn workspace <app> dev- ブラウザで
/api/og/<4S slug>→ 1200×630 のカード画像が描画される。
curl -sI http://localhost:<port>/api/og/<slug> の content-type が image/png であること
/share/<slug>→ カードプレビュー + プロフィールが出る。ページソースにog:image/
twitter:image メタが入っていること
- 存在しない slug(例
/api/og/no-such-user)でも 500 にならず、slug 名 + イニシャルの
汎用カードが返ること
完了条件: typecheck が通り、/api/og/<slug> が PNG を返し、/share/<slug> の og:image が それを指し、4S 未接続 / 不正 slug でも汎用カードにフォールバックする。