ガイド 第24

パーソナルシェアカード

4S user → OG 自動生成

@event/fours-sdk@event/visuals

参加者が自分の 4S プロフィールを SNS にシェアしたときに出る OGP 画像を per-request で自動生成する。第22章の「経路B: Satori/OG」の実装例であり、next/ogImageResponse(Satori が server 側で SVG→PNG)で 1200×630 のカードを焼く。新規 npm 依存は無いnext/og は Next 内蔵、外部の画像生成モデルも不要)。

配線は starter に 2 ファイルだけ:

  • src/app/api/og/[slug]/route.tsx — 画像本体を返す route handler
  • src/app/share/[slug]/page.tsxgenerateMetadataog:image を上記に向ける公開シェアページ

1. slug → 4S プロフィール → EventBrand で描く

route handler は 4S へライブ fetch するため runtime = "nodejs"@event/fours-sdkprocess.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>generateMetadataog: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-dynamicfetchPublicProfile 側が 10 分 TTL を持つ(負値もキャッシュ)
フォントNoto Sans JP を best-effort ランタイム取得(同梱 woff を持たない)。失敗は既定フォント
フォールバックプロフィール null → slug を氏名に / アバター無し → 頭文字。500 を返さない
feature flag不要(公開ページ。event.config に足さない)