Nextjs+firebase+jotaiでログイン機能付きアプリのセットアップをする
参考リンク
基本的には以下の流れを汲んで進めてみる
目的
- Nextjsのapp router使ってみたい
- firebaseのauthenticationを試したい
- firebaseの一通りの環境構築をしてみたい
Nextjsの環境構築
-
npx create-next-app {appname} --ts
でnextjsの初期化 - app routerを選択
- tailwindcssは選択せず(chakraUI使いたいので)
firebaseのセットアップとNextjsの接続
まずはfirebaseプロジェクトを作成
firestoreの初期化
- firebaseコンソールからデータベースを作成。
- ロケーションは海外で使われることを想定していないのでasia-notheast1を選択した。
アプリを作成
- firebaseコンソールからwebアイコン(</>こんな感じのやつ)をクリック
- アプリのニックネームを入力し, アプリを作成
blazeプランへのアップグレード
- cloud functionsを利用するためにblazeプランへアップグレード
- 予算アラートを設定し, 一定額の課金が発生した時にメールでアラートを受け取れるように設定
firebase SDK
- firebaseとアプリの連携のためにSDKを導入
- Nextjsではクライアントサイドとサーバーサイドの両方が混在するので
firebase JavaScript SDK
とfirebase Admin SDK
の両方を導入する必要がある
# Firebase JavaScript SDK
npm install firebase
# Firebase Admin SDK
npm install firebase-admin --save
firebase JavaScript SDKの設定
- src下に
firebase/client.ts
を作成
import { initializeApp, getApps } from 'firebase/app';
// 必要な機能をインポート
import { getAnalytics } from 'firebase/analytics';
import { getFirestore } from 'firebase/firestore';
import { getStorage } from 'firebase/storage';
import { getAuth } from 'firebase/auth';
import { getFunctions } from 'firebase/functions';
const firebaseConfig = {
// TODO:認証情報を設置
apiKey: 'xxx',
authDomain: 'xxx',
projectId: 'xxx',
storageBucket: 'xxx',
messagingSenderId: 'xxx',
appId: 'xxx',
measurementId: 'xxx',
};
if (!getApps()?.length) {
// Firebaseアプリの初期化
initializeApp(firebaseConfig);
}
// 他ファイルで使うために機能をエクスポート
export const analytics = getAnalytics();
export const db = getFirestore();
export const storage = getStorage();
export const auth = getAuth();
export const funcions = getFunctions();
firebase admin SDKの設定
- src下に
firebase/server.ts
を作成
import { initializeApp, cert, getApps } from 'firebase-admin/app';
import { getFirestore } from 'firebase-admin/firestore';
if (!getApps()?.length) {
initializeApp({
credential: cert(
// 環境変数から認証情報を取得
JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT_KEY as string)
),
});
}
export const adminDB = getFirestore();
認証情報の取得
- firebaseコンソールの[設定] > [サービスアカウント]から[新しい秘密鍵の生成] > [キーを作成]でキーを作成し, jsonファイルをダウンロード
- プロジェクトディレクトリ配下の
.env.local
に以下のようにコードを記述
FIREBASE_SERVICE_ACCOUNT_KEY=<JSONの内容を一行にして設置>
jotaiの導入
jotaiのinstall
npm install jotai
ユーザのデータ型を作成
- src下の
types/user.ts
に以下を記述
export type User = {
id: string;
name: string;
email: string;
createdAt: number;
};
user atomの作成
import { User } from "firebase/auth";
import { atom } from "jotai";
export const user = atom<User | null>(null);
chakraUIの導入
auth用のprovider用意していてやっていないことに気づいたので先にやる。
基本的には以下のリンクの通りだが、Nextjs13の方を参照するように気を付ける。
必要ライブラリのimport
npm i @chakra-ui/react @chakra-ui/next-js @emotion/react @emotion/styled framer-motion
providerの作成
// app/providers.tsx
'use client'
import { CacheProvider } from '@chakra-ui/next-js'
import { ChakraProvider } from '@chakra-ui/react'
export function Providers({
children
}: {
children: React.ReactNode
}) {
return (
<CacheProvider>
<ChakraProvider>
{children}
</ChakraProvider>
</CacheProvider>
)
}
authProviderを作成
このアプリではログイン、サインアップ処理にfirebaseのAuthenticationのgoogleアカウントを用いる。このサインアップ処理の際にusers collectionにドキュメントを作成したかったり、画面遷移を実装したかったりするのでこれをauthProviderで実装する。
firestore converterの準備
firebaseのCRUD処理は以下のように行うことができる
しかし、この方法だとfirestoreとのやりとりがあったデータに対して型を保証できない。そこでfirebaseではFirestoreDataConverterなるものが用意されている。 これを先ほど作成したUser typeに対して作成してみるimport { User } from "@/types/user";
import { DocumentData, FirestoreDataConverter } from "firebase/firestore";
export const UserConverter: FirestoreDataConverter<User> = {
toFirestore(user: User): DocumentData {
return {
id: user.id,
name: user.name,
email: user.email,
createdAt: user.createdAt,
};
},
fromFirestore(snapshot, options) {
const data = snapshot.data(options);
return {
id: data.id,
name: data.name,
email: data.email,
createdAt: data.createdAt,
};
},
};
authProviderを作成
コードは以下の通り。
import { onAuthStateChanged, User as AuthUser } from "firebase/auth";
import { auth, db } from "@/firebase/client";
import { userAtom } from "@/globalState/user";
import { useAtom } from "jotai";
import { ReactNode, useEffect } from "react";
import { useRouter } from "next/navigation";
import { collection, doc, getDoc, setDoc } from "firebase/firestore";
import { set } from "firebase/database";
import { User } from "@/types/user";
import { UserConverter } from "@/converter/user";
interface Props {
children: ReactNode;
}
const AuthProvider = (props: Props) => {
const router = useRouter();
const [currentUser, setCurrentUser] = useAtom(userAtom);
const setCurrentUserFunc = async (authUser: AuthUser | null) => {
if (authUser) {
// userがfirestoreのusersコレクションに存在するか確認し,存在しなければ作成する
const userRef = doc(db, "users", authUser.uid).withConverter(
UserConverter
);
const userSnapShot = await getDoc(userRef);
if (!userSnapShot.exists()) {
await setDoc(userRef, {
id: authUser.uid,
name: authUser.displayName ?? "",
email: authUser.email ?? "",
createdAt: Date.now(),
});
const userSnapShot = await getDoc(userRef);
if (!userSnapShot.exists()) {
throw new Error("user not found");
}
const _user: User = userSnapShot.data();
setCurrentUser(_user);
} else {
setCurrentUser(userSnapShot.data());
}
void router.push(`/`);
} else {
setCurrentUser(null);
void router.push(`/signin`);
}
};
useEffect(() => {
// authの情報が変更されたらsetCurrentUserFuncを実行する
onAuthStateChanged(auth, (authUser) => void setCurrentUserFunc(authUser));
}, [router]);
return <>{props.children}</>;
};
export default AuthProvider;
以上の処理ではonAuthStateChangedにより, authの情報の変更を監視し, 変更された場合に以下3パターンに分けて処理する
- authUserがnull, undefined
- userAtomにnullをsetし, signinページに遷移
- authUserがnull, undefinedでなく, 同じuidを持つUserドキュメントが存在
- 対応するUserドキュメントをfetchし, userAtomにset
- authUserがnull, undefinedでなく, 同じuidを持つUserドキュメントが存在しない
- 対応するUserドキュメントをsetし, getしなおして, userAtomにset
emulatorを導入する
ローカルでfirestore, auth, functionsなどを実行できるemulatorを立てる
firebase CLIのインストール
# グローバルにインストールしたくない場合は -Dで良い
npm install -g firebase-tools
emulatorのインストール
firebase init
- which Firebase CLI features do you want to set up for this folder?
- firestore, emulators, functions, authを選択
- please select an option
- use an existing projectを選択
以降の使用するポート番号やrulesに関する質問はすべてyesまたはenterで進める
firebase initialization complete!と表示されれば完了
firebase emulators:start
でエミュレータを立ち上げ、localhost:4000
にアクセスしてエミュレータの管理ページが表示されればOK
- use an existing projectを選択
ローカルで開発する際にemulatorと接続するように設定
先ほど作成したclient.tsを以下のように変更
import { initializeApp, getApps } from "firebase/app";
// 必要な機能をインポート
import { getAnalytics } from "firebase/analytics";
import {
Firestore,
connectFirestoreEmulator,
getFirestore,
} from "firebase/firestore";
import { connectStorageEmulator, getStorage } from "firebase/storage";
import { Auth, connectAuthEmulator, getAuth } from "firebase/auth";
import { connectFunctionsEmulator, getFunctions } from "firebase/functions";
const ENV = process.env.NEXT_PUBLIC_ENV ?? "";
const firebaseConfig = {
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY ?? "",
authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN ?? "",
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID ?? "",
storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET ?? "",
messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID ?? "",
appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID ?? "",
measurementId: process.env.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID ?? "",
};
// Firebaseアプリの初期化
export const app = initializeApp(firebaseConfig);
// ローカルで実行中はエミュレータを使う
const isEmulating = ENV === "local";
export const initFirestore = (): Firestore => {
try {
console.log("isEmulating: ", isEmulating);
const firestore: Firestore = getFirestore(app);
if (isEmulating) {
connectFirestoreEmulator(firestore, "localhost", 8080);
}
return firestore;
} catch (error) {
console.error("Error initializing database", error);
throw error;
}
};
export const initAuth = (): Auth => {
const auth = getAuth(app);
if (isEmulating) {
connectAuthEmulator(auth, "http://localhost:9099");
}
return auth;
};
export const initStorage = () => {
const storage = getStorage(app);
if (isEmulating) {
connectStorageEmulator(storage, "localhost", 9199);
}
return storage;
};
export const initFunctions = () => {
const functions = getFunctions(app, "asia-northeast1");
if (isEmulating) {
connectFunctionsEmulator(functions, "localhost", 5001);
}
return functions;
};
export const firestore = initFirestore();
export const auth = initAuth();
export const storage = initStorage();
export const functions = initFunctions();
// 他ファイルで使うために機能をエクスポート
export const analytics = getAnalytics();
.env.localに以下の内容を追加しておく
NEXT_PUBLIC_ENV=local
これにより, ローカルで開発している時にはisEmulatingがtrueとなり, 各サービスがemulatorと接続されるようになる
導通確認
src/app/signin/page.tsxを作成
"use client";
import { login } from "@/lib/auth";
import { Button, Flex, Text } from "@chakra-ui/react";
const SignInPage = () => {
return (
<main>
<Flex align="center" justify="center">
<Text>Sign In Page</Text>
<Button onClick={login}>google login</Button>
</Flex>
</main>
);
};
export default SignInPage;
app routerを使っているので"user client";が必要なことに注意
これでsign inページにログイン用のボタンが表示される
クリックすると、authProvider内の処理によってusersコレクション内にドキュメントが追加される
firebase emulators:start
でエミュレータを立ち上げてlocalhost:4000/firestore
にアクセス
usersコレクションにuserが追加されていればOK