Next.js+TWAで多言語に対応した個人開発アプリをつくった!
こんにちは。初Zennです。
個人開発してきたアプリが完成したので、宣伝ついでに記事を書きます!
🌏つくったアプリ
テニスのようなスポーツで、ペアの組み合わせを作成することができるアプリです。
TWA化したのでAndroidアプリとしても公開しています。
これを作るにあたって、考えたことなどを書きます。
🔥アプリを作ったモチベーション
おもに以下になります。
- 無職で暇だったから
- 自分がほしいアプリだったから
- 人に使われるアプリを作ってみたかったから
- あわよくば日銭を稼ぎたかったから
⚙技術スタック
各技術について考えたことについて書きます。
Next.js
Next.jsを使って何か作りたかったので、Next.jsにしました。
今話題のApp Routerを使いたかったのですが、以下の理由からPage Routerにしました👇
- このアプリを開発はじめた当初はβだった(バグを踏みたくなかった)
- MantineがApp Routerに対応していなかった
Mantine + Tailwind
スタイリングはMantine + Tailwindにしました。
選んだ理由は「どちらも実務で使うなら選択肢から外れそうなので、どうせなら個人開発で使ってみたい」と思ったからです。
Mantineは最初から最後まで使いやすかったです。
ただTailwindについては最初はキツかったです。というのも簡単なプロパティですらいちいちググらないといけなくて「何なのだこれは。一体何が良いのだ。面倒くさくてやってられんぞ・・」となりました。ですが後半になるに連れて、よく使うプロパティは大体覚えてきて「ふーん、便利じゃん」ってなりました。
特に以下が便利だと思いました👇
- class名を考えなくて良い
-
lg:w-full
みたいにclass名にブレークポイントを直接書ける- こちらのLPのような単純なレスポンシブデザインなら、すぐにコーディングが終わってよかったです。
ただ、Mantineみたいに「最初からデザインがある程度完成しているコンポーネント」にちょい足しする用途には向いてる気がしますが、Headlessコンポーネントとかにゼロからデザインするという場合には向いてない気がしました。class名が長くなって見にくそうだからです。
ちなみに自分の場合、以下のような優先順位でスタイルを当ててました👇
- 基本はMantineのコンポーネントをデフォルトのまま使う
- 変更を加えたいときはコンポーネントのPropsを変更する
- それでも足りない場合だけTailwindを使う
Zustand
状態管理はZustandにしました。
実務ではRecoilとContextを使ったことがあるのですが、以下が気になったので使いませんでした👇
- Recoil:いつまで経ってもexperimentalのまま
- Context:パフォーマンスが悪いしコード量も多くなる
Zustandは2023年08月現在、かなり勢いがある状態管理ライブラリみたいです👇
▲スターだけでいうと、Reduxも抜きそうな勢い
実際使ってみて、とても使いやすかったです!
Firebase
Firebaseの以下の2つを使いました👇
- 認証:Authentication
- データの保管:Firestore
JSONを保存できれば何でも良かったのでFirestoreじゃなくても良かったのですが、「フロントエンドだけで完結して楽だから」という理由で選びました。
i18n-next
需要の少ないニッチアプリですが、世界相手ならそこそこ使ってもらえる気がしました。
なのでi18n化することにしました。
JavaScriptでi18n化する場合、「18next」というライブラリがデファクトスタンダードらしく、さらに「18next」には以下のように2つの亜種があるようです👇(名前がすごくややこしいです!)。
- react-i18next:18nextをReact用にラップしたやつ
- next-i18next:react-i18nextをNext.js用にラップしたやつ
なので最初は「じゃあnext-i18nextを使えばいいじゃん!」と思ったのですが、SSGに対応していなかったので(というかnext export
できない)、react-i18nextにしました。
localstorageに言語情報を持たせて、react-i18nextで内部的に言語を切り替えるようにしています。
というわけで現状、以下の言語に対応しています👇
- 英語
- 中国語
- ヒンディー語
- スペイン語
- フランス語
- 日本語
人口数的にアラビア語も対応したかったのですが、RTLの対応が面倒くさかったのでやめました。 Mantine自体はRTLもサポートしているみたいです。
react-hook-form + Zod
ほとんどバリデーションする箇所がなかったのですが、使ってみたかったのでreact-hook-form と Zodを使いました。
日本人の@aiji42_devさんがZodのバリデーションエラーを良い感じにi18n対応させてくれるライブラリ「zod-i18n」 を公開してくれていたので、これも合わせて使用させていただきました。素晴らしい使い心地でした。
ただ、ヒンディー語などの一部の言語はまだ対応していないようだったので、それらの言語は英語にfallbackするようにしました。
TWA
TWAという技術を使えば、WEBアプリをAndroidアプリ化できます。
なので以下の流れでTWA化しました👇
- next-pwaというライブラリでPWA化。
- PWA化したアプリを、BubblewrapというCLIツールでTWA化。
🤔考えたこと、やったこと
翻訳
i18n対応するために、各言語のテキストを以下のようなTSファイルで管理しています。
▲AppLocaleという共通の型を使って、satisfiesで各言語ファイルに不足しているプロパティがないかチェックして、as constでVScodeの補完が効くようにしてます(もっと良いやり方がある気がしてます)
たとえば、この日本語ファイル👆の中身をChatGPTに投げて、
「私は今こういうアプリを作っており、i18n対応させようとしています。そのアプリの日本語用のi18n用ファイルがこれです。これをもとにして英語バージョンを書いてください。」
とお願いして、色々な言語のJSONを作ってもらいました👇。
▲プレースホルダーとかも考慮して翻訳してくれるので素晴らしいです。
ただ、さらに言語の数が増えてくると流石に手動で管理するのは面倒くさいので、以下みたいにできれば理想だなーと思っています👇
- ChatGPTのAPIを契約する
-
git push
をフックにして、さきほどのプロンプトを投げて各言語のJSONファイルを自動で完成させる
ロゴ
Microsoftが「Microsoft Designer」というジェネレートAIツールを無料公開しています。
このツールを使って、
「バドミントン/テニス/卓球で、ダブルスやシングルスの組み合わせを作成することができるツールのアイコン。シンプル。フラットデザイン。」
のような文章を英語にして100回くらい投げました。
その結果がこんな感じです👇(One Driveと連携してるとOne Driveの中に自動的に保存されるようです)。
この中から「これなら加工すれば使えそうかも」と思うアイコンを選びました👇
そしてこれをGIMPを使って以下のように加工しました👇
-
背景をグレーから白色にした。
-
アイコン本体を青っぽくした。(アプリのUIが全体的に青なので)
- 単色の青だとダサかったので、グラデーションにした。
最終的にできたのがこれです👇
Google Playだとこんな風に見えます👇
ディレクトリ構成
基本的に以下のような考えで作りました👇
-
✅機能毎にディレクトリを分ける。
-
✅そこでしか使わないものはそこに置く。
-
✅他でも使うファイルは上位ディレクトリに移動する(格上げする)
- 格上げした結果、同じ種類のファイルが増えた場合は別途ディレクトリを作ります。
- たとえば
📝use◯◯.tsx
みたいな全体で使うフックが増えてきたら📁src
直下に📁hooks
みたいなディレクトリを作ってその中に入れます。
イメージ的には以下のような感じです👇
├📁pages
│ └📝lp.tsx(ルーティング、SEO設定、レイアウトのみを書く)🔴
│
└📁src
├📝use◯◯.tsx(全体で使うフック)
├📝◯◯.type.ts(全体で使う型)
└📝layout.tsx(全体で使うレイアウトを書く)
└📁pages
└📁lp
└📝index.tsx(ページの中身を書く)🔴
└📝use△△.tsx(lpのページでしか使わないフック)
└📝△△.type.ts(lpのページでしか使わない型)
特に「✅そこでしか使わないものはそこに置く」はとても重要だと思っています。
この考えを徹底すると、意識するファイル数が減るので考えるのが楽になります。
SEO
このアプリは、ユーザーを獲得できる流入経路が検索エンジンくらいしかありません。
なので言語の数だけSSGでLPを作って、以下のようなSEO対策をしました👇
- titleとdescriptionとOGPを設定した。
- Googleに確実にインデックスされるようにGoogleサーチコンソールでサイトマップを登録した。
- サイトマップはnext-sitemapで作成した。
- でもなぜか今のところ、フランス語のLPしかインデックスされてないし、noindex付けてるページもindexされてる🙃
- このアプリを使いそうな人が検索しそうなワードをLPに無理やり詰め込んだ。
- バドミントン
- テニス
- 卓球
- フットサル など。
- この記事を書いた(被リンクを送るため)。
SEO(パフォーマンス)
Googleはコアウェブバイタルを1つの評価基準にしているらしいです。
なので試しにLPページを測ってみたらこんな感じでした👇
before
この点数を上げるために、以下などをやりました👇
-
img
タグとかにwidth
とheight
を付けて、レイアウトシフトを修正。 - LinkコンポーネントからprefetchしないようにしてJSサイズを削減。
- LPで使ってる動画のサイズを削減。
- 以下のコマンドを使って15fpsまで間引き&全ブラウザ対応の高圧縮コーデックvp9で圧縮率高めで再エンコ。
ffmpeg -i input.mp4 -an -r 15 -c:v libvpx-vp9 -crf 30 -b:v 0 -strict experimental output.webm
- これで300kb→60kbまで圧縮
- CloudFlare pagesでキャッシュが効くように設定。
- デフォルトだと何もキャッシュの設定がされないみたいなので、
Cache-Control: public, max-age=31536000
を追加。 - キャッシュ対象はwebmだけにした(警告が出てたのがwebmだけだったので)。
- デフォルトだと何もキャッシュの設定がされないみたいなので、
これらをやると、こんな感じになりました👇
ぐあーー!あんまり変わってないーー!😭😭😭
(ユーザー補助の項目については、重箱の隅をつつくような指摘だったので、ほとんど直してません。なので100点にしようと思えばできると思います。)
問題はパフォーマンスの方です!
パフォーマンスのほうの指摘を詳しく見てみると、どうやらJSサイズが原因で全体的に点数が大きく下がってるようみたいでした👇
どのコードが問題なのか特定するために、@next/bundle-analyzerを入れて計測してみました。
その結果がこちら👇
見たところ、firebaseの割合がとても大きいです。
LPではfirebaseは使っていないので、たぶんfirebaseをどうにかすればかなりマシにできそう?
というわけで以下ページに書いているみたいに、dynamic importでバンドルサイズを小さくしようかと思いました👇
参考:Firebase を初期化するちょっといい方法
ですがふと思いました。
「ただだか数十kbを削るために、わざわざソースを汚くしてまで時間をかけて直すメリットある・・・?」と。なのでこれを対策するのは止めました。
というかデスクトップ表示のほうを見ると👇、ほぼ満点スコアだったから問題なし!!😀
▲モバイル表示のほうは速度などを制限した状態でのテストらしいので、デスクトップ表示のほうが満点を取りやすいみたいです
ドメイン
アプリ名を変えたくなったときに、ドメイン名も変えるのはキツイです。
なのでメインで使っているドメインのサブドメインから始めることにしました。
というのも、たとえば仮にA.com
みたいなドメイン名を取得してしまったとします。それで後からアプリ名をBに変えたので、それに合わせてドメインもB.com
に変えたくなったとします。
その場合、A.com
からB.com
に永続的にリダイレクトしないといけないわけですが、A.com
のドメインの有効期限が切れた時点でリダイレクトも切れてしまいます。
そうなると万が一、誰かがA.com
にリンクを送ってくれていた場合はすべてリンク切れになってしまいます😭(被リンクを失うのだけは避けたい!)
それに対して、メインでやってるドメイン(penpen-dev.com
)のサブドメインなら私が生きてる限り永遠に更新し続けるので、もしドメインを新しく取得してはじめるとなった場合でも、サブドメインから永遠にリダイレクトすればリンク切れにもならないしリンク評価も引き継げます💪
なのでサブドメインから始めることにしました。
あと「最近ドメイン代の高騰がエグいのでわざわざドメインを取りたくなかった」というのもあります。
UI
アプリをはじめて使う人が、学習コスト0ですぐに使えるUIにしたいと思いました。
どうしたらいいのか考えた結果、普段使っている有名アプリのUIをそのまま真似すればいいのでは?と考えました。
なので有名アプリ(X,Googleなど)をいろいろ観察したら、大体こんな感じ👇で情報が整理されていたので、これをそのまま真似することにしました。
- 下部にメインメニューの切り替えがある
- アクションを起こしたい場合は、右下のフローティングアクションボタンを押す
- 上部の中央にそのメニューの情報などがある
- 上部の左右にそのメニューの追加情報が開けるボタンがある
次に具体的なデザインですが、自分はデザインのことは全然分かりません。なので以下の2つをなるべく守るようにしました。
- 使う色は少なくする
- 位置を揃える
以上をすべて意識した結果、以下のような真っ青なUIになりました👇
青をメインカラーにしたのは、Mantineのコンポーネントをデフォルトのまま使ってたら自然と青だらけになったので「もうこれでいいか」となったからです。
ただ、全部が青だと流石にヒドいので「ここは注目を引いた方が良いかも」という部分だけ、オレンジや赤を使うようにしました。
あとダークモードは実装しませんでした。実装コストの割に合わないからです。
それとパソコンのデザインは無視しました。スマホかタブレットでしか使われないと思ったからです。
リロードしてもデータが消えないように
アプリの性質上、リロードしてもデータ📋を残す必要がありました。
当初はFirestoreのオフライン機能を使って「ページを開いた瞬間はFirestoreのキャッシュからデータを取得。もしオンラインなら全部同期する」のようにしようかと思いました。
ですが「手動でバックアップできれば十分じゃね?そのほうが実装がシンプルになって楽じゃね」となったのでやめました。
手動でバックアップ方式だと、仮にユーザー数が激増したとしてもFirebaseの無料プランで十分にいけるというのもありますし、オフラインで使用できるようにするのも簡単というのもあります。
なので「ローカルストレージに地道にstateを保存するかぁ」となったのですが、「でも面倒くさいなぁ。なんか良い方法ないかなぁ」と思って調べたら、Zustandの公式に「persist()で囲うだけでstoreをまるごとLocalstorageに保存できるよ!」な方法が紹介されてたいたので、これを使うことにしました。
これとは別に「1つのコンポーネントでしか使わないけど永続化したい!」なstateは、Mantine の useLocalStorageというフックを使いました。
ホスティング
ホスティングはCloudflare Pagesにしました。
おもな理由は以下などです👇
- 帯域無制限(万が一バズったとしても大丈夫)。
- 商用利用無料。
- 世界中にサーバーを分散してるらしい(日本だけでも4箇所)。
ただ、こんなアプリがバズるはずもないので、正直どこでもよかったです。
PWA/TWAとして使ってもらえたら、ローカルキャッシュが効いて更に負荷がかからないと思うので尚更どこでもよかったです。
別のサービスにデプロイし直してCNAMEを変更すれば、すぐに引っ越しできるので「まぁ不満があれば引っ越せばいいか」みたいに考えてます。
アクセス解析
「どれだけユーザーに使ってもらえてるのか」を計測するために、GA4を入れています。
指標としてはMAUを使うつもりです。直近の目標はMAU50人です💪
それはさておき、GA4を入れる上でオフライン時のことについて考える必要がありました。
というのもPWA/TWAはデバイスがオフラインでも起動できてしまうので、オフライン時はAnalyticsにデータを送信できないからです。
こういうとき「workbox-google-analytics」というライブラリを使えば、「オフライン時はIndexedDBに保存してオンラインになったタイミングで一括送信する」ができるみたいです。
ですが、「workbox-google-analytics」はGA4には非対応みたいでした。なのでオフライン対応は諦めました。自力で実装してまで正確に計測したいとは思わなかったためです。
URLクエリでの状態管理
たとえば、https://example.com?select_user_id=123
のように、ステートをURLクエリとして持つ手法についてです。
この手法でステートを保存することで、以下のようなメリットがあります👇
- 「戻る」が有効になる
- 特にモバイルアプリではユーザーはすぐに戻るボタンを押しがちなので重要
- 「その状態を誰かにシェアしたい場合」もURLをそのままシェアすると状態を復元できる
ただ自分の場合は、これは実装しませんでした。
というのも、地味に実装が面倒で考えることが増えるし、別に実装しなくてもNext.jsでページを分けてるおかげである程度の「戻る」が有効なので、「別に実装しなくてもいいのでは」と思ったからです。
ちなみにこれを実装する場合は、こちらのライブラリが便利そうでした👇
(historyAPIに対する操作で、pushとreplaceを切り替えたりもできるみたいです)
エラー監視
クライアントのエラー監視にSentryを入れてます。
使い方はよくわかっていなくて「throwされたErrorを勝手に送信してくれるやつ」くらいの知識しかないのですが、最低限「なんか全体的にこのエラーが連発しまくってるぞ」くらいは把握したいので入れました。
簡単なアプリなので、エラーはほとんど出ないと思っています。
あくまで保険です。むしろこれが役立つ機会がないことを祈っています😑
テスト
テストは一部のコードの単体テストだけ書きました。
具体的にいうと、組み合わせを作成する部分のロジックはややこしくて、合ってるかどうかが分からなかったので、Vitestでその部分のロジックだけテストを書きました。
Vitestはとてもよかったです!👇
-
最初からTypeScriptに対応。
-
設定ファイルとか何も書かずとも、◯◯.test.tsのようなファイルを作って
vitest
と実行するだけでテストできた。 -
Jestと同じ書き方ができる。
-
esbuildなので高速。
- ただ、型チェックはできないので、
tsc --emit
で事前に型チェックをしたほうが良いみたいです。
- ただ、型チェックはできないので、
第三者にフィードバックをもらう
ほぼほぼ完成した時点で、前会社のエンジニアやデザイナーの方に使ってもらってフィードバックをもらいました。(感謝:@regonn_haizine, @hikonaz)
その結果、「この設定がここにあるのは分かりにくい」とか「ここは◯◯じゃなくて△△という表記のほうが良いのでは?」といった有り難いレビューをいくつか頂きました🙏
それらのレビューを考慮してUIなどを修正しました。
🙃苦労した点など
ペアの組み合わせを作成するのに苦労しました。これに半分以上時間を使いました。
というのも、組み合わせを作るには「◯◯万通りの組み合わせの中から、どれが条件を満たす組み合わせか」を計算する必要があります。
このロジック自体を作るのもややこしかったのですが、なによりコート数や参加人数が増えると、処理が重すぎてアプリが反応しなくなったりクラッシュするという問題が起きました。
なので以下の3つを上から順に実行して対策しました👇
-
Chromeのデベロッパーツール → パフォーマンスタブで計測。時間がかかっている部分を割り出して以下のどちらかで改善した。
-
ロジック自体を改善。
-
ロジックを改善できない場合はキャッシュを使って計算コストを下げる
- classのインスタンス変数を生成する際に最初からcacheメンバを作るようにしたり、
- 計算の途中で入れれるcacheメンバを作ったり。
▲イメージ的にこんな感じです
-
-
Webworkerで別スレッド化して、UIがフリーズしないようにした。
-
計算コストが増えすぎてヤバそうな場合は「これ以上はヤバいヨ」と表示するようにした
▲「これ以上はやばいヨ」の表示
1.をやるだけでかなり改善したのですが、それでも人数やコート数によってはどうしても処理が重くなってUIが反応しなくなることがありました。
なので仕方なく2.をやりました。
それでも、場合によっては組み合わせの数が天文学的に跳ね上がってしまうらしく、メモリが足りなくなってクラッシュする・・みたいなことになりました。
なので仕方なく3.もしました。
「これ以上はやばいヨ」な表示を出すのではなくて、「そのヤバい設定自体できないようにしたほうが良いのでは?」と思ったのですが、ユーザーの使用しているデバイスのスペックによってヤバい設定の閾値は違います。
なのでユーザーに実際にクラッシュして頂いて(🤔)、ユーザー自身に「ここがヤバい設定の境目なのだな」と学習してもらう方式にしました。
ただ、もっと効率的にメモリを使う術を知っていれば、そもそも1.の対策だけで済んだ気がしています。
さいごに
個人開発、たのしかったです😀
シンプルなアプリでしたが、学びもたくさんありました。
こうやってアウトプットするのも、自分の頭が整理できてる感じがして良きです。
このアプリ以外にも、まだまだ作りたいアプリがあるので、これからも個人開発を続けたいと思います。
その際はまたXやZennに書きたいと思います!
おわり
Discussion