🎈

風船屋さんを支える技術。200時間でゼロからweb検索システムを構築した裏側のすべて。(Next.js, Firebase...)

2022/02/17に公開

はじめに

こんにちは、フロントエンドエンジニアの多田です。
フロントエンド領域の勉強をはじめて 1 年が経ち、今回個人でゼロからアニプラさん(バルーンショップを経営してる会社)のバルーンサイトの検索システムを作らせていただく機会に恵まれました。

開発にかけられる総時間が 200 時間(つまり工数 1 人月ちょいぐらい、、)というだいぶヤバめなチャレンジングな時間的制約の中、要件定義・デザイン・フロントエンド・バックエンド・インフラ周りまで通して全て 1 人で開発し、完成させることができました。


はじめてfigmaでデザインしてみた

アニプラさんより記事を書く許可をいただいたので、開発の裏側の話を記事にまとめます。

開発したサイトはこちら ↓
(今回の記事のメインとなる Next.js で作ったバルーンプラン一覧検索画面)
https://plan.anipla-balloon.jp/

本記事の内容

  • (ほぼ)個人開発においての、要件定義 → デザイン → 開発 → 公開 までの流れ
  • 時間的制約のある中で、ひとりで開発するための技術選定に関して
  • それぞれのフェーズで工夫したこと
  • 大部分はバルーンプランの一覧検索ページを Next.js で作った話

今回は開発の流れをメインに、全体的にざっくり書きます。長くなったので、興味のある部分だけ読んでいただいてもいいかもしれません。

開発の背景

半年ほど前、アニプラ社長さんより出張装飾のバルーンショップ、『アニプラバルーン』の web サイトのリニューアルの相談を受けました。
もともと WordPress で構築された web サイトが運用されていましたが、バルーンのプランが非常に検索しにくいという課題があり、スタッフ、ユーザー共に使いにくいサイトでした。
そこで、私にリニューアルを任せていただくことになりました。

要件定義

某ウイルスのため直接クライアントに会うことができず、全て Zoom ミーティングで進めました。

ヒアリング

まず、初回は 2 時間ぐらいでざっくり作りたいものや予算をヒアリングします。

  • どのようなものを作りたいのか
  • 今のサイトのなにが不便なのか
  • ユーザーからの注文対応などの業務の流れ
  • 予算
  • 納期

その上で、具体的にできることを提案します。
今回予算的に開発にかけられる時間が 200 時間だったので、サイト全てをリニューアルすることはせずに、バルーンプラン一覧検索画面・バルーンプラン詳細画面は新規作成し、その他は既存の WordPress で作られているものを改修する方針で提案しました。

具体的に 200 時間の作業時間の内訳は以下です。

  1. 要件定義 (20h)
  2. デザイン (10h)
  3. トップページやお問い合わせなどの既存 WordPress ページ改修 (40h)
  4. バルーンプラン一覧検索ページ、バルーンプラン詳細ページを Next.js で新規に作成 (100h)
  5. スタッフがバルーンプランデータを登録するためのバルーンプラン管理画面(React, Firebase) (30h)

要件定義を 20 時間と設定しましたが、この短さは特殊なケースだと思います。
私自身の前職が風船屋さんで、バルーン業界という超マイナーなドメイン知識が豊富だった & クライアントと元々知り合いで、既に信頼関係が構築されていたためコミュニケーションが容易だった、という前提があります。

コミュニケーション方法

コミュニケーションに使用したツールは ZoomLine です。
Line はアニプラさん社内で元々メインで使用されているコミュニケーションツールだったため、採用しました。

1, 2 時間のミーティングを Zoom で定期的に行い、その他の細かいテキストでの相談は Line で行いました。ミーティング回数は全部で 6,7 回ほどです。
Zoom では常に画面共有して、Figma や資料を常に共有しながら進めます。
Line のノート機能を活用し、Zoom での会話の内容を簡易議事録として記録したり、次回までに考えてきてほしいこと一覧などをノートにメモしていきました。

要件定義の進め方・工夫

やらないことを決める

名著アジャイルサムライで、プロジェクトに求められる「スコープ」「予算」「時間」「品質」にはトレードオフの関係があり、「スコープ」が最も柔軟に動かしやすい要素であるという記述があります。
つまりクオリティを下げて顧客の要望すべてを無理に開発するより、品質は保ったまま開発する範囲を絞る方が良いということです。

今回は限られた時間で開発する必要があったので、まずやらないことを決めました。
ユーザーの問い合わせフォームなど、ユーザーのバルーンプラン検索に関わる機能以外はスコープ外としました。

価値提供の中心となる機能を考える

上記のやらないことを決めるという話と重なるのですが、
要件定義を進めていくと、あれもやりたいこれもやりたいと、夢が膨らみ無限にやりたいことがでてきます。
限られたリソースの中で全てを実現しようとしてしまうと開発が期限内に終わらないリスクが発生しますし、ユーザーにとっても中途半端な機能が多く盛り込まれた使いにくいサイトになりやすいです。
作るサイトがユーザーに提供できる中心となる価値は何かを言語化し、本当に必要な機能を予め考え、優先順位を決めて開発するべきです。

今回の場合、

  • バルーンプランを検索できる機能
  • スマホユーザーにとって使いやすいサイト

の 2 つだけを重視して作りました。

そのため、pc のデザインはちょっと妥協してたり、検索後の予約に関しては既存で使っていたフォームを流用したりしています。
もちろん妥協した部分も今後改善していきたいですが、今回は目をつぶり、次回フェーズ以降の開発で提案することにしました。

