Next.js で Firestore を使う場合 CSR と SSR で初期化方法は異なるという話

# はじめに

Next.js で Firestore を使う際に、データを CSR で取得したり SSR で取得したり、
ページによって取得方法が変わったりすると思いますが、
初期化方法を間違えてちょいちょいハマったりするので記事にまとめます。

### この記事に書かれていること

- Firestore とのやり取りを CSR と SSR の両方でおこなう場合、初期化はそれぞれ分けて書く必要があるということを Vercel 社が用意しているサンプルコードを使ったりしながら説明しています。

　※ 便宜上 SSR とだけ書いていますが SSG でも同じです。

## CSR の場合
### CSR する際に必要になる初期化
コンソールにログインし、`プロジェクトの設定` → `全般` からAPIキーが表示されている画面を開きます。
![スクリーンショット 2021-06-12 16.16.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631600/8fb4eaf4-15d5-4346-fca2-b8259d6dd961.png)
↑ の情報を .env に貼り付けます。

```.env.local
NEXT_PUBLIC_FIREBASE_API_KEY="***************"
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN="***************"
NEXT_PUBLIC_FIREBASE_PROJECT_ID="***************"
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET="***************"
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID="***************"
NEXT_PUBLIC_FIREBASE_APP_ID="***************"
```
そして ↓ こんな感じで初期化します。

``` clientApp.js
import firebase from 'firebase/app';
import 'firebase/firestore';

const credentials = {
  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,
};

if (!firebase.apps.length) {
  firebase.initializeApp(credentials);
}

export default firebase;

```

これだけですね。あとは必要な場所でインポートして使います。
注意点としては環境変数のプレフィクスには`NEXT_PUBLIC_FIREBASE_`が必要ということです。

## SSR の場合

先に書いておくと、セキュリティルールのことを何も考えなければ上に書いた初期化でも動きます。
しかし実際にはセキュリティルールを書くことは必須になるはずです。

例えば以下のようなセキュリティルールを設定したとします。

