Next.js × Firebase で Web サービスを作った

2021-03-14

先月から更新が少ない当ブログですが、実は 1 月末からとある Web サービスを作るお仕事を頂いており、そっちにかなり時間を割いていました。

内容としてはフリーランサー向けの案件照会サービスで、よくあるタイプのサービスです。

ただ今回頂いた案件は、デザイン・フロント・サーバー・インフラ、その他全て一任されるという結構重めかつ責任重大なものでして。

以前より仲良くさせてもらっている方からの依頼ということもあり、頑張って作ってみました。


デザイン~フロント周りについてはかなり詳しい自負がある一方、サーバー~インフラ側は決して強くない自分です。

今回 Web サービスを作るにあたり、そこまで大きいサービスでもなく費用もそこまでかけられないということで、とにかく無難 of 簡単な構成で素早く組んでみました。

ということで今回使用したものは以下のとおりです。

  • Next.js
  • Vercel
  • Firebase(Authentication, Cloud Firestore, Functions)
  • Algolia
  • Atomic Design
  • reCAPTCHA

我ながらいかにもフロントエンドディベロッパーが選択した感じのスキルセットだなーと思いますが、開発自体はかなりの速度で行うことができました。

とはいえ Firebase の Authentication を本格的に触ったのは初めてでしたし、Algolia や reCAPTCHA に至っては今回の開発で初めて導入してみました。


で、当たり前ですが、開発中に色々と詰まることも多かったわけで。

今回の開発で詰まったポイントやその解消方法、その他得られた知見や感想などをダラダラーっと書いていこうと思います。

Algolia の使いやすさはヤバい

そもそも、もともと Algolia を導入する予定はありませんでした。

てっきり Firebase の Functions から Cloud Firestore を検索する関数くらいあるだろうと思いこんでいたのですが。

開発の途中に気づいたのですが、Cloud Firestore でワード検索って基本的にはできないんですね…これにはびっくりしました。

ということで開発途中に急遽 Algolia を導入することにしました、Firebase の公式 にも書かれてますし。

Algolia を触るのはこれで 2 度目だったのですが、様々な大企業が使っているだけあって使いやすさは相当高いですね。

Algolia 自体を把握されていない方も多いと思いますのでざくっと説明しますと、それ自体が DB となっていて、その DB に検索ワードなどでアクセスするための便利ツールもたくさん存在するサービスというイメージですかね?

偶然今携わっている現場でも Algolia を使っていて、そっちでは React InstantSearch という Algolia 公式の React コンポーネントを使用しています。

これも結構使いやすいんですが、公式ドキュメントが結構不親切なのと型が非常に弱い(any だらけ)ので使い勝手は正直微妙です…パッケージ名もズレているのがさらにややこしいです。

あと hooks に対応していないため記述っぷりが結構古臭く、個人的にはイマイチ感が否めないです。

それに対し今回は Firebase の Functions 側に組み込んだので algoliasearch を使用しましたが、こいつは結構無難に動いてくれました。

後ほど React InstantSearch に移り変わるかもしれないですが、なんにせよ Algolia の使い勝手の良さには驚きました。

無料枠も結構あるので、ぜひぜひ使ってみてはいかがでしょうか。

Authentication のセッションを保つのは結構難しい

今回の開発で一番手こずったのが、セッションの維持です。

Firebase の Authentication を使用すると、まず idToken という ID トークンが取得できます。

このトークンからユーザー情報を引っ張り出したり、このトークンを使用して Cloud Firestore にアクセスしたりするのですが。

このトークンは発行されてから 1 時間しか使用できないため、単純にこのトークンだけを使用した場合 1 時間後に再度ログインが求められることになります。

そのため毎日使用するようなサービスであれば再度トークンを発行し直す必要があるのですが、ここの実装にやたら手こずりました。

まずセッションを貼り続ける方法としては 2 種類準備されていまして。

  1. セッション Cookie を発行する
  2. 更新トークン(Refresh Token)を利用し ID トークンを更新する

公式的にはどちらかといえば 1 の方法を推奨しているみたいですが、こちらの方法は最長で 2 週間しか持たせることができません。

一方 2 の方法は基本的にセッションが途切れることなく、半永久的に保ち続けることができます。

ちなみに最初 1 の方法で実装を行っていたのですが、途中でどうしても実装方法がわからなくなってしまい諦めて 2 に切り替えてしまいました。(多分今なら実装できると思います)

実装時にいくら調べても情報が出なくてなんとか自力で解決したポイントをいくつか書くと。

ID トークンはクライアント側で扱う必要がない

