💨

Typescriptで実装するsupabase + next.js のクイックスタート

2023/01/13に公開

目的

この記事を書いた目的の1つは、supabaseの公式ドキュメントにあるNext.jsで始めるクイックスタートの最新バージョンの手順を紹介するためと、2つめは、typescript版でのクイックスタートの手順を残すためです。

supabaseの公式ドキュメントにあるNext.jsで始めるクイックスタートの情報(https://www.supabase.jp/docs/guides/with-nextjs)は、supabase-jsの現在バージョン2.4.0ではエラーが起きてしまうため、現在バージョン(2.4.0)での正しい手順を残すために記載しました。

バージョン1から2への互換は、公式ドキュメントの以下のページを参考にしています。
https://supabase.com/docs/reference/javascript/upgrade-guide

バージョン情報

  • 手元の作業PC: Apple M1 Pro
  • node: 14.16.1
  • typescript: 4.6.3
  • Next.js: 13.1.1

手順

まずは、公式ドキュメント(https://www.supabase.jp/docs/guides/with-nextjs)を見て、プロジェクトの作成, データベース・スキーマの設定,APIキーを取得までを完了してください。
この記事は、公式ドキュメントの「アプリの構築」部分からの手順です。

アプリの構築

Next.jsアプリの初期化

create-next-appを使って、supbase-nextjsというアプリを初期化する。公式ドキュメントでは、名前をsupabase-nextjsにしていたので、ここではsupabase-nexttsにする。

npx create-next-app supabase-nextts --typescript
cd supabase-nextts

依存関係のsupbase-jsをインストールしましょう。(2023/01/13 現在の最新バージョン 2.4.0)

npm install @supabase/supabase-js

@supabase/supabase-jsのインストールされたバージョン確認

npm list --depth=0 | grep @supabase/supabase-js
├── @supabase/supabase-js@2.4.0

環境変数を.env.localに保存します。 必要なのは、APIのURLと、先ほどコピーしたanonキーだけです。

.env.local
NEXT_PUBLIC_SUPABASE_URL=https://xxxxxxxxxxxxxxxxxxxxxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Supabase CLIもインストールしておきます。これがあると、ローカルでコンテナによる開発環境を立ち上げたり、DBのマイグレーションを管理したりできます。今回は、DBの各テーブルから型を取得するために使います。公式のドキュメントにはsupabase startと書かれていますが、supabase linkを使えばDockerを立ち上げずとも型情報だけを取ってこれます。

brew install supabase/tap/supabase

supabase login #Access Tokenを貼る

supabase init

supabase link --project-ref <プロジェクトのReference ID>

supabase gen types typescript --linked > schema.ts

schema.tsが生成されている

schema.ts
export type Json =
  | string
  | number
  | boolean
  | null
  | { [key: string]: Json }
  | Json[]

export interface Database {
  graphql_public: {
    Tables: {
      [_ in never]: never
    }
    Views: {
      [_ in never]: never
    }
    Functions: {
      graphql: {
        Args: {
          operationName: string
          query: string
          variables: Json
          extensions: Json
        }
        Returns: Json
      }
    }
    Enums: {
      [_ in never]: never
    }
  }
  public: {
    Tables: {
      profiles: {
        Row: {
          avatar_url: string | null
          full_name: string | null
          id: string
          updated_at: string | null
          username: string | null
          website: string | null
        }
        Insert: {
          avatar_url?: string | null
          full_name?: string | null
          id: string
          updated_at?: string | null
          username?: string | null
          website?: string | null
        }
        Update: {
          avatar_url?: string | null
          full_name?: string | null
          id?: string
          updated_at?: string | null
          username?: string | null
          website?: string | null
        }
      }
    }
    Views: {
      [_ in never]: never
    }
    Functions: {
      [_ in never]: never
    }
    Enums: {
      [_ in never]: never
    }
  }
  storage: {
    Tables: {
      buckets: {
        Row: {
          created_at: string | null
          id: string
          name: string
          owner: string | null
          public: boolean | null
          updated_at: string | null
        }
        Insert: {
          created_at?: string | null
          id: string
          name: string
          owner?: string | null
          public?: boolean | null
          updated_at?: string | null
        }
        Update: {
          created_at?: string | null
          id?: string
          name?: string
          owner?: string | null
          public?: boolean | null
          updated_at?: string | null
        }
      }
      migrations: {
        Row: {
          executed_at: string | null
          hash: string
          id: number
          name: string
        }
        Insert: {
          executed_at?: string | null
          hash: string
          id: number
          name: string
        }
        Update: {
          executed_at?: string | null
          hash?: string
          id?: number
          name?: string
        }
      }
      objects: {
        Row: {
          bucket_id: string | null
          created_at: string | null
          id: string
          last_accessed_at: string | null
          metadata: Json | null
          name: string | null
          owner: string | null
          path_tokens: string[] | null
          updated_at: string | null
        }
        Insert: {
          bucket_id?: string | null
          created_at?: string | null
          id?: string
          last_accessed_at?: string | null
          metadata?: Json | null
          name?: string | null
          owner?: string | null
          path_tokens?: string[] | null
          updated_at?: string | null
        }
        Update: {
          bucket_id?: string | null
          created_at?: string | null
          id?: string
          last_accessed_at?: string | null
          metadata?: Json | null
          name?: string | null
          owner?: string | null
          path_tokens?: string[] | null
          updated_at?: string | null
        }
      }
    }
    Views: {
      [_ in never]: never
    }
    Functions: {
      extension: {
        Args: { name: string }
        Returns: string
      }
      filename: {
        Args: { name: string }
        Returns: string
      }
      foldername: {
        Args: { name: string }
        Returns: string[]
      }
      get_size_by_bucket: {
        Args: Record<PropertyKey, never>
        Returns: { size: number; bucket_id: string }[]
      }
      search: {
        Args: {
          prefix: string
          bucketname: string
          limits: number
          levels: number
          offsets: number
          search: string
          sortcolumn: string
          sortorder: string
        }
        Returns: {
          name: string
          id: string
          updated_at: string
          created_at: string
          last_accessed_at: string
          metadata: Json
        }[]
      }
    }
    Enums: {
      [_ in never]: never
    }
  }
}

Supabaseクライアントを初期化するためのヘルパーファイルを作成

utils/supabaseClient.ts
import { createClient } from "@supabase/supabase-js";
import type { Database } from "../schema";

const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;

export const supabase = createClient<Database>(supabaseUrl!, supabaseAnonKey!);

また、オプションとして、CSSファイルのstyles/globals.cssを更新して、アプリの外観を整える。 こちら(https://raw.githubusercontent.com/supabase/supabase/master/examples/user-management/nextjs-ts-user-management/styles/globals.css)にあるファイルをコピー。

ログイン・コンポーネントの設定

ログインとサインアップを管理するReactコンポーネントを設定する。マジック・リンクを使用することで、ユーザーはパスワードを使わずに電子メールでサインインできる。

components/Auth.tsx
import { useState } from 'react'
import { supabase } from '../utils/supabaseClient'

export default function Auth() {
  const [loading, setLoading] = useState(false)
  const [email, setEmail] = useState('')

  const handleLogin = async (email: string) => {
    try {
      setLoading(true)
      // @supabase/supabase-js V1の書き方
    //   const { error } = await supabase.auth.signIn({ email })
      // supabase/supabase-js V2の書き方
      const { error } = await supabase.auth.signInWithOtp({ email })
      if (error) throw error
      alert('Check your email for the login link!')
    } catch (error: any) {
      alert(error.error_description || error.message)
    } finally {
      setLoading(false)
    }
  }

  return (
    <div className="row flex flex-center">
      <div className="col-6 form-widget">
        <h1 className="header">Supabase + Next.js</h1>
        <p className="description">Sign in via magic link with your email below</p>
        <div>
          <input
            className="inputField"
            type="email"
            placeholder="Your email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
          />
        </div>
        <div>
          <button
            onClick={(e) => {
              e.preventDefault()
              handleLogin(email)
            }}
            className="button block"
            disabled={loading}
          >
            <span>{loading ? 'Loading' : 'Send magic link'}</span>
          </button>
        </div>
      </div>
    </div>
  )
}

アカウント・ページ

ユーザーがログインした後、プロフィールの詳細を編集したり、アカウントを管理できるする。

そのための新しいコンポーネント、Account.jsを作る。

components/Account.tsx
import { useState, useEffect } from "react";
import { supabase } from "../utils/supabaseClient";
import { Session } from "@supabase/supabase-js";

type Props = {
  session: Session;
};

export default function Account({ session }: Props) {
  const [loading, setLoading] = useState<boolean>(true);
  const [username, setUsername] = useState<string | null>(null);
  const [website, setWebsite] = useState<string | null>(null);
  const [avatar_url, setAvatarUrl] = useState<string | null>(null);

  useEffect(() => {
    getProfile();
  }, [session]);

  async function getProfile() {
    try {
      setLoading(true);

      //   @supabase/supabase-js V1の書き方
      //   const user = supabase.auth.user()

      // supabase/supabase-js V2の書き方
      const {
        data: { session: Session },
      } = await supabase.auth.getSession();
      const { user } = session;

      let { data, error, status } = await supabase
        .from("profiles")
        .select(`username, website, avatar_url`)
        .eq("id", user?.id)
        .single();

      if (error && status !== 406) {
        throw error;
      }

      if (data) {
        setUsername(data.username);
        setWebsite(data.website);
        setAvatarUrl(data.avatar_url);
      }
    } catch (error: any) {
      alert(error.message);
    } finally {
      setLoading(false);
    }
  }

  async function updateProfile({
    username,
    website,
    avatar_url,
  }: {
    username?: string | null,
    website?: string | null,
    avatar_url?: string | null,
  }) {
    try {
      setLoading(true);
      // @supabase/supabase-js V1の書き方
      // const user = supabase.auth.user()

      // supabase/supabase-js V2の書き方
      const {
        data: { session: Session },
      } = await supabase.auth.getSession();
      const { user } = session;

      const updates = {
        id: user?.id,
        username,
        website,
        avatar_url,
        updated_at: (new Date()).toLocaleString('ja-JP'),
      };

      let { error } = await supabase.from("profiles").upsert(updates);

      if (error) {
        throw error;
      }
    } catch (error: any) {
      alert(error.message);
    } finally {
      setLoading(false);
    }
  }

  return (
    <div className="form-widget">
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" type="text" value={session.user.email} disabled />
      </div>
      <div>
        <label htmlFor="username">Name</label>
        <input
          id="username"
          type="text"
          value={username || ""}
          onChange={(e) => setUsername(e.target.value)}
        />
      </div>
      <div>
        <label htmlFor="website">Website</label>
        <input
          id="website"
          type="website"
          value={website || ""}
          onChange={(e) => setWebsite(e.target.value)}
        />
      </div>

      <div>
        <button
          className="button block primary"
          onClick={() => updateProfile({ username, website, avatar_url })}
          disabled={loading}
        >
          {loading ? "Loading ..." : "Update"}
        </button>
      </div>

      <div>
        <button
          className="button block"
          onClick={() => supabase.auth.signOut()}
        >
          Sign Out
        </button>
      </div>
    </div>
  );
}

ローンチ

すべてのコンポーネントがそろったところで、pages/index.tsxを更新する

pages/index.tsx
import { useState, useEffect } from "react";
import { supabase } from "../utils/supabaseClient";
import Auth from "../components/Auth";
import Account from "../components/Account";
import { Session } from "@supabase/supabase-js";

export default function Home() {
  const [session, setSession] = useState<Session | null>(null);

  useEffect(() => {
    // セッションデータを返す
    (async () => {
      const {
        data: { session },
      } = await supabase.auth.getSession();

      setSession(session);
    })();

    // 認証のイベントを受け取る
    supabase.auth.onAuthStateChange((_event, session) => {
      setSession(session);
    });
  }, []);

  return (
    <div className="container" style={{ padding: "50px 0 100px 0" }}>
      {!session ? (
        <Auth />
      ) : (
        <Account key={session.user.id} session={session} />
      )}
    </div>
  );
}

更新が完了したら、ターミナル・ウィンドウでこれを実行する

npm run dev

そして、ブラウザーでlocalhost:3000を開くと、完成したアプリを見ることができる

おまけ:プロフィール写真

Supabaseのプロジェクトには、写真や動画などの大容量ファイルを管理するためのストレージが用意されている。

アップロード・ウィジェットの作成

ユーザーがプロフィール写真をアップロードできるように、ユーザーのアバターを作成する。まず、新しいコンポーネントを作成。

components/Avatar.tsx
import { useEffect, useState } from "react";
import { supabase } from "../utils/supabaseClient";
import Image from "next/image";

export default function Avatar({
  url,
  size,
  onUpload,
}: {
  url: string | null;
  size: any;
  onUpload: any;
}) {
  const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
  const [uploading, setUploading] = useState<boolean>(false);

  useEffect(() => {
    if (url) downloadImage(url);
  }, [url]);

  async function downloadImage(path: string) {
    try {
      const { data, error } = await supabase.storage
        .from("avatars")
        .download(path);
      if (error) {
        throw error;
      }
      const url = URL.createObjectURL(data);
      setAvatarUrl(url);
    } catch (error: any) {
      console.log("Error downloading image: ", error.message);
    }
  }

  async function uploadAvatar(event: any) {
    try {
      setUploading(true);

      if (!event.target.files || event.target.files.length === 0) {
        throw new Error("You must select an image to upload.");
      }

      const file = event.target.files[0];
      const fileExt = file.name.split(".").pop();
      const fileName = `${Math.random()}.${fileExt}`;
      const filePath = `${fileName}`;

      let { error: uploadError } = await supabase.storage
        .from("avatars")
        .upload(filePath, file);

      if (uploadError) {
        throw uploadError;
      }

      onUpload(filePath);
    } catch (error: any) {
      alert(error.message);
    } finally {
      setUploading(false);
    }
  }

  return (
    <div>
      {avatarUrl ? (
        <Image
          src={avatarUrl}
          alt="Avatar"
          className="avatar image"
          width={size}
          height={size}
        />
      ) : (
        <div
          className="avatar no-image"
          style={{ height: size, width: size }}
        />
      )}
      <div style={{ width: size }}>
        <label className="button primary block" htmlFor="single">
          {uploading ? "Uploading ..." : "Upload"}
        </label>
        <input
          style={{
            visibility: "hidden",
            position: "absolute",
          }}
          type="file"
          id="single"
          accept="image/*"
          onChange={uploadAvatar}
          disabled={uploading}
        />
      </div>
    </div>
  );
}

components/Account.tsx にて、Avatarコンポーネントを追加する

components/Account.tsx
import { useState, useEffect } from "react";
import { supabase } from "../utils/supabaseClient";
import { Session } from "@supabase/supabase-js";
import Avatar from './Avatar'

type Props = {
  session: Session;
};

export default function Account({ session }: Props) {
  const [loading, setLoading] = useState<boolean>(true);
  const [username, setUsername] = useState<string | null>(null);
  const [website, setWebsite] = useState<string | null>(null);
  const [avatar_url, setAvatarUrl] = useState<string | null>(null);

  useEffect(() => {
    getProfile();
  }, [session]);

  async function getProfile() {
    try {
      setLoading(true);

      //   @supabase/supabase-js V1の書き方
      //   const user = supabase.auth.user()

      // supabase/supabase-js V2の書き方
      const {
        data: { session: Session },
      } = await supabase.auth.getSession();
      const { user } = session;

      let { data, error, status } = await supabase
        .from("profiles")
        .select(`username, website, avatar_url`)
        .eq("id", user?.id)
        .single();

      if (error && status !== 406) {
        throw error;
      }

      if (data) {
        setUsername(data.username);
        setWebsite(data.website);
        setAvatarUrl(data.avatar_url);
      }
    } catch (error: any) {
      alert(error.message);
    } finally {
      setLoading(false);
    }
  }

  async function updateProfile({
    username,
    website,
    avatar_url,
  }: {
    username?: string | null,
    website?: string | null,
    avatar_url?: string | null,
  }) {
    try {
      setLoading(true);
      // @supabase/supabase-js V1の書き方
      // const user = supabase.auth.user()

      // supabase/supabase-js V2の書き方
      const {
        data: { session: Session },
      } = await supabase.auth.getSession();
      const { user } = session;

      const updates = {
        id: user?.id,
        username,
        website,
        avatar_url,
        updated_at: (new Date()).toString(),
      };

      let { error } = await supabase.from("profiles").upsert(updates);

      if (error) {
        throw error;
      }
    } catch (error: any) {
      alert(error.message);
    } finally {
      setLoading(false);
    }
  }

  return (
    <div className="form-widget">
    {/* Add to the body */}
    <Avatar
      url={avatar_url}
      size={150}
      onUpload={(url: string) => {
        setAvatarUrl(url)
        updateProfile({ username, website, avatar_url: url })
      }}
    />
    {/* ... */}
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" type="text" value={session.user.email} disabled />
      </div>
      <div>
        <label htmlFor="username">Name</label>
        <input
          id="username"
          type="text"
          value={username || ""}
          onChange={(e) => setUsername(e.target.value)}
        />
      </div>
      <div>
        <label htmlFor="website">Website</label>
        <input
          id="website"
          type="website"
          value={website || ""}
          onChange={(e) => setWebsite(e.target.value)}
        />
      </div>

      <div>
        <button
          className="button block primary"
          onClick={() => updateProfile({ username, website, avatar_url })}
          disabled={loading}
        >
          {loading ? "Loading ..." : "Update"}
        </button>
      </div>

      <div>
        <button
          className="button block"
          onClick={() => supabase.auth.signOut()}
        >
          Sign Out
        </button>
      </div>
    </div>
  );
}

以上で完成。

画面

トップ

メール

ログイン後

完成物

githubに残しておきます。
https://github.com/napojin/Nextjs_Supabase_Quickstar

Discussion