また、その他にも機能の優先順位を細かく決めてアニプラさんの合意を取りました。
例えば「お気に入り機能」は後回しにして余裕があれば実装する、といった具合です。

このように優先順位を明確に決め、顧客の期待値調整のために妥当なバッファを持たせることは、なにか問題が発生した際のために重要です。(大体何か想定外の問題は発生するので)

デザインを並行して進める

要件定義を進めながら、大枠が決まった部分から、同時に Figma でデザインを進めました。
そのデザインを見ながら、さらに要件定義の詳細を詰めていきます。

具体的にデザインをしないと開発するもののイメージはクライアントに伝わらないです。
これは当たり前ですが忘れがちな部分で、人間の頭の中のものを正確に他人に伝えるのは非常に難しく、デザインや実際に動くもので見せないと、予想以上に伝わらないです。
これは開発者とクライアントとの技術知識の差が大きい場合により顕著になります。

自分の当たり前の知識は相手にとっての当たり前ではない、という当たり前のことを前提として常に意識しながら、丁寧なコミュニケーションを心がける必要があります。

全体的な技術選定

ある程度作るものの全体像が見えてきたら、次は使用する技術の選定です。
技術選定で重視したポイントは以下の 2 点です。

  • 素早く開発できること
  • エンジニアを確保しやすい技術であること

エンジニアのいない風船屋さんで今後の保守や改修を考えると、ある程度メジャーで人気の言語やフレームワークを選ぶことは必須です。

以上を踏まえた技術選定結果は以下になりました。

  1. トップページやお問い合わせなどの既存ページ - WordPress
  2. プラン一覧検索ページ、プラン詳細ページ - Next.js
  3. バルーンプラン管理画面 - React
  4. バックエンド - Firebase

以下ひとつずつ、選定理由を説明します。

1.トップページやお問い合わせなどの既存ページの技術選定(Wordpress)


トップ画面デザイン

プラン一覧検索ページ、プラン詳細ページ以外は既存で使われている WordPress をそのまま使い、トップページのみデザインを大幅に改修することにしました。

WordPress のサイトを残した理由は以下の 2 つです。

  • 200 時間という時間的制約の中ですべてをリニューアルすることが難しい
  • 既存のサイトはバルーンショップスタッフによって運営されており、ブログや Q&A ページなどの資産は引き続きスタッフにより管理、運営できる状態にしたかった

既存のサイトは特に Git などで管理されていなかったので、 Git で管理するようにし、トップページだけ HTML と CSS でコーディングしてデザインを改修する方針にしました。

2. プラン一覧検索ページ、プラン詳細ページの技術選定(Next.js)

今回のリニューアルのメインとなる部分で、バルーンプランの一覧検索画面と詳細画面です。


プラン一覧・詳細画面デザイン

まず私が経験したことのある言語・フレームワークに絞りました。
200 時間という限られた時間の中で、新しい言語・フレームワークをキャッチアップすることは難しいと判断したためです。

候補は以下の 4 つです。

  • Ruby on Rails (Ruby)
  • SpringBoot (Kotlin)
  • Vue.js (Nuxt.js, TypeScript)
  • React (Next.js, TypeScript) ← 採用!

上記 4 つの中からまずは、フロントエンドとバックエンドが同包されている昔ながらの MVC アーキテクチャの Rails, SpringBoot か、フロントエンド部分だけのフレームワークである Vue, React か、どちらにするかを選びます。
昨今のトレンドを踏まえると、モダンなサイトはフロントエンドとバックエンドを分離してフロント部分を Vue, React などで作られていることが多いかと思います。
しかし、それほどパフォーマンスを求められず、限られたリソースで開発する場合、バックエンドとフロントエンドを一気に開発できる Rails なども有力な候補として挙がります。

今回は以下の 2 つの理由により、VueReact のフロントエンドフレームワークを採用することにしました。

  1. バルーンプランの検索の際にアプリのようなサクサク感がほしい。
  2. バックエンド部分は Firebase を使えば爆速で作れそう。

そのため、Rails と SpringBoot は候補から外れます。

最後に Vue(Nuxt.js)React(Next.js) ですが、

  • React の方が人気で今後も勢いがありそう。
  • ISR を使用し、 Vercel というプラットフォームにのりたかった。

という 2 つの理由より、React(Next.js) を最終的に選びました。

3. バルーンプラン管理画面の技術選定(React)

次にバルーンプランを登録するための、バルーンショップスタッフ用の画面です。

データ登録画面スクショ

管理画面をゼロから作る時間はないと判断したため、
React のデザインライブラリである Material UI の開発元が販売している、管理画面のテンプレートを購入しました。
React, Typescript で書かれている管理画面テンプレートで、ログイン周りやデータベースへのアクセス部分も含まれています。(しかも Firebase にも対応してる!)
購入に$129 かかりましたが、実装時間の削減を考えると安いです。
管理画面はパフォーマンスや SEO を気にする必要がないため、SPA であることも気になりませんでした。

実は React を学習し始めた頃にこのテンプレートのコードを読んで遊んでいたということもあり、迷いなく導入することができました。

4. バックエンド、DB の技術選定(Firebase)

バルーンプランの情報を保存する DB や認証など、バックエンド部分にはFirebase を採用しました。
バックエンドとしての候補は以下の 4 つありました。

  • SpringBoot(Kotlin)
  • Ruby on Rails
  • Firebase ← 採用!
  • Supabase

