LoginSignup
22
12

Next.js 14とSupabaseで陸上100m選手の練習場検索サービス「スプトレ」を作った話

Last updated at Posted at 2024-02-16

株式会社Neverのshoheiです。

株式会社Neverは「NEVER STOP CREATE 作りつづけること」をビジョンに掲げ、理想を実現するためにプロダクトを作り続ける組織です。モバイルアプリケーションの受託開発、技術支援、コンサルティングを行っております。アプリ開発のご依頼や開発面でのお困りの際はお気楽にお問合せください。

概要

Next.js 14とSupabaseで陸上100m選手の練習場検索サービス「スプトレ」を作りました。Next.jsとSupabaseでの開発は初めてでしたので、技術的なポイントや開発してみた感想を紹介します。

開発経緯

学生の頃に陸上競技の短距離をやっていまして、そろそろ再開しようと思い近所の練習場所を探していましたが思ったような場所が見つかりませんでした。

Google Map等では全速力で走れる場所を探すのが難しかったので、じゃあ全速力で走れる練習場をまとめたサービスを作ろうと思ったのがきっかけです。

また、野球やサッカーと比較すると陸上競技の人気は低いので、陸上競技にスポットが当たるよう陸上ファンである私の立場としてできることはWebサービスを作ることであると思い作りました。

その他にも理由はありますが、こちらにまとめていますので興味ある方は見ていただければと思います。

開発メンバー

技術スタック

項目 技術
フロントエンド(SSR) Next.js 14 + App Router + Tailwind CSS + MUI
DB Supabase Database(PostgreSQL)
ストレージ Supabase Storage
認証 Supabase Authentication
メール Supabase + SendGrid
ホスティング AWS Amplify ホスティング
DNS AWS Route 53
その他 AWS CDK

SSRなフレームワークとしてNext.jsを選定しました。ちょうどNext.jsとApp Routerが話題になっていたので使いました。

Tailwind CSSNext.jsで推奨されていたのでそれをメインとして、難しそうなUIはMUIで作りました。

バックエンドはSupabaseを選定しました。情報検索系のサービスをFirebase(NoSQLのFirestore)で作るのは相当しんどいので、PostgreSQL搭載のSupabaseにしました。

ホスティングは、AWSの無料クレジット沢山あるのと困ったら目黒のAWS Loftへ駆け込みやすいのもありAmplify ホスティングにしました。インフラはシンプルですが、AWS CDKで構築しました。

機能一覧

機能一覧
  • 認証
    • メールアドレス認証
    • サインアップ、サインイン、本人確認、パスワードリセット
    • 退会
  • スプリントスポット・エリア
    • キーワード・条件検索、並び替え
    • マップで検索
    • 閲覧
    • 新規登録・更新
    • オススメ登録・解除
    • お気に入り登録・解除
    • X、Facebookへ共有
  • プロフィール
    • 閲覧、設定
  • トップ
    • ヘッダー
      • ロゴ、アイコン、認証機能の動線ボタン
    • フッター
      • Aboutページ、プライバシーポリシー、利用規約、問い合わせ
    • メイン
      • 登録件数、最新スポット表示
  • その他
    • レスポンシブ対応
    • ドメイン購入 + DNS設定
    • OGP
    • サイトマップ + Google Search Console
    • Googleアナリティクス
    • SendGrid対応

※開発期間:2023年12月1日〜2024年2月12日(月)
※開発工数:1.5人月程度(30日 * 8時間 = 240時間)

Next.js 14

ディレクトリ構成

こちらのポストを参考にしました。

ややFeatureFirstな構成にし、URLパスの単語間の繋ぎ合わせはケバブケースを採用しました。

