0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Next.js 16 × Supabase】ゲーム遍歴記録サービス「Gamemory」をゼロから作ってVercelにデプロイした

0
Posted at

はじめに

「自分がこれまでプレイしてきたゲームを記録・管理して、URLで友達に見せたい」

そんなシンプルな動機で、GamemoryというWebサービスをゼロから作りました。

  • ゲームのプレイ状況(プレイ中・クリア済み・積んでる etc.)を記録
  • RAWG APIと連携してカバー画像を自動取得
  • /u/ユーザー名 の公開URLで誰でも閲覧可能

この記事では、設計・実装・デプロイまでの一通りの流れを紹介します。

完成品: https://gamemory-cyan.vercel.app/


使った技術スタック

技術 用途
Next.js 16(App Router) フロントエンド・APIルート
Supabase PostgreSQL DB + 認証
Tailwind CSS v4 スタイリング
TypeScript 型安全
RAWG API ゲーム情報・カバー画像取得
Vercel ホスティング・自動デプロイ

全体のアーキテクチャ

ユーザーのブラウザ
    ↕
Vercel(Next.jsアプリ)
    ↕
Supabase(DB + 認証)  RAWG API(ゲーム検索)

GitHubにpushすると、VercelがWebhookで検知して1〜3分で自動デプロイされます。
SupabaseはコードとDB両方を担うため、GitHubとは独立しています。


Phase 1: Supabaseのセットアップ

プロジェクト作成

Supabaseでプロジェクトを作成し、.env.localに接続情報を設定します。

NEXT_PUBLIC_SUPABASE_URL=https://xxxxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGci...
SUPABASE_SERVICE_ROLE_KEY=eyJhbGci...

DBスキーマ

-- ユーザープロフィール(auth.usersと1:1)
create table profiles (
  id uuid references auth.users on delete cascade primary key,
  username text unique not null,
  avatar_url text,
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);

-- ゲームログ
create table game_logs (
  id uuid default gen_random_uuid() primary key,
  user_id uuid references profiles(id) on delete cascade not null,
  title text not null,
  platform text not null,
  status text not null check (status in ('playing','cleared','stacked','dropped','unplayed')),
  cover_image text,
  start_date date,
  end_date date,
  play_hours numeric,
  rating integer check (rating between 1 and 10),
  memo text,
  rawg_id integer,
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);

Row Level Security(RLS)

Supabaseのセキュリティの核心部分。「誰が何を読み書きできるか」をDBレベルで制御します。

-- profilesのRLS
alter table profiles enable row level security;

-- 誰でも読める(公開プロフィール)
create policy "profiles are viewable by everyone"
  on profiles for select using (true);

-- 自分のプロフィールのみ作成・更新可能
create policy "users can insert own profile"
  on profiles for insert with check (auth.uid() = id);
create policy "users can update own profile"
  on profiles for update using (auth.uid() = id);

-- game_logsのRLS
alter table game_logs enable row level security;

create policy "game_logs are viewable by everyone"
  on game_logs for select using (true);
create policy "users can manage own game_logs"
  on game_logs for all using (auth.uid() = user_id);

サインアップ時にprofilesを自動作成するトリガー

create or replace function public.handle_new_user()
returns trigger as $$
begin
  insert into public.profiles (id, username)
  values (
    new.id,
    new.raw_user_meta_data->>'username'
  );
  return new;
end;
$$ language plpgsql security definer;

create trigger on_auth_user_created
  after insert on auth.users
  for each row execute procedure public.handle_new_user();

Phase 2: 認証の実装

Next.js 16の破壊的変更に注意

Next.js 16では middleware.ts が proxy.ts に変わり、エクスポートする関数名も middleware → proxy に変更されています。

// src/proxy.ts(Next.js 16)
import { type NextRequest, NextResponse } from "next/server";
import { createServerClient } from "@supabase/ssr";

export async function proxy(request: NextRequest) {
  // 未ログインで /dashboard にアクセス → /login へ
  // ログイン済みで /login・/signup にアクセス → /dashboard へ
  // ...
}

Server Actionsで認証処理

Next.js App Routerの"use server"を使ったサインアップ処理です。

// src/app/actions/auth.ts
"use server";

export async function signUp(formData: FormData) {
  const email = formData.get("email") as string;
  const password = formData.get("password") as string;
  const username = (formData.get("username") as string)?.toLowerCase().trim();

  const supabase = await createClient();

  // ユーザー名の重複チェック
  const { data: existing } = await supabase
    .from("profiles")
    .select("username")
    .eq("username", username)
    .single();

  if (existing) return { error: "このユーザー名はすでに使用されています" };

  // サインアップ(metadataにusernameを渡すとトリガーでprofilesに保存される)
  const { error } = await supabase.auth.signUp({
    email,
    password,
    options: { data: { username } },
  });

  if (error) return { error: error.message };

  redirect("/check-email"); // メール確認フロー
}

Phase 3: ゲームCRUD

ゲーム追加・編集・削除のServer Actions

// src/app/actions/game.ts
export async function createGame(formData: FormData) {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) redirect("/login");

  const { error: validationError, data } = parseGameFormData(formData);
  if (validationError || !data) return { error: validationError };

  const { error } = await (supabase as any)
    .from("game_logs")
    .insert({ user_id: user.id, ...data });

  if (error) return { error: "保存に失敗しました" };

  revalidatePath("/dashboard");
  redirect("/dashboard");
}

