株式会社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 CSS
はNext.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 CSS
とMUI
を利用しました。
全体的にTailwind CSS
で構築しつつ、ダイアログなど実装が複雑になるものはMUI
で実装しました。
Tailwind CSS
はレスポンシブデザイン対応が簡単にできます。sm
やmd
を使って画面サイズに応じてレイアウトを切り替えることができました。
マップ
React-Leaflet
とOpenStreetMap
を利用しました。
Google MapはWebから扱うとお金がかかってしまうので(モバイルSDKだと無料)無料で使えるOpenStreetMap
を利用しました。
Route Handlers
退会機能など、バックエンドで実行したい機能はRoute Handlers
を利用しました。
※開発初期になぜかAPI Routesで実装していたのでリファクタリングしました
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ではない環境変数は読み込まれませんでした。
/** @type {import('next').NextConfig} */
const nextConfig = {
...
// 👇 追加
env: {
SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY,
},
}
module.exports = nextConfig
OGP
OGPが標準搭載されています。
layout.tsx
やpage.tsx
内でmetadata: Metadata
やfunction generateMetadata({ params, searchParams }: Props)
をexportするとOGPを作ってくれます。これらはServer Component
のみ設定できます。
サイトマップ
サイトマップが標準搭載されています。
src/app/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 Component
とClient 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の機能を使って色々実現できます。
- RLS(Row Level Security)でFirestoreのセキュリティルールのように対応できる
- 制約(constraint)でColumnに対してバリデーションできる
- トリガーでRowに対してバリデーションできる
- functionやviewを使って生SQLを書ける
- PostGISで緯度と経度を扱った検索ができる
ローカル環境でスムーズに開発できるので、ローカル上でDBを構築して開発を進めました。
ローカル上でmigrationファイルを作り適応後にスキーマコードを自動生成して開発を進めます。
よく使うコマンド
# DBのマイグレーションファイルを生成
supabase migration new xxx
# ローカル環境のDBをリセットします(マイグレーションファイルを適応)
supabase db reset
# スキーマコードを自動生成
supabase gen types typescript --local > src/app/xxx/schema.ts
schema.ts
のDatabase
をcreateClient
に指定するとタイプセーフに扱えます。また、テーブル結合(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を書きたい場合はfunction
やview
を作成してその中で実装して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-in
かhttps://xxx/*
を設定します。
URLの設定はダッシュボードのAuthentication → URL Configuration
から設定します。私はSite URLをサービスのURLに設定し、Redirect URLsにデバッグで使うURLも含めたものを設定しました。
本人確認設定を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を指定します。
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サービス作る上で参考になれば幸いです。