【第2回】路線バス乗り換え PWA — Firestore のデータ設計と Firebase Auth の実装

2026年6月17日

はじめに

前回は、React + TypeScript + Firebase で自作した路線バス乗り換え PWA「BusTime」の概要・主要機能・技術スタックを紹介しました。今回は、このアプリを支える Firestore のデータ設計Firebase Auth の認証フローを詳しく掘り下げます。

「個人〜家族向けの小規模アプリで Firestore のコレクションをどう分ければいいのか」「認証まわりはどこまで作り込むべきか」——同じ規模感のアプリを作ろうとすると、こうした設計判断に迷うことが多いはずです。BusTime では「2名だけが使うプライベートアプリ」という前提を活かし、あえてシンプルに倒す設計を選びました。本記事では、その具体的な設計とコードを紹介します。

Firestore のコレクション構成とスキーマ設計

BusTime の Firestore は、大きく4つのコレクションで構成されています。

コレクション用途主なフィールド
bookmarksバス路線のブックマーク(全ユーザー共有)site, title, url, start, end, label, dateCreated
links路線図 PDF などの静的リンク集(共有)site, title, url, refreshable, dateCreated
users/{uid}ユーザーごとのお気に入り設定favoriteBookmarks: string[], favoriteRoutes: {start, end}[]
busRouteCache/{startId}_{endId}Cloud Functions が取得した時刻表情報のキャッシュ(7日 TTL)courses: RouteInfoCourse[], cachedAt

TypeScript では、これらのドキュメント形状を以下のようなインターフェースで定義しています。

export interface Bookmark {
  id?: string;
  site: string;       // "市営バス" | "民営バス" など
  title: string;
  url: string;
  start: string;      // 発地(バス停名)
  end: string;        // 着地(バス停名)
  label: string;      // 系統番号や行き先
  dateCreated: Timestamp;
}

export type FavoriteRoute = { start: string; end: string };

// users/{uid} ドキュメントの形状
interface UserDoc {
  favoriteBookmarks: string[];   // bookmarks のドキュメントID配列
  favoriteRoutes: FavoriteRoute[];
}

bookmarks・links コレクションへのデータの入れ方

スキーマを定義しても、コレクションにデータを入れる仕組みがないと使えません。2つのコレクションは、それぞれ異なる方法で管理しています。

  • bookmarks(バス路線の登録):アプリ内に「ルートを追加」ダイアログを用意しています。発地・着地・系統番号・市営バスか民営バスかを選ぶフォームを入力するだけで Firestore に追加できます。専用の管理画面を別途作らなくてもアプリの UI から登録・削除ができるため、2名での運用がシンプルに完結します。
  • links(リンク集の登録):Firebase Admin SDK を使った Node.js スクリプトで初期データを一括登録しました。Admin SDK はサービスアカウントの認証情報を使って Firestore に直接書き込めるため、アプリを開かずにデータを投入できます。以降の追加・削除は Firebase コンソールのデータビューア(GUI)から直接行っており、更新頻度が低いためスクリプトは初回のみで十分でした。

設計のポイントは、ユーザー固有のデータを users/{uid} 1つのドキュメントに集約していることです。お気に入りのブックマーク ID とルートの並び順を配列としてまとめて持たせることで、コレクションを増やさずに済み、読み書きも「ドキュメント1件の取得・更新」で完結します。

また、BusTime では onSnapshot によるリアルタイムリスナーをあえて使っていません。初期表示時に getDocs / getDoc で一度だけ取得し、お気に入りの追加・並び替え時に updateDoc で書き戻すだけのシンプルな単発クエリ構成です。常時リスナーを張らない分、Firestore の読み取り課金やコネクション管理を気にする必要がなく、2名で使うアプリには十分な設計だと判断しました。

なお、Firestore Security Rules はカスタム実装していません。これは「2名のみが使うプライベートアプリなのでデフォルトルールで問題ない」という意図的な判断です。不特定多数に公開するアプリであれば必須の設計ですが、個人〜家族向けの小規模アプリでは、こうした「作り込まない」判断も立派な設計の一部だと考えています。

Firebase Auth の実装:Context API と保護ルート

認証まわりも、Firestore の設計と同じ思想で「必要十分な機能だけ」を実装しています。

  • サインイン方式はメール/パスワードのみ(Google サインインなどの OAuth は未実装)
  • サインアップ画面なし。アカウントは Firebase コンソールから手動で作成
  • 許可リスト(allowlist)も未実装。認証の通過自体がアクセスの唯一のゲート

これは「オーナーとその配偶者の2名だけが使う」という前提があるからこそ成立する割り切りです。サインイン処理自体は、Firebase SDK の関数をそのまま薄くラップするだけで完結します。

// services/Firebase.ts
const firebaseConfig = {
  apiKey: process.env.REACT_APP_FIREBASE_APIKEY,
  authDomain: process.env.REACT_APP_FIREBASE_AUTHDOMAIN,
  projectId: process.env.REACT_APP_FIREBASE_PROJECTID,
  // ...envファイルから読み込み、コードに直書きしない
};

const app = initializeApp(firebaseConfig);
export const auth = getAuth(app);

export const signIn = (email: string, password: string) =>
  signInWithEmailAndPassword(auth, email, password);

export const signOut = () => auth.signOut();