ディレクトリ構成
package.json
middleware.ts
.env.local
...
src
 ┗ app
   ┣ api
   ┃ ┗ users
   ┃   ┗ me
   ┃     ┗ route.ts
   ┣ (athlete)
   ┃ ┣ _components
   ┃ ┃ ┣ calculata_age.tsx
   ┃ ┃ ┣ hundred_meter_best_tile.tsx
   ┃ ┃ ┣ input_only_single_selection_field.tsx
   ┃ ┃ ┣ personal_best_input_form.tsx
   ┃ ┃ ┗ user_profile_card.tsx
   ┃ ┣ _entities
   ┃ ┃ ┣ hundred_meter_bests_type.ts
   ┃ ┃ ┗ user_data.ts
   ┃ ┣ _repositories
   ┃ ┃ ┗ hundred_meter_bests_repositories.ts
   ┃ ┣ profile
   ┃ ┃ ┗ [id]
   ┃ ┃ ┃ ┗ page.tsx
   ┃ ┣ setting
   ┃ ┃ ┣ _components
   ┃ ┃ ┃ ┣ annual_best_card.tsx
   ┃ ┃ ┃ ┣ avatar_with_button.tsx
   ┃ ┃ ┃ ┣ delete_account_button.tsx
   ┃ ┃ ┃ ┣ hundred_meter_bests_list_tile.tsx
   ┃ ┃ ┃ ┗ personal_best_card.tsx
   ┃ ┃ ┣ _entities
   ┃ ┃ ┃ ┗ profile.ts
   ┃ ┃ ┣ _repositories
   ┃ ┃ ┃ ┣ athlete_constants.ts
   ┃ ┃ ┃ ┣ delete_account.ts
   ┃ ┃ ┃ ┣ hundred_meter_bests_repository.ts
   ┃ ┃ ┃ ┣ profile_repository.ts
   ┃ ┃ ┃ ┣ storage_repository.ts
   ┃ ┃ ┃ ┗ utils.ts
   ┃ ┃ ┗ page.tsx
   ┃ ┗ layout.tsx
   ┣ (auth)
   ┃ ┣ _components
   ┃ ┃ ┣ input_email_field.tsx
   ┃ ┃ ┗ input_password_field.tsx
   ┃ ┣ change-password
   ┃ ┃ ┗ page.tsx
   ┃ ┣ confirm-email
   ┃ ┃ ┗ page.tsx
   ┃ ┣ forgot-password
   ┃ ┃ ┗ page.tsx
   ┃ ┣ sign-in
   ┃ ┃ ┗ page.tsx
   ┃ ┣ sign-up
   ┃ ┃ ┗ page.tsx
   ┃ ┗ layout.tsx
   ┣ (main)
   ┃ ┣ _components
   ┃ ┃ ┣ feature_plan_card.tsx
   ┃ ┃ ┣ latest_sprint_spot_card.tsx
   ┃ ┃ ┗ wanted_message.tsx
   ┃ ┣ layout.tsx
   ┃ ┗ page.tsx
   ┣ (map)
   ┃ ┣ map
   ┃ ┃ ┣ _components
   ┃ ┃ ┃ ┣ map_page.tsx
   ┃ ┃ ┃ ┗ map_view.tsx
   ┃ ┃ ┗ page.tsx
   ┃ ┗ layout.tsx
   ┣ (service)
   ┃ ┣ about
   ┃ ┃ ┗ page.tsx
   ┃ ┣ privacy-policy
   ┃ ┃ ┗ page.tsx
   ┃ ┣ terms-of-service
   ┃ ┃ ┗ page.tsx
   ┃ ┗ layout.tsx
   ┣ (sprint_spot)
   ┃ ┣ _components
   ┃ ┃ ┣ serch_form
   ┃ ┃ ┃ ┣ filter_dialog.tsx
   ┃ ┃ ┃ ┣ search_form.tsx
   ┃ ┃ ┃ ┣ types.ts
   ┃ ┃ ┃ ┗ utils.ts
   ┃ ┃ ┗ spot_tag.tsx
   ┃ ┣ _entities
   ┃ ┃ ┣ lat_lng.ts
   ┃ ┃ ┣ search_query_type.ts
   ┃ ┃ ┣ sprint_area_detail.ts
   ┃ ┃ ┣ sprint_spot_detail.ts
   ┃ ┃ ┣ sprint_spot_update_history.ts
   ┃ ┃ ┗ storage_file.ts
   ┃ ┣ _repositories
   ┃ ┃ ┣ bookmarked_sprint_spot_repository.ts
   ┃ ┃ ┣ recommended_sprint_spot_repository.ts
   ┃ ┃ ┣ sprint_spots_constants.ts
   ┃ ┃ ┣ sprint_spots_repository.ts
   ┃ ┃ ┣ storage_repository.ts
   ┃ ┃ ┗ utils.ts
   ┃ ┣ register
   ┃ ┃ ┗ sprint-spot
   ┃ ┃ ┃ ┣ _components
   ┃ ┃ ┃ ┃ ┣ input_field.tsx
   ┃ ┃ ┃ ┃ ┣ input_images.tsx
   ┃ ┃ ┃ ┃ ┣ input_location_field.tsx
   ┃ ┃ ┃ ┃ ┣ input_sprint_area_detail_card.tsx
   ┃ ┃ ┃ ┃ ┣ input_sprint_spot_detail_card.tsx
   ┃ ┃ ┃ ┃ ┣ input_text_area_field.tsx
   ┃ ┃ ┃ ┃ ┣ segmented_text_area_field.tsx
   ┃ ┃ ┃ ┃ ┗ toggle_button_rows.tsx
   ┃ ┃ ┃ ┗ page.tsx
   ┃ ┣ search
   ┃ ┃ ┗ sprint-spot
   ┃ ┃ ┃ ┣ _components
   ┃ ┃ ┃ ┃ ┣ order_select_button.tsx
   ┃ ┃ ┃ ┃ ┗ sprint_spot_card.tsx
   ┃ ┃ ┃ ┗ page.tsx
   ┃ ┣ sprint-spot
   ┃ ┃ ┗ [id]
   ┃ ┃ ┃ ┣ _components
   ┃ ┃ ┃ ┃ ┣ bookmarked_button.tsx
   ┃ ┃ ┃ ┃ ┣ edit_button.tsx
   ┃ ┃ ┃ ┃ ┣ editor_label.tsx
   ┃ ┃ ┃ ┃ ┣ facebook_share_button.tsx
   ┃ ┃ ┃ ┃ ┣ list_location_tile.tsx
   ┃ ┃ ┃ ┃ ┣ list_tel_tile.tsx
   ┃ ┃ ┃ ┃ ┣ list_tile.tsx
   ┃ ┃ ┃ ┃ ┣ recommended_button.tsx
   ┃ ┃ ┃ ┃ ┣ recommended_fab.tsx
   ┃ ┃ ┃ ┃ ┣ sprint_area_card.tsx
   ┃ ┃ ┃ ┃ ┣ sprint_spot_detail_card.tsx
   ┃ ┃ ┃ ┃ ┣ sprint_spot_route_card.tsx
   ┃ ┃ ┃ ┃ ┗ x_share_button.tsx
   ┃ ┃ ┃ ┗ page.tsx
   ┃ ┗ layout.tsx
   ┣ _components
   ┃ ┣ avatar.tsx
   ┃ ┣ button.tsx
   ┃ ┣ footer.tsx
   ┃ ┣ header.tsx
   ┃ ┣ image_dialog.tsx
   ┃ ┣ input_single_selection_field.tsx
   ┃ ┣ input_single_selection_prefecture_field.tsx
   ┃ ┣ modal.tsx
   ┃ ┣ ok_cancel_dialog.tsx
   ┃ ┣ picker.tsx
   ┃ ┣ root_link.tsx
   ┃ ┣ simple_snackbar.tsx
   ┃ ┣ single_selection_dialog.tsx
   ┃ ┣ single_selection_prefecture_dialog.tsx
   ┃ ┗ spinner.tsx
   ┣ _entities
   ┃ ┗ ui_result.ts
   ┣ _hooks
   ┃ ┣ create_context.tsx
   ┃ ┣ use_auth_user.tsx
   ┃ ┗ use_master.tsx
   ┣ _images
   ┃ ┣ res
   ┃ ┃ ┣ favorite_fill.svg
   ┃ ┃ ┗ favorite_outline.svg
   ┃ ┗ favorite_icon.tsx
   ┣ _providers
   ┃ ┗ mui_theme_provider.tsx
   ┣ _repositories
   ┃ ┣ local
   ┃ ┃ ┗ local_storage_repository.ts
   ┃ ┗ supabase
   ┃ ┃ ┣ auth
   ┃ ┃ ┃ ┣ auth_repository.ts
   ┃ ┃ ┃ ┣ constants.ts
   ┃ ┃ ┃ ┗ utils.ts
   ┃ ┃ ┣ db
   ┃ ┃ ┃ ┗ master_repository.ts
   ┃ ┃ ┣ schema.ts
   ┃ ┃ ┣ supabase.ts
   ┃ ┃ ┣ supabase_client.ts
   ┃ ┃ ┗ supabase_server.ts
   ┣ _utils
   ┃ ┣ constants.ts
   ┃ ┗ snake_to_camel.ts
   ┣ favicon.ico
   ┣ globals.css
   ┣ layout.tsx
   ┗ sitemap.ts