実装に時間をかけられないので、まず BaaS[1] である Firebase と Supabase の 2 つに絞りました。
Supabase は、Firebase の代替プラットフォームとなる最近の BaaS です。
両者の大きな違いは Firebase の Firestore が NoSQL[2] であるのに対し、Supabase は RDB であることです。

調査をすすめるうちに、Firestore は NoSQL に起因する制約により、しっかりと使いこなすにはそれなりの学習時間が必要であることが分かりました。
更に私には NoSQL の豊富な経験もなかったため、はじめは Supabase が魅力的でした。
しかし、最終的に以下の理由により Firebase を選択しました。

  • データ取得は全件取得しかしないことにしたので(後述)、複雑なデータ取得は行わないため Firestore をそこまで学習しなくても簡単に使えそう
  • ホスティングや認証なども、Firebase エコシステム内で完結させたかった
  • Firebase の方が情報量が多く、Supabase は最近できたサービスなため不安だった

ちなみに Firebase では以下のサービスを使用しています。

  • 管理画面のホスティング - Firebase Hosting
  • 管理画面のユーザー認証 - Firebase Authentication
  • データベース - Cloud Firestore
  • 画像置き場 - Cloud Storage for Firebase

以上、ざっくりとした全体的な技術選定の紹介でした。
ここからデザイン、実装の中身に入っていきます。

デザイン

デザイン経験はゼロだったのですが、
アニプラさんに「センス良いしデザインもできるんじゃない〜」と軽く言われて、良い気になって軽く引き受けてしまいました。。。(本当はダメ、ゼッタイ)

もともと web デザインをやってみたいと思っていたので、
心優しいアニプラさんにデザイン経験はゼロという事情を説明したところ、チャレンジさせていただけることになりました。

Figma 学習

Web 上で編集や共有ができ、無料で使えて1番メジャーっぽいツールということで、 Figma を採用しました。
業務でエンジニアとしてデザイナーさんが作った Figma のデザインを確認する、ということはありましたが、自分自身でデザインをするのは初めてです。

デザインに取り掛かる前に以下の YouTube の動画を 1 本観て使い方を学習しました。
この 40 分の動画 1 本だけで Figma が使えるようになりました、素晴らしい時代ですね。
(非常に分かりやすかったです。素晴らしい動画をありがとうございます!)

https://www.youtube.com/watch?v=JuaXJ4DgItY

あとは以下の本をざっと読みました。


Web デザイン良質見本帳 目的別に探せて、すぐに使えるアイデア集


デザイン入門教室 確かな力を身に付けられる ~学び、考え、作る授業~ (Design&IDEA)


ノンデザイナーズ・デザインブック

もともと Web デザインに興味があり、エンジニアになる前に Web デザインの本を読み漁ってた時期があったので、その時の経験も無意識下でデザインの助けになったかもしれません。

また、以前カメラマンをやっていてフォトショップなどは使ったことがあったためか、Figma も基本的な機能はすぐに使えるようになりました。

いざデザイン

めっちゃ難しいです、、、
イメージしたものを Figma で作ってみると、なぜかイマイチになります。
デザインは言語化するのが非常に難しく、経験不足のため何が悪いのかも分からないので、とにかく手を動かして実際に作ってみることしか私にはできません。

難しいのは、デザインは目的の機能をユーザーが使用するための手段にすぎない、ということです。
例えば複数個のボタンをデザインする際、ボタンの数が 4 つだと見た目的にキレイにデザインできるけど、機能的には 5 つ欲しいし、さらに今後もボタンの数は増える可能性がある、といった場合です。
デザインの目的は、機能をユーザーに使いやすく使ってもらうことなので、見た目のために機能側の要件を変更してしまうのは本末転倒です。

経験豊富なデザイナーさんはこの辺りの、必要な機能を使いやすく提供するためのユーザーインターフェイスの設計に長けているのだと思います。

四苦八苦しながら何回も試していくうちになんとか見れるものができあがりました。
トータル 15 時間ぐらいかかりました。

デザインって奥深えぇ、、

時間がなかったため、あまり厳密には作り込んでいません。
レスポンシブの際のマージンやフォントサイズなど、細かい部分は適当にデザインしました。

目的はクライアントに実装の雰囲気を伝えることだったので、細かい部分は実装しながら調整していくということで事前にクライアントと合意を取りました。

Figma の url を共有して、常にクライアントが進捗を確認できる状態にして進めました。

最終的にはクライアントに喜んでいただけるデザインが完成しました。
(改善点は多々あると思っていますが、、、)

バルーンプラン管理画面実装(React, Firebase)

次にバルーンプラン管理画面の実装です。
バルーンショップのスタッフが、サイトに表示させたいバルーンプランを登録する画面です。

機能は管理画面として基本的なもので、ユーザーのログイン、バルーンプランの一覧表示・詳細表示・新規作成・編集・削除ができます。
また、一覧表示でプランの検索や並べ替えも可能です。

管理画面のテンプレートを購入してそのまま使いましたが、このテンプレートは素晴らしいです。
デザインだけでなく、ログイン機能や API からのデータ取得部分など、管理画面に必要な機能が一通り TypeScript で全て用意されています。
しかも、ログインは FirebaseAuth0 など複数の認証サービスに予め対応しています。
Firebase Aurhentication を使い、ログイン機能は 1 時間ぐらいで実装できました。

その他も基本的にテンプレートのコピペで作っていくので、
管理画面の作成にはトータルで 30 時間ぐらいしかかけていないです。

画像について

