Supabase CLI Next.js(app router) vibe codingで楽々開発!!(環境構築 ~ 認証編)
概要
最近久々にSupabaseを触ってみました。
過去はなんとなくで使っていたのですが、AIの力を使ってどこまで実装が出来るのかを紹介したいと思います。
さて今回は、Supabase Next.jsとvibe codingでやっていきたいと思います!
使う技術スタック
- supabase(@supabase/supabase-js、@supabase/ssr)
- supabase CLI(今回はコマンドラインでローカル環境からsupabaseのダッシュボードの管理をします)
- Next.js15系(app router)
- tailwind
- Docker(バックエンドの立ち上げに使います。)
- cursor(ruleを使います。)
まずはSupabaseのダッシュボードでプロジェクトを作成しておいてください!
環境構築
まずは,Next.jsのプロジェクトを立ち上げます。今回はapp routerで作成しようと思います。
後ろの -e with-supabase
を追加することによって、UIライブラリのtailwind
やshadcn
のUIライブラリが一式で入っています。また今回使うsupabase
ディレクトリも作成されます。
npx create-next-app -e with-supabase
環境変数の設定
NEXT_PUBLIC_SUPABASE_URL=<SUBSTITUTE_SUPABASE_URL>
NEXT_PUBLIC_SUPABASE_ANON_KEY=<SUBSTITUTE_SUPABASE_ANON_KEY>
Supabase のプロジェクト URL と anon key は、Supabase のダッシュボードから確認できます。プロジェクトの URL は、プロジェクト名の下に表示され、anon key はプロジェクトの「API Settings」から確認できます。
Supabase CLI
SupabaseはCLIを提供しているのでこちらを使ってやっていきます。
まずは、Supabase CLIをインストールします。
brew install supabase/tap/supabase
## インストールされているかを確認
supabase --version
次にDokcerを立ち上げます。
インストールされていない方はこちらのDocker Desktopでダウンロードをしておいてください。
Supabaseを初期化
supabase init
supabaseを立ち上げ
supabase start
Started supabase local development setup.
API URL: http://localhost:54321
DB URL: postgresql://postgres:postgres@localhost:54322/postgres
Studio URL: http://localhost:54323
Mailpit URL: http://localhost:54324
anon key: eyJh......
service_role key: eyJh......
動作確認されていることを確認します。
停止コマンド
supabase stop
Supabaseにログイン
supabase login
コマンドを実行してEnterを押した時、ブラウザが開きます。その時にverification code
が表示されるのでコピーしてターミナルに貼り付けます。
You are now logged in. Happy coding!
と表示されればOKです!
リモートプロジェクトとのリンク
下記を実行してターミナルに表示されるREFERENCE ID
を確認します
supabase projects list
project_id = "env(SUPABASE_PROJECT_ID)"
supabase link --project-ref your-project-id
Finished supabase link.と接続出来ていることを確認します。
実際の開発の流れ
今回は認証とTodoをサンプルにして紹介します。
手順としては、
- CursorのRule設定
- 認証機能を実装(ログイン、サインアップ)
1.CursorのRule設定
まずはCursorのRuleの設定をします。こちらを作成しておくことでAI Agentで実行する時にファイルのルールを読み込み、そのルールの範囲内でコードを生成してくれます。
設定方法
Project Rules は Cursor > General > Project Rules > Add new rule
もしくは ⌘(ctrl) + Shift + P > File: New Cursor Rule
から追加できます。
追加するボタンを押すと、.cursor/rules
ディレクトリが作成されます。
僕が今回設定したのは
- coding-rules.mdc(コーディングルールを設定)
- next.js.mdc(Next.jsのディレクトリ設計、コンポーネント設計、エラーハンドリングなど)
- uiux.mdc(UI周りのルール設定)
- techstack.mdc(今回使う技術スタックとバージョン)
- supabase.mdc(supabaseの設定)
- db-buleprint.mdc(DBのスキーマ、テーブル、型定義の設定)
他にもありますが、僕のgithubにまとめてありますのでコピペして使ってください。細かい箇所に関してはお好みでカスタマイズしてください。
2.認証機能を実装(ログイン、サインアップ)
今回の認証はSupabase Auth
を使っての認証です。
環境構築のセットアップ時に既にインストールされていますが、
npm install @supabase/supabase-js @supabase/ssr
こちらのライブラリを使って実装をします。
基本はこちらのドキュメント内にあるコードを参考に実装しました。
middlewareの設定、confirmのAPIの設定はコピペして使っています
今回はNext.jsのAPI Routesでの開発です。
signInWithPassword()
を使って認証をしています。
今回はCookieにトークンを付与するように実装を変更しています。
UIに関してはvibe codingで必要な項目やバリデーションなどのキーワードを入れると実装してくれますし、「リッチなサービスにしてください」とするだけでも簡単作れてしまいます。
ログイン認証
ログインAPI
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/utils/supabase/server'
export async function POST(request: NextRequest) {
try {
const { email, password } = await request.json()
console.log('Sign in attempt for email:', email)
// 入力バリデーション
if (!email || !password) {
console.log('Missing email or password')
return NextResponse.json(
{ error: 'メールアドレスとパスワードが必要です' },
{ status: 400 }
)
}
const supabase = await createClient()
// Supabase Authでサインイン
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) {
console.log('Supabase auth error:', error.message)
return NextResponse.json(
{ error: 'メールアドレスまたはパスワードが正しくありません' },
{ status: 401 }
)
}
console.log('Sign in successful for user:', data.user?.id)
// レスポンスを作成
const response = NextResponse.json({
user: {
id: data.user?.id,
email: data.user?.email,
},
session: data.session,
})
// セッション情報をCookieに保存
if (data.session) {
// アクセストークンをCookieに保存
response.cookies.set('dev-access-token', data.session.access_token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: data.session.expires_in, // セッションの有効期限
path: '/',
})
// リフレッシュトークンをCookieに保存
response.cookies.set('dev-refresh-token', data.session.refresh_token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30, // 30日
path: '/',
})
// ユーザーIDをCookieに保存(オプション)
response.cookies.set('dev-user-id', data.user?.id || '', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: data.session.expires_in,
path: '/',
})
}
return response
} catch (error) {
console.error('Sign in error:', error)
return NextResponse.json(
{ error: 'サーバーエラーが発生しました' },
{ status: 500 }
)
}
}
ログインフォーム
'use client'
import { useState, useEffect } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Eye, EyeOff, Mail, Lock, Loader2, CheckCircle } from 'lucide-react'
export default function SignInForm() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [message, setMessage] = useState('')
const router = useRouter()
const searchParams = useSearchParams()
useEffect(() => {
const errorParam = searchParams.get('error')
const messageParam = searchParams.get('message')
if (errorParam === 'email_confirmation_failed') {
setError('メール確認に失敗しました。再度お試しください。')
} else if (errorParam === 'invalid_confirmation_link') {
setError('無効な確認リンクです。')
}
if (messageParam === 'email_confirmed') {
setMessage('メール確認が完了しました。サインインしてください。')
}
}, [searchParams])
const handleSignIn = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError('')
setMessage('')
try {
const response = await fetch('/api/auth/signin', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include', // Cookieを含める
body: JSON.stringify({ email, password }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'サインインに失敗しました')
}
// ダッシュボードページに遷移
router.push('/dashboard')
router.refresh()
} catch (error) {
console.error('Error signing in:', error)
setError(
error instanceof Error
? error.message
: 'サインインに失敗しました。メールアドレスとパスワードを確認してください。'
)
} finally {
setLoading(false)
}
}
return (
<Card className="w-full shadow-xl border-0 bg-white/80 backdrop-blur-sm">
<CardHeader className="space-y-1 pb-4">
<CardTitle className="text-2xl font-bold text-center text-gray-900">
サインイン
</CardTitle>
<p className="text-sm text-center text-gray-600">
アカウントにログインしてください
</p>
</CardHeader>
<CardContent>
<form onSubmit={handleSignIn} className="space-y-4">
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg">
{error}
</div>
)}
{message && (
<div className="p-3 text-sm text-green-600 bg-green-50 border border-green-200 rounded-lg flex items-center">
<CheckCircle className="mr-2 h-4 w-4" />
{message}
</div>
)}
<div className="space-y-2">
<label
htmlFor="email"
className="text-sm font-medium text-gray-700"
>
メールアドレス
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
className="pl-10 h-11 border-gray-300 focus:border-blue-500 focus:ring-blue-500"
required
/>
</div>
</div>
<div className="space-y-2">
<label
htmlFor="password"
className="text-sm font-medium text-gray-700"
>
パスワード
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="パスワードを入力"
className="pl-10 pr-10 h-11 border-gray-300 focus:border-blue-500 focus:ring-blue-500"
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
</div>
<Button
type="submit"
disabled={loading}
className="w-full h-11 bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white font-medium rounded-lg transition-all duration-200 transform hover:scale-[1.02] disabled:transform-none"
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
サインイン中...
</>
) : (
'サインイン'
)}
</Button>
<div className="text-center text-sm text-gray-600">
アカウントをお持ちでないですか?{' '}
<Link
href="/auth/signup"
className="text-blue-600 hover:text-blue-700 font-medium hover:underline transition-colors"
>
サインアップ
</Link>
</div>
</form>
</CardContent>
</Card>
)
}
ログインページ
import SignInForm from '@/components/auth/signin-form'
export default function SignInPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100">
<div className="max-w-md w-full space-y-8 p-8">
<div className="text-center">
<h2 className="mt-6 text-3xl font-extrabold text-gray-900">
アカウントにサインイン
</h2>
<p className="mt-2 text-sm text-gray-600">
あなたのアカウントにアクセスしてください
</p>
</div>
<SignInForm />
</div>
</div>
)
}
新規登録認証
新規登録周りに関しては、サインアップをするとメールが届くのアカウントの有効化をします。
有効化されるとSupabase Auth
にアカウントが登録されます。
今回はすぐにサインインする実装ではないですが、実際にメールアドレスが届くを検証のために実装しています。
新規登録(API、フォーム周りの実装)
新規登録API
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/utils/supabase/server'
export async function POST(request: NextRequest) {
try {
const { email, password } = await request.json()
// 入力バリデーション
if (!email || !password) {
return NextResponse.json(
{ error: 'メールアドレスとパスワードが必要です' },
{ status: 400 }
)
}
if (password.length < 6) {
return NextResponse.json(
{ error: 'パスワードは6文字以上である必要があります' },
{ status: 400 }
)
}
const supabase = await createClient()
// Supabase Authでサインアップ
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/confirm`,
},
})
if (error) {
console.error('Supabase signup error:', error.message)
// 既存ユーザーの場合
if (error.message.includes('already registered')) {
return NextResponse.json(
{ error: 'このメールアドレスは既に使用されています' },
{ status: 400 }
)
}
return NextResponse.json(
{ error: 'アカウント作成に失敗しました' },
{ status: 400 }
)
}
// メール確認が必要な場合
if (data.user && !data.session) {
return NextResponse.json({
user: {
id: data.user.id,
email: data.user.email,
},
message: '確認メールを送信しました。メールを確認してアカウントを有効化してください。',
requiresEmailConfirmation: true,
}, { status: 201 })
}
// 即座にサインインされる場合
const response = NextResponse.json({
user: {
id: data.user?.id,
email: data.user?.email,
},
session: data.session,
message: 'アカウントが作成されました。',
}, { status: 201 })
// セッション情報をCookieに保存
if (data.session) {
// アクセストークンをCookieに保存
response.cookies.set('dev-access-token', data.session.access_token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: data.session.expires_in, // セッションの有効期限
path: '/',
})
// リフレッシュトークンをCookieに保存
response.cookies.set('dev-refresh-token', data.session.refresh_token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30, // 30日
path: '/',
})
// ユーザーIDをCookieに保存(オプション)
response.cookies.set('dev-user-id', data.user?.id || '', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: data.session.expires_in,
path: '/',
})
}
return response
} catch (error) {
console.error('Sign up error:', error)
return NextResponse.json(
{ error: 'サーバーエラーが発生しました' },
{ status: 500 }
)
}
}
ダッシュボード
タイトル
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import LogoutButton from '@/components/auth/logout-button'
import { ListTodo, Plus } from 'lucide-react'
import Link from 'next/link'
interface User {
id: string
email: string
}
export default function DashboardPage() {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const router = useRouter()
useEffect(() => {
// 認証状態をチェック
checkAuthStatus()
}, [router])
const checkAuthStatus = async () => {
try {
const response = await fetch('/api/auth/status', {
method: 'GET',
credentials: 'include', // Cookieを含める
})
if (response.ok) {
const data = await response.json()
if (data.user) {
setUser(data.user)
setLoading(false)
return
}
}
} catch (error) {
console.error('Auth status check failed:', error)
}
// 認証されていない場合はサインインページにリダイレクト
router.push('/auth/signin')
}
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg">読み込み中...</div>
</div>
)
}
if (!user) {
return null
}
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="container mx-auto px-4">
<Card className="max-w-2xl mx-auto">
<CardHeader>
<CardTitle className="text-2xl text-center text-gray-900">
ようこそ、AIの世界へ!!
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="text-center">
<p className="text-lg text-gray-700">
<span className="font-semibold">{user.email}</span>さん
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Link href="/todos">
<Card className="hover:shadow-md transition-shadow cursor-pointer">
<CardContent className="p-6">
<div className="flex items-center space-x-3">
<ListTodo className="h-8 w-8 text-blue-600" />
<div>
<h3 className="font-medium text-gray-900">Todo一覧</h3>
<p className="text-sm text-gray-500">
タスクを管理する
</p>
</div>
</div>
</CardContent>
</Card>
</Link>
<Link href="/todos/new">
<Card className="hover:shadow-md transition-shadow cursor-pointer">
<CardContent className="p-6">
<div className="flex items-center space-x-3">
<Plus className="h-8 w-8 text-green-600" />
<div>
<h3 className="font-medium text-gray-900">
新しいTodo
</h3>
<p className="text-sm text-gray-500">
タスクを作成する
</p>
</div>
</div>
</CardContent>
</Card>
</Link>
</div>
<div className="flex justify-center">
<LogoutButton />
</div>
</CardContent>
</Card>
</div>
</div>
)
}
所感
環境構築から、認証周りのセットアップまではvibe codingを使えば簡単に出来る。しかし、生成されたコードが多すぎると確認するのが大変になるので、
rule設計 -> vibe codingで生成 -> ソースコードやセキュリティの確認 -> ruleの追加、修正 => vibe codgingで生成...
のようにサイクルを回すといいかもです。
React、Next.jsのエラーが下記で起きており、
Next.js 15のApp Routerでは、paramsオブジェクトがPromiseになったようです。以前は直接アクセスできましたが、現在はReact.use()でPromiseを解決する必要があります。
A param property was accessed directly with `params.id`. `params` is now a Promise and should be unwrapped with `React.use()` before accessing properties of the underlying params object.
など最新バージョンの対応だとまだまだエラーが出たりするので、ReactやNext.jsの新技術にキャッチアップする必要がある。
Discussion