Open8

Nextjs+firebase+jotaiでログイン機能付きアプリのセットアップをする

nanahiryunanahiryu

目的

  • Nextjsのapp router使ってみたい
  • firebaseのauthenticationを試したい
  • firebaseの一通りの環境構築をしてみたい
nanahiryunanahiryu

Nextjsの環境構築

  • npx create-next-app {appname} --tsでnextjsの初期化
  • app routerを選択
  • tailwindcssは選択せず(chakraUI使いたいので)
nanahiryunanahiryu

firebaseのセットアップとNextjsの接続

まずはfirebaseプロジェクトを作成

firestoreの初期化

  • firebaseコンソールからデータベースを作成。
  • ロケーションは海外で使われることを想定していないのでasia-notheast1を選択した。

アプリを作成

  • firebaseコンソールからwebアイコン(</>こんな感じのやつ)をクリック
  • アプリのニックネームを入力し, アプリを作成

blazeプランへのアップグレード

  • cloud functionsを利用するためにblazeプランへアップグレード
  • 予算アラートを設定し, 一定額の課金が発生した時にメールでアラートを受け取れるように設定

firebase SDK

  • firebaseとアプリの連携のためにSDKを導入
  • Nextjsではクライアントサイドとサーバーサイドの両方が混在するのでfirebase JavaScript SDKfirebase Admin SDKの両方を導入する必要がある
# Firebase JavaScript SDK
npm install firebase

# Firebase Admin SDK
npm install firebase-admin --save

firebase JavaScript SDKの設定

  • src下にfirebase/client.tsを作成
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を作成
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の内容を一行にして設置>

https://qiita.com/YumaInaura/items/4d25f3548120838e0b37

nanahiryunanahiryu

jotaiの導入

jotaiのinstall

npm install jotai

ユーザのデータ型を作成

  • src下のtypes/user.tsに以下を記述
types/user.ts
export type User = {
  id: string;
  name: string;
  email: string;
  createdAt: number;
};

user atomの作成

globalState/user.ts
import { User } from "firebase/auth";
import { atom } from "jotai";

export const user = atom<User | null>(null);

nanahiryunanahiryu

chakraUIの導入

auth用のprovider用意していてやっていないことに気づいたので先にやる。
基本的には以下のリンクの通りだが、Nextjs13の方を参照するように気を付ける。
https://chakra-ui.com/getting-started/nextjs-guide#installation

必要ライブラリのimport

npm i @chakra-ui/react @chakra-ui/next-js @emotion/react @emotion/styled framer-motion

providerの作成

provider/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>
  )
}
nanahiryunanahiryu

authProviderを作成

このアプリではログイン、サインアップ処理にfirebaseのAuthenticationのgoogleアカウントを用いる。このサインアップ処理の際にusers collectionにドキュメントを作成したかったり、画面遷移を実装したかったりするのでこれをauthProviderで実装する。

firestore converterの準備

firebaseのCRUD処理は以下のように行うことができる
https://firebase.google.com/docs/firestore/manage-data/add-data?hl=ja
https://firebase.google.com/docs/firestore/query-data/get-data?hl=ja
しかし、この方法だとfirestoreとのやりとりがあったデータに対して型を保証できない。そこでfirebaseではFirestoreDataConverterなるものが用意されている。
https://firebase.google.com/docs/reference/js/v8/firebase.firestore.FirestoreDataConverter
これを先ほど作成したUser typeに対して作成してみる

converter/user.ts
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を作成

コードは以下の通り。

provider/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パターンに分けて処理する

  1. authUserがnull, undefined
  • userAtomにnullをsetし, signinページに遷移
  1. authUserがnull, undefinedでなく, 同じuidを持つUserドキュメントが存在
  • 対応するUserドキュメントをfetchし, userAtomにset
  1. authUserがnull, undefinedでなく, 同じuidを持つUserドキュメントが存在しない
  • 対応するUserドキュメントをsetし, getしなおして, userAtomにset
nanahiryunanahiryu

emulatorを導入する

ローカルでfirestore, auth, functionsなどを実行できるemulatorを立てる
https://qiita.com/ak2ie/items/a50ea4e3da37f904bd1a

firebase CLIのインストール

# グローバルにインストールしたくない場合は -Dで良い
npm install -g firebase-tools

emulatorのインストール

firebase init
  1. which Firebase CLI features do you want to set up for this folder?
    • firestore, emulators, functions, authを選択
  2. please select an option
    • use an existing projectを選択
      以降の使用するポート番号やrulesに関する質問はすべてyesまたはenterで進める
      firebase initialization complete!と表示されれば完了
      firebase emulators:startでエミュレータを立ち上げ、localhost:4000にアクセスしてエミュレータの管理ページが表示されればOK

ローカルで開発する際にemulatorと接続するように設定

先ほど作成したclient.tsを以下のように変更

firebase/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を作成

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