メモ: Supabaseクライアントに<Database>ジェネリクスを渡すと型推論がneverになる問題があり、(supabase as any)で回避しています。


Phase 4: RAWG APIでゲーム検索

RAWGは500,000以上のゲームデータを持つAPI(無料プランあり)です。

APIルート

// src/app/api/games/search/route.ts
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const q = searchParams.get("q") ?? "";

  const res = await fetch(
    `https://api.rawg.io/api/games?key=${process.env.RAWG_API_KEY}&search=${encodeURIComponent(q)}&page_size=10`,
    { next: { revalidate: 60 } } // 1分キャッシュ
  );

  const data = await res.json();
  return Response.json({ results: data.results ?? [] });
}

検索コンポーネント(300msデバウンス)

// src/components/GameSearch.tsx
const search = useCallback(async (q: string) => {
  if (q.trim().length < 2) return;
  const res = await fetch(`/api/games/search?q=${encodeURIComponent(q)}`);
  const json = await res.json();
  setResults(json.results ?? []);
}, []);

useEffect(() => {
  if (debounceRef.current) clearTimeout(debounceRef.current);
  debounceRef.current = setTimeout(() => search(query), 300);
}, [query, search]);

ポイント: RAWGは英語タイトルで検索するとヒット率が上がります。プレースホルダーに「英語で検索推奨(例: Zelda, Monster Hunter)」と明記しました。


Phase 5: 公開プロフィールページ

/u/[username] でゲーム一覧を公開できます。

// src/app/u/[username]/page.tsx(Server Component)
export default async function UserProfilePage({ params }) {
  const { username } = await params;
  const supabase = await createClient();

  const { data: profile } = await supabase
    .from("profiles")
    .select("id, username, avatar_url, created_at")
    .eq("username", username)
    .single();

  if (!profile) notFound();

  const { data: games } = await supabase
    .from("game_logs")
    .select("*")
    .eq("user_id", profile.id)
    .order("start_date", { ascending: false });

  // 自分のページかどうか判定
  const { data: { user } } = await supabase.auth.getUser();
  const isOwner = user?.id === profile.id;

  return (/* ... */);
}

フィルタリング(ステータス・プラットフォーム・プレイ年・キーワード)はクライアントコンポーネントのGameFilter.tsxに分離しています。


Phase 6: Vercelへのデプロイ

GitHubリポジトリを作成してpush

git init
git add .
git commit -m "initial commit"
git remote add origin https://github.com/ユーザー名/gamemory.git
git push -u origin main

Vercelの設定

  1. vercel.com でGitHubリポジトリを連携
  2. 環境変数を設定(.env.localの内容をそのまま入力)
  3. Deployボタンを押すだけ

GitHubにpushするたびに自動デプロイされます。


本番公開時の注意:メール確認フロー

開発中はSupabaseの「Confirm email」をOFFにしていましたが、本番公開前に必ずONに戻しましょう。

ONにした場合、サインアップ後の処理を変更する必要があります。

// メール確認コールバックルート
// src/app/auth/callback/route.ts
export async function GET(request: NextRequest) {
  const { searchParams, origin } = new URL(request.url);
  const code = searchParams.get("code");

  if (code) {
    const supabase = await createClient();
    const { error } = await supabase.auth.exchangeCodeForSession(code);
    if (!error) {
      return NextResponse.redirect(`${origin}/dashboard`);
    }
  }

  return NextResponse.redirect(`${origin}/login?error=auth`);
}

Supabase Dashboard → Authentication → URL Configuration で以下を設定:

  • Site URL: https://あなたのドメイン.vercel.app
  • Redirect URLs: https://あなたのドメイン.vercel.app/auth/callback

ハマったポイントまとめ

1. Next.js 16でmiddleware.tsが廃止

proxy.ts + export async function proxy() に変更が必要。
エラーメッセージが分かりにくいので要注意。

2. Supabaseのジェネリクスで型がneverになる

// NG: ジェネリクスを渡すと型推論がおかしくなる
const supabase = createBrowserClient<Database>(url, key);

// OK: ジェネリクスなしにして、必要な箇所に型アサーション
const supabase = createBrowserClient(url, key);
const { data } = await supabase.from("profiles").select("*");
const profile = data as Profile | null;

3. 日本のゲームがRAWGで見つからない

RAWGは英語タイトルでの検索精度が高いため、UIに「英語で検索推奨」と明示しました。


完成したもの

プレイ状況のフィルタリング、RAWG連携によるカバー画像取得、公開プロフィールページなど、シンプルながら実用的なサービスができました。

残タスク(今後やりたいこと)

  • UIのレスポンシブ対応
  • ゲーム追加後のレコメンド機能

まとめ

ポイント 内容
Next.js 16 App Router + Server Actions で認証・CRUD がスッキリ書ける
Supabase RLS + トリガーでセキュアなDB設計がノーコードで完結
RAWG API 無料でゲーム情報・カバー画像が取得できる
Vercel GitHubと連携するだけで自動デプロイが完成
0
2
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
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?