投稿したデータは Cloud Firestore に保存しているのですが、画像だけは Cloud Storage for Firebase に保存しています。
Cloud Storage for Firebase はデータを安価に保存できるストレージサービスで、AWS でいう S3 みたいなものですね。

画像はサイズが大きすぎるとパフォーマンスや維持費用に影響してしまうので、投稿する際にフロント側で圧縮することにしました。
いくつか画像圧縮のライブラリを試して、使いやすそうなbrowser-image-compressionというライブラリを採用しています。

最近はスマホで撮った写真も容量が大きくなるので、この機能は必須でした。
どれだけ大きな画像を投稿しても 100kB 以下になります。

バルーンプラン一覧検索ページ実装(Next.js, Firebase)

次にプラン一覧検索画面とプラン詳細画面です。
本記事のメインとなる部分です。
https://plan.anipla-balloon.jp/

サクサク絞り込み検索ができます

ざっくり使用技術

Next.js, TypeScript で構築し、データは Firestore から取得しています。
状態管理にはContext API、テストには React Testing LibraryCypress を採用しました。
ホスティングは Vercel を利用し、CD/CI は Github に push すると自動的に Vercel 側でテストとデプロイが行われるようになっています。

ちなみに package.json はこんな感じ
package.json
{
  "name": "anipla_balloon_plans",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint --dir src",
    "lint:fix": "eslint src --ext .js,jsx,.ts,.tsx --fix",
    "test": "jest",
    "cy:open": "cypress open",
    "cy:run": "cypress run",
    "format": "prettier --check ./src",
    "format:fix": "prettier --write ./src"
  },
  "dependencies": {
    "firebase": "9.1.0",
    "next": "11.1.2",
    "react": "17.0.2",
    "react-dom": "17.0.2",
    "sass": "^1.45.2",
    "swiper": "^7.4.1"
  },
  "devDependencies": {
    "@testing-library/cypress": "^8.0.2",
    "@testing-library/jest-dom": "^5.16.1",
    "@testing-library/react": "^12.1.2",
    "@types/eslint": "7.28.0",
    "@types/node": "^16.10.2",
    "@types/react": "17.0.26",
    "@types/react-dom": "17.0.2",
    "@typescript-eslint/eslint-plugin": "^5.10.0",
    "babel-jest": "^27.4.6",
    "cypress": "^9.4.1",
    "eslint": "7.32.0",
    "eslint-config-next": "11.1.2",
    "eslint-config-prettier": "^8.3.0",
    "identity-obj-proxy": "^3.0.0",
    "jest": "^27.4.7",
    "jest-css-modules": "^2.1.0",
    "next-page-tester": "^0.30.0",
    "prettier": "^2.5.1",
    "typescript": "4.4.3",
    "typesync": "^0.8.0"
  }
}

ディレクトリ構成(Next.js)

めちゃめちゃ悩んだ結果、コンポーネントのディレクトリ構成は AtomicDesign もどきにしました。

参考: アトミックデザインについての記事 ↓
https://bradfrost.com/blog/post/atomic-web-design/

以下は今回のディレクトリ構成を一部抜粋したものです。

Next.js のディレクトリ構成
.
├── .vscode
│   └── settings.json(vscodeの設定)
├── cypress
│   └── E2Eテストの中身たち
├── public
│   └── ファビコンや画像たち
├── src
│   ├── api
│   │   └── 外部へのAPI接続(firebaseにバルーンプラン情報を取りに行くやつら)
│   ├── components
│   │   ├── common
│   │   │   ├── BaseLayout
│   │   │   │   ├── index.tsx
│   │   │   │   └── styles.module.scss
│   │   │   ├── Header
│   │   │   │   └── ...
│   │   │   └── ...
│   │   └── pages
│   │       ├── PlanListTemplate
│   │       │   ├── index.tsx
│   │       │   └── organisms
│   │       │       ├── PlanList
│   │       │       │   ├── index.tsx
│   │       │       │   ├── styles.module.scss
│   │       │       │   ├── PlanList.test.tsx
│   │       │       │   └── molecules
│   │       │       │       ├── PlanBox
│   │       │       │       │   ├── index.tsx
│   │       │       │       │   ├── styles.module.scss
│   │       │       │       │   └── atoms
│   │       │       │       │       └── LikeButton
│   │       │       │       │           ├── index.tsx
│   │       │       │       │           └── styles.module.scss
│   │       │       │       └── ...
│   │       │       └── ...
│   │       ├── PlanDetailTemplate
│   │       │    └── ...
│   │       └── ...
│   ├── pages
│   │   ├── _app.tsx
│   │   ├── _document.tsx
│   │   ├── index.tsx
│   │   └── plans
│   │       └── [planId].tsx
│   ├── config
│   │   └── 設定値ファイルたち(firebaseやgtmの設定など)
│   ├── constants
│   │   └── 定数たち
│   ├── contexts
│   │   └── 状態管理を担当する人たち(プラン情報や絞り込み情報を保持)
│   ├── hooks
│   │   └── ロジックを切り出したhooksたち
│   ├── logic
│   │   └── その他のロジックたち(純粋関数)
│   ├── styles
│   │   └── スタイル関連のファイルたち(リセットcssや定数など)
│   └── types
│       └── 型定義ファイルたち
├── cypress.json
├── jest.config.js
├── jest.setup.js
├── next-env.d.ts
├── next.config.js
├── package.json
├── tsconfig.json
└── yarn.lock

AtomicDesign もどきとは言ってますが、コンポーネントの再利用性ゼロで、大規模プロジェクトだと確実にアンチパターンな設計にしています。

