Supabase + Next.jsで画像投稿アプリを作ってみた

最近話題のオープンソースのBaaS(Backend As A Service)である「Supabase」とNext.jsで、試しに画像投稿アプリを作ってみました。

スタイルやUIの作り込みは最低限なのでご容赦ください。

未ログインの状態では他ユーザーの投稿の閲覧のみ、ログインすれば画像を投稿したり、投稿画像にコメントやいいねをしたりできます。

Demo: https://supabase-photo-app.vercel.app/
Source: https://github.com/shimotsu4431/supabase-photo-app


Supabase は公式サイトで「Firebase Alternative」と謳っており、Authentication、Database、Storage、Functions など、β版(2021年現在)でありながらも Firebase とほぼ同等の機能を提供しています。

よく言われている Firebase との最大の違いは、Firebase で使えるデータベースが NoSQL なデータベース(Firestore、Firebase Realtime Database)であるのに対して、Supabase は RDB(PostgreSQL)が使えるという点です。個人的にも、Supabase で提供している RDB を使ってみたいというモチベーションでアプリを作るに至りました。

今回は Supabase の機能の中から、

  • Authentication(Googleログイン、メールアドレスログイン)
  • Database(画像の投稿、画像へのコメント&いいね)
  • Storage(画像のアップロード)

を使い、認証機能付きでCRUDを実現しました。シンプルなTodoリストとかでもよかったのですが、せっかくならもう少し実用に寄った機能を作りたいと思い、この形にしました。なお、今回はすべての機能を無料枠の範囲内で作っています。

認証について

Supabase の Authentication 機能では、Google や Facebook、Twitter はもちろん、Github、Discord、Slackなどさまざまなプロバイダを活用したソーシャルログインが実装可能です。

Firebase と同様、ダッシュボードの Authentication ページでユーザー情報を管理してくれます。

Supabase クライアントの API も非常にシンプルで、サインアップ、サインインはそれぞれ以下のように書けます。この辺の扱いは Firebase とほぼ同じでお手軽です。

// Googleアカウントでサインイン
const { user, error } = await supabase.auth.signIn({ provider: 'google' })

// メールアドレスでサインアップ
const { user, error } = await supabase.auth.signUp({
        email: email,
        password: password
      })

// メールアドレスでサインイン
const { user, error } = await supabase.auth.signIn({
        email: email,
        password: password
      })

ソーシャルログインを実装する場合には各プロバイダの client IDsecret などが必要になるので、それらは都度設定が必要です。

なお、Googleアカウントでのログインのために必要な設定については以下の記事が詳しいので参考にしてみてください。

参考: How To Get Google OAuth in Supabase With Next.js

以下の記事にあるように、Supabase Auth で新規にユーザー情報を登録したら自動で users テーブルに登録するトリガー(PostgreSQL Trigger)を作っておくと便利です。

参考: Next.jsでsupabaseを使って、認証からデータベースの更新までをやってみる

また今回のアプリでは、アカウント情報の変更を検知すると session を Cookie に保存しておき、

import { NextApiRequest, NextApiResponse } from "next";
import { supabase } from "../../utils/supabaseClient";

export default function handler(req: NextApiRequest, res: NextApiResponse) {
  supabase.auth.api.setAuthCookie(req, res);
}

ログインが必要なページ(アカウント情報ページやダッシュボードなど)では、以下のように getServerSideProps 内で、Cookie から得たユーザー情報をもとに閲覧の制御をおこなっています。Cookie からデータを取得するメソッドが用意されているのがありがたいですね。


export async function getServerSideProps({ req }: GetServerSidePropsContext) {
  const { user } = await supabase.auth.api.getUserByCookie(req);

  if (!user) {
    return {
      redirect: {
        destination: "/",
        permanent: false,
      },
    };
  }
  // If there is a user, return it.
  return { props: { user } };
}

なお、デフォルトで用意されている Auth コンポーネントの Auth.UserContextProvider(内部的に React の Context を利用している) を利用すれば、通常のコンテキストと同様に認証したユーザー情報を読み取ることができます。

import '../styles/globals.css'
import { Auth } from '@supabase/ui'
import type { AppProps } from 'next/app'
import { supabase } from '../utils/supabaseClient'

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <Auth.UserContextProvider supabaseClient={supabase}>
      <Component {...pageProps} />
    </Auth.UserContextProvider>
  )
}

export default MyApp

画像のアップロード

画像のアップロードには Storage 機能を利用しました。

Storage は Buckets > Folders > Files という階層構造になっており、さらに Buckets 自体は Private / Public モードを切り替えることができます( public に設定すると、誰でも参照できるようになる)。postgreSQL の RLS (Row Level Security)機能を使って bucket ごとに細かいセキュリティーポリシーを設定することも可能です。

参考: Add security rules

今回はひとまず photos(Bucket) > [userId](Folder) > [photoのkey](File)という感じで作ってみました(上図)。フォルダ名、ファイル名はそのままIDを入れていますが、これは独自に命名も可能です。

Storage 設計の定石はまだ分かっていないのですが、公式サイトの Buckets の説明欄にあるように、閲覧権限の有無によって publicrestricted を使い分けるみたいな使い方も良さそうです。

Buckets are distinct containers for files and folders. You can think of them like “super folders”. Generally you would create distinct buckets for different Security and Access Rules. For example, you might keep all public files in a “public” bucket, and other files that require logged-in access in a “restricted” bucket.

