はじめに
「自分がこれまでプレイしてきたゲームを記録・管理して、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の設定
- vercel.com でGitHubリポジトリを連携
- 環境変数を設定(
.env.localの内容をそのまま入力) - 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と連携するだけで自動デプロイが完成 |