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

[株式会社Never](https://neverjp.com/)のshoheiです。

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

https://neverjp.com/service/

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

https://sptr100.com/

## 開発経緯

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

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

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

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

https://sptr100.com/about


## 開発メンバー

- [shohei](https://twitter.com/hobbydevelop)
- [kondo](https://twitter.com/kamooooooon)
- [kouso](https://twitter.com/chobu0706)

# 技術スタック

|項目 |技術  |
|---|---|
|フロントエンド（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 CSS`は`Next.js`で推奨されていたのでそれをメインとして、難しそうなUIは`MUI`で作りました。

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

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

# 機能一覧

<details><summary>機能一覧</summary>

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

</details>

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

# Next.js 14

https://nextjs.org/

## ディレクトリ構成

こちらの[ポスト](https://x.com/d151005/status/1659359491551547392)を参考にしました。

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

<details><summary>ディレクトリ構成</summary><div>

```
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
```
</div></details>

## ナビゲーション
App Routerを利用しました。

https://nextjs.org/docs/app

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

- 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 CSS`と`MUI`を利用しました。

https://tailwindcss.com/

https://mui.com/

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

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

https://tailwindcss.com/docs/responsive-design

## マップ
`React-Leaflet`と`OpenStreetMap`を利用しました。

https://react-leaflet.js.org/

https://www.openstreetmap.org/

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

## Route Handlers

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

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

https://nextjs.org/docs/app/building-your-application/routing/route-handlers

```ts: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 })
  }
}

```


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

```ts
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ではない環境変数は読み込まれませんでした。


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

module.exports = nextConfig
```

https://stackoverflow.com/a/68048439/14154450


## OGP

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

https://nextjs.org/docs/app/api-reference/file-conventions/metadata/opengraph-image

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


## サイトマップ

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

https://nextjs.org/docs/app/api-reference/file-conventions/metadata/sitemap

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

```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の読み込みが標準搭載されています。

https://nextjs.org/docs/pages/building-your-application/configuring/environment-variables

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


## Googleアナリティクス
`@next/third-parties`を利用しました。

https://nextjs.org/docs/app/building-your-application/optimizing/third-party-libraries#google-analytics

```tsx
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ページを作成します。

https://nextjs.org/docs/app/api-reference/file-conventions/not-found


# Supabase

https://supabase.com/

## Supabase Client
`@supabase/ssr` を利用します。

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

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

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

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

https://supabase.com/docs/reference/javascript/v0/admin-api

## Database PostgreSQL

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

https://supabase.com/docs/guides/database/overview

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

- [RLS（Row Level Security）](https://www.postgresql.jp/docs/15/ddl-rowsecurity.html)でFirestoreのセキュリティルールのように対応できる
- [制約（constraint）](https://www.postgresql.jp/docs/15/ddl-constraints.html)でColumnに対してバリデーションできる
- [トリガー](https://www.postgresql.jp/docs/15/sql-createtrigger.html)でRowに対してバリデーションできる
- [function](https://www.postgresql.jp/document/15/html/sql-createfunction.html)や[view](https://www.postgresql.jp/docs/15/sql-createview.html)を使って生SQLを書ける
- [PostGIS](https://postgis.net/)で緯度と経度を扱った検索ができる

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

https://supabase.com/docs/guides/cli/local-development

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

よく使うコマンド

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

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

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

`schema.ts`の`Database`を`createClient`に指定するとタイプセーフに扱えます。また、テーブル結合（JOIN）も簡単にできます。

```ts
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を書きたい場合は`function`や`view`を作成してその中で実装してclient経由で実行します（これが結構めんどくさい）

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

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

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

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

https://supabase.com/docs/reference/javascript/typescript-support#helper-types-for-tables-and-joins


```ts
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の変更を検知できる仕組みがあります。

https://supabase.com/docs/guides/realtime

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

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

```ts
// 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を扱った実装はこちらのドキュメント通りに実装すればちゃんと動きます。

https://supabase.com/docs/guides/database/extensions/postgis

```js
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を利用します。

https://supabase.com/docs/guides/storage

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

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

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

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

```sql
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]

https://supabase.com/docs/guides/storage/serving/image-transformations


## Authentication

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

https://supabase.com/docs/guides/auth

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


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

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

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

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

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

https://supabase.com/docs/guides/platform/going-into-prod#rate-limiting-resource-allocation--abuse-prevention

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

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

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

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

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

![スクリーンショット 2024-02-15 11.25.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183031/70eb6275-bce5-542a-43d9-23bb8d839b8e.png)


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

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

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


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

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

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

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

```sql
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です）

```ts
// ❌ 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認証ができます。

https://supabase.com/docs/guides/auth/social-login/auth-google

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


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



https://github.com/supabase/auth-helpers/issues/436


## Supabaseを触ってみた感想

### 良かったこと

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

### しんどかったこと

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

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

# ホスティング

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

https://aws.amazon.com/jp/amplify/hosting/

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

https://qiita.com/hukusuke1007/items/ecc1c1f40a9bca4ad4cf#amplify-hosting%E3%81%AB%E3%83%87%E3%83%97%E3%83%AD%E3%82%A4%E3%81%99%E3%82%8B

# まとめ

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

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

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

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

# 参考文献

- [進化が止まらない！Next.js13の基本機能をしっかり理解しよう](https://reffect.co.jp/react/next-js-13)
- [飛躍的に伸びているBaaS「Supabase」の概要と所感](https://qiita.com/kabochapo/items/6a2a391832825d17af7d)
- [Supabaseのデータベースを使うときに役立つ情報](https://qiita.com/kabochapo/items/26b1bb753116a6904664)

[^1]: Supabase Proプランで有効になる