0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

React Router v7(SPA Mode) + AWS LambdaでTodoアプリ作る(フロントエンド編)

0
Posted at

はじめに

前回のバックエンド編では、VPC・RDS・Lambda・API Gateway・Cognito を構築しました。
今回はフロントエンドアプリを作成し、S3 + CloudFront でホスティングします。

最終的な構成は以下の通りです。

名称未設定ファイル.drawio.png

今回やること

  • React Router の SPA を実装する
  • S3 にデプロイして静的ホスティングする
  • CloudFront 経由で配信する

実装手順

1. プロジェクトのセットアップ

1-1. プロジェクトの作成

以下のコマンドでプロジェクトを作成します。

npx create-react-router@latest todo-spa
cd todo-spa

1-2. 依存パッケージのインストール

Cognito 認証に AWS Amplify を使用します。

npm install aws-amplify

1-3. SPA モードの有効化

React Router はデフォルトで SSR(サーバーサイドレンダリング)が有効になっています。
今回は S3 に静的ファイルをホスティングするだけなので、SPA モードに切り替えます。

react-router.config.ts
import type { Config } from "@react-router/dev/config";

export default {
  ssr: false,
} satisfies Config;

ssr: false にすることで、ビルド時に静的な HTML / JS / CSS が生成されます。


2. Amplify の設定

Cognito と連携するための設定ファイルを作成します。

app/lib/amplify.ts
import { Amplify } from 'aws-amplify';

const config = {
  Auth: {
    Cognito: {
      userPoolId: import.meta.env.VITE_COGNITO_USER_POOL_ID,
      userPoolClientId: import.meta.env.VITE_COGNITO_CLIENT_ID,
      region: import.meta.env.VITE_COGNITO_REGION
    }
  }
};

Amplify.configure(config);

環境変数は VITE_ プレフィックスを付けることで、Vite のビルド時にクライアントサイドのコードに埋め込まれます。

アプリ起動時に必ず実行されるよう、root.tsx でインポートします。

app/root.tsx(抜粋)
import '../lib/amplify'; // Amplify 初期化

3. 認証ロジックの実装

Cognito の操作を抽象化したヘルパー関数を作成します。

app/lib/auth.ts
import { signIn, signOut, getCurrentUser, fetchAuthSession } from 'aws-amplify/auth';

// ログイン
export async function handleSignIn(username: string, password: string) {
  try {
    const user = await signIn({ username, password });
    return { success: true, user };
  } catch (error) {
    return { success: false, error };
  }
}

// ログアウト
export async function handleSignOut() {
  try {
    await signOut();
    return { success: true };
  } catch (error) {
    return { success: false, error };
  }
}

// 現在のユーザー取得
export async function checkCurrentUser() {
  try {
    const user = await getCurrentUser();
    return { success: true, user };
  } catch (error) {
    return { success: false, error };
  }
}

// ID トークン取得
export async function getAuthToken() {
  try {
    const session = await fetchAuthSession();
    const token = session.tokens?.idToken?.toString();
    return token;
  } catch (error) {
    return null;
  }
}

4. ルーティングの設定

3 つのルートを定義します。

app/routes.ts
import { type RouteConfig, index, route } from "@react-router/dev/routes";

export default [
  index("routes/home.tsx"),
  route("login", "routes/login.tsx"),
  route("todos", "routes/todos.tsx"),
] satisfies RouteConfig;
パス ファイル 役割
/ home.tsx ホーム画面
/login login.tsx ログインフォーム
/todos todos.tsx Todo CRUD 画面

5. 画面の実装

5-1. ホーム画面

スクリーンショット 2026-03-27 7.04.04.png

app/routes/home.tsx
import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router";
import { checkCurrentUser, handleSignOut } from "../lib/auth";

interface User {
  username: string;
  userId: string;
}

export default function Home() {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    checkCurrentUser().then((result) => {
      if (result.success) setUser(result.user);
      setLoading(false);
    });
  }, []);

  const onSignOut = async () => {
    await handleSignOut();
    setUser(null);
  };

  if (loading) {
    return (
      <div className="flex items-center justify-center min-h-screen bg-gray-50">
        <div className="text-gray-600">読み込み中...</div>
      </div>
    );
  }

  return (
    <div className="min-h-screen bg-gray-50 flex items-center justify-center">
      <div className="text-center">
        <h1 className="text-4xl font-bold text-gray-800 mb-8">Todo App</h1>

        {user ? (
          <div className="space-y-4">
            <p className="text-gray-600 mb-6">ようこそ、{user.username}さん</p>
            <div className="flex flex-col gap-3">
              <Link
                to="/todos"
                className="px-8 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
              >
                Todoリストを開く
              </Link>
              <button
                onClick={onSignOut}
                className="px-8 py-3 text-gray-600 hover:bg-gray-100 rounded-lg transition"
              >
                ログアウト
              </button>
            </div>
          </div>
        ) : (
          <Link
            to="/login"
            className="inline-block px-8 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
          >
            ログイン
          </Link>
        )}
      </div>
    </div>
  );
}