ナビゲーション

App Routerを利用しました。

十分に使いこなせている自信はありませんが、基本的には以下のような勘所で実装しました。

  • SEO強化したいページは Server Component
  • noindexにしたいページや複数の入力系や状態が必要となるページは Client Component
  • Server Componentを親Viewとして、子ViewをClient Componentに指定する
    • Server Componentとして作るのが難しいViewはClient Componentとして別ファイルで切り出す
  • 遷移手段は'next/link'Link'next/navigation'useRouterを利用
    • useRouterで1度閲覧したページへ遷移する際はpushした後にrefreshしないとキャッシュが効いた状態になる場合があるので注意
  • ページによってヘッダーとフッターの表示切り替えをしたい場合は、layout.tsxをそれぞれのディレクトリのルートに設置する

UI

Tailwind CSSMUIを利用しました。

全体的にTailwind CSSで構築しつつ、ダイアログなど実装が複雑になるものはMUIで実装しました。

Tailwind CSSはレスポンシブデザイン対応が簡単にできます。smmdを使って画面サイズに応じてレイアウトを切り替えることができました。

マップ

React-LeafletOpenStreetMapを利用しました。

Google MapはWebから扱うとお金がかかってしまうので(モバイルSDKだと無料)無料で使えるOpenStreetMapを利用しました。

