この記事のまとめ
- 22 枚のメジャーアルカナで 1 枚引き / 3 枚スプレッドできるタロットサイト「Lumière Tarot」を 1 日で構築
- スタック: Next.js 15 (App Router) + TypeScript + Tailwind + Vercel
- Anthropic SDK → OpenAI SDK に乗り換え(差分 30 行、自宅 LM 移行を見据えて)
- 22 カード SSG + per-card OGP でロングテール SEO を仕込み
-
next/ogの Windows ローカルバグ 注意
技術判断の理由を意思決定単位で書きます。同じ構成を組む人の参考になれば。
何を作ったか
恋愛特化のタロット占いサイト「Lumière Tarot」。22 枚のメジャーアルカナで 1 枚引き / 3 枚スプレッドができ、質問内容を AI が個別解釈する。
ChatGPT を毎日触っている身からすると、「カードを引く儀式感」と「文脈を読んで個別に答える AI」の両方を備えた占いサイトが意外と少なかったので、自分で作った、という背景。プロダクト・ブランド側の話は note 版に書いたので、こちらは技術判断中心にいく。
スタック構成
| レイヤー | 採用 | 選定理由 |
|---|---|---|
| フレームワーク | Next.js 15(App Router) | SSG + Edge Streaming + SEO が一発で揃う |
| 言語 | TypeScript | 型がないと夜眠れない |
| スタイル | Tailwind CSS | 個人開発の速度命 |
| AI ランタイム | OpenAI SDK(gpt-5.4-mini) |
コスト最安・自動キャッシュ・自宅 LM 互換 |
| インフラ | Vercel | Next.js と 1:1 で噛む |
| 解析 | GA4(@next/third-parties/google) |
公式 wrapper |
| 22 カードページ | SSG(generateStaticParams) |
ロングテール SEO |
| OGP |
generateMetadata でカード別に出力 |
SNS シェア時のプレビュー |
SEO 戦略: 22 カードページの SSG 化
22 枚のメジャーアルカナを /cards/[slug] で全枚 SSG。generateStaticParams で build 時に 22 枚すべて事前生成し、generateMetadata でカードごとの title / description / canonical / OpenGraph を per-card に動的生成する。
// app/cards/[slug]/page.tsx
import type { Metadata } from "next";
import { ALL_CARDS, cardSlug, findCardBySlug } from "@/lib/cards";
type Props = { params: { slug: string } };
export async function generateStaticParams() {
return ALL_CARDS.map((card) => ({ slug: cardSlug(card) }));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const card = findCardBySlug(params.slug);
if (!card) return {};
return {
title: `${card.nameJa}(${card.nameEn}) - Lumière Tarot`,
description: card.description,
alternates: { canonical: `/cards/${cardSlug(card)}` },
openGraph: {
images: [{ url: card.image, width: 1114, height: 1919 }],
},
};
}
これで「[カード名] 恋愛」「[カード名] 逆位置 意味」のようなロングテール検索クエリを 22 ページ × 正逆 = 44 文脈で取りに行ける。
app/sitemap.ts と app/robots.ts も併設して、NEXT_PUBLIC_SITE_URL 駆動でビルド時に sitemap.xml / robots.txt を生成。Search Console に submit するだけで全 22 カード + 主要ページのインデックス促進が走る設計。
AI ランタイム選定の意思決定
最初は Anthropic SDK + Claude Haiku 4.5 で組んでいた。1 日の中で 3 モデルの実測比較スクリプトを書いて、最終的に OpenAI SDK に乗り換えた。
比較スクリプトの構成
scripts/compare-models.ts で 2 サンプル(1 枚引き恋愛 / 3 枚仕事)× 2 モデル × 2 回 = 8 リクエストを順次実行し、テキスト + usage(input / cache_read / cache_creation / output トークン + 経過 ms)を side-by-side で標準出力に表示した。
実測結果
| モデル | 品質 | キャッシュ | 実コスト/req | 備考 |
|---|---|---|---|---|
| Haiku 4.5 | フォーマット崩れがち | 0(4,096 tok 未到達) | 0.85 円 | 文章温度はいい |
| Sonnet 4.6 | 字数・段落数遵守、明らかに優位 | 完璧(3,780 tok cache_read 再利用) | 1.0〜2.85 円 | 価格きつい |
| gpt-5.4-mini | バランス良好 | 自動(≥1,024 tok prefix) | 安い | OpenAI 互換 |
特に効いたのは prompt caching の最低長。Haiku 4.5 は 4,096 tok 以上の prefix が必要だが、現プロンプトは 3,899 tok で届かず、cache_write も cache_read も 0 のままだった。Sonnet 4.6 はキャッシュ完璧だが価格が約 3 倍。
決め手は「自宅 LM 互換」
OpenAI 互換 API は Ollama / vLLM / LM Studio がすべて実装している。将来 7〜13B 級の自宅 LM へ移行する際、baseURL を変えるだけで切り替えられる。
const client = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
baseURL: process.env.OPENAI_BASE_URL, // ← 自宅 LM に向ける時はここ
});
Anthropic SDK のままだと SDK 書き換えが発生する。「今クラウド、将来自宅」の移行設計を含めると OpenAI SDK が三方良し(コスト・品質・拡張性)という結論になった。
provider-agnostic な prompt 設計
lib/prompt.ts を低レベル primitive と高レベル adapter に分離している。これは後で気が変わった場合に Anthropic に戻せるようにするためだが、結果的に OpenAI 移行も 30 行で済んだ。
// lib/prompt.ts
export const SYSTEM_PROMPT = `あなたは「リュミエール」というタロットの伴走者で...`;
export const CARDS_REFERENCE = `# カード辞書(メジャーアルカナ 22 枚)\n...`;
export function buildUserMessage(
question: string,
spread: "single" | "three",
drawn: Drawn[]
): string {
return [
`# 質問`,
question,
`# 引いたカード`,
drawn.map((d) => `- ${d.cardId}(${d.orientation})`).join("\n"),
].join("\n\n");
}
// Anthropic 用(cache_control 付き)
export function buildAnthropicRequest(...) {
return {
system: [
{ type: "text", text: SYSTEM_PROMPT },
{ type: "text", text: CARDS_REFERENCE, cache_control: { type: "ephemeral" } },
],
messages: [{ role: "user", content: buildUserMessage(question, spread, drawn) }],
};
}
// OpenAI 用(cache_control 不要、自動キャッシュ)
export function buildOpenAIRequest(
question: string,
spread: "single" | "three",
drawn: Drawn[]
): ChatCompletionMessageParam[] {
return [
{ role: "system", content: `${SYSTEM_PROMPT}\n\n${CARDS_REFERENCE}` },
{ role: "user", content: buildUserMessage(question, spread, drawn) },
];
}
これで本気で気が変わったら 30 行で Anthropic に戻せるし、baseURL を変えれば自宅 LM にも向けられる。
/api/interpret の実装
// app/api/interpret/route.ts
import OpenAI from "openai";
import { buildOpenAIRequest } from "@/lib/prompt";
const client = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
baseURL: process.env.OPENAI_BASE_URL, // 自宅 LM に向ける時はここ
});
export async function POST(req: Request) {
const { question, spread, drawn } = await req.json();
// ... 入力検証(question は 200 字、spread は single|three、drawn の枚数チェック等) ...
const stream = await client.chat.completions.create({
model: process.env.LUMIERE_INTERPRET_MODEL ?? "gpt-5.4-mini",
messages: buildOpenAIRequest(question, spread, drawn),
stream: true,
});
const readable = new ReadableStream({
async start(controller) {
try {
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta?.content ?? "";
if (delta) controller.enqueue(new TextEncoder().encode(delta));
}
} catch (e) {
controller.enqueue(new TextEncoder().encode(`\n[error] ${String(e)}`));
} finally {
controller.close();
}
},
});
return new Response(readable, {
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
Edge runtime にしなかった理由は、SDK 互換性とデバッグの素直さ優先。Streaming は Web 標準の Response(ReadableStream) で問題なく配信できる。
prompt caching を効かせる設計
OpenAI の自動 prompt caching は ≥1,024 tok の prefix を 10% 価格で再利用する。Anthropic の cache_control ほど明示的に制御できないが、prefix 不変性さえ守れば自動で効く。
具体的には:
- 安定部分(システムプロンプト + 22 枚カード辞書)を全部 system にまとめる
- 可変部分(質問・引いたカード)を user message に分離
- system は常に完全一致するので prefix としてキャッシュに乗る
これは Anthropic / OpenAI どちらでも共通の設計指針 で、tools → system → messages の render 順を踏まえて「変わらないものを上に」配置する。
ハマりポイント
1. next/og の Windows ローカルバグ
動的 OGP 画像生成(next/og の ImageResponse)を試したら、Windows ローカルで @vercel/og 同梱フォント(noto-sans-v27-latin-regular.ttf)のパス解決が壊れていてエラーが消えなかった。
ERR_INVALID_URL: '.\file:\C:\Users\...\noto-sans-v27-latin-regular.ttf'
URL が file:\ ではなく .\file:\ という不正な接頭辞で構築されていて、new URL() で必ず弾かれる。Next.js 15.1.6 + @vercel/og の組み合わせで再現する既知バグ系の挙動。Vercel 本番では問題ない可能性が高いが、ローカルで検証できないリスクは取れなかった。
最終的に静的な app/icon.svg(lavender → pink グラデ背景 + gold 8 ポイントスター)と per-card の静的 OGP(カード画像直貼り)に切り替えた。generateMetadata 側で openGraph.images に各カードの画像 URL を直接渡す方式。
教訓: アイコン・OGP は静的が一番確実。動的にする理由がはっきりしない限り、個人開発では後回し。
2. AI ランタイムの選定で数時間溶かした
実測比較スクリプトを書いて出力を比較しないと判断できなかった。最初から OpenAI 互換 API で組んでおけば、書き直しの 30 行は要らなかった。
教訓: AI 機能を作るときは、最初から 「ランタイムを差し替えられるか」「キャッシュに乗る prefix か」 を意識して組む。後から効いてくる。
これから
- レート制限(IP × 1 日 N 回)を
/api/interpretに追加してコスト爆発防止 - Stripe + 認証 + クレジット束課金(DAU 50 + リピート 20% で着手)
- 自宅 LM への移行(
baseURLを Ollama / vLLM の URL に変更するだけ) - 履歴保存サブスク + 複数デッキ(小アルカナ追加)
ブランド・グロース側の話は note 版に書いたので、興味ある方はそちらもどうぞ。
参考リンク
- サイト: Lumière Tarot
- note 一般版: https://note.com/glad_mint3943/n/n7e6e340f79ee
- Next.js 公式: generateStaticParams / generateMetadata
- OpenAI 公式: Prompt Caching
- Anthropic 公式: Prompt Caching
この記事は Zenn にも同内容を投稿しています: https://zenn.dev/nassos/articles/d7bee1af98ab19
ブランド・グロース側の話は note 一般版にも書いています: https://note.com/glad_mint3943/n/n7e6e340f79ee
===== コピペ終了 =====