ページが画面に表示された瞬間に checkCurrentUser() を呼び出し、ログイン済みかどうかで表示を切り替えます。

SPAはリロードすると状態がリセットされるため、毎回Cognitoに認証状態を確認しに行きます。AmplifyがトークンをlocalStorage(※デフォルト)に保持しているので、セッションが残っていれば再ログインなしで復元できます。


5-2. ログイン画面

スクリーンショット 2026-03-27 7.03.18.png

app/routes/login.tsx
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router';
import { handleSignIn, checkCurrentUser } from '../lib/auth';

export default function Login() {
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const [loading, setLoading] = useState(false);
  const [checking, setChecking] = useState(true);
  const navigate = useNavigate();

  // すでにログイン済みなら /todos へ
  useEffect(() => {
    checkCurrentUser().then((result) => {
      if (result.success) navigate('/todos');
      setChecking(false);
    });
  }, []);

  const onSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');
    setLoading(true);

    const result = await handleSignIn(username, password);

    if (result.success) {
      navigate('/todos');
    } else {
      setError('ログインに失敗しました');
    }

    setLoading(false);
  };

  if (checking) {
    return (
      <div className="flex items-center justify-center min-h-screen bg-gray-50">
        <div className="text-gray-600">認証確認中...</div>
      </div>
    );
  }

  return (
    <div className="flex min-h-screen items-center justify-center bg-gray-50">
      <div className="w-full max-w-sm p-4">
        <h1 className="text-2xl font-bold text-gray-800 mb-6 text-center">ログイン</h1>

        <form onSubmit={onSubmit} className="space-y-4">
          <input
            type="text"
            required
            value={username}
            onChange={(e) => setUsername(e.target.value)}
            placeholder="ユーザー名"
            className="w-full px-3 py-2 border rounded text-black focus:outline-none focus:ring-2 focus:ring-blue-500"
          />
          <input
            type="password"
            required
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            placeholder="パスワード"
            className="w-full px-3 py-2 border rounded text-black focus:outline-none focus:ring-2 focus:ring-blue-500"
          />

          {error && (
            <div className="text-red-600 text-sm text-center">{error}</div>
          )}

          <button
            type="submit"
            disabled={loading}
            className="w-full bg-blue-600 px-4 py-2 text-white rounded hover:bg-blue-700 disabled:opacity-50"
          >
            {loading ? 'ログイン中...' : 'ログイン'}
          </button>
        </form>
      </div>
    </div>
  );
}

submit(ログインボタン押下)されたら handlleSignIn() を呼び出します。
サインインが成功すれば /todos へ遷移し、失敗すれば「ログインに失敗しました」とエラーメッセージを表示します。


5-3. Todo 画面

スクリーンショット 2026-03-27 7.02.35.png

Todo 画面は React Router の clientLoader / clientAction を使ってデータ取得・更新を行います。

app/routes/todos.tsx
import * as React from "react";
import { useLoaderData, useFetcher, redirect, useNavigate } from "react-router";
import { getAuthToken, checkCurrentUser, handleSignOut } from "../lib/auth";

type Todo = { id: number; title: string; completed: boolean; created_at: string };

// 開発時はViteプロキシ経由(CORSエラーになるため)、本番は環境変数から取得
const API_BASE = import.meta.env.DEV
  ? "/api"
  : import.meta.env.VITE_API_BASE_URL;

// 認証トークンをヘッダーに付けてAPIリクエストを送る共通関数
async function api<T>(path: string, init?: RequestInit): Promise<T> {
  const token = await getAuthToken();
  if (!token) throw new Error('認証トークンが取得できません');

  const res = await fetch(`${API_BASE}${path}`, {
    headers: {
      "content-type": "application/json",
      "Authorization": `Bearer ${token}`,
      ...(init?.headers ?? {})
    },
    ...init,
  });

  if (!res.ok) {
    const message = await res.text();
    throw new Error(message || res.statusText);
  }

  const text = await res.text();
  return text ? JSON.parse(text) : null;
}