よくある通常の AtomicDesign の場合、以下のように、まずpages, templates, organisms, molecules, atomsという 5 種類のフラットな構造でディレクトリを切って、その中に様々なコンポーネントを格納していきます。

通常のAtomicDesign
components
├── pages
│   └── ...
├── templates
│   └── ...
├── organisms
│   └── ...
├── molecules
│   └── ...
└── atoms
    └── ...

はじめは私も上記のようにディレクトリを切って実装しようとしていたのですが、途中で今回の規模の開発だとコンポーネントを再利用することはほぼないということに気が付きました。

そのため、以下のようにtemplates > organisms > molecules > atomsと、全て階層になるようなディレクトリ構成に変更しました。

今回採用したAtomicDesignもどき
src
├── pages
│   └── ...
└── components
    └── PlanListPageTemplate
        ├── index.tsx
        └── organisms
            └── PlanList
                ├── index.tsx
                ├── styles.module.scss
                ├── PlanList.test.tsx
                └── molecules
                    └── PlanBox
                        ├── index.tsx
                        ├── styles.module.scss
                        └── atoms
                            └── LikeButton
                                ├── index.tsx
                                └── styles.module.scss

つまり、organisms, molecules, atoms といった名前のディレクトリがプロジェクトにそれぞれ複数存在することになります。
ある organism は特定の template からしか使用されず、同様にある molcules はそのmolculesを配下に格納している特定の organism からしか使用されません。

この構成のメリットはコンポーネントがどこに格納されているか見つけるのが容易になり、新たにコンポーネントを作成するときも作成場所に迷いがなくなることです。

逆にデメリットは再利用できないことにつきます。
この設計は、部品をコンポーネントとしてカプセル化し、コンポーネントを再利用しながらページを組み立てていくコンポーネント指向の React の思想に反します。

ただ不必要に汎用化すると、コードが複雑化し、実装にかかる時間も長くなり、可読性も下がります。
今回の規模だと再利用に備えた汎用化をするより、コードをシンプルに分かりやすく保つ方がメリットが大きいと判断したため、このような構成にしました。

ただしロジック部分に関しては可能な限り hooks に切り出して再利用するようにしています。

レイヤーごとのルールは以下のように定めています。

レイヤー 役割
pages Next.js の規約により/component 配下ではなく、ルート配下に pages ディレクトリが存在する。
ルーティング、データ取得をここで行う。
template を 1 つ読み込む。
template レイアウトを決めるだけ。ロジックは持たない。
例えばヘッダー・フッターとかはここで表示させるようにしてる。
organisms ロジックを持つ。ContextAPI からデータ取得とかしても OK。
具体的にはプラン一覧エリアとか、プラン検索エリアとか。
organism もしくは molecule を複数読み込む。
molecules ロジックを持たない。他の modules か atoms を読み込める。
atoms ロジックを持たない。molcules より細かいパーツ。 ボタンとか。

今後 web サイトの規模を大きくすることがあれば、その際に共通のコンポーネントに切り出して行こうと思います。

レンダリング戦略

ISRを採用しました

Next.js ではサーバーとクライアント、どこでデータを取得してどこで HTML をレンダリングするのかを選択可能です。
サーバー側でレンダリングするか、クライアント側でレンダリングするか。
ユーザーリクエストの前にレンダリングするか、リクエスト後にレンダリングするか。
いわゆる CSR(SPA), SSR, SSG, ISR とかいうやつですね。

ページの表示速度やサーバーの負荷が変わるため、状況に合わせて選択する必要があります。
Next.js では、ページごとに柔軟にレンダリング方法を選択可能です。

それぞれのレンダリング方法の簡単な説明は以下です。

レンダリング方法 説明
CSR(Crient Side Rendering) JS を使ってブラウザ側でレンダリングする方法。
SPA(Single Page Application)とか呼ばれることもある。
SSR(Server Side Rendering) ユーザーのリクエストがあった際に、都度サーバー側でレンダリングする方法。
ただし、初回表示はサーバー側でデータ取得やレンダリングを行い、それ以降はクライアント側で内容を更新することを SSR と呼ぶ場合もある。
SSG(Static Site Generation) 静的サイトジェネレーション。
ビルド時に HTML を構築する。
その際に API でのデータフェッチも済ませておく。
ユーザーリクエストの事前にすべての HTML が構築される。
CDN にキャッシュさせることにより、SSR より高速な配信が可能。
ISR(Incremental Static Regeneration) SSG の進化版。
データが更新された場合に再ビルドするまでページの内容が更新されないという SSG の問題を解決する。
stale-while-revalidate という挙動をする。
ページに有効期限を設け、有効期限が切れたあとの次のリクエストはとりあえず古いキャッシュを返し、裏側でページを自動的に再ビルドすることによって、ページ内容を更新できる。

それぞれの主なメリット、デメリットをまとめました。

レンダリング方法 メリット デメリット
CSR ページの中身が毎回最新になる。
サーバー側の CPU 負荷が低い。
初回時ブラウザにコンテンツが表示されるまでが遅い。
OGP 表示など、サーバー側でレンダリングされた値を返せない。
SEO 的に若干不利かも。
SSR ページの中身が毎回最新になる。
OGP 表示など、サーバー側でレンダリングされた値を返せる。
サーバーの CPU 負荷ががかなり高い。
ユーザーのリクエストのたびに毎回サーバー側でレンダリングするので、動作のサクサク感がなく遅く感じる。
SSG 完成した HTML を配信するだけなので、サーバー側の負荷が低い。
ユーザーリクエスト前に 事前にビルドされてるので速度が爆速。
再ビルドしないとコンテンツの更新ができない。
ページの中身が最新の情報でない。
ISR SSG の欠点を補ったもので、再ビルドしなくてもページの中身の更新が可能。 中身が最新の情報でないタイミングがある。
ISR に対応しているインフラを使用しなければいけない。

