✈️

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のダッシュボードでプロジェクトを作成しておいてください!

https://supabase.com/

環境構築

まずは,Next.jsのプロジェクトを立ち上げます。今回はapp routerで作成しようと思います。
後ろの -e with-supabaseを追加することによって、UIライブラリのtailwindshadcnのUIライブラリが一式で入っています。また今回使うsupabaseディレクトリも作成されます。

npx create-next-app -e with-supabase

環境変数の設定

.env.local
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でダウンロードをしておいてください。

https://www.docker.com/ja-jp/get-started/

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
supabase/config.toml
project_id = "env(SUPABASE_PROJECT_ID)"
supabase link --project-ref your-project-id

Finished supabase link.と接続出来ていることを確認します。

実際の開発の流れ

今回は認証とTodoをサンプルにして紹介します。
手順としては、

  1. CursorのRule設定
  2. 認証機能を実装(ログイン、サインアップ)

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にまとめてありますのでコピペして使ってください。細かい箇所に関してはお好みでカスタマイズしてください。

https://github.com/masahiro96848/todo-with-next15-app-router/tree/feat/develop/.cursor/rules

2.認証機能を実装(ログイン、サインアップ)

今回の認証はSupabase Authを使っての認証です。
環境構築のセットアップ時に既にインストールされていますが、

npm install @supabase/supabase-js @supabase/ssr

こちらのライブラリを使って実装をします。

基本はこちらのドキュメント内にあるコードを参考に実装しました。
middlewareの設定、confirmのAPIの設定はコピペして使っています

https://supabase.com/docs/guides/auth/server-side/nextjs

今回はNext.jsのAPI Routesでの開発です。

signInWithPassword()を使って認証をしています。
今回はCookieにトークンを付与するように実装を変更しています。

UIに関してはvibe codingで必要な項目やバリデーションなどのキーワードを入れると実装してくれますし、「リッチなサービスにしてください」とするだけでも簡単作れてしまいます。

ログイン認証

ログインAPI

src/app/api/auth/signin/route.ts
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 }
    )
  }
} 

ログインフォーム

src/components/auth/signin-form.tsx
'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>
  )
}

ログインページ

src/app/auth/signin/page.tsx
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

src/app/api/auth/signup/route.ts
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 }
    )
  }
} 

ダッシュボード

タイトル
src/app/dashboard/page.tsx
'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