0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

FF14 のミラプリ管理サイトをClaude CodeとNext.js + Supabaseの無料枠だけで作って公開した

0
Posted at

FF14 のミラプリ管理サイトを Claude Code と Next.js + Supabase の無料枠だけで作って公開した

はじめに

Sonnet君、ちょっと不具合多くない!??

……という愚痴から始まった個人開発です。この話は後半の「Claude Code との開発の回し方」で回収します。

作ったもの

投影書庫 という、FF14 のミラージュプリズム(ミラプリ = 見た目装備)を記録・共有する Web サービスを作りました。

FF14 には見た目装備の構成を保存する「ミラージュプレート」という仕組みがありますが、上限が 20 枚しかありません。

お気に入りでも使っていないものから断腸の思いで消すことになります。「せめて消す前に残したい」というのが開発の動機です。

主な機能は以下のとおりです。

  • 装備 12 部位 + 染色 2 色をアイテム名のオートコンプリート付きで記録
  • SS を ミラプリ 1 件につき最大 4 枚アップロード可能
  • タグとキーワードで検索
  • 「公開」にしたミラプリは URL を知っていれば未ログインでも閲覧可能

ランニングコストは 0 円、実装はほぼすべて Claude Code に任せました。

この記事では 「無料枠に収める設計」「実装でハマったポイント」「Claude Code との開発の回し方」 の 3 つを書きます。

構成

領域 採用
フレームワーク Next.js 16(App Router)+ React 19 + TypeScript
UI Tailwind CSS v4 + shadcn/ui(base-ui)
認証・DB・画像 Supabase(Auth / PostgreSQL / Storage、無料枠)
ホスティング Vercel(Hobby)
テスト Vitest
ゲームデータ XIVAPI(装備アイテム名・アイコン)
メール送信 Gmail SMTP(Supabase Custom SMTP に設定)
開発 Claude Code

無料枠に収める設計

上限は無料枠から逆算して決める

無料で運用する以上、先に枯渇するリソースを把握しておかないと、ユーザーが増えた瞬間にサービスが止まります。Supabase 無料枠の主な制約は Storage 1GB と egress 5GB/月です。

そこで「1 ユーザーあたり画像 10MB・ミラプリ 30 件まで」という上限を最初に決め、コードの定数に根拠ごと書いています。

// Supabase 無料枠の Storage は 1GB。10MB/人なら最大 100 人まで受け入れられる。
// 実測中央値 142KB/枚では 10MB は約 72 枚(4 枚/件で約 18 件)で埋まるため、
// 多くのユーザーは 30 件に達する前に容量上限が先に効く。
export const MAX_GLAMOURS = 30;
export const MAX_STORAGE_BYTES = 10 * 1024 * 1024;

10MB ÷ 1GB で最大 100 ユーザー。個人のファンサイトとしては十分で、超えそうになったらそれは有料化を検討すべきシグナルです。「制限がある」ことより「どこまで受け入れられるか分かっている」ことが重要でした。なお、アップロード前のクォータ判定は純粋関数に切り出して Vitest でテストしています。

アクセス制御は RLS に寄せる

「自分のミラプリは全操作可、公開中のものは誰でも閲覧可」というアクセス制御は、アプリのコードではなく PostgreSQL の Row Level Security で表現しています。

CREATE POLICY "own_glamours_all"    ON glamours FOR ALL
  USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id);
CREATE POLICY "public_glamours_sel" ON glamours FOR SELECT
  USING (is_public = true);

公開ページ側のクエリは「id で引いて is_public = true を条件に付ける」だけです。非公開の id も存在しない id も等しく null になるため、404 の出し分けを誤って非公開の存在が漏れる事故がクエリ構造上起きません。アプリ側の書き忘れで他人のデータが見える、という種類のバグを DB 層で潰せるのが RLS の利点です。

Storage の「孤児画像」は GitHub Actions で週次掃除する

DB のレコード削除と Storage のファイル削除は別システムなので、トランザクションで括れません。削除処理の途中失敗や、アップロード後に保存せず離脱したケースで、DB に親レコードのない画像(孤児)が Storage に残ります。10MB/人の設計なので、放置すると容量を静かに食い潰します。

対策として、「DB に存在するミラプリ ID の集合」と「Storage のファイルパス」を突き合わせて孤児を削除するスクリプトを、GitHub Actions のスケジュール実行(週次)で回しています。