メリット・デメリットを比較して、以下の理由により、ISR を採用しました。

  • サクサク動くサイトにしたかった
  • ISR は stale-while-revalidate の挙動でキャッシュを返すため、コンテンツの中身が最新でない瞬間があるが、許容できる
  • もともとインフラは Vercel を使おうとしていたので、ISR の導入が一瞬でできる

具体的な実装は、
まずgetStaticPropsという Next.js で用意されている関数を使用すると、その関数はビルド時にサーバー側で実行され、SSG となります。
そのgetStaticPropsの中でrevalidateというオプションで秒数を指定してあげるだけで、ISR になります。

src/pages/index.tsx
// getStaticPropsでreturn されたpropsをこのコンポーネント内で使用できる
const TopPage: NextPage<Props> = ({ plans }) => {
  return <PlanListTemplate plans={plans} />;
};

// この関数の中でプラン一覧をデータ取得する
export const getStaticProps: GetStaticProps = async () => {
  const plans = await getPlans();

  // ISRにする場合、revalidateで秒数を指定
  return { props: { plans }, revalidate: 10 };
};

めちゃ簡単にできた。Next.js すごい。

このコードの場合、
まず前回のビルドから 10 秒以上過ぎた次のユーザーからのリクエストをトリガーに、裏側でそのページが再ビルドされページ内容が更新されます。
そのトリガーとなったユーザーのアクセスに対してはキャッシュされている更新前のページが返され、次回以降のアクセスに対しては更新後のページが返されるようになります。

データ取得

ISRなので、サーバー側でユーザーからのリクエストより前に Firestore からデータフェッチして事前にレンダリングを終わらせることにより、高速な配信を実現しています。

作りたいものはバルーンプランを一覧表示して、ユーザーが並び替えや条件絞り込みができるサイトでした。
これを実現するにあたり工夫したポイントとしては、絞り込みの度に API を叩いてデータを取得するのではなく、データ取得はバルーンプラン全件取得をサーバー側で1 回だけ行うようにしていることです。
その後の並べ替えや絞り込みは全てフロント側だけで処理していて、フロント側に全てのロジックを持たせています。
つまり、フロント側からデータ取得の API が叩かれることはありません。

通常、商品一覧が並んでいて、ユーザーが絞り込んでいくような EC サイトの場合、絞り込みごとにフロントからその絞り込み条件で API を叩いて商品情報をバックエンドから取得するのが一般的かと思います。
また、フロント側にロジックを寄せてしまうのもあまりキレイな構成ではないかもしれません。

ただ今回は以下の理由により、データはサーバー側で全件取得のみ、ロジックはフロント側で持つ、という方針を採用しました。

  • データは実際に風船屋さんが提供しているプランのため、データ数は多くても数百件で、数千件、数万件とデータが増えていくことはないため、全件取得でも OK
  • ISR のため、レンダリングはバックエンド側で事前に行われるので数百件レンダリングされても問題なさそう
  • 全件取得のみにすることによって、バックエンド側で複雑なロジックを持つ必要がなく、Firestore をスキルが浅くても安心して使える。
    • 複雑なセキュリティルールや NoSQL の制約を考えなくて良い。
    • 今回 Firestore を採用して工数を削減できた理由はここにあります。
  • Firestore を使うことによって、絞り込みのための API を作る必要がなくなり 工数が圧倒的に削減される

開発にトータル 200 時間しかかけられない、という時間的な制約があったというのもこの判断の大きな理由です。

開発環境まわり(VSCode, ESLint, Prettier)

エディタは VisualStudioCode、リンターとフォーマッターには ESLintPrettier を導入しています。このあたりは最近のフロントエンド開発には必須なので、特に何も考えずに導入しました。

ESLint

ESLint は JavaScript の静的解析ツールで、 よろしくないコードの書き方をした時に警告を出して教えてくれます。
今回作った設定ファイルは以下で 、next.js 公式が作ってくれている設定に、typescript 用の設定を追加しています。

.eslintrc.json
{
  "extends": [
    // ESlintのおすすめ設定
    "eslint:recommended",
    // TypeScript用設定
    "plugin:@typescript-eslint/recommended",
    "plugin:@typescript-eslint/recommended-requiring-type-checking",
    // Next.js公式が提供してくれてる設定
    "next/core-web-vitals",
    // prettierと競合しないための設定
    "prettier"
  ],
  "parserOptions": {
    "project": "./tsconfig.json"
  }
}

Prettier

Prettier はコードのフォーマットを自動的に強制してくれるツールです。
Prettier はフォーマット宗教戦争に終止符を打つために作られたライブラリで、作者の思想として「カスタマイズは最小限にして、みんなオレが決めたデフォルトの設定に従ったら平和でハッピーじゃね?」という考えがあります。
そのため Prettier の設定は最小限に留めてます。

.prettierrc.json
{
  "singleQuote": true
}

VSCode 設定

ESLint と Prettier は、コード保存時に VSCode 側で自動的に適用されるようにしました。
Mac でcommand + S を押して保存すると自動的にコードが整形されます。