```firestore.rules
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if request.auth != null;
    }
  }
}
```
[Firebaseのドキュメント](https://firebase.google.com/docs/firestore/security/get-started?hl=ja)にも載っているオーソドックスなルールです。
認証されたユーザーでのみアクセスできます。

認証されたユーザーであれば OK という、匿名ユーザーでもアクセスできてしまうゆるいルールですが、
このままでは SSR ではアクセスできません。通常、認証情報はクライアント側が持っているからです。

### SSR する際に必要になる初期化

[firebase-admin](https://firebase.google.com/docs/admin/setup?hl=ja)を使います。
firebase-admin は特権環境から Firebase を操作するため**ルールに関係なく**サーバー側から Firestore にアクセスできます。

firebase-admin を使用するにはまず`プロジェクトの設定`の`サービスアカウント`から`新しい秘密鍵の生成`をクリックし、秘密鍵（json ファイル）をダウンロードします。

※このファイルは絶対に外部に漏れてはいけないやつです。上に書いたAPIキーは漏れたところでルールを強固しておけば問題ない話ですが、このファイルが漏れたら事故です。

![スクリーンショット 2021-06-12 16.22.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631600/426ad870-e667-5378-4112-40d515b1af4b.png)

ダウンロードした json ファイルの中に`project_id`, `client_email`, `private_key`があるので、それを`.env`に追加します。

```.env.local
# for client-side
NEXT_PUBLIC_FIREBASE_API_KEY="***************"
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN="***************"
NEXT_PUBLIC_FIREBASE_PROJECT_ID="***************"
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET="***************"
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID="***************"
NEXT_PUBLIC_FIREBASE_APP_ID="***************"

# for firebase-admin
FIREBASE_PROJECT_ID="***************"
FIREBASE_CLIENT_EMAIL="***************"
FIREBASE_PRIVATE_KEY="***************"
```

※`NEXT_PUBLIC_`は不要です。このプレフィクスはブラウザ側のjsにバンドルさせるために必要なものです。
※`NEXT_PUBLIC_FIREBASE_PROJECT_ID`と`FIREBASE_PROJECT_ID`の中身は同じなので`NEXT_PUBLIC_FIREBASE_PROJECT_ID`をそのまま流用することもできますが、それぞれが何を必要としているかわかりやすくするために敢えて追記しています。
.
以下のようにして初期化します。

```nodeApp.js
import * as admin from 'firebase-admin';

if (!admin.apps.length) {
  admin.initializeApp({
    credential: admin.credential.cert({
      projectId: process.env.FIREBASE_PROJECT_ID,
      clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
      privateKey: process.env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, '\n'),
    }),
  });
}

export default admin;
```

これで初期化のファイルが2つできましたので、
あとは CSR, SSR が必要な場所でそれぞれインポートして使います。

# Next.js のサンプルコードを使って Firestore とのやり取りをしてみる

初期化の方法だけ並べられてもイメージが付きにくいと思うので、
Vercel 社が用意している Firebase との連携用のサンプルコードを使って、実際にエラーなども出しながら説明していきたいと思います。

https://github.com/centerfield77/nextjs-firebase-sample

これはこの[サンプルコード](https://github.com/vercel/next.js/tree/canary/examples/with-firebase)をよりシンプルにアレンジしたものです。

クライアント側でデータを登録し、それを SSR で画面に表示します。
具体的には以下のような流れです。

1. ページを開いたタイミングで匿名認証のログイン

1. 名前とメッセージを入力し、「Firestoreにデータを作成」をクリックするとクライアント側の処理で Firestore にデータが登録される。

1. 「Go to SSR Page」をクリックすると先ほど登録したデータが SSR で画面に表示される。

↓ 実際にデプロイしたものがこちらです

https://nextjs-firebase-sample.vercel.app/

## クライアント側の処理

Firestore のルールは上で書いた例と同じように認証ユーザーであれば OK としています。

```pages/index.js
import Head from 'next/head';
import Link from 'next/link';
import { useState, useEffect } from 'react';
// ↓ CSR 用として初期化してあるものインポート
import firebase from '../firebase/clientApp';

export const Home = () => {
  const [name, setName] = useState('');
  const [message, setMessage] = useState('');

  const data = { name, message };

  useEffect(() => {
    firebase.auth().onAuthStateChanged(async (user) => {
      // 匿名ユーザーを作成する
      if (!user) {
        firebase.auth().signInAnonymously();
      }
    });
  });

  // Firestore にデータを登録する関数
  const createData = async () => {
    if (!name || !message) {
      alert('名前とメッセージを入力してください');
      return;
    }
    const db = firebase.firestore();
    await db.collection('profile').doc(name).set(data);
    alert('Firestoreにデータを作成できました！');
  };

  return (
    <div className="container">
      <Head>
        <title>Next.js / Firestore</title>
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main>
        <h1 className="title">Next.js / Firebase CSR</h1>
        <p className="description">名前とメッセージを入力してください。</p>
        <div className="labelBox">
          <label>
            名前：
            <input value={name} onChange={(e) => setName(e.target.value)} />
          </label>
          <label>
            メッセージ：
            <input value={message} onChange={(e) => setMessage(e.target.value)} />
          </label>
        </div>

        <button onClick={createData}>Firestoreにデータを作成</button>

        <Link href={`/profile/${data.name}`} passHref>
          <a>Go to SSR Page</a>
        </Link>
      </main>

//これより下はただのスタイルのため省略...
```

注意すべきことは CSR 用に初期化したファイルをインポートすることです。
もし誤って SSR 用に初期化した admin を使って firestore を呼んでもエラーになります。

実際にやってみましょう。

```index.js
import Head from 'next/head';
import Link from 'next/link';
import { useState, useEffect } from 'react';
import firebase from '../firebase/clientApp';
// ↓ SSR 用に初期化した admin をインポート
import admin from '../firebase/nodeApp';
```

```pages/index.js
// Firestore にデータを登録する関数
 const createData = async () => {
    if (!name || !message) {
      alert('名前とメッセージを入力してください');
      return;
    }
    // db を admin から作成
    const db = admin.firestore();
    await db.collection('profile').doc(name).set(data);
    alert('Firestoreにデータを作成できました！');
  };
```

この状態でアプリを起動すると

![スクリーンショット 2021-06-12 19.19.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631600/ba868e00-843d-c712-b9a7-86be0fedd828.png)

`Module not found: Can't resolve 'fs'`というエラーや、
`Module not found: Can't resolve 'child_process'`のようなエラーが出ます。

node.js 特有のコードがブラウザ側で読まれてしまっていることが原因です。

## サーバー側の処理
次に SSR の処理を書いていきます。

```fetchData/getProfileData.js
// admin をインポート
import admin from '../firebase/nodeApp';

export const getProfileData = async (name) => {
  // admin から db を作成
  const db = admin.firestore();
  const profileCollection = db.collection('profile');
  const profileDoc = await profileCollection.doc(name).get();

  if (!profileDoc.exists) {
    return null;
  }
  // 取得したデータを返す
  return profileDoc.data();
};
```

```pages/profile/[name].js
import Head from 'next/head';
// ↓ 上に書いた関数をインポート
import { getProfileData } from '../../fetchData/getProfileData';

const SSRPage = ({ data }) => {
  const { name, profile } = data;

  return (
    <div className="container">
      <Head>
        <title>Next.js / Firestore</title>
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main>
        <h1 className="title">Next.js / Firebase SSR</h1>
        <h2>{name}</h2>
        <p>{profile.message}</p>
      </main>
    </div>
  );
};

export const getServerSideProps = async ({ params }) => {
  const { name } = params;
  // getServerSideProps 内で getProfileData() を実行する
  const profile = await getProfileData(name);
  if (!profile) {
    return { notFound: true };
  }
  return { props: { data: { name, profile } } };
};

export default SSRPage;
```
注意すべきことは SSR 用に初期化した admin をインポートすることです。
もし誤って CSR 用に初期化した方をインポートするとエラーになります。

こちらも実際にやってみましょう。

```fetchData/getProfileData.js
// CSR 用に初期化した方をインポートします
import firebase from '../firebase/clientApp';

export const getProfileData = async (name) => {
  // ここも admin から firebase に書き換えます。
  const db = firebase.firestore();
  const profileCollection = db.collection('profile');
  const profileDoc = await profileCollection.doc(name).get();

  if (!profileDoc.exists) {
    return null;
  }

  return profileDoc.data();
};
```

この状態でアプリを起動してみます。
データの登録までは問題なくできますが、いざ SSR のページをクリックすると
![スクリーンショット 2021-06-12 20.05.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631600/0feeea3d-cbf5-f451-c134-2a2497ec1722.png)
`Missing or insufficient permissions.`というエラーが表示されます。

これは Firestore のルールに引っかかったときに表示されるエラーです。
クライアント側では匿名認証がされていますが、サーバー側ではされていないので弾かれてしまいます。

試しに Firestore のルールを緩めてみましょう。

```firestore.rules
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if true;
    }
  }
}
```

誰でもアクセスできる状態にしました。
これで再度アプリを実行してみます。
![スクリーンショット 2021-06-12 20.20.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631600/8f470f42-a8ab-6806-6394-c36c2f69be54.png)
読み込めました。
世に公開しないものであれば、この方法でも問題ないでしょう。

# まとめ

以上、初期化方法の違いについてでした。

この[サンプルコード](https://github.com/vercel/next.js/tree/canary/examples/with-firebase)でもある通り、
初期化のファイルは分けてわかりやすく管理することが大事だと思いました。
![スクリーンショット 2021-06-12 21.57.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631600/9cb4a6bc-08e5-a12a-3857-20928a8e4880.png)