Supabase をローカルで立ち上げて React から使ってみる 〜 Redux Toolkit と React Router を用いた認証編 〜
本記事で作成したアプリの全ソースコードをこちらに公開しています。
[2022/08/15 更新]
この記事を更新してから Supabase も進化しました。
こちらで新たに VsCode の Remote Containers 拡張機能を使った開発環境の立ち上げ方法を紹介しています。
開発環境の構築方法については、そちらの記事の方が最新の情報に基づいていますので併せてご覧ください。
Supabase とは?
Supabaseは Firebase の代替となることを目指して開発されているバックエンドサービスです。オープンソースのソフトウェアを利用して開発されており、Supabase そのものもオープンソースになっています。
Firebase の代替とは言うものも1対1で入れ替え可能なものを目指しているわけではなく、特に Database にリレーショナルデータベース(Postgres)を用いているのが特徴です。
ローカルでの開発
Supabase.io でホストされているサービスを用いて開発することもできますが、Supabase は自身のサーバーやローカルで立ち上げて開発することもできます。
自身でホストした場合まだ UI が用意されていませんので、気軽に開発したい場合は Supabase.io を使う方が楽かもしれないですね(現在開発中途のことなので期待!)
今回は、ローカルで立ち上げた Supabase と React を使って簡単なアプリを作ってみます!
作成するもの
今回は Supabase Auth を利用して認証機能を実装していきます。
Redux Toolkit を使ってログイン状態を管理し、React Router で画面遷移を制御するところまで作ります。
基本的にはログインとログアウトができるだけのアプリです。
アプリの全ソースコードはこちらに公開しています。
Supabase の起動
必要なもの
Supabase CLI のインストール
$ npm install -g supabase
Supabase の初期化
あらかじめ、Docker を起動しておきます。
# 任意のディレクトリを作成/移動
$ mkdir supabase-sample
$ cd supabase-sample
# 初期化
$ supabase init
supabase init
コマンドを叩くとポート番号を色々聞かれます。特にこだわりがなければそのままエンターキーを押していくとInitializing project...
と表示されて作成が開始されます。
処理が完了すると以下のようなキーや URL の一覧が表示されますのでどこかにメモしておきます。
$ supabase init
✔ Port for Supabase URL: · 8000
✔ Port for PostgreSQL database: · 5432
✔ Port for email testing interface: · 9000
✔ Project initialized.
Supabase URL: http://localhost:8000
Supabase Key (anon, public): eyJ0eXAiOiJKV1Q...
Supabase Key (service_role, private): eyJ0eXAiOiJKV1Q...
Database URL: postgres://postgres:postgres@localhost:5432/postgres
Email testing interface URL: http://localhost:9000
Run supabase start to start local Supabase.
また.supabase
フォルダがコマンドを実行したディレクトリに作成されます。中身には supabase を起動するのに必要なdocker-compose.yml
などがありました。
Supabase の起動
.supabase
が作成されたディレクトリで以下のコマンドを実行します。
$ supabase start
これで、Supabase の Auth や Database のサービスがローカルで利用可能になります。
データベース
supabase start
で Postgres が起動しているのでpsql
や SQL クライアントアプリ等で接続可能です。少し覗いてみます。今回はDBeaverを使ってみます。
supabase init
コマンド完了時の出力に PostgreSQL の Connection URI が表示されています。
postgresql://{ユーザー:パスワード}@{ホスト名}:{ポート番号}/{DB名}
postgres://postgres:postgres@localhost:5432/postgres
接続設定を確認して、入力します。
接続してみると、このようにデフォルトでスキーマやロールなどが設定されています。
テーブルについても、Auth に必要な users 等がデフォルトで作成されています。
React アプリから Supabase を利用する
Supabase の設定が完了したので、React でアプリを作って接続してみます。
Supabase の Auth のサービスを使って、ログイン周りの仕組みを備えたアプリを作ってみましょう。
React での Supabase の使い方はこちらのチュートリアルが参考になります(他にも Angular, Vue, Flutter などいろんなフレームワークでチュートリアルが用意されています)。
アプリの作成
create-react-app
で通常通りプロジェクトを作成して起動します。
$ npx create-react-app supabase-react-sample --template typescript
$ cd chat-app
$ yarn start
特に必須ではないですが、楽をするためにちょっとだけtsconfig.json
をいじっておきます。
tsconfig.json
{
"compilerOptions": {
...,
"baseUrl": "src",
},
...
}
絶対パスで import するための設定です(Create React App - Absolute Imports)。
supabase-js のインストール
次にsupabase-jsをプロジェクトに追加します。
yarn add @supabase/supabase-js
また、supabase init
コマンド実行時の出力にあったキーを.env
ファイルに書いておきます。
.env
REACT_APP_SUPABASE_URL=http://localhost:8000
REACT_APP_SUPABASE_ANON_KEY=eyJ0eXAiOiJKV1Q...
SUPABASE_ANON_KEY
には先程の出力の Supabase Key (anon, public)
のキーを貼り付けておきます。
出力のもう一方の Supabase Key (service_role, private)
は開発者のロールで Supabase の API を叩く時に使うもののようです。private
と書いているので公開してはいけないキーですね。(ローカルで開発している分には関係ないですが)
.env
を読み込む
アプリから .env
を読み込んでアプリ内で利用できるようにします。
config/env.ts
export const Env = {
SUPABASE_URL: process.env.REACT_APP_SUPABASE_URL || "",
SUPABASE_ANON_KEY: process.env.REACT_APP_SUPABASE_ANON_KEY || "",
} as const;
Supabase client を初期化する
Supabase の Auth や Database などのサービスに接続するための Supabase client を取得します。
services/supabase/supabase-client/index.ts
import { createClient } from "@supabase/supabase-js";
import { Env } from "config/env";
const supabaseUrl = Env.SUPABASE_URL;
const supabaseAnonKey = Env.SUPABASE_ANON_KEY;
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
services/supabase/index.ts
export * from "./supabase-client";
これで、Supabase を利用することができます!
ログイン画面の作成
ログイン画面を作成していきましょう。
初めにライブラリをいくつか追加しておきます。
Material UI と React Router はお試しにベータのバージョンを使ってみます(最近個人的に触ってみたからというだけの理由です)。
# Material UI
yarn add @material-ui/core@next @emotion/react @emotion/styled
# React Router
yarn add history react-router-dom@next
# Redux Toolkit
yarn add @reduxjs/toolkit react-redux
# React Hook Form
yarn add react-hook-form
Supabase Auth を使った認証と Redux によるステートの管理
認証情報を管理するために Redux をセッティングしていきます。
まず認証情報のストアを定義ます。
また、上で export した Supabase のクライアントと Redux Toolkit の createAsyncThunk
を使って
Sign In, Sign Out の処理も記述し、 createSlice
の extraReducers
に追加します。
redux/auth/slice.ts
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { Session } from "@supabase/supabase-js";
import { signIn, signOut } from "./action";
export type AuthState = {
session: Session | null;
loading: boolean;
error: Error | null | undefined;
};
const initialState: AuthState = {
session: null,
loading: false,
error: null,
};
type SetSessionPayload = {
session: Session | null;
};
export const authSlice = createSlice({
name: "auth",
initialState,
reducers: {
setSession: (state, action: PayloadAction<SetSessionPayload>) => ({
...state,
session: action.payload.session,
}),
},
extraReducers: (builder) => {
// signIn
builder.addCase(signIn.pending, (state) => ({
...state,
loading: true,
}));
builder.addCase(signIn.fulfilled, (state, action) => ({
...state,
loading: false,
session: action.payload,
}));
builder.addCase(signIn.rejected, (state, action) => ({
...state,
loading: false,
session: null,
error: action.payload,
}));
// signOut
builder.addCase(signOut.pending, (state) => ({
...state,
loading: true,
}));
builder.addCase(signOut.fulfilled, (state) => ({
...state,
loading: false,
session: null,
error: null,
}));
builder.addCase(signOut.rejected, (state, action) => ({
...state,
loading: false,
error: action.payload,
}));
},
});
redux/auth/action.ts
import { createAsyncThunk } from "@reduxjs/toolkit";
import { Session } from "@supabase/supabase-js";
import { supabase } from "services/supabase";
export const signIn = createAsyncThunk<
Session | null,
string,
{ rejectValue: Error }
>("auth/signIn", async (email, thunkApi) => {
const { error, session } = await supabase.auth.signIn({ email });
if (error) {
return thunkApi.rejectWithValue(error);
}
return session;
});
export const signOut = createAsyncThunk<void, void, { rejectValue: Error }>(
"auth/signOut",
async (_, thunkApi) => {
const { error } = await supabase.auth.signOut();
if (error) {
return thunkApi.rejectWithValue(error);
}
}
);
今回 Magic Link を使ったパスワードのいらないログイン方法を使うため、Sign Up は用意しません。
Supabase では email & password の認証方法はもちろん、Google、Github などのアカウントを使った OAuth 認証もサポートしています。詳しくはこちらをご覧ください。
email & password で認証する場合は、詳細は省きますが以下のように、引数に両方渡す形にするだけで OK です。
// before
const { error, session } = await supabase.auth.signIn({ email });
// after
const { error, session } = await supabase.auth.signIn({
email: "email@example.com",
password: "password",
});
次にこれらの Auth 関連処理を呼び出すカスタムフックも定義しておきます。
hooks/use-auth.tsx
import React from "react";
import { useDispatch } from "react-redux";
import * as asyncActions from "redux/auth/action";
export const useAuth = () => {
const dispatch = useDispatch();
const signIn = React.useCallback(
async (email: string) => {
await dispatch(asyncActions.signIn(email));
},
[dispatch]
);
const signOut = React.useCallback(async () => {
await dispatch(asyncActions.signOut());
}, [dispatch]);
return {
signIn,
signOut,
};
};
ルートのストアを定義
redux/store.ts
import { configureStore } from "@reduxjs/toolkit";
import { authSlice } from "./auth/slice";
export const store = configureStore({
reducer: {
[authSlice.name]: authSlice.reducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
auth のセレクタを定義
redux/auth/selector.ts
import { RootState } from "../store";
// 認証済みかどうか
export const isAuthenticatedSelector = (state: RootState) =>
state.auth.session != null;
ストアの Provider を定義
この Provider をアプリのルートに置いて全体を囲めば、アプリのどこからでもストアにアクセスすることができます。後に、App.tsx
で利用します。
redux/provider.tsx
import React from "react";
import { Provider } from "react-redux";
import { store } from "./store";
type Props = {
children: React.ReactNode;
};
export const StoreProvider: React.VFC<Props> = ({ children }) => {
return <Provider store={store}>{children}</Provider>;
};
React Router のセッティング
Redux の Store に保存した Supabase の認証情報にしたがってルーティングできるようにします。
ログイン済みユーザーのみがアクセスできる PrivateRoute
を定義します。
未ログイン時は Sign In ページに遷移します。
routes/private-route.tsx
import React from "react";
import { useSelector } from "react-redux";
import { Navigate, RouteProps, Route } from "react-router-dom";
import { isAuthenticatedSelector } from "redux/auth/selector";
export const PrivateRoute: React.VFC<RouteProps> = (props) => {
const isAuthenticated = useSelector(isAuthenticatedSelector);
if (!isAuthenticated) {
return <Navigate to="/signin" />;
}
return <Route {...props} />;
};
ログインページなど認証していない時だけアクセスできる PublicRoute
を定義します。
ログイン済みなら/channels
に遷移します。
routes/public-route.tsx
import React from "react";
import { useSelector } from "react-redux";
import { Navigate, RouteProps, Route } from "react-router-dom";
import { isAuthenticatedSelector } from "redux/auth/selector";
export const PublicRoute: React.VFC<RouteProps> = (props) => {
const isAuthenticated = useSelector(isAuthenticatedSelector);
if (isAuthenticated) {
return <Navigate to="/channels" />;
}
return <Route {...props} />;
};
再アクセス時、メール確認時の認証情報取得
何も対処しないと、画面をリロードするたびに当然再度アプリは初めから実行され、せっかく取得した Redux のストア内の認証情報もクリアされてしまいます。また、上で説明した通り Magic Link を使ったパスワードなしのログインを行うため、メールアドレスの確認がされたかどうかで、認証済みかどうかが変わってきます。
これに対処するために、認証情報のリスナーを作成します。
services/supabase/auth-listener/index.tsx
import React from "react";
import { useDispatch } from "react-redux";
import { authSlice } from "redux/auth/slice";
import { supabase } from "services/supabase/supabase-client";
type Props = {
children: React.ReactNode;
};
export const AuthListener: React.VFC<Props> = ({ children }) => {
const dispatch = useDispatch();
const session = supabase.auth.session();
React.useEffect(() => {
if (session) {
// すでにログインしている場合
dispatch(authSlice.actions.setSession({ session }));
}
// メールの確認等で認証された場合
supabase.auth.onAuthStateChange((_event, _session) => {
dispatch(authSlice.actions.setSession({ session: _session }));
});
}, [dispatch, session]);
return <>{children}</>;
};
services/supabase/index.ts
export * from "./supabase-client";
export * from "./auth-listener"; // 追加
ルーティング
ルーティングの処理を実装します。各 Route
の element
にはとりあえずダミーを入れておきます。
App.tsx
import React from "react";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { StoreProvider } from "redux/provider";
import { PublicRoute } from "routes/public-route";
import { AuthListener } from "services/supabase";
import { PrivateRoute } from "./routes/private-route";
const App: React.VFC = () => {
return (
<StoreProvider>
<AuthListener>
<BrowserRouter>
<Routes>
<Route path="/" element={<div>Home</div>} />
<PublicRoute path="/signin" element={<div>Sign In</div>} />
<PrivateRoute path="/channels" element={<div>Channels</div>} />
<Route path="*" element={<div>Not Found</div>} />
</Routes>
</BrowserRouter>
</AuthListener>
</StoreProvider>
);
};
export default App;
これで以下のようなルーティング機能が実装されているはずです。
/
・・・Home ページ、よくあるランディングページを想定、誰でも参照可能。/signin
・・・Sign In ページ、認証前のみ参照可能。/channels
・・・アプリのメインメージを想定、認証後のみ参照可能。*
・・・Not Found ページ、上で定義していないパスにアクセスした時に表示。誰でも参照可能。
試しに http://localhost:3000
でそれぞれのパスにアクセスすれば、それぞれの element の内容が表示されるはずです。
また、まだログインしていないので、 /channels
にアクセスすると、 /signin
にリダイレクトされます。
Sign In ページの作成
いよいよ認証ページを作成していきます。
まずはアカウントを新規作成する Sign In ページを作成します。
React Hook Form も使ってみます。
components/signup/index.tsx
import React from "react";
import { useForm, Controller, SubmitHandler } from "react-hook-form";
import {
Box,
Button,
Container,
TextField,
Typography,
} from "@material-ui/core";
import { useAuth } from "hooks/use-auth";
type FormInput = {
email: string;
};
export const Signin: React.VFC = () => {
const { control, handleSubmit } = useForm();
const { signIn } = useAuth();
const onSubmit: SubmitHandler<FormInput> = async (data) => {
if (data.email) {
await signIn(data.email);
}
};
return (
<Container component="main" maxWidth="xs">
<Box
sx={{
marginTop: 8,
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<Typography component="h1" variant="h5">
Sign In
</Typography>
<Box
component="form"
noValidate
sx={{ mt: 1 }}
onSubmit={handleSubmit(onSubmit)}
>
<Controller
name="email"
control={control}
defaultValue=""
rules={{
required: "This field is required.",
}}
render={({ field, fieldState }) => (
<TextField
margin="normal"
required
fullWidth
id="email"
label="Email Address"
name="email"
autoFocus
value={field.value}
onChange={field.onChange}
helperText={fieldState.error?.message}
/>
)}
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
>
Sign In
</Button>
</Box>
</Box>
</Container>
);
};
App.tsx
の/signin
を指定しているルートの element をelement={<Signin />}
と忘れずに変更します。
すると次のような画面になると思います。
一度試しにメールアドレスを入力して SIGN IN
ボタンを押してみます。
何も起こらないと思います。
パスワードのない認証方法ではメールアドレスの確認を行って初めてログインができるからです。
メールアドレスの確認
では、メールアドレスはどこで確認するのでしょう。
Supabase はローカルで起動しているだけですし、何もせずに本物のメールが送られているわけもありません。
Supabase はローカル起動時にもメールアドレスの確認を行う仕組みを用意してくれています!
supabase init
を実行した時に Email testing interface URL: http://localhost:9000
という記述があったことを思い出してください!
http://localhost:9000
にアクセスしてみると、以下のような画面が表示されます。
Inbucket というメール送信をテストするオープンソースのソフトウェアが supabase start
をした際に一緒にホストされています。
ではmailbox
欄に先程サインインに用いたメールアドレスを入力して Enter して確認してみます。
画像のように、メールボックスが開かれ、届いたメールを確認することができます。
Your Magic Link
の方の Log In
リンクをクリックしてみます。
すると http:localhost:3000
に遷移すると思います。これで認証も完了しました!
/channels
にアクセスしても /signin
に戻されることもなくなります。
/channels
の画面を作成
メイン画面となる /channels
を作っておきます。といっても、長くなってきましたので、ひとまずログアウトボタンのみ置いておきます。次回以降で作っていきたいと思います。
components/channels
import React from "react";
import { Button } from "@material-ui/core";
import { useAuth } from "hooks/use-auth";
export const Channels: React.VFC = () => {
const { signOut } = useAuth();
const handleClick = async () => {
await signOut();
};
return (
<Button onClick={handleClick} variant="contained">
Sign Out
</Button>
);
};
/channels
の遷移先を上のコンポーネントに差し替えておきます。
App.tsx
の/channels
を指定しているルートの element をelement={<Channels />}
に変更します。
ちなみに、一度ログアウトすると、次回ログイン時にはまた送信されるリンクをクリックしないと認証されません。少し面倒ですが、パスワードがないので当然ですね。
リダイレクト関係の調整
メール確認で認証が完了することは確認できましたが、まだいくつか問題があります。
- Sign In 時は、メールを確認するようにユーザーに促す必要がある。
- メールの Magic Link クリック後に遷移する先は、
/channels
にしたい。 - 認証後にリロードした時に一瞬ログイン画面が見える。
これらを直していきます。
Sign In 時は、メールを確認するようにユーザーに促す
確認を促すメッセージを表示する画面を実装して、 Sign In
ボタン押下時に遷移するようにしておきます。
/components/check-email
import React from "react";
import { Box, Container, Typography } from "@material-ui/core";
import { Link } from "react-router-dom";
export const CheckEmail: React.VFC = () => {
return (
<Container component="main" maxWidth="xs">
<Box
sx={{
marginTop: 8,
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<Typography variant="h5">Check your email for login link!</Typography>
<Link to="/signin">
<Typography variant="body2">Back to sign in page.</Typography>
</Link>
</Box>
</Container>
);
};
components/signin.tsx
import React from 'react';
import { useForm, Controller, SubmitHandler } from 'react-hook-form';
import {
Box,
Button,
Container,
TextField,
Typography,
} from '@material-ui/core';
import { useAuth } from 'hooks/use-auth';
import { useNavigate } from 'react-router'; // 追加
type FormInput = {
email: string;
};
export const Signin: React.VFC = () => {
const { control, handleSubmit } = useForm();
const { signIn } = useAuth();
const navigate = useNavigate(); // 追加
const onSubmit: SubmitHandler<FormInput> = async (data) => {
if (data.email) {
await signIn(data.email);
navigate('/check-email'); // 追加
}
};
...
App.tsx
import React from "react";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { CheckEmail } from "components/check-email";
import { Channels } from "components/channels";
import { Signin } from "components/signin";
import { StoreProvider } from "redux/provider";
import { PublicRoute } from "routes/public-route";
import { AuthListener } from "services/supabase";
import { PrivateRoute } from "./routes/private-route";
const App: React.VFC = () => {
return (
<StoreProvider>
<AuthListener>
<BrowserRouter>
<Routes>
...
{/* 以下を追加 */}
<PublicRoute path="/check-email" element={<CheckEmail />} />
...
</Routes>
</BrowserRouter>
</AuthListener>
</StoreProvider>
);
};
export default App;
簡易的ですが、 Sign In
ボタンクリック後 このような画面が表示されます。
/channels
に
メールの Magic Link クリック後の遷移先を こちらは、 supabase.auth.signin
の第二引数のオプションに redirectTo
を指定することで解決です。
redux/auth/action.ts
import { createAsyncThunk } from "@reduxjs/toolkit";
import { Session } from "@supabase/supabase-js";
import { supabase } from "services/supabase/supabase-client";
export const signIn = createAsyncThunk<
Session | null,
string,
{ rejectValue: Error }
>("auth/signIn", async (email, thunkApi) => {
const { error, session } = await supabase.auth.signIn(
{ email },
{ redirectTo: "http://localhost:3000/channels" } // これを追加
);
if (error) {
return thunkApi.rejectWithValue(error);
}
return session;
});
これでメールのリンクをクリックした時に /channels
に遷移できます。
リロード時にログイン画面が一瞬見える点を修正
Redux の Auth ステートの session
を null
で初期化しているのが原因でした。
Auth のリスナーで session が取得できるまでの一瞬の間、未認証判定になってしまっていました。
以下のように修正すれば解決です。
redux/auth/slice.ts
const initialState: AuthState = {
session: supabase.auth.session(), // 初期値を変更
loading: false,
error: null,
};
supabase.auth.session()
はローカルストレージから現在のセッションを復元するようなので、これを Redux のステートの初期化時に入れておけば、認証済みならリロード時にも最初から session が取れているというわけです。
まとめ
以上で Supabase をローカルで起動して、React から認証機能を利用するサンプルが完成です。
次回以降、データベースを利用する部分についても書いていきたいと思います。
何かアドバイス等もあれば、コメントください!