顧客モバイルを Next.js 16 で全面リニューアル — 論文ベース UX 設計と発見したバグ修正
旧 React (Vite) アプリを Next.js 16 App Router で完全再構築。Hick's Law・Choice Overload・Progressive Disclosure といった最新 UX 論文を設計指針に据え、Server Components と httpOnly cookie + API proxy でセキュリティと速度を両立。並行して旧コードに潜むサーバーバグ 3 件を修正しました。
顧客モバイル (guest.no-tel.com) を Next.js 16 + 最新 UX 論文ベース設計 で全面リニューアルしました。2023 年から稼働していた React (Vite) アプリからの完全差し替えです。本記事では、採用した技術スタックと設計判断、そして再実装の過程で発見した旧コードのサーバーバグとその修正内容をまとめます。
なぜ再実装が必要だったか
旧顧客モバイルは外部委託で開発されたあと、ソースコードを引き継げない状態 で長期運用されていました。
- GitHub リポジトリのアクセス権なし (委託先アカウントに紐付き)
- 本番 S3 には minified bundle のみ配置
- EC2 上の逆解析ソースは復元度が不完全
この状態で障害対応や機能追加を続けるのは現実的ではなく、ゼロベースで作り直す 判断をしました。
技術スタック
| レイヤ | 採用 |
|---|---|
| フレームワーク | Next.js 16 (App Router) |
| 言語 | TypeScript |
| UI | Tailwind CSS 4 + shadcn/ui (radix-nova preset) |
| バリデーション | Zod + react-hook-form |
| 状態管理 | TanStack Query v5 + React state |
| 認証 | JWT (httpOnly cookie) + profile cookie |
| デプロイ | Vercel (Production / Preview 分離) |
認証アーキテクチャ: httpOnly cookie + API proxy
XSS による JWT 漏洩を防ぐため、JWT は httpOnly cookie で保管 しています。ただしクライアント JS から cookie は読めないため、バックエンド API を直接叩けません。解決策として、
Client → Next.js API Route (proxy) → 上流 API (api.no-tel.com)
↑ ここで cookie から JWT を取り出して Authorization ヘッダーに載せる
の 3 段構成にしました。proxy は app/api/... 配下に配置し、レスポンス整形や shopIds フィルタなど BFF 的な役割も兼ねています。
SSR の認証には getProfileCookie() / apiFetch() のユーティリティを用意し、Server Components から直接呼び出せるようにしています。
論文ベース UX 設計 — 3 つの柱
「なんとなく」ではなく、既存研究を根拠にデザイン判断 しました。
1. Hick's Law: 決定時間は選択肢に対数的に増加
出勤キャストが 20 名を超える店舗では、全員を並べるとスクロールと認知負荷が爆発します (Laws of UX)。
- 初期表示は 5 件 (Hick's Law の最適レンジ 3〜5 個)
- 「もっと見る」ボタン で段階開示 (Progressive Disclosure)
- お気に入りキャスト優先 → リピーターの初期認知負荷ゼロ
2. Choice Overload: 選択肢過多は離脱率を押し上げる
Baymard Institute によると、カート放棄率は 69.57%、その主因の一つが選択肢の多さです (Baymard)。
対策:
- カテゴリ化: お気に入り → 直近順 → 空き枠順 の優先度で自動ソート
- 検索ボックス: 5 名超えたら表示、名前インクリメンタルフィルタ
3. Progressive Disclosure: 必要になったときに見せる
お気に入りカードは タップするまでシフト情報を fetch しない 設計です。
初期: 名前 + 店舗名のみ (軽い)
タップ: /api/favorites/shifts 呼び出し → 1 週間シフトをアコーディオン展開
シフトあり日タップ: 予約画面へ
これにより、ユーザー数 × キャスト数の API リクエストが削減され、サーバー負荷も軽減。
速度最適化 — 体感 3 秒が劇的に改善
Vercel Edge を東京リージョン (hnd1) に固定
初期デプロイではカレンダー表示に 3 秒 かかっていました。プロファイリングすると、Vercel の serverless 関数が Washington D.C. (iad1) で動作しており、東京の API サーバーとの往復で毎回 200ms 近い RTT を払っていたことが判明。
// vercel.json
{
"regions": ["hnd1"]
}
この 1 行で RTT がおよそ 1/10 になり、他ページの体感速度も大幅改善。
クライアント側キャッシュで日付切替を一瞬に
カレンダーの日付切替を毎回 API 取得にすると、3 秒 × 7 日分の体験悪化になります。そこで useRef(new Map()) でセッション中キャッシュを実装:
const cacheRef = useRef(new Map<string, Data>());
const fetchDate = useCallback(async (date: string) => {
const key = `${shopId}-${date}`;
const cached = cacheRef.current.get(key);
if (cached) {
setData(cached);
return;
}
// ... fetch + cacheRef.current.set(key, data)
}, [shopId]);
同じ日に戻ったときの表示が即座になり、「滑らか」な体験を実現しました。
営業日 6 時切替
深夜帯 (00:00〜05:59) は「前日の営業」と扱うべきなのに、カレンダーが深夜 0 時で日付を切り替えると 深夜帯の予約枠が消える 問題がありました。
全社の changeDateTime を集計すると、大半の店舗が 03:00〜09:00 に切り替わっていたため、一律 06:00 切替 で共通化:
export function getBusinessToday(): Date {
const now = new Date();
if (now.getHours() < 6) now.setDate(now.getDate() - 1);
now.setHours(0, 0, 0, 0);
return now;
}
旧コードに放置されていた 3 件のサーバーバグ
再実装の過程で、旧コードに放置されていた API バグを発見し、修正しました。いずれも機能そのものが動いていない、もしくは限定的にしか動いていない ものでした。
Bug #1 — お知らせ start_date NULL で全件除外
// 旧コード (notification/service.ts)
.andWhere("notifications.start_date < NOW()")
ほとんどのお知らせは start_date = NULL (即時公開) で作成されるため、NULL < NOW() は常に false → 全件フィルタアウト。
旧顧客モバイルでお知らせがまったく表示されなかった原因はこれでした。
// 修正後
.andWhere("(notifications.start_date IS NULL OR notifications.start_date < NOW())")
Bug #2 — 既読 API のロジック反転
// 旧コード (notification/service.ts:isRead)
const createNotificationReadIds = body.filter((id) =>
notificationReads.map(n => n.notificationId).includes(id) // ← 反転
);
既に既読になっているものを再度登録 しようとして、未読のものは登録されない という見事な反転。
// 修正後
const alreadyReadIds = new Set(notificationReads.map(nr => nr.notificationId));
const createNotificationReadIds = body.filter((id) => !alreadyReadIds.has(id));
Bug #3 — カレンダーの予約枠除外が効かない
findOrderCalendar が status: [paid, booking] のみで絞り込んでいたため、申請中 (guestRequest) / 提案中 (guestConfirm) の予約が空き枠として表示 されていました。
また、castNameIds が空配列の場合に WHERE IN () で SQL エラー (500) を返すバグも同時修正:
status: [
OrderStatus.paid,
OrderStatus.booking,
OrderStatus.guestRequest,
OrderStatus.guestConfirm,
]
// castNameIds が配列空のときは条件追加をスキップ
新機能のハイライト
予約申請フロー (提案ベース)
ユーザーが日時・キャスト・コースを選んで 申請 → 店舗スタッフが内容を確認・編集 → 提案 として差し戻し → ユーザーが承認で予約確定、というフローに統一しました。これにより、
- 店舗ごとのルール (オプション / 延長料金 / 最低利用時間) を実運用に合わせて柔軟に反映
- ユーザー側は申請後に待つだけで、入力ミスを店舗が補正
TOP ステータスカード
ホーム画面に「申請中」「提案あり」「確定済」の 3 色分けカードを配置。特に 提案ありは coral で強調、タップで詳細へ直行できるようにしました。
お気に入り + 履歴のアコーディオン統一
お気に入りキャストと利用履歴のカードは、タップすると その場で 1 週間分のシフトを展開 する共通 UI に統一。「いつ空いてる?」という疑問にページ遷移なしで答え、シフトあり日をタップするとそのまま予約カレンダー に遷移できます。
ポイント画面
店舗別残高 + 「グループ内で共通」の注意書き。「ポイントがあるのに店舗ごとに違う?」という問い合わせが多かったため、データ構造を見直し perCompany と perShop を分離。
QA 環境の独立化
新実装では QA 環境を 完全独立 させました。
- Vercel Preview (develop branch) → QA API サーバー → QA DB
- Vercel Production (main branch) → 本番 API → 本番 DB
これにより、本番に一切影響を出さずにテスト できるようになりました。QA 環境で 4242 テストカードによる決済テストも可能です。
旧アプリへのロールバック経路
万が一の問題発生時は、Route53 の CNAME を元の CloudFront に戻せば旧アプリに即座に戻せます (旧 S3 バケットも保持)。サーバー側のクエリ修正も個別ファイル単位でバックアップ (/tmp/*-backup-{timestamp}.ts) を取っているため、影響範囲の小さいロールバックが可能です。
参考資料
- Hick's Law - Laws of UX
- Choice Overload - Laws of UX
- Progressive Disclosure - NN/g
- Baymard: Cart Abandonment Statistics
- Booking UX Best Practices 2025 - Ralabs
- Load More Pattern - UX Patterns
今後の展望
- α-4 の残機能 (プッシュ通知、LINE 連携) は β フェーズで追加予定
- 129 万行級の
ordersテーブル、29 万行級のcast_shiftsテーブルは アーカイブ戦略 を別プロジェクトで整備中 (直近 1 ヶ月 + 履歴テーブル分離) - 認証基盤の MFA 対応も検討中
tasteck では引き続き、論文ベース + データドリブン で UX と基盤を強化していきます。