// DB に親のミラプリが無いファイル。削除漏れと、保存せず離脱した分を回収する
export function orphanPaths(
  stored: StoredImage[],
  aliveGlamourIds: Set<string>,
): string[] {
  return stored
    .filter((image) => !aliveGlamourIds.has(image.glamourId))
    .map(storedImagePath);
}

判定ロジックは純粋関数なのでテスト済みです。Actions 側は service role キー(RLS を迂回するキー)で実行するため、アプリのコードには一切持ち込まず、GitHub Secrets とバッチ専用にしています。

1 点注意として、GitHub Actions のスケジュール実行はデフォルトブランチのワークフロー定義しか見ない仕様です。develop で直しても main にマージするまで反映されません。

実装でハマったポイント

認証メールのリンクは code ではなく token_hash で検証する

一番ハマったのがここです。パスワードリセットとメール確認を実装する際、Supabase の標準的なフロー({{ .ConfirmationURL }}exchangeCodeForSession)をそのまま使うと、PC で申請してスマホでメールを開くと必ず失敗します

原因は PKCE フローの仕組みです。@supabase/ssr はフローを PKCE に固定しており、申請時に code verifier が Cookie に保存されます。メール内の code はこの verifier とペアで初めて検証できるため、申請した端末・ブラウザ以外でリンクを開くと verifier がなく検証に失敗します。メールを別端末で開くのはごく普通の行動なので、これでは使い物になりません。

解決策は、メールテンプレートを token_hash 方式に書き換え、サーバー側で verifyOtp を使うことです。token_hash は単体で検証できるため端末をまたげます。

Supabase ダッシュボードのメールテンプレート(例: Reset password):

<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=recovery&next=/update-password">
  パスワードを再設定する
</a>

受け側の Route Handler:

// メール内リンクからセッションを確立する。別端末でメールを開いても成立するよう、
// PKCE の code ではなくメールテンプレートの token_hash を検証する
const SUPPORTED_OTP_TYPES = ["recovery", "email"] as const;

export async function GET(request: NextRequest) {
  const { searchParams, origin } = request.nextUrl;
  const tokenHash = searchParams.get("token_hash");
  const type = searchParams.get("type");
  const otpType = type && isSupportedOtpType(type) ? type : null;

  if (tokenHash && otpType) {
    const supabase = await createClient();
    const { error } = await supabase.auth.verifyOtp({
      type: otpType,
      token_hash: tokenHash,
    });

    if (!error) {
      const next = safeNextPath(searchParams.get("next"));
      return NextResponse.redirect(new URL(next, origin));
    }
  }

  const failurePath = otpType ? FAILURE_PATH[otpType] : FALLBACK_FAILURE_PATH;
  return NextResponse.redirect(new URL(failurePath, origin));
}

公式ドキュメントのサーバーサイドフローも token_hash + verifyOtp を案内しています(Server-Side Auth · Email Auth with PKCE flow)。なお、メール確認の type は signup が非推奨のため email を使うこと、next パラメータはオープンリダイレクト対策として / で始まり // で始まらないパスのみ許可すること(上記の safeNextPath)にも注意してください。

Next.js の metadata は「浅いマージ」で継承される

公開ページを X に貼ったときに SS 入りのカードを出すため、generateMetadata で OGP を出力しています。ここで 1 つ罠がありました。

root layout で openGraph: { siteName, locale } を定義していても、ページ側で openGraph を定義すると layout の値は引き継がれません。metadata のマージはオブジェクト単位の浅いマージで、openGraph キーごと置き換わるためです(Next.js Docs: generateMetadata · merging)。

return {
  title: `${glamour.title} | ${SITE_NAME}`,
  openGraph: {
    // openGraph は親から浅くマージされるため siteName / locale も明示する
    siteName: SITE_NAME,
    locale: "ja_JP",
    type: "article",
    images: ogImages,
  },
  // ...
};

もう 1 点、OGP の画像 URL は絶対 URL である必要があり、metadataBase の設定が必要です。Vercel なら環境変数 VERCEL_PROJECT_PRODUCTION_URL(スキームなしの本番ドメイン)が build・runtime 両方で参照でき、プレビューデプロイでも本番ドメインを指すので、OGP 用途に向いています(Vercel Docs: System environment variables)。

運用まわりの細かい話

メール送信は Gmail SMTP

