
AIツールを用いて全国のフリーランスや、日々勉強している人々が気軽につながれるサービスを作成する本企画。これまでにChatGPTを用いた要件定義から利用環境の選定、環境構築の実施と作業を進めてきました。
前回の記事「Supabaseでデータベースを作成してNext.jsと連携させる手順を紹介」ではSupabaseとNext.jsを連携し、データベースの情報をブラウザ上で表示する所まで進める内容でした。
今回はその続きとして、アカウント認証機能と投稿機能を実装する過程について解説します。
認証機能と投稿機能をChatGPTへ聞きながら実装する
まずはChatGPTにどのような作業を実施するのか聞いてみます。
*ChatGPTの出力結果が長いので、スキップしたい方はこちらをクリックしてください。


1. 認証機能の完成 🔐
/app/auth/page.tsx でのログイン/サインアップフォームの実装
tsxCopy'use client';
import { useState } from 'react';
import { supabase } from '@/lib/supabase';
import { useRouter } from 'next/navigation';
export default function AuthPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const router = useRouter();
const handleSignUp = async () => {
setIsLoading(true);
setError(null);
const { data, error } = await supabase.auth.signUp({
email,
password,
});
setIsLoading(false);
if (error) {
setError(error.message);
} else {
alert('サインアップが完了しました!メールを確認してください。');
}
};
const handleSignIn = async () => {
setIsLoading(true);
setError(null);
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
setIsLoading(false);
if (error) {
setError(error.message);
} else {
router.push('/');
router.refresh(); // セッション変更を反映
}
};
return (
<div classname="max-w-md mx-auto mt-10 p-6 bg-white rounded-lg shadow-md">
<h1 classname="text-2xl font-bold mb-6">ログイン / 新規登録</h1>
{error && (
<div classname="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
)}
);
<div classname="mb-4">
<label classname="block text-gray-700 mb-2">メールアドレス</label>
<input type="email" classname="w-full p-2 border rounded" value="{email}" onchange="{(e) => setEmail(e.target.value)}" />
</div>
<div classname="mb-6">
<label classname="block text-gray-700 mb-2">パスワード</label>
<input type="password" classname="w-full p-2 border rounded" value="{password}" onchange="{(e) => setPassword(e.target.value)}" />
</div>
<div classname="flex space-x-4 mb-6">
<button onclick="{handleSignIn}" disabled classname="w-1/2 bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded">
{isLoading ? '読み込み中...' : 'ログイン'}
</button>
<button onclick="{handleSignUp}" disabled classname="w-1/2 bg-green-500 hover:bg-green-600 text-white py-2 px-4 rounded">
{isLoading ? '読み込み中...' : '新規登録'}
</button>
</div>
);
}
/app/auth/callback/page.tsx の実装(パスワードリセット等に必要)
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { supabase } from '@/lib/supabase';
export default function AuthCallbackPage() {
const router = useRouter();
useEffect(() => {
const handleAuthCallback = async () => {
const { data, error } = await supabase.auth.getSession();
// セッション取得済みならトップページへリダイレクト
if (data?.session || !error) {
router.push('/');
}
};
handleAuthCallback();
}, [router]);
return (
<div className="flex justify-center items-center min-h-screen">
<div>認証処理中...</div>
</div>
);
}
セッション管理と保護されたルートの設定
1,まず、セッション管理用のコンテキストを作成します。/lib/contexts/AuthContext.tsx:
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
import { User, Session } from '@supabase/supabase-js';
import { supabase } from '@/lib/supabase';
type AuthContextType = {
user: User | null;
session: Session | null;
isLoading: boolean;
};
const AuthContext = createContext({
user: null,
session: null,
isLoading: true,
});
export const useAuth = () => useContext(AuthContext);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState(null);
const [session, setSession] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const setupAuth = async () => {
// 初期セッション取得
const { data: { session } } = await supabase.auth.getSession();
setSession(session);
setUser(session?.user ?? null);
setIsLoading(false);
// セッション変更の監視
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(_, session) => {
setSession(session);
setUser(session?.user ?? null);
setIsLoading(false);
}
);
return () => subscription.unsubscribe();
};
setupAuth();
}, []);
return (
<AuthContext.Provider value="{{ user, session, isLoading }}">
{children}
</AuthContext.Provider>
);
}
2,ルートレイアウトでAuthProviderを使用します。/app/layout.tsx:
import './globals.css';
import { AuthProvider } from '@/lib/contexts/AuthContext';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ja">
<body>
<AuthProvider>
{children}
</AuthProvider>
</body>
</html>
);
}
3, 保護されたルートを作成するためのHOC。/lib/auth/withAuth.tsx:
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/contexts/AuthContext';
export default function withAuth<P extends object>(
Component: React.ComponentType<P>
) {
return function ProtectedRoute(props: P) {
const { user, isLoading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!isLoading && !user) {
router.push('/auth');
}
}, [isLoading, user, router]);
if (isLoading) {
return <div>Loading...</div>;
}
if (!user) {
return null;
}
return <Component {...props} />;
};
}
2. 投稿機能の実装 📝
/app/posts/page.tsx で投稿一覧表示の実装
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { supabase } from '@/lib/supabase';
import withAuth from '@/lib/auth/withAuth';
import { useAuth } from '@/lib/contexts/AuthContext';
// 投稿の型定義
type Post = {
id: number;
created_at: string;
user_id: string;
prefecture: string;
category: string;
type: string;
content: string;
users?: {
name: string;
};
};
function PostsPage() {
const [posts, setPosts] = useState<Post[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { user } = useAuth();
useEffect(() => {
const fetchPosts = async () => {
setIsLoading(true);
try {
const { data, error } = await supabase
.from('posts')
.select(`
id,
created_at,
user_id,
prefecture,
category,
type,
content,
users (
name
)
`)
.order('created_at', { ascending: false });
if (error) {
throw error;
}
setPosts(data || []);
} catch (err: any) {
setError(err.message);
console.error('Error fetching posts:', err);
} finally {
setIsLoading(false);
}
};
fetchPosts();
}, []);
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('ja-JP', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
if (isLoading) {
return <div className="text-center py-10">読み込み中...</div>;
}
if (error) {
return (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded max-w-4xl mx-auto mt-10">
<p>エラーが発生しました: {error}</p>
</div>
);
}
return (
<div className="max-w-4xl mx-auto p-4">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">投稿一覧</h1>
<Link href="/posts/new" className="bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded">
新規投稿
</Link>
</div>
{posts.length === 0 ? (
<div className="text-center py-10">
<p className="text-gray-500">投稿がありません。最初の投稿を作成しましょう!</p>
</div>
) : (
<div className="space-y-6">
{posts.map((post) => (
<div key={post.id} className="border rounded-lg p-6 bg-white shadow-sm">
<div className="flex justify-between items-start mb-4">
<div>
<h2 className="text-xl font-semibold">{post.category}</h2>
<div className="text-sm text-gray-500">
投稿者: {post.users?.name || '不明'} • {formatDate(post.created_at)}
</div>
</div>
<div className="px-3 py-1 bg-gray-100 text-gray-800 rounded-full text-sm">
{post.prefecture}
</div>
</div>
<p className="text-gray-700 mb-4">{post.content}</p>
<div className="flex justify-between items-center">
<span className="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm">
{post.type}
</span>
<Link href={`/posts/${post.id}`} className="text-blue-500 hover:underline">
詳細を見る
</Link>
</div>
</div>
))}
</div>
)}
</div>
);
}
export default withAuth(PostsPage);
/app/components/CreatePost.tsx の実装
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { supabase } from '@/lib/supabase';
import { useAuth } from '@/lib/contexts/AuthContext';
// 都道府県のリスト
const PREFECTURES = [
'北海道', '青森県', '岩手県', '宮城県', '秋田県', '山形県', '福島県',
'茨城県', '栃木県', '群馬県', '埼玉県', '千葉県', '東京都', '神奈川県',
'新潟県', '富山県', '石川県', '福井県', '山梨県', '長野県', '岐阜県',
'静岡県', '愛知県', '三重県', '滋賀県', '京都府', '大阪府', '兵庫県',
'奈良県', '和歌山県', '鳥取県', '島根県', '岡山県', '広島県', '山口県',
'徳島県', '香川県', '愛媛県', '高知県', '福岡県', '佐賀県', '長崎県',
'熊本県', '大分県', '宮崎県', '鹿児島県', '沖縄県'
];
// 募集カテゴリのリスト
const CATEGORIES = [
'デザイン', 'プログラミング', 'マーケティング', 'ライティング', '翻訳',
'イラスト', '動画制作', '写真', '営業', '企画', 'コンサルティング',
'その他'
];
// 募集タイプのリスト
const TYPES = [
'仕事仲間募集', '勉強仲間募集'
];
export default function CreatePost() {
const { user } = useAuth();
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// フォームの状態
const [formData, setFormData] = useState({
prefecture: '',
category: '',
type: '',
content: '',
});
// フォーム入力の変更ハンドラ
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
// フォーム送信ハンドラ
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!user) {
setError('ログインが必要です');
return;
}
if (!formData.prefecture || !formData.category || !formData.type || !formData.content) {
setError('すべての項目を入力してください');
return;
}
setIsSubmitting(true);
setError(null);
try {
const { data, error } = await supabase
.from('posts')
.insert([{
user_id: user.id,
prefecture: formData.prefecture,
category: formData.category,
type: formData.type,
content: formData.content,
}]);
if (error) {
throw error;
}
router.push('/posts');
router.refresh();
} catch (err: any) {
setError(err.message);
console.error('Error creating post:', err);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="max-w-2xl mx-auto p-4">
<h1 className="text-2xl font-bold mb-6">新規投稿作成</h1>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-gray-700 mb-2">都道府県 *</label>
<select
name="prefecture"
value={formData.prefecture}
onChange={handleChange}
className="w-full p-2 border rounded"
>
<option value="">選択してください</option>
{PREFECTURES.map(pref => (
<option key={pref} value={pref}>{pref}</option>
))}
</select>
</div>
<div>
<label className="block text-gray-700 mb-2">募集カテゴリ *</label>
<select
name="category"
value={formData.category}
onChange={handleChange}
className="w-full p-2 border rounded"
>
<option value="">選択してください</option>
{CATEGORIES.map(category => (
<option key={category} value={category}>{category}</option>
))}
</select>
</div>
<div>
<label className="block text-gray-700 mb-2">募集タイプ *</label>
<select
name="type"
value={formData.type}
onChange={handleChange}
className="w-full p-2 border rounded"
>
<option value="">選択してください</option>
{TYPES.map(type => (
<option key={type} value={type}>{type}</option>
))}
</select>
</div>
<div>
<label className="block text-gray-700 mb-2">募集内容 *</label>
<textarea
name="content"
value={formData.content}
onChange={handleChange}
rows={6}
className="w-full p-2 border rounded"
placeholder="募集内容を詳しく書いてください。"
/>
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded"
>
{isSubmitting ? '投稿中...' : '投稿する'}
</button>
</form>
</div>
);
}
新規投稿ページの作成 /app/posts/new/page.tsx
'use client';
import CreatePost from '@/app/components/CreatePost';
import withAuth from '@/lib/auth/withAuth';
function NewPostPage() {
return <CreatePost />;
}
export default withAuth(NewPostPage);
ユーザープロファイル自動作成の設定
認証と同時にusersテーブルにプロファイルを自動作成するには、SupabaseのSQLエディタで次のコードを実行します:
-- ユーザーが新規登録された時にusersテーブルに自動的に行を追加
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.users (id, name)
VALUES (NEW.id, COALESCE(NEW.raw_user_meta_data->>'name', NEW.email));
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- 認証が追加された時に関数を発火するトリガー
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
これで認証機能と投稿機能の基本的な実装が完了しました!これらのコードを適切なファイルに配置し、必要なフォルダ構造を作成してください。
具体的にやるべきことを出力してくれたので、指示に従ってフォルダ・ファイルの生成とコードを入力していきます。コードを入力する過程でエラーが発生しする場合、下記のように都度ChatGPTに聞いて解決していきます。

[page.tsx] '{ id: any; created_at: any; user_id: any; prefecture: any; category: any; type: any; content: any; users: { name: any; }[]; }[]' の引数を型 'SetStateAction' のパラメーターに割り当てることはできません。型 '{ id: any; created_at: any; user_id: any; prefecture: any; category: any; type: any; content: any; users: { name: any; }[]; }[]' を型 'Post[]' に割り当てることはできません。型 '{ id: any; created_at: any; user_id: any; prefecture: any; category: any; type: any; content: any; users: { name: any; }[]; }' を型 'Post' に割り当てることはできません。プロパティ 'users' の型に互換性がありません。プロパティ 'name' は型 '{ name: any; }[]' にありませんが、型 '{ name: string; }' では必須です。ts(2345)

このエラーは、Next.jsでの重要なルーティング衝突を示しています。同じパス (/auth/callback) に対して2つの異なるタイプのファイル(route.ts と page.tsx)が存在しているため発生しています。
解決方法
下記の方法で解決できます:
方法: route.ts を優先する
/app/auth/callback/page.tsx を削除します
既存の route.ts が正しく実装されているか確認します
Supabaseの認証コールバック処理は通常、API ルート(route.ts)で行われます。おそらく、すでに正しいコールバック処理が route.ts に実装されていると思われます。
今回はひとつのパスに表示べき内容が重複していることが原因だったので、削除するだけで解決できました。
ユーザープロファイルの自動作成を設定
次にChatGPTから指示されたのが、下記のSQLを利用する「ユーザープロファイル自動作成の設定」です。
-- ユーザーが新規登録された時にusersテーブルに自動的に行を追加
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.users (id, name)
VALUES (NEW.id, COALESCE(NEW.raw_user_meta_data->>'name', NEW.email));
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- 認証が追加された時に関数を発火するトリガー
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
ユーザーがアプリに新規登録すると、自動でusersテーブルにプロフィール情報が作成される仕組みです。これを実現するために「トリガー(Trigger)」という機能を使います。
プロファイル自動生成の具体的な設定方法について解説します。

Supabaseより
Supabaseのダッシュボードを開き、左のメニュー欄から「SQL Editor」をクリックします。すると上記のようにSQL入力欄が表示されるので、先ほどの内容を入力して「Run CTRL」をクリックしてください。
画面下部の実行結果に「Success. No rows returned」と表示されていればOKです。
Supabaseの特徴や初期設定の方法は、下記の記事で詳しく解説しています。

Next.jsを動かして機能を確認してみる
では実際にNext.jsを立ち上げてこれまで実装した機能が問題なく動いているのか確認してみます。
ターミナルを開いてプロジェクトフォルダへ移動し、下記のコマンドでNext.jsの開発サーバーを起動してください。
npm run dev
Next.jsのトップ画面が表示されたら、ログイン画面である「http://localhost:3000/auth」へアクセスします。

Next.jsより
ログイン画面が表示されるので、任意のメールアドレスをパスワードを入力して「新規登録」をクリックします。

Next.jsより
するとメールアドレスに認証リンクが送付されました。メール内のリンクをクリックすることでアカウント認証完了です。

Next.jsより
認証したメールアドレスとパスワードでログインしたあとは投稿一覧画面である「http://localhost:3000/posts」へアクセスします。
画面右上の「新規投稿」をクリックします。

Next.jsより
都道府県やカテゴリなどちゃんと表示されていますね。各内容を入力して投稿してみます。

Next.jsより
ここでエラーが発生しました。内容をそのままコピペしてChatgGPTへ聞いてみましょう。

new row violates row-level security policy for table "posts"

このエラー「new row violates row-level security policy for table "posts"」は、Supabaseのセキュリティ機能であるRLSによって投稿作成が拒否されていることを示しています。
解決方法 (2つの選択肢があります)
方法1: RLSを無効化する(開発中の簡易解決法)
- Supabaseダッシュボードにアクセスします。
- 左側のナビゲーションから「Table Editor」を選択します。
- 「posts」テーブルを選択します。
- 右上の「Row Level Security (RLS)」スイッチを「OFF」に切り替えます。
- 確認ダイアログで「確認」を選択します。
方法2: RLSポリシーを正しく設定する(推奨)
- Supabaseダッシュボードにアクセスします。
- 左側のナビゲーションから「Table Editor」を選択します。
- 「posts」テーブルを選択します。
- 「Policies」タブを選択します。
- 「New Policy」ボタンをクリックします。
- 「Get started quickly」の「INSERT」を選択します。
- 次の設定を行います:
- Policy name:
allow_insert_for_authenticated
- Using expression:
auth.role() = 'authenticated'
- With check expression: そのまま(空白)
- Policy name:
- 「Save Policy」をクリックします。
投稿の閲覧や編集なども必要な場合は、以下のポリシーも追加すると良いでしょう:
閲覧ポリシー(全員が投稿を見られる):
「New Policy」→「Get started quickly」→「SELECT」
Policy name: allow_select_all
Using expression: true
「Save Policy」
更新ポリシー(自分の投稿のみ):
「New Policy」→「Get started quickly」→「UPDATE」
Policy name: allow_update_own_posts
Using expression: auth.uid() = user_id
「Save Policy」
削除ポリシー(自分の投稿のみ):
「New Policy」→「Get started quickly」→「DELETE」
Policy name: allow_delete_own_posts
Using expression: auth.uid() = user_id
「Save Policy」
確認方法
RLSポリシーを設定した後、再度新規投稿を作成してみてください。エラーが解消されているはずです。
セキュリティの観点では、方法2(適切なRLSポリシーの設定)が推奨されますが、開発環境では方法1(一時的にRLSを無効化)も問題ありません。本番環境では必ず適切なRLSポリシーを設定してください。
どうやらSupabaseのセキュリティ機能「RLS」によって投稿作成が拒否されているようです。ChatGPTの指示に従って「方法2: RLSポリシーを正しく設定する(推奨)」を実施します。
SupabaseのRLSポリシーを設定する

Supabaseより
はじめにSupabaseのダッシュボードを開き、画面左のメニュー欄から「Table Editor」を選択します。表示内容から「posts」をクリックし、画面右上にある「Add RLS policy」をクリックしてください。

Supabaseより
上記画面が表示されるので「Create policy」をクリックします。

Supabaseより
ポリシー入力欄が表示されるので、下記の3か所だけ変更して下さい。
- Policy Name:allow_insert_for_authenticated
- Policy Command:INSERT
- with check():auth.role() = 'authenticated'
最後に「Save policy」をクリックして完了です。

Supabaseより
次に「UPDATE」操作のポリシーを変更します。同じく「Create policy」から編集画面を開き、下記のように入力して下さい。
ポリシー入力欄が表示されるので、下記の3か所だけ変更して下さい。
- Policy Name:allow_update_own_posts
- Policy Command:UPDATE
- with check():auth.uid() = user_id
同じようにSELECTとDELETEも設定していきます。

Supabaseより
上記のように4つのポリシー設定が完了すればOKです。これらの設定が完了したあと、再度投稿欄で入力するとエラー表示されませんでした。

Next.jsより
投稿した内容も上記のように反映されています。

Supabaseより
Supabaseのテーブルにも問題なく反映されていますね。細かい改善点が複数ありますが、ログイン認証や投稿に関する機能はひとまずOKで良さそうです。
次にやるべきことについてChatGPTへ聞いてみましょう。

上手くいきました!次に何を実行すべきですか?

おめでとうございます!次のステップへ進みましょう 🎉
基本的な認証機能と投稿機能が正常に動作するようになりました。次は、プロジェクトの核となる「日本地図UI」の実装に進むのがいいでしょう。
次に実装すべき項目:日本地図UI 🗾
最初の要件にあった「日本地図をUIベースにして、都道府県ごとに仕事仲間や勉強仲間を探せるマッチングプラットフォーム」を実現するために、以下の手順で進めていきましょう:
-
1. 日本地図コンポーネントの作成
- まず、日本地図のSVGを準備または作成
- 地図コンポーネントを実装
- 都道府県をクリック可能にする
-
2. 地図UIの実装手順
- SVG日本地図の準備:
- /public/images/ ディレクトリを作成し、そこに日本地図のSVGを保存する
- 地図コンポーネントの作成:
- /app/components/JapanMap.tsx を作成
- トップページに地図を統合:
- /app/page.tsx を修正して地図コンポーネントを表示
- 都道府県別の投稿ページ:
- /app/prefecture/[name]/page.tsx を作成して、特定の都道府県の投稿を表示
-
3. 全体のUI/UXの改善
- ナビゲーションヘッダー:アプリ全体のナビゲーション
- ユーザープロフィールページ:ユーザー情報の編集機能
- レスポンシブデザイン:モバイル対応
具体的に進めるステップ
次に具体的な実装コードを提供しますが、どこから始めたいですか?
- 日本地図コンポーネントの実装
- ナビゲーションヘッダーの実装
- ユーザープロフィールページの実装
- 既存機能の改善/バグ修正
どの部分から進めたいか教えていただければ、詳細な実装コードを提供します!
次回はいよいよトップ画面の機能とUIの実装ですね!これまでで一番イメージの沸かない作業ですが、生成AIの力を借りながらうまく実装したいところ。本記事が参考になった方は、ぜひ次回もチェックしてみて下さい。