Route Handlers

退会機能など、バックエンドで実行したい機能はRoute Handlersを利用しました。

※開発初期になぜかAPI Routesで実装していたのでリファクタリングしました

app/api/users/me/route.ts
import { createClient } from '@supabase/supabase-js'
import { NextRequest, NextResponse } from 'next/server'

export async function DELETE(req: NextRequest) {
  try {
    const authorization = req.headers.get('Authorization')
    if (!authorization) {
      return NextResponse.json({ message: `Invalid parameters.` }, { status: 400 })
    }
    const token = authorization.split(' ')[1]

    const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? ''
    const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? ''
    
    // https://supabase.com/docs/reference/javascript/v0/admin-api
    const supabase = createClient(supabaseUrl, supabaseServiceRoleKey, {
      auth: {
        autoRefreshToken: false,
        persistSession: false,
      },
    })

    const userRes = await supabase.auth.getUser(token)
    if (userRes.error) {
      return NextResponse.json({ message: `Invalid authorization. ${userRes.error.message}` }, { status: 400 })
    }

    const { error } = await supabase.auth.admin.deleteUser(userRes.data.user.id)
    if (error) {
      return NextResponse.json({ message: error.message }, { status: 401 })
    } else {
      return NextResponse.json({ message: 'success' }, { status: 200 })
    }
  } catch (e) {
    console.error(e)
    return NextResponse.json({ message: `${e}` }, { status: 500 })
  }
}

