Typescriptで実装するsupabase + next.js のクイックスタート
目的
この記事を書いた目的の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への互換は、公式ドキュメントの以下のページを参考にしています。
バージョン情報
- 手元の作業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キーだけです。
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が生成されている
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クライアントを初期化するためのヘルパーファイルを作成
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コンポーネントを設定する。マジック・リンクを使用することで、ユーザーはパスワードを使わずに電子メールでサインインできる。
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を作る。
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を更新する
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のプロジェクトには、写真や動画などの大容量ファイルを管理するためのストレージが用意されている。
アップロード・ウィジェットの作成
ユーザーがプロフィール写真をアップロードできるように、ユーザーのアバターを作成する。まず、新しいコンポーネントを作成。
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コンポーネントを追加する
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に残しておきます。
Discussion