// ページ遷移時にデータを取得
export async function clientLoader() {
  const { success } = await checkCurrentUser();
  if (!success) throw redirect('/login'); // 未認証なら /login へ

  return api<{ items: Todo[] }>("/todos");
}

// フォーム送信時の処理
export async function clientAction({ request }: { request: Request }) {
  const fd = await request.formData();

  // どの操作か(add / toggle / delete)を取り出す
  const intent = String(fd.get("_intent") ?? "");

  // タスク追加
  if (intent === "add") {
    const title = String(fd.get("title") ?? "").trim();
    if (!title) return null; // タイトルが空なら何もしない
    await api("/todos", { method: "POST", body: JSON.stringify({ title }) });
  }

  // 完了状態の切り替え
  if (intent === "toggle") {
    const id = Number(fd.get("id"));
    const completed = fd.get("completed") === "true"; 
    await api(`/todos/${id}`, { method: "PATCH", body: JSON.stringify({ completed }) });
  }

  // タスク削除
  if (intent === "delete") {
    const id = Number(fd.get("id"));
    await api(`/todos/${id}`, { method: "DELETE" });
  }

  return null;
}

export default function TodosRoute() {
  const data = useLoaderData() as { items: Todo[] };
  const fetcher = useFetcher();
  const navigate = useNavigate();

  const onSignOut = async () => {
    await handleSignOut();
    navigate('/login');
  };

  return (
    <div className="min-h-screen bg-gray-50 p-4">
      <div className="max-w-2xl mx-auto">
        <div className="flex justify-between items-center mb-6">
          <h1 className="text-2xl font-bold text-gray-800">Todo</h1>
          <button onClick={onSignOut} className="text-sm text-gray-600 hover:text-gray-800">
            ログアウト
          </button>
        </div>

        {/* タスク追加フォーム */}
        <fetcher.Form method="post" className="mb-4">
          <div className="flex gap-2">
            <input type="hidden" name="_intent" value="add" />
            <input
              name="title"
              placeholder="新しいタスク"
              className="flex-1 px-3 py-2 border rounded text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500"
            />
            <button type="submit" className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
              追加
            </button>
          </div>
        </fetcher.Form>

        {/* Todo リスト */}
        {data.items.length === 0 ? (
          <div className="text-center py-8 text-gray-500">タスクなし</div>
        ) : (
          <ul className="space-y-2">
            {data.items.map(t => (
              <li key={t.id} className="bg-white border rounded p-3 flex items-center gap-3">
                {/* 完了チェックボックス */}
                <fetcher.Form method="post">
                  <input type="hidden" name="_intent" value="toggle" />
                  <input type="hidden" name="id" value={t.id} />
                  <input type="hidden" name="completed" value={String(!t.completed)} />
                  <input
                    type="checkbox"
                    checked={t.completed}
                    readOnly
                    onClick={(e) => fetcher.submit(e.currentTarget.form)}
                    className="w-4 h-4 cursor-pointer"
                  />
                </fetcher.Form>

                <span className={`flex-1 ${t.completed ? 'line-through text-gray-400' : 'text-gray-800'}`}>
                  {t.title}
                </span>

                {/* 削除ボタン */}
                <fetcher.Form method="post">
                  <input type="hidden" name="_intent" value="delete" />
                  <input type="hidden" name="id" value={t.id} />
                  <button type="submit" className="text-sm text-red-600 hover:text-red-800">
                    削除
                  </button>
                </fetcher.Form>
              </li>
            ))}
          </ul>
        )}
      </div>
    </div>
  );
}

データフローの解説

仕組み 役割
clientLoader ページ遷移時に実行。認証チェック後、API から Todo 一覧を取得
clientAction フォーム送信時に実行。_intent の値で操作を振り分け
useFetcher ページ遷移なしでフォームを非同期送信。action 完了後に loader が再実行され UI が更新される

_intent を hidden input で持たせることで、1 つの clientAction に複数の操作(追加・完了・削除)をまとめています。


6. 開発時の API プロキシ設定

開発中に API Gateway へ直接リクエストすると CORS エラーになります。

Vite のプロキシ機能を使うことで、/api へのリクエストをViteが受け取り、裏でAPI GatewayのURLに転送してくれます。ブラウザからはあくまで localhost あてのリクエストに見えるため、CORSエラーが発生しません。