呼び出しは以下の通りです。

export const deleteAccount = async () => {
  ...
  await fetch(`/api/users/me`, {
    method: 'DELETE',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${accessToken}`,
    },
  })
}

また、環境変数にSupabaseのServiceRoleKeyを設定している場合はnext.config.jsに環境変数を設定します。これをしないと後述するAmplifyホスティングで環境変数を設定しても、Route Handlers側でPublicではない環境変数は読み込まれませんでした。

next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  ...
  // 👇 追加
  env: {
    SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY,
  },
}

module.exports = nextConfig

OGP

OGPが標準搭載されています。

layout.tsxpage.tsx内でmetadata: Metadatafunction generateMetadata({ params, searchParams }: Props)をexportするとOGPを作ってくれます。これらはServer Componentのみ設定できます。

サイトマップ

サイトマップが標準搭載されています。

src/app/sitemap.tsに設置して、サイトマップのコードを実装します。

sitemap.ts
import { MetadataRoute } from 'next'
...

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const url = ...
  const items = ... // DBから取得する
  return [
    {
      url: url,
      lastModified: new Date(),
      changeFrequency: 'daily',
      priority: 1,
    },
    ...items,
  ]
}

サイトマップの確認は https://xxx/sitemap.xml にアクセスすると生成されたサイトマップを確認できます。

また、Generating multiple sitemapsという大規模なWebアプリケーション向けにサイトマップを分割する方法もありますが、本サービスは規模が小さいので使っていません。

環境変数

.envの読み込みが標準搭載されています。

ローカル環境のみで扱うため、.env.localのファイルをsrcディレクトリと同じ階層に用意し、環境変数を定義します。Server ComponentClient Componentの両方利用する場合は、環境変数名の先頭にNEXT_PUBLIC_を付与します。Server Componentのみ利用したい場合は、付与しません。

Googleアナリティクス

@next/third-partiesを利用しました。

import { GoogleAnalytics } from '@next/third-parties/google'
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  const isProduction = process.env.NODE_ENV == 'production'
  return (
    <html lang="ja">
      <body className={`${font.className}`}>
        ...
      </body>
      {isProduction && <GoogleAnalytics gaId={'G-XXX'} />}
    </html>
  )
}

404ページ

app/not-found.tsxにファイルを設置して、404ページを作成します。

Supabase

Supabase Client

@supabase/ssr を利用します。

Supabaseのドキュメントに書いてある通り、Client ComponentではcreateBrowserClient、Server ComponentではcreateServerClientをカプセル化したcreateClientを実装して利用します。

また、ログイン状態を維持するためにmiddleware.tsを実装します。これを実装しないとログインしても1日程でログアウトされてしまいます。

加えて、@supabase/supabase-jsも利用しました。主に型をexportしたり、cookie不要でサーバー側で扱う場合に利用しました。

Database PostgreSQL

PostgreSQL + PostgREST + GoTrueにより、フロントエンドからSupabase Clientを経由してセキュアにDBを操作できます。

PostgreSQLの機能を使って色々実現できます。

ローカル環境でスムーズに開発できるので、ローカル上でDBを構築して開発を進めました。

ローカル上でmigrationファイルを作り適応後にスキーマコードを自動生成して開発を進めます。

よく使うコマンド

# DBのマイグレーションファイルを生成
supabase migration new xxx

# ローカル環境のDBをリセットします(マイグレーションファイルを適応)
supabase db reset

# スキーマコードを自動生成
supabase gen types typescript --local > src/app/xxx/schema.ts

schema.tsDatabasecreateClientに指定するとタイプセーフに扱えます。また、テーブル結合(JOIN)も簡単にできます。

import { createBrowserClient } from '@supabase/ssr'
import type { Database } from './schema'

export const createClient = () => {
  return createBrowserClient<Database>( // 👈 スキーマをジェネリックスに指定
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
  )
}

...

export const fetchXXX = async (id: number) => {
  const supabase = createClient()
  const { data, error } = await supabase
    .from('xxx')
    .select(
      '*,\
      xxx_type (id, value),\ // 👈 JOINが簡単にできる
      yyy_type!inner (id, value)\ // 👈 内部結合の場合は !inner をつける
    ',
    )
    .eq('id', id)
    .maybeSingle()
  ...
}

Supabaseはセキュリティ的に生SQLを展開することができないので、もし生SQLを書きたい場合はfunctionviewを作成してその中で実装してclient経由で実行します(これが結構めんどくさい)

FunctionパラメータをNullableにする方法

PostgreSQLのFunctionを構築後、supabase cliのコマンドでスキーマを生成した際に、パラメータは全てNon-nullableになります。

SQLではNullableを静的には判断できないため、生成するスキーマはNon-nullableに扱われるようです。

スキーマのパラメータをNullableにしたい場合は、MergeDeepを使って対象IFを上書きします。

import { MergeDeep } from 'type-fest'
import { Database as DatabaseGenerated } from './database-generated.types'

export type Database = MergeDeep<
  DatabaseGenerated,
  {
    public: {
      Functions: {
        create_xxx: {
          Args: {
            text: string | null // 👈 上書きする
          }
          Returns: number
        }
      }
    }
  }
>

...

const supabase = createClient<Database>(
  process.env.SUPABASE_URL,
  process.env.SUPABASE_ANON_KEY
)

Realtimeを有効にする

SupabaseにはFirestoreのスナップショットリスナーのようにDBの変更を検知できる仕組みがあります。

注意として、Realtimeはダッシュボードから設定を有効にしないと変更通知がきません。

Supabaseダッシュボードより、Database → Replication → supabase_realtimeのSource → 有効にしたいテーブルのスイッチをONにすることでRealtimeが有効になります。

// DBの変更を購読
const channel = supabase
  .channel('xxx')
  // RLSのルールに従い通知される
  // "*"はINSERT, UPDATE, DELETE操作全て通知する
  // 個別の操作のみ受け取りたい場合は、INSERT, UPDATE, DELETEを指定する
  // filter: `id=eq.${userId}` のようにクエリも利用できる
  .on('postgres_changes', { event: '*', schema: 'public', table: 'xxx' },
  (data) => {
    // ...
  })
  .subscribe()

...

// 購読を解除
supabase.removeChannel(channel)

PostGISの注意点

PostGISを扱った実装はこちらのドキュメント通りに実装すればちゃんと動きます。

const { error } = await supabase.from('restaurants').insert([
  {
    name: 'Supa Burger',
    location: 'POINT(-73.946823 40.807416)',
  },
  ...
])

私だけかもしれませんが、POINTに設定する緯度と経度のパラメータ順を間違えてしまい、時間を溶かしてしまいました。

// ❌ NG
'POINT(緯度, 経度)'

// ✅ OK
'POINT(経度, 緯度)'

シードデータ

ローカル環境のみseed.sqlに挿入したいデータを指定するとsupabase db resetで反映してくれます。シードはChatGPTにテーブル設計を伝えて作ってもらいました。

本番環境へ反映する方法は見つからなかったので、seed.sqlに書いたコマンドを本番環境のダッシュボードからSQL Editorで実行しました。

保守・運用での課題

1度作ったマイグレーションファイルを変更したり、新しいマイグレーションファイルを作ってそれだけを適応したい場合のコマンドが見つからなかったので、ローカル環境という理由もありsupabase db resetをして反映していました(1度作ったマイグレーションファイルをいじることは御法度かもですが)

保守運用する上で、いちいちsupabase db resetするわけにもいかないので、ここのベストプラクティスな手段を探しています。知ってる方教えてください。

現状、既存のmigrationファイルは最新の状態に保ちつつ、Supabaseダッシュボード(SQL Editor等)から操作して反映しています。

Storage

画像管理はStorageを利用します。

FirebaseだとCDNキャッシュをするFunctionを実装する必要がありましたが、Supabase StorageにはCDNキャッシュが標準で搭載されているのでとても感動しました。1

StorageもDatabaseでメタ情報やRLSを管理しているので、storage.bucketsに対してRowデータを作成します

insert into storage.buckets (id, name, public) values ('xxx', 'xxx', true);

RLSで作成・閲覧・削除の権限を付与します。閲覧権限がないと画像を保存するとエラーになるので設定します。

create policy "Insert policy for xxx." on storage.objects as permissive for 
insert to authenticated
with check (
    bucket_id = 'xxx' and
    (storage.foldername(name))[1] = auth.uid()::text
);

create policy "Select policy for xxx." on storage.objects as permissive for 
select to authenticated
using (
    bucket_id = 'xxx' and
    (storage.foldername(name))[1] = auth.uid()::text
);

create policy "Delete policy for xxx." on storage.objects as permissive for 
delete to authenticated
using (
    bucket_id = 'xxx' and
    (storage.foldername(name))[1] = auth.uid()::text
);

Supabase Clientより、getPublicUrlを利用すると、誰でも閲覧できるURLを取得できます。

画像サイズも自由に変更できます。1

Authentication

認証認可はAuthenticationを利用します。

ローカル環境がメールアドレス&パスワード認証のみ対応していたので、そちらの認証方法を利用しました。実装自体はドキュメントに書いてある通りでとてもシンプルに実装できましたが、いくつか注意点があるので紹介します。

ローカル環境と本番環境との違い

ローカル環境にはメールサーバーがないので、本人確認メールやパスワード再設定のメールが飛んできません。よってローカル環境上ではメール送信系のテストができません。

Supabaseからのメール送信にはリミットがある

スパム対策のためSupabaseからのメール送信回数の上限が設けられています。そのためテストをする際はそのリミット制限にひっかかり、スムーズに開発できない場合があります。

ダッシュボードのAuthentication → Rate Limitsからリミットの上限を変更できます。

また、SendGridなどのメールサーバーを使う手段もあります。私はSendGridを利用しました。設定方法はダッシュボードのProject Settings → Authentication → SMTP Settingsから設定できます。

Supabaseに登録していないリダイレクトURLは無効になる

本人確認メールやパスワード再設定後に、リダイレクトURLを指定します。そのURLがSupabaseに登録されていなければデフォルトのURL(デフォルトはhttp://localhost)になってしまいます。

また、パス付きURLも厳密に設定しなければいけません。例えばhttps://xxx/sign-inへリダイレクトする場合はhttps://xxx/sign-inhttps://xxx/*を設定します。

URLの設定はダッシュボードのAuthentication → URL Configurationから設定します。私はSite URLをサービスのURLに設定し、Redirect URLsにデバッグで使うURLも含めたものを設定しました。

スクリーンショット 2024-02-15 11.25.06.png

本人確認設定をONにした場合、本人確認しないと認証状態にならない

Firebaseだと本人確認されていなくてもFirebase Authでサインアップすれば認証状態になっていましたが、Supabaseだとサインアップしても本人確認しなければ認証状態になりません。ここの挙動を理解しとかないと後々改修が必要になり大変です。

この挙動はローカル環境では検証できず、本番環境のみ検証できます。

本人確認設定有りでサインアップ後に実行するfunctionauth.uid()nullであることを考慮する

テーブル作成時にユーザーIDの該当するカラムのデフォルト値にauth.uid()を指定しています。クライアントが認証状態であればauth.uid()が有効ですが、認証状態でない場合はnullになります。

サインアップ成功後にユーザー情報を作成するfunctionを実装しました。本人確認がされないと認証状態にならないため必然的にsecurity definerを設定してafter insert on auth.usersのトリガー経由でfunctionを実行していましたが、その場合カラムデフォルトのauth.uid()nullになっていました。

対策としては、デフォルト値を使わずに、明示的にidを指定します。

create function public.create_xxx_after_sign_up()
returns trigger as $$
begin
    insert into public.xxx (id) values (new.id); -- new.idがauth.uid()に該当する
    return new;
end;
$$ language plpgsql security definer;

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

パスワード再設定後のサインインがうまくいかない

Supabase Clientインスタンスをグローバルスコープ常に展開すると、認証状態の伝搬がうまくいかずエラーになりました。パスワード再設定時に指定したサインインページでリダイレクトリンク経由のSupabase Clientを実行すると発生しました。

対応としては、Clientインスタンスをグローバルスコープ常に展開せずに、それぞれのページがインスタンスを生成して保持するようにします(公式ドキュメント通りに実装していればOKです)

// ❌ NG
export const supabase = createBrowserClient<Database>(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
)

// ✅ OK
export const createClient = () => {
  return createBrowserClient<Database>(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
  )
}

const Page = () => {
  const supabase = createClient()
  ...
}

Google認証

Supabaseを使えば簡単にGoogle認証ができます。

また、メールアドレス認証とGoogle認証との併用ですが、既にメールアドレス認証したユーザーが同じメールアドレスのGoogleアカウントで認証した場合は既存アカウントを併用する挙動でした、その逆も然り(アカウントは1つのまま)

ログイン状態が1日程しか継続されない

Supabaseを触ってみた感想

良かったこと

  • ローカル環境でスムーズに開発できる
  • PostgreSQLが良い(キャッチアップが大変だけど)
    • テーブル結合最高
    • 検索クエリ最高
    • PostGIS最高
  • 公式ドキュメントが豊富にある

しんどかったこと

  • Authenticationの挙動を理解するのが大変だった
  • RLSで認可失敗に気づくのが難しい。エラーが出ずにデータが返ってこないため
  • PostgreSQL FunctionやViewのデバッグしづらい、エラー内容がよくわからない

改善してほしいこと

  • ダッシュボードが不安定、読み込みできない時がある
    • 読み込みできない場合は、ブラウザを再起動する
  • ダッシュボードのSQL Editor実行後のエラー理由がわかりづらい
    • ローカル環境だとエラー発生したらダッシュボードがバグり原因特定できない
  • Supabase Clientに生SQLを書けるIFを提供してほしい
    • 個人的にPostgreSQL FunctionやViewだと開発体験悪い、PostgreSQLに登録するのが結構負担(ダッシュボードの課題もあるため)
  • Firestoreセキュリティルールのテストのように、RLSのテストパッケージを作ってほしい

ホスティング

AWS Amplify ホスティングを利用しました。

Amplifyホスティングに関してはこちらの記事でまとめているのでそちらをご確認ください。

まとめ

規模の小さいサービスだったこともありスムーズに開発できました。フロントエンドで気になるところはたくさんあるのでパフォーマンス改善やっていきます。

このサービスを作る上で一番難しかったのはTailwind CSSでしょうか。慣れていないのもあり、なかなか厄介でした。

現状、コンテンツやユーザーがいないので、Supabaseは一旦Freeプランで稼働してます。ユーザーが増えたらProプランに切り替えます。

Webサービス作る上で参考になれば幸いです。

参考文献

  1. Supabase Proプランで有効になる 2

22
12
5

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
22
12