./vscode/setting.json を作って VSCode の設定を共有できるようにしています。
これは、今後私以外の誰かがこのプロジェクトのコードを触る際、
VSCode の設定ができていないためにコードのフォーマットが効かないという初心者あるあるを無くすためです。

./vscode/settings.json
{
  "editor.formatOnSave": true,
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "editor.tabSize": 2,
  "editor.codeActionsOnSave": {
    "source.fixAll": true,
    "source.organizeImports": true
  }
}

ESLint と Prettier の VSCode 拡張機能もプロジェクトを開いた時におすすめされるように、以下のファイルも追加しました。

.vscode/extensions.json
{
  "recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
}

この構成のデメリットとしては VSCode 以外のエディタを使おうとしたときに自動フォーマットが効かないことですが、
フロントエンドエンジニアは今後しばらくは基本的に VSCode を使うだろうという想定です。

エディタを超えて Linter や Formetter を効かせようと思うと、husky などを使用して、コミット時に ESLint や Prettier を強制実行させたら良いのですが、YAGNI[3] の精神のもと導入していません。

スタイル

CSS Modulesを採用しました。
css の技術選定で挙がった候補は以下です。

  • CSS Module
  • CSS in JS 系 (styled-components など)
  • TailwindCSS

小規模な開発なのでパフォーマンスに関してはどれでもいいのかなと思いました。

TailwindCSS は最近流行っている印象があって、たしかに css を書かずに実装でき、かつクラス名を考える必要もなくなるのは魅力的です。

ただ、今回は今後ジョインするエンジニアの学習コストが最小になることを重視し、CSS Moduleを採用しました。
CSS Module なら普通の css と同じ書き方なので、web 開発の経験が浅くてもスムーズに理解できる可能性が高くなります。
また、CSS Module は Next.js にビルトインでサポートされているため、なにも設定せずにそのまま使用することができます。

クラスの命名規則

BEM[4] っぽく書いていますが、厳密なルールは設けませんでした。
1 つだけ気をつけたのは、クラス名をプロジェクトの中で一意な名前にすることです。

もっとも、CSS Module は名前空間をファイル単位で分離しているので、別コンポーネントで同じクラス名を使うことは問題ありません。

ただ、私はデバッグの際にブラウザ上からデベロッパーツールで要素を特定し、VSCode の ファイル横断検索で class 名で検索する、という探し方をよくするため、プロジェクト内でクラス名が一意になっていると、検索性が上がり便利です。
若干クラス名が長くなってしまうというデメリットはありますが、そこに関しては BEM で慣れているので違和感はなかったです。

状態管理

Context API を採用しました。

React の状態管理戦略に関するこちらの素晴らしい記事を参考にさせていただきました。
https://zenn.dev/yoshiko/articles/607ec0c9b0408d

アプリケーションに存在する状態(State)を以下の 3 種類に分類し、それぞれのやり方で管理しています。
1.サーバーデータのキャッシュ
2.Global State
3.Local State

今回 ISR で事前にバルーンプラン情報は全件取得してしまっているため、フロント側から直接 Firestore を叩いてバルーンプラン情報を取得しに行くことはありません。
そのためswrなどを使ってサーバーデータをキャッシュすることを考える必要がありませんでした。

Global State としてはバルーンプランの絞り込み状態などを Context API で保持しています。
Global State の状態管理を実現するための候補には、以下の 3 つが挙がりました。

  • Context API
  • Recoil
  • Redux

そこまで複雑な状態管理ではないため、Redux ははじめに候補から外れました。
Context API と Recoil はどちらでも良かったのですが、迷ったときはシンプルでメジャーな方を選ぶという原則に従い、React にはじめから付随している Context API を採用しました。
また、useReducer も併せて使用し、データの流れを管理しやすくしています。

テスト

はじめテストを検討したとき、『時間もないしテストを書かなくても良いじゃないか、誰にもバレやしないさ、、』という悪魔の囁きに負けそうになりましたが、自分含め将来の誰かのために思い留まり、最低限のテストを書くことにしました。えらい。(えらくはない、当たり前)

  • React Testing Library
  • Cypress

を採用しました。
テストの方針としては、トロフィー型テストを参考にしています。

React Testing Library の作者である、Kent C. Dodds 氏の記事 ↓
https://kentcdodds.com/blog/the-testing-trophy-and-testing-classifications

トロフィー型テスト
トロフィー型テスト

上記の記事の内容を私の理解でギュッと要約すると、

  • テストは 4 種類に分けることができて、Integration テストを最も頑張るべきである
  • テストはユーザーの振る舞いに近いほうが信頼性が上がって良い

です。

以下の技術でテストを書きました。

  • End to End → Cypress
  • Integration → React Testing Library
  • Unit → Jest
  • Static → TypeScript

テストのメインは React Testing Library での Integration テストです。
例えば、「バルーンプラン一覧で絞り込みボタンを押したとき、絞り込まれたプランが画面に表示されているか」といった内容のテストです。

また、ロジックは切り出しているため、ロジック部分だけで単体テストしています。

インフラ・CD/CI

インフラには 迷いなく Vercel を採用しました。
一瞬で環境が構築でき、ISR に対応していて、Next.js の開発元である Vercel が運営しているので安心感もあります。
Pro プランで契約するため、料金は月 20$かかりますが、AWS とかで運用することを考えると安いです。
Vercel は半年ほど前に大型の資金調達をしていて、今後もしばらくは安心して使えそうです。

CD/CI

Vercel の体験は素晴らしく、 CD/CI パイプラインの構築が一瞬で完成します。30 分かかってないです。