Supabase 標準のメール送信は極めて厳しいレート制限があり、本番運用には Custom SMTP が前提です。無料の SMTP サービス(Brevo 等)も検討しましたが、2024 年以降の Gmail / Yahoo の送信者要件により、フリーメールアドレスを差出人にした第三者 SMTP 経由の送信は認証を通せず迷惑メール行きになります。独自ドメインを持たない段階では、Gmail のアプリパスワードで smtp.gmail.com:587 から送るのが現実解でした(送信上限は 1 日 100 通程度)。

アクセス解析は Vercel Web Analytics

Cookie を使わない集計型で、Hobby プランは 50,000 イベント/月まで無料、超過しても課金されず収集が止まるだけです(Vercel Docs: Web Analytics limits and pricing)。Cookie 同意バナーを出さずに済み、プライバシーポリシーには集計データのみ取得する旨を明記しました。

ゲームデータは DB にキャッシュ

装備名のオートコンプリートのために XIVAPI のアイテムデータを PostgreSQL にインポートし、週次の GitHub Actions で再インポートしています。検索のたびに外部 API を叩かないので、XIVAPI 側の負荷にもレイテンシにも優しい構成です。

Claude Code との開発の回し方

実装・調査・検証は Claude Code に任せ、私は方針の決定と確認に回りました。

モデルは使い分ける

冒頭の愚痴の回収です。当初 Sonnet で実装を進めていたところ細かい不具合が目立ったため、基本的な実装は Opus 4.8、計画立案や最終チェックは Fable 5 という分担に切り替えました。速さとコストを取るか品質を取るかはタスクごとに違うので、「実装はこのモデル、レビューはこのモデル」と決めてしまうと迷いがなくなります。

そのうえで、安定して回すためにやったことは 3 つです。

プロジェクトルールを CLAUDE.md に書く

リポジトリ直下の CLAUDE.md は Claude Code が毎セッション読み込む指示書です。ここに TDD(Red → Green → Refactoring)、提案前に必ず実行して検証すること、any 禁止などの型規約、ブランチ・コミット規約(feature/#<issue番号>、Semantic Commit Message、コミット前にメッセージを人間が確認)を書いておくと、セッションをまたいで同じ品質で作業が進みます。口頭で毎回指示するのではなく、指摘したことをルールとして書き足していく運用です。

フェーズと Issue で区切る

「Phase N」という傘 Issue を立て、その配下の Issue 1 つを 1 回の依頼の単位にしました。ブランチも feature/#<issue番号> で 1 対 1 に対応させます。一度に任せる範囲を絞ると、変更が把握可能な範囲に収まり、PR のレビューも現実的な量になります。

検証は実物で

先述の PKCE 問題は、Claude Code が Supabase のドキュメントと @supabase/ssr のソースコードを読んで「フローが PKCE に固定されている」ことを特定したものです。OGP の実装後は、一時的な公開データを作り、本番の meta タグと og:image が実際に取得できることを確認してからデータを削除する、という検証まで Claude Code が行っています。「推測で答えず、実行して確かめる」を CLAUDE.md に明記しておくと、この動きが既定になります。

一方で、Supabase ダッシュボードの設定変更(Custom SMTP やメール確認の ON/OFF)、実際にメールが届くかの最終確認、マージと本番反映の判断は人間側の作業として残しました。役割分担をはっきりさせると、任せる部分は思い切って任せられます。

ファンサイトとしての注意点

FF14 の二次創作サイトなので、スクウェア・エニックスの 著作物利用許諾条件 に従っています。特に注意したのは以下の点です。

  • 非営利であること。 広告収入や対価の受け取りは「商用」扱いになるため、広告は載せない(Vercel Hobby プランの非商用限定とも整合)
  • 権利表記。 © SQUARE ENIX をフッターに表示
  • 利用規約・プライバシーポリシーを設置し、問い合わせ先を明記

このあたりは機能開発より地味ですが、公開して人に使ってもらう以上は先に片付けておくべき部分でした。

おわりに

プレート上限 20 枚に困っている FF14 プレイヤーの方は、ぜひ使ってみてください: 投影書庫

今後は未ログイン向けのランディングページ、sitemap 等の検索基盤、ユーザーが増えた場合の独自ドメイン移行を予定しています。無料枠の上限(約 100 ユーザー)に近づいたら、それは嬉しい悲鳴として有料化を検討します。

なお、この記事自体も Claude Code で下書きを生成し、Claude(chat)と手作業でブラッシュアップしたものです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?