ID トークンはクライアント及びサーバー側問わず使用できます。

が、Next.js を使用した場合クライアント側で ID トークンを扱う必要がないことに途中で気づき、そこから実装がシンプルかつとても楽になりました。

今回はクライアント側で ID トークンを扱うのは発行時のみとしており、すぐに Cookie に埋めてしまいます。

基本的に ID トークンを扱うのを Next.js の api (以下 BFF の api と書きます) と、あとは サーバー側での検証 のみです。

実装次第ではクライアント側で扱う必要すらないと思います。

サーバーに向けて叩く Api を全て BFF の api に集約するというのが一つポイントかなーと。

クライアント側から呼び出される BFF の api の書き方にやたら苦戦した

これはまじでむちゃくちゃ試行錯誤しました。

今も今の実装で良いとは思えていないので書くのが少しためらわれるのですが…。

上に書いたとおり、今回は Firebase の Functions やその他全ての api の叩く場所を BFF に集約しました。

そうすると、BFF には以下の 2 種類の api が存在することになります。

  1. getServerSideProps など、BFF から呼ばれる api(主に GET)
  2. クライアント側から呼ばれる api(主に POST や PUT など)

で、これらを混在させた状態でかつ api のリクエストヘッダーに Cookie を埋め込むのはどうやれば良いのやら…。

悩みに悩んだ結果なんとか実装することができたので、以下にイメージを書いておきます。