Next.js プロジェクトを push した Github リポジトリを、 Vercel の web 上の画面からポチポチと連携するだけで、ほぼ環境が完成します。

Github に main ブランチが push された時に、本番環境に自動的にデプロイされます。
さらに、Github 上でプルリクエストを作成すると、そのブランチが本番環境とは別の環境に自動的に反映されます。
その環境に紐付いた url が自動的に発行されるので、ステージング環境としてプルリクで修正した内容を動作確認することができます。

また、デプロイ前に自動的にテストを走らせてテストに成功した場合のみデプロイする、といった設定ができます。

実装から本番環境への反映までの流れは以下のようになります。

ローカルで機能実装
↓
プルリクを作成
↓
自動的にテスト用環境にテスト&デプロイ
↓
プルリクを main ブランチにマージ
↓
自動的に本番環境にテスト&デプロイ

昔 AWS で CD/CI パイプラインを構築した際は丸 1 日はかかった記憶があるので、これが Vercel で 30 分かからず完成したのには感動しました。

パフォーマンス / SEO 対策

Chrome の Lighthouse で本番環境のパフォーマンスを測定し、最低限の項目だけ修正しました。
時間の都合上、細かなパフォーマンスチューニングは行っていません。

主に以下の対応をしました。

  • 画像のサイズを小さくする
  • スマホのタップ領域を 48px 以上にする
  • 基本的な meta タグ設定
  • aria-label を付けたり、コントラストを修正したり、アクセシビリティ対応

その結果 Lighthouse の測定結果は以下です。

スマホ ↓

PC↓

Next.js では高いパフォーマンスがでるように設計されたフレームワークなので、特になにもしなくてもそこそこ良い結果がでます。
例えば、Next.js では画像を表示する際に <Image /> コンポーネントを利用するのですが、これを使うだけで自動的に画像サイズの最適化や画像の遅延ローディングなどが実装されます。すごい。
その他にもいろいろ、何も考えずに実装してもある程度自動的にパフォーマンス最適化が施されます。すごい。

反省点・今後の改善点

web サービスは 1 度開発してリリースしたら終わりではありません。
ビジネスフェーズや市場の変化に対応して常に改善していく必要があります。

今後の保守・追加開発も引き続きアニプラさんより依頼していただくことができたので、今回の反省点と今後の方針を最後に考えます。

バックエンドに Firebase 以外の選択肢があったのでは

管理画面を React、バックエンドを Firebase で作りましたが、開発終盤になって、もしかしてヘッドレス CMS を使っても実現できたのでは、、と気づいてしまいました。
ヘッドレス CMS を利用できれば、管理画面を作る工数がまるっと削減されます。

バルーンショップのスタッフが利用する管理画面なので、日本製で使い勝手が良さそうなヘッドレス CMS サービスを探してみたところ、microCMSというサービスを見つけました。
今回の利用用途だと無料で利用することは難しそうだったので、結果的には 管理画面を React と Firebase で作って正解だったのですが、次からは簡単な API ならヘッドレス CMS も事前に候補に入れて考えたいです。

デザインは外注したほうが良いのでは

デザイン未経験がいきなりデザインするのはやはりなかなか厳しいものがあります。(当たり前)
完成したデザインをアニプラさんには褒めていただけたのですが、もっとより良いデザインがあったのでは、、という思いが今でも拭えません。

web の使い勝手はデザインに大きく左右されることもあるので、デザインに関しては今後もユーザーの意見を取り入れながら改善案を提案していけたらなと思います。

バルーンプランを探すところから、注文までの一貫したユーザー体験をデザイン・実装したい

今回予算の都合上、バルーンプランの一覧検索画面、詳細画面のみをメインに作りました。
ユーザーがバルーンプランを探す部分は作りましたが、それ以降の注文やその後のユーザーとのコミュニケーションの部分には触れていないので、今後そのあたりも取り組んでいけたらなと思います。

例えば、アニプラはユーザーとのコミュニケーションを「LINE 公式アカウント」というサービスを利用してライン上で行っているので、
今回開発した web を LIFF アプリに乗せて、LINE 上でバルーンプランの検索から注文までを行える一貫した体験をユーザーに提供できるようなサービスを作る、などを勝手に妄想構想しています。

おわりに

当初の目標通り、200 時間でなんとか作り切ることができました。

エンジニアのスキルとして基本的なプログラミングの技術力が求められるのは当然ですが、それに加えて、そもそもの解決したい問題の定義、クライアントとのコミュニケーションや、時間とクオリティのトレードオフを意識した技術選定とそれを関係者に説明するための言語化力などなど、開発周辺の技術以外のスキルの大切さを実感しました。

まったくのゼロからひとりで開発するのは初めてでしたが、自分の開発したものがクライアントに喜んでもらえるのは非常に嬉しいです。
経験の浅い私を信頼して任せていただいたアニプラさんには感謝の気持ちしかありません。

今後も精進します。

脚注
  1. Backend as a Service アプリのバックエンド機能をまるっと提供してくれるクラウドサービスのこと ↩︎

  2. Not Only SQL リレーショナルデータベース以外のデータベースのことで、処理速度や拡張性に優れている。筆者は RDB の方が使い慣れている。 ↩︎

  3. "You ain't gonna need it" 必要になるまで実装するな。というエクストリーム・プログラミングにおける原則 ↩︎

  4. CSS を管理しやすくするためのクラス名の命名規則。Block,Element,Modifier の頭文字を取って BEM。Block__element--modifier のようなクラス名をつける。 ↩︎

Discussion