画像のアップロードについて、大まかに ①Storageに画像をアップロードし返り値でKeyを取得、②DBの photos テーブルにレコードを新規作成、という流れで行いました。

なお、DBにレコードを追加する際、画像のソースを保持するカラムを用意し、そこに直接参照用のURLの値を入れるようにしました。画像のパブリックなURLを取得するには、標準で用意されている getPublicUrl() メソッドを利用しています。

参考: from.getPublicUrl() | Supabase

// storage の key から bucket 名を取り除く
export const removeBucketPath = (key: string, bucketName: string) => {
  return key.slice(bucketName.length + 1) // "/"の分だけ加算している
}
// storage に画像をアップロード
      const { data: inputData } = await supabase
        .storage
        .from("photos")
        .upload(`${user.id}/${newImageKey}`, newImage, {
          cacheControl: '3600',
          upsert: false
        })

      const key = inputData?.Key

      if (!key) {
        throw new Error("Error")
      }

      // .from() で bucket 指定しているので、getPublicUrl() に渡すパスからは、bucket 名は取り除く必要がある
      // NG: photos/25aea8bc-aa5e-42ce-b099-da8815c2a50f/fdf945886dfd
      // OK: 25aea8bc-aa5e-42ce-b099-da8815c2a50f/fdf945886dfd
      const { publicURL } = supabase.storage.from("photos").getPublicUrl(removeBucketPath(key, "photos"))

      // DBにレコード作成
      await supabase.from("photos").insert([{
        userId: user.id,
        title: title,
        is_published: is_published,
        src: publicURL
      }])

画像アップロード時に得られる Key の値には bucket名(今回でいうと photos )が含まれているのですが、getPublicUrl()メソッドで画像のキーを渡す際には bucket名は不要、というところが少しつまずきやすいポイントかもしれません。

テーブル結合について

まず、今回のDBのスキーマは以下のようになっています。

# ユーザー
users:
  id: string
  fullname: string
  avatarURL: string
  nickname: string

# 投稿画像
photos:
  id: string
  userId: string
  createdAt: Timestamp
  updatedAt: Timestamp
  title: string
  is_published: string
  url: string

# 画像へのコメント
comments:
  id: string
  userId: string
  createdAt: Timestamp
  updatedAt: Timestamp
  body: string
  isEdited: boolean
  photoId: number

# 画像へのいいね
likes:
  id: string
  userId: string
  photoId: number
  createdAt: Timestamp

この前提を踏まえたうえで、今回はユーザーが投稿した画像の詳細ページには画像の詳細情報と、画像に紐づくコメントおよびいいね数を表示したかったので、以下のようにDBから取得しています(comments および likesphotoId を外部キーとして設定している)。


export async function getServerSideProps({ params }: GetServerSidePropsContext) {
  const { data: photoData } = await supabase
    .from("photos")
    .select(`*, user: userId(*), likes(*), comments(*, user: userId(*))`)
    .eq("id", params?.id)
    .single()

  if (!photoData || !photoData.is_published) {
    return { notFound: true }
  }
  return { props: { photoData } };
}

SELECT文(.select())では、投稿に紐づくコメントにおいて、コメントしたユーザー情報を表示したかったので、さらにコメントに紐づくユーザー情報を取得するようにしています。そのため階層が多めなやや複雑な入れ子構造になっています。

また、select() 内で user: userId(*) のように記述すると任意のプロパティ名で値がセットされるようになります。

参考: Fetch data: select() | Supabase

PostgreSQL の RLS について

Supabase で利用できる PostgreSQL には、テーブルの各行に対して操作(SELECT, UPDATEなど)をする際に、特定の条件を満たしている場合にのみ許可するように設定することができます(RLS: Row Level Security)。Firebase の Firestore では Cloud Firestore セキュリティルールを定義してアクセス制御を行いますが、感覚としてはほとんどこれに近いです。

参考: PostgreSQL 9.5.4文書

Supabase では、新規にポリシーを作成する際に汎用テンプレートから選択して編集するか、スクラッチで作成するかの2つの方法を取ることができます。

テンプレートではいくつかのありがちなパターンを用意してくれているので、ここで初めて RLS に触れる方でも、なんとなく雰囲気は理解できるんじゃないかと思います。

参考: Row Level Security | Supabase
参考: Supabaseの行単位セキュリティーについて学ぶ

さいごに

今回、Supabase を使って認証と CRUD 機能を持つ簡単なアプリケーションを一通り作ってみました。

同じ BaaS である Firebase と比較すると、まだβ版であるため実装中の機能がちらほらあったり、ダッシュボードの挙動がやや不安定な部分は見受けられるも、小〜中規模なアプリケーションをまるっと作るには十分な機能を備えているなという印象を受けました。

その他、例えば Firebase であればセキュリティルールは Firestore の中でしか通用しないスキルですが、PostgreSQL の Row Level Security は PostgreSQL を活用しているプロジェクトであればどこでも使える汎用スキルなので、そうした面でもオープンソースであることのメリットを感じました。個人としても業務では RDB に触れる機会のほうが多いので、その意味でも触ってみる意義はあるかなと思います。

今後は、今回触れなかった機能(FunctionsPostgres Views など)にも触れつつ、実際にプロダクションとして公開できるサービスを作ってみたいなと思っています。