vite.config.ts
import { reactRouter } from "@react-router/dev/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig, loadEnv } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";

export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd(), '');

  return {
    plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
    define: {
      'import.meta.env.VITE_COGNITO_USER_POOL_ID': JSON.stringify(env.VITE_COGNITO_USER_POOL_ID),
      'import.meta.env.VITE_COGNITO_CLIENT_ID': JSON.stringify(env.VITE_COGNITO_CLIENT_ID),
      'import.meta.env.VITE_COGNITO_REGION': JSON.stringify(env.VITE_COGNITO_REGION),
    },
    server: {
      proxy: {
        "/api": {
          target: "https://<APIエンドポイント>.execute-api.ap-northeast-1.amazonaws.com",
          changeOrigin: true,
          secure: true,
        },
      },
    },
  };
});

7. ローカルで動作確認する

プロジェクトルートに .env ファイルを作成します。

.env
VITE_COGNITO_USER_POOL_ID=ap-northeast-1_2Me6o5Aad
VITE_COGNITO_CLIENT_ID=63vfic0174pjkmscqohoq2fat9
VITE_COGNITO_REGION=ap-northeast-1

開発サーバーを起動します。

npm run dev

ブラウザで開き、前回作成したテストユーザーでログインして Todo の追加・完了・削除が動作することを確認します。


8. S3 バケットを作成する

S3 のダッシュボードを開き、バケットを作成 を選択します。

設定

項目 設定
バケット名 任意の名前(グローバルで一意)
リージョン 任意(例: ap-northeast-1)
パブリックアクセスのブロック すべてブロック(デフォルトのまま)
バケットのバージョニング 無効

静的ウェブサイトホスティングは設定不要です。CloudFront からプライベートな S3 バケットへアクセスする設定を行うため、バケットを直接公開する必要はありません。


9. CloudFront ディストリビューションを作成する

CloudFront のダッシュボードを開き、ディストリビューションを作成 を選択します。

9-1. オリジン、キャッシュの設定

項目 設定
Origin type S3
S3 origin 手順8 で作成した S3 バケット
オリジン設定 Use recommended origin settings
Cache settings Use recommended cache settings tailored to serving S3 content

9-2. SPA 用のエラーページ設定(重要)

ディストリビューション作成後、エラーページ タブを選択して設定を追加します。

React Router はクライアントサイドでルーティングを行います。
ユーザーが https://<Cloudfrontドメイン>/todos に直接アクセスすると、S3 には /todos というファイルが存在しないため 403 エラーが返ります。
このエラーを index.html にリダイレクトすることで、React Router がルーティングを引き継げるようになります。

項目 設定
HTTP エラーコード 403
エラーキャッシュの最小 TTL 0
レスポンスページのパス /index.html
HTTP レスポンスコード 200

手順10の前に差し込むのが自然だと思います。


10. API Gateway の CORS 設定

本番環境ではブラウザから CloudFront のドメイン(https://<xxxx>.cloudfront.net)経由で API Gateway に直接リクエストを投げるため、オリジンが異なり CORS エラーが発生します。デプロイ前に API Gateway の CORS 設定を行います。

項目 設定
Access-Control-Allow-Origin https://<xxxx>.cloudfront.net
Access-Control-Allow-Headers Content-Type, Authorization
Access-Control-Allow-Methods GET, POST, PATCH, DELETE, OPTIONS

11. ビルドしてデプロイする

11-1. ビルド

本番ビルド時は VITE_API_BASE_URL に API Gateway のエンドポイントを指定します。

VITE_API_BASE_URL=https://<APIエンドポイント>.execute-api.ap-northeast-1.amazonaws.com/api \
npm run build

build/client ディレクトリに静的ファイルが生成されます。

11-2. S3 へアップロード

生成されたファイルを S3 にアップロードします。


12. 動作確認

ブラウザでアクセス

CloudFront のダッシュボードからディストリビューションドメイン名を確認します。

https://<xxxxxx>.cloudfront.net

ブラウザでアクセスし、以下を確認します。

  • ホーム画面が表示されること
  • テストユーザーでログインできること
  • Todo の追加・完了・削除が正常に動作すること

直接 URL アクセスの確認

アドレスバーに https://<xxxxxx>.cloudfront.net/todos を直接入力してアクセスし、正常に Todo 画面が表示されることを確認します。


以上で、フロントエンドの実装とデプロイが完了しました。
これでバックエンド編と合わせて、React Router + Lambda による Todo アプリが完成です。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?