import { parseCookies } from "nookies"; export type Context = | GetServerSidePropsContext<ParsedUrlQuery> | { req: NextApiRequest; res: NextApiResponse }; const fetcher = (ctx: Context): AxiosInstance => { const cookies = parseCookies(ctx); const instance = axios.create({ baseURL: `${baseUrl}/api`, headers: { cookie: JSON.stringify(cookies), }, // もしかしたらいらないかも withCredentials: true, }); return instance; }; export default fetcher;
export const getHoge = (ctx: Context) => (params: GetHogeParams): Promise<AxiosResponse<GetHogeData>> => fetcher(ctx).get<Hoge>("/hoge", { params, }); const postHoge = (ctx: Context) => (params: PostHogeParams): Promise<AxiosResponse<PostHogeData>> => fetcher(ctx).post("/hoge", params); const handler = async ( req: NextApiRequest, res: NextApiResponse<PostHogeData> ): Promise<void> => { const ctx = { req, res }; const { method } = req; if (method === "POST") { const { body } = req; const { data } = await postHoge(ctx)(body); res.send(data); res.end(); return; } }; export default handler;
import { getHoge } from "./api/hoge"; const Pages: FC<PagesProps> = ({ hoge }: PagesProps) => { ... const onSubmit = useCallback(async (values: FieldValues) => { await axios.post("/api/hoge", values); }, []); ... }; export const getServerSideProps: GetServerSideProps<ServerSideProps> = async ( ctx ) => { const { data } = await getHoge(ctx)({ fuga: "piyo", }); return { props: { hoge: data, }, }; }; export default Pages;

うーん、我ながら汚い実装ですね。

fetcher 周りのごちゃごちゃ感はもう少しなんとかしたいのですが、今のところこれ以上の実装が思いついていません、無念。

一応、覚えておいたほうが良いポイントは 3 つで。

  1. BFF が api を呼ぶときは直接関数を呼び出す必要があること(当たり前っちゃ当たり前なんですが)
  2. クライアント側が api を呼び出すと、対応する handler が走ること(公式ドキュメント)
  3. nookies は Custom Express server にも対応していること

自分が最も引っかかったのは 3 でした、公式ドキュメントにちゃーんと書かれていますね。

恐らく Next.js で Cookie を扱うとなると nookies を使用することになると思いますので、しっかりと公式ドキュメントを読むようにしましょう。

サーバー側から返ってきた Cookie を埋め込み直す方法がよくわかっていない

今回はサーバー側で ID トークンの検証を行い、適切でなければ更新トークンを利用し ID トークンを発行し直すようにしています。

この場合、再発行された ID トークンをレスポンスヘッダーに付与して返すのですが、返ってきた Cookie を埋め込みなおす処理をどこに書けば良いのかよくわかっていません。

一応実装自体は行ったのですが、正直とても正解とは言えない感じなので省略させて頂きます。

もしご存じの方がおられましたら、ぜひメールフォームから教えて下さい!

サーバー側で値を保持するのに苦労した

app.use をまたぐように実装すると、app.use から次の app.use に値を渡したくなるケースってたまにあると思うんですが。

これってどうすれば解決できるのかなぁと思い、色々調べてみました。

で、結果としては response.locals というところにぶちこんでいるのですが、果たしてこれが正解なのかはよくわかっていません。

上記の公式ドキュメントにはそれっぽいことが書いてありそうなんですが…。

ベーシック認証は簡単に実装できる

今回は簡単な管理画面も作りました。

管理画面ということで何らかの認証は必要だろうということで、初めてベーシック認証をかけてみました。

実装に使ったのは nextjs-basic-auth-middleware というやつです。

ダウンロード数は少ないですが、実装も簡単で特に詰まることなく動いてくれました。

クライアント側の Firebase の Authentication には FirebaseUI React Components がオススメ

認証周りを作るにあたり Firebase は様々な認証方式に対応しているため、各種ボタンと処理を準備してあげないといけないのですが。

FirebaseUI React Components というやつを使ったところ、ボタンのデザインから機能周りまであっという間に実装できちゃいました。

日本語化についてはいくつか方法があるみたいですが。

今のところ自分は next.config.js に以下を追加することで解決しています。

module.exports = { ... webpack: (config) => { config.resolve.alias.firebaseui = "firebaseui-ja"; return config; }, ... };

Vercel からメールの送信を行うことはできない

最初 BFF から直接メールを送信しようとしていたのですが。

ローカル環境では成功するのに本番環境ではなぜかメールが送れず、結構詰まっていました。

で、色々調べたところ Vercel は SMTP を扱うことができない とのことで…まじかよ。

しょうがないので Firebase の Functions に実装を移したところ、無事送信できるようになりました。

その他細かい話

Vercel で deploy する際に Firebase の functions フォルダーが含まれているとエラーが起きる

ルートディレクトリに .vercelignore ファイルを作成し functions を追記したら解決しました。

フロント側で使用した主なパッケージ

linter 系は除きます。

  • @hookform/resolvers
  • @rooks/use-outside-click-ref
  • @rooks/use-window-size
  • algoliasearch
  • axios
  • camelcase-keys
  • firebase
  • firebaseui-ja
  • next
  • next-seo
  • nextjs-basic-auth-middleware
  • no-scroll
  • nookies
  • rc-pagination
  • react
  • react-dom
  • react-firebaseui
  • react-google-recaptcha
  • react-hook-form
  • react-icons
  • react-no-ssr
  • react-portal-overlay
  • react-spinners
  • react-table
  • react-toastify
  • react-use-measure
  • ress
  • sass
  • sass-mq
  • yup
  • yup-phone

めんどくさいので現時点での dependencies をそのままコピペしました、我ながら呆れるほどブレない選択だなーと思います。

Rooks とか useHooks を知っていると、趣味の開発くらいなら結構はかどると思います。

他に追加するとしたら以下とかですかね?

Firebase の Functions でグローバル変数を使いたい場合

.runtimeconfig.json というファイルをローカルに生成する必要があります。

自分の場合 package.json に以下のスクリプトを追加しちゃっています。

{ ... "scripts": { ... "build:config": "firebase functions:config:get > .runtimeconfig.json", ... }, ... }

Firebase の Functions で serve にホットリロードをかませたい場合

package.json に以下のスクリプトを追加し、serve と並列で叩けば OK です。

{ ... "scripts": { ... // これを追加 "build:watch": "tsc --watch", ... "serve": "npm run build && firebase emulators:start --only functions", ... }, ... }

開発時に絶対に必要なスクリプトだと思うんですけど、公式のドキュメントには書かれてないんですよね…。


そんな感じでした。

Atomic Design や reCAPTCHA に至っては触れてすらいないですが、書けるほどのこともないので省略しました。

Vercel や Firebase の deploy 周りについてはもう言わずもがな、ほとんど手がかからないですね。

というか Algolia も reCAPTCHA も Firebase もなんでもかんでも簡単に開発できすぎて怖いです、すごい世の中だぁ…。

今回は初めて使用したものも多かったので色々と詰まることもありましたが、それでも実装に時間はあまりかかりませんでした。

画面数は 10 画面を超えるくらい、api も十数本は実装したので決して小規模とは言えない規模感だと思うのですが。

全体的なコーディング量はかなり少なく済んだので、改めてシンプルな実装ってすごく大切だなーとしみじみ感じました。

あと NoSQL は本当に偉大ですね、フロントエンドディベロッパーの大きな大きな味方だと思います。

サクッと Web サービスを作りたいけど DB が必要なケースであれば Next.js × Vercel × Firebase の組み合わせは本当にオススメです。

もし似たような構成で開発に詰まっていましたら、お気軽にメールフォームから聞いて頂ければと思います。

© 2018 kk-web