はじめに
前回のバックエンド編では、VPC・RDS・Lambda・API Gateway・Cognito を構築しました。
今回はフロントエンドアプリを作成し、S3 + CloudFront でホスティングします。
最終的な構成は以下の通りです。
今回やること
- 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 モードに切り替えます。
import type { Config } from "@react-router/dev/config";
export default {
ssr: false,
} satisfies Config;
ssr: false にすることで、ビルド時に静的な HTML / JS / CSS が生成されます。
2. Amplify の設定
Cognito と連携するための設定ファイルを作成します。
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 でインポートします。
import '../lib/amplify'; // Amplify 初期化
3. 認証ロジックの実装
Cognito の操作を抽象化したヘルパー関数を作成します。
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 つのルートを定義します。
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. ホーム画面
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. ログイン画面
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 画面
Todo 画面は React Router の clientLoader / clientAction を使ってデータ取得・更新を行います。
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エラーが発生しません。
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 ファイルを作成します。
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 アプリが完成です。