ログイン状態はアプリ全体で共有する必要があるため、Context API + カスタムフックでラップしています。onAuthStateChanged を1箇所だけで購読し、ユーザー情報をコンテキスト経由で配布する構成です。

// services/AuthContext.tsx
const AuthContext = createContext<ContextProps | null>(null);
export const useAuthContext = () => useContext(AuthContext);

export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
  const [currentUser, setCurrentUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const unsubscribe = auth.onAuthStateChanged((user) => {
      setCurrentUser(user);
      setLoading(false);
    });
    return unsubscribe;
  }, []);

  if (loading) return <div>Loading...</div>;

  return (
    <AuthContext.Provider value={{ currentUser }}>
      {children}
    </AuthContext.Provider>
  );
};

ログインしていないユーザーが /bookmark にアクセスした場合は、ラッパーコンポーネントが /login にリダイレクトします。ルーティングの定義側からは認証状態を意識せず、コンポーネントを差し替えるだけで保護を実現できる点がポイントです。

// component/ProtectedComponent.tsx
export const ProtectedComponent = ({ path, element: Component }: Props) => {
  const authContext = useAuthContext();
  if (!authContext) return <Navigate to="/login" />;

  const { currentUser } = authContext;
  return (
    <Routes>
      <Route
        path={path}
        element={currentUser ? <Component /> : <Navigate to="/login" replace />}
      />
    </Routes>
  );
};

// route/Route.tsx での利用例
<Route path="/bookmark/*" element={<ProtectedComponent path="*" element={BookmarkPage} />} />
<Route path="/login" element={<Login />} />

セッションの永続化も Firebase のデフォルト動作(browserLocalPersistence)に任せており、明示的な設定はしていません。ブラウザを閉じてもログイン状態が保持されるため、家族のスマホでも毎回ログインし直す手間がありません。

Firestore 設計と認証で実現した3つのポイント

1. ユーザー固有データを users/{uid} に集約してシンプルな単発クエリにする

課題:お気に入りのブックマークとルートの並び順という、性質の異なる2種類のユーザーデータをどう管理するか。
解決策:コレクションを分けず、users/{uid} 1つのドキュメントに配列としてまとめて持たせる。

こうすることで、初期表示時のデータ取得は getDoc(doc(db, "users", uid)) の1回だけで完結します。お気に入りの追加やドラッグ&ドロップでの並び替えも、配列を加工して updateDoc で書き戻すだけです。コレクションをまたいだクエリやトランザクションを考える必要がなく、コードの見通しが良くなります。

2. Context + 保護ルートで認証ガードを最小限のコードに収める

課題:認証状態をアプリ全体で共有しつつ、未ログイン時のアクセスを防ぎたい。
解決策:「状態の共有」は Context、「アクセス制御」は保護ルートのコンポーネントへと役割を分離する。

AuthProvider がログイン状態の監視と配布を一手に引き受け、ProtectedComponent はその状態を見てルーティングを切り替えるだけ、という関心の分離ができています。新しく保護したい画面が増えても、ルート定義に ProtectedComponent をかぶせるだけで済むため、将来的な拡張にも対応しやすい構成です。

3. localStorage → Firestore → Cloud Functions の3段キャッシュでコストを抑える

課題:バス時刻表の URL 取得にはスクレイピング処理が伴い、毎回実行するとレスポンスが遅く、コストもかさむ。
解決策:取得結果を「ブラウザ」と「Firestore」の2段階でキャッシュし、両方とも外れた場合だけ Cloud Functions を呼び出す。

具体的には、まず localStorage に保存された結果を確認し(7日 TTL)、なければ Firestore の busRouteCache/{startId}_{endId} を確認し(同じく7日 TTL)、それでもなければ Cloud Functions でスクレイピングを実行してその結果を Firestore に書き戻す、という流れです。Firestore をキャッシュ層として使うことで、デバイスをまたいでもキャッシュの恩恵を受けられ、スクレイピングの実行回数を最小限に抑えられます。

まとめ:個人開発で Firestore 設計と Firebase Auth を使うときのポイント

今回の記事で紹介したポイントをまとめます。

  • ユーザー固有のデータは users/{uid} に集約すると、単発クエリだけで完結するシンプルな設計にできる
  • 常時リスナー(onSnapshot)を使わず、必要なタイミングだけ取得・更新する設計でも小規模アプリには十分
  • 認証は「状態の共有(Context)」と「アクセス制御(保護ルート)」を分離すると、画面が増えても拡張しやすい
  • 利用者数や用途に応じて、許可リストや Security Rules を「あえて作り込まない」判断も有効な設計の選択肢になる
  • 外部 API・スクレイピングを伴う処理は、ブラウザ→ Firestore → サーバー関数の多段キャッシュでコストとレスポンスを両立できる

なお、スクレイピングを伴う実装を参考にする際は、対象サービスの利用規約を必ず事前にご確認ください。本アプリは個人・プライベート利用を前提としており、商用利用や不特定多数のリクエストを想定した設計ではありません。

次回(第3回)は、3段キャッシュの最終段を担う Cloud Functions でバス時刻表 URL を取得する仕組みを詳しく解説します。スクレイピング処理の実装や busRouteCache への書き込みロジックなど、サーバーレスでスクレイピングを運用するうえで参考になる設計を紹介する予定です。