はじめに
前回の記事では Terraform で AWS インフラ(ECS Fargate + RDS + ALB + Amplify)を構築し、バックエンド API の動作確認まで完了した。残っていた課題が「Amplify フロントエンドが動かない」問題だった。
今回はこれを完全に解消し、ブラウザからログイン → 映画追加 → 詳細確認まで一通り動作するフルスタック構成を完成させた。
今回解決したこと:
- Amplify SSR 問題 →
output: 'export'静的エクスポートへ切り替え - HTTPS/HTTP Mixed Content 問題 → CloudFront で解消
- 動的ルートの
_shellハイドレーションバグ(一番ハマった) - Next.js 16 Turbopack の Google Fonts ビルドバグ
- Amplify リライトルールの順序と落とし穴
- Apple Silicon(M2 Mac)での ARM64/AMD64 互換問題
ハマりポイントが多かったので、なぜそうなるのかの説明を丁寧に書いた。
アーキテクチャ(完成形)
ブラウザ(Amplify HTTPS)
↓ NEXT_PUBLIC_API_URL
[CloudFront](HTTPS → HTTP 変換)
↓
[ALB](HTTP / ポート80)
↓
[ECS Fargate](Django REST Framework)
↓
[RDS PostgreSQL]
前回からの変更点は CloudFront を ALB の前に追加したこと。この理由は後述する。
ハマりポイント ① Amplify SSR がモノレポと相性が悪い
問題
前回の記事で、Amplify の WEB_COMPUTE(SSR モード)はモノレポ構成の Next.js と相性が悪く deploy-manifest.json エラーが出ると書いた。
もう少し詳しく言うと、当時のフロントエンドは JWT を httpOnly Cookie で管理していて、Cookie 処理に Next.js の API Route(サーバーサイドの機能)を使っていた。そのため SSR が必須だった。
対応方針
2択を検討した。
| 方針 | 概要 | 難易度 |
|---|---|---|
| A. 静的エクスポート |
output: 'export' に切り替え。JWT を localStorage に戻す |
30分 |
| B. Amplify SSR アダプター |
@aws-amplify/adapter-nextjs を導入 |
1〜2時間 |
今回は A(静的エクスポート) を選んだ。理由はシンプルさ。学習目的のプロジェクトでは、まず動く状態を作ることを優先した。
変更内容
next.config.ts
const nextConfig: NextConfig = {
output: 'export',
trailingSlash: true,
};
lib/api.ts(JWT を localStorage ベースに変更)
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000/api';
export async function login(username: string, password: string) {
const res = await fetch(`${API_BASE}/token/`, { ... });
const data = await res.json();
localStorage.setItem('access', data.access);
localStorage.setItem('refresh', data.refresh);
}
export async function fetchWithAuth(path: string, options: RequestInit = {}) {
let res = await fetch(`${API_BASE}${path}`, {
headers: { Authorization: `Bearer ${localStorage.getItem('access')}`, ... },
});
if (res.status === 401) {
// リフレッシュ処理
const refreshed = await tryRefresh();
if (!refreshed) {
window.location.href = '/login';
throw new Error('Session expired');
}
res = await fetch(...); // リトライ
}
return res.json();
}
components/AuthGuard.tsx
function isLoggedIn(): boolean {
if (typeof window === 'undefined') return false;
return !!localStorage.getItem('access');
}
ハマりポイント ② Mixed Content(HTTPS + HTTP の組み合わせ問題)
問題
Amplify は HTTPS(https://master.xxxx.amplifyapp.com)でフロントエンドを配信する。一方 ALB は HTTP(ポート80)で待ち受けていた。
ブラウザは HTTPS ページから HTTP のリソースを読み込む「Mixed Content」をセキュリティ上ブロックする。
Mixed Content: The page at 'https://...' was loaded over HTTPS,
but requested an insecure resource 'http://...'. This request has been blocked.
なぜ起きるか
ブラウザのセキュリティポリシーで、HTTPS ページから HTTP への fetch は自動的にブロックされる。これは仕様なのでフロントエンドのコードでは回避できない。
解決策:CloudFront を ALB の前に追加
# cloudfront.tf
resource "aws_cloudfront_distribution" "api" {
origin {
domain_name = aws_lb.main.dns_name
origin_id = "alb"
custom_origin_config {
http_port = 80
https_port = 443
origin_protocol_policy = "http-only" # CloudFront → ALB は HTTP
}
}
default_cache_behavior {
viewer_protocol_policy = "https-only" # ブラウザ → CloudFront は HTTPS のみ
cache_policy_id = "4135ea2d-..." # CachingDisabled
origin_request_policy_id = "b689b0a8-..." # AllViewerExceptHostHeader
}
}
ブラウザ → CloudFront は HTTPS。CloudFront → ALB は HTTP。ブラウザからは HTTPS だけ見えるので Mixed Content が解消される。
# amplify.tf
environment_variables = {
NEXT_PUBLIC_API_URL = "https://${aws_cloudfront_distribution.api.domain_name}/api"
}
ハマりポイント ③ Apple Silicon(M2 Mac)で作った Docker イメージが ECS で動かない
問題
ローカルの MacBook Pro(M2、ARM64 アーキテクチャ)で docker build したイメージを ECR に push して ECS で動かすと、タスクが起動せずすぐ停止した。
原因
M2 Mac の Docker はデフォルトで ARM64 アーキテクチャのイメージを作る。ECS Fargate の標準インスタンスは x86_64(AMD64) なので、アーキテクチャが合わずコンテナが起動できない。
解決策
docker buildx でクロスコンパイルする。
docker buildx build \
--platform linux/amd64 \
-t 825478277103.dkr.ecr.ap-northeast-1.amazonaws.com/movielogrecord-backend:latest \
--push \
backend/
--platform linux/amd64 を明示することで、M2 Mac でも x86_64 向けのイメージを生成できる。CI/CD(GitHub Actions)を使えばこの問題は自動的に解消される(GitHub Actions のランナーは x86_64 のため)。
ハマりポイント ④ Amplify の動的ルーティング
静的エクスポートにすると、/movies/123/ のような動的 URL をどう扱うかが問題になる。
静的エクスポートとは
output: 'export' を使うと、Next.js はビルド時に HTML ファイルを生成してその場で固定する。サーバーがいないので「リクエストが来てからページを生成する」ことはできない。
動的ルート(/movies/[id]/)の場合、ビルド時に「どの id のページを生成するか」を generateStaticParams で明示する必要がある。
// app/movies/[id]/page.tsx
export function generateStaticParams() {
return [{ id: '1' }, { id: '2' }]; // これらのページを事前生成
}
しかし映画の ID はユーザーが追加するたびに増えるので、ビルド時に全 ID は分からない。
解決策:_shell プレースホルダー
アイデアは「汎用シェル HTML を1つだけ生成して、すべての動的 URL にその HTML を配信する」というもの。
export function generateStaticParams() {
return [{ id: '_shell' }]; // ダミーのシェルページを生成
}
これで out/movies/_shell/index.html が生成される。あとは Amplify のリライトルールで /movies/ 以下のすべてのリクエストをこのシェルに向ける。
# amplify.tf
custom_rule {
source = "/movies/<*>"
target = "/movies/_shell/index.html"
status = "200"
}
Amplify リライトルールの罠
Amplify のルールは上から順に評価され、最初にマッチしたルールが使われる。また、<*> はスラッシュを含むすべての文字列にマッチする。
これを知らずに書いたルールが壊れていた:
# ❌ 問題あり:edit ページも _shell/index.html が返ってしまう
custom_rule {
source = "/movies/<*>" # /movies/123/edit/ にもマッチしてしまう
target = "/movies/_shell/index.html"
status = "200"
}
/movies/123/edit/ にアクセスすると詳細ページ用のシェルが返るので、編集ページが表示されない。
修正後(より具体的なルールを先に置く):
# 静的ルート(スラッシュあり・なし両方)
custom_rule { source = "/movies/new/", target = "/movies/new/index.html", status = "200" }
custom_rule { source = "/movies/new", target = "/movies/new/index.html", status = "200" }
custom_rule { source = "/directors/new/", target = "/directors/new/index.html", status = "200" }
custom_rule { source = "/directors/new", target = "/directors/new/index.html", status = "200" }
# 編集ページ(具体的なパターンを先に)
custom_rule { source = "/movies/<id>/edit/", target = "/movies/_shell/edit/index.html", status = "200" }
custom_rule { source = "/directors/<id>/edit/", target = "/directors/_shell/edit/index.html", status = "200" }
# 詳細ページ(汎用ワイルドカードは最後)
custom_rule { source = "/movies/<*>", target = "/movies/_shell/index.html", status = "200" }
<id> は単一パスセグメントにマッチし、<*> は複数セグメントを含む文字列にマッチする。より具体的なパターンを前に書くことで正しく振り分けられる。
スラッシュなし(/movies/new)のルールも必要な理由:Amplify の <*> ワイルドカードは new にもマッチするため、スラッシュなしのルールを先に置かないと /movies/new が /movies/_shell/index.html にリライトされてしまう。
ハマりポイント ⑤ _shell HTML のハイドレーションバグ(最大の難所)
これが今回一番手こずったバグ。
現象
映画詳細ページ(/movies/123/)にアクセスすると、スピナーが表示されたまま画面が出てこない。ネットワークタブを見ると /api/movies/_shell/ への404リクエストが飛んでいた。
原因の解説
Next.js の静的エクスポートは「プリレンダリング」と「クライアントサイドハイドレーション」の2段階で動く。
プリレンダリング(ビルド時):
generateStaticParams → [{id: '_shell'}]
↓
Next.js がパラメータ {id: '_shell'} でページをレンダリング
↓
out/movies/_shell/index.html を生成
(この HTML の初期状態: params.id = '_shell')
ユーザーが /movies/123/ にアクセスしたとき:
ブラウザ → /movies/123/ をリクエスト
↓
Amplify リライトルール → out/movies/_shell/index.html を返す
↓
React がハイドレーション開始
(HTML の初期状態が params.id = '_shell' なので、まずこの状態で描画)
↓
useEffect 発火 → fetchWithAuth('/movies/_shell/') → 404 エラー
↓
「映画情報の取得に失敗しました」
問題の本質は、ハイドレーション時に useParams() が {id: '_shell'} を返すこと。通常はすぐに実際の URL({id: '123'})に更新されるが、Next.js 16 では params.id がいつまでも '_shell' から更新されないケースがあった。
試みた失敗策:generateStaticParams を空配列に
「シェルを作らなければいい」と考えて return [] に変えると、ビルドエラーになった。
Error: Page "/movies/[id]/edit" is missing "generateStaticParams()"
so it cannot be used with "output: export" config.
output: 'export' は「ビルド時にすべてのページを静的ファイルとして生成する」という意味なので、動的ルートには最低1つのパラメータが必要。空配列は「生成するページが0個」なのでビルドできない。
試みた失敗策:_shell ガード
useEffect(() => {
if (params.id === '_shell') return; // _shell なら何もしない
loadMovie();
}, [params.id]);
こうすると「params.id が '_shell' なら API コールをスキップ → params.id が実際の値に更新されたら API コール」という想定だが、前述のとおり params.id が更新されないのでスピナーが永続する。
最終解決策:window.location.pathname から直接 ID を取得
useParams() の代わりにブラウザの実 URL から ID を読む。
function getUrlId() {
return typeof window !== 'undefined'
? window.location.pathname.split('/').filter(Boolean)[1]
: String(params.id);
}
window.location.pathname は常に現在のブラウザ URL を返す。params.id が '_shell' のままでも、window.location.pathname は /movies/123/ を正しく返す。
修正後のコンポーネント(抜粋):
function MovieDetailPage() {
const params = useParams();
const [movie, setMovie] = useState<Movie | null>(null);
function getUrlId() {
return typeof window !== 'undefined'
? window.location.pathname.split('/').filter(Boolean)[1]
: String(params.id);
}
function loadMovie() {
fetchWithAuth(`/movies/${getUrlId()}/`)
.then(setMovie)
.catch(() => setLoadError(true));
}
useEffect(() => {
if (typeof window === 'undefined') return;
const id = getUrlId();
if (!id) return;
loadMovie();
}, [params.id]); // クライアントサイドナビゲーション時の再実行に必要
// ハンドラは movie.id を使う(params.id に依存しない)
async function handleDeleteMovie() {
await fetchWithAuth(`/movies/${movie!.id}/`, { method: 'DELETE' });
}
async function handleAddLog(e: React.FormEvent) {
await fetchWithAuth('/logs/', {
body: JSON.stringify({ ..., movie: movie!.id }), // movie.id を使う
});
}
}
なぜこれで動くか:
- ハード ナビゲーション(URL 直打ち・リロード):
_shell.htmlが配信されてもwindow.location.pathnameは実際の URL を返すので正しい ID が取得できる - クライアントサイドナビゲーション(ページ内リンク):
params.idが正しく'123'になるため、useEffectの依存配列で再実行が走る
ハマりポイント ⑥ Next.js 16 Turbopack の Google Fonts ビルドバグ
問題
next build(Turbopack)がフォントのビルドで失敗する。
Error: Turbopack build failed with 1 errors:
Module not found: Can't resolve '@vercel/turbopack-next/internal/font/google/font'
layout.tsx でデフォルトの Google Fonts(Geist)を使っているため、Turbopack のビルド時に fonts.gstatic.com へのアクセスが必要になる。ネットワーク環境によって失敗するようだ(Amplify のビルド環境でも発生)。
解決策:--webpack フラグで Webpack を使う
// package.json
{
"scripts": {
"build": "next build --webpack"
}
}
Next.js 16 はデフォルトで Turbopack を使うが、--webpack フラグで Webpack に切り替えられる。Amplify 上でのビルドもこのスクリプトを使うので、Amplify 側も自動的に Webpack でビルドされる。
動的ルートのサーバーラッパーパターン
App Router の静的エクスポートでは、generateStaticParams はサーバーコンポーネントに書く必要がある。一方、ページの中身(データ取得・ルーターナビゲーション)はクライアントコンポーネント('use client')でないと useEffect や useRouter が使えない。
これを両立するための構成:
// app/movies/[id]/page.tsx(サーバーコンポーネント)
import MovieDetailClient from './MovieDetailClient';
export function generateStaticParams() {
return [{ id: '_shell' }];
}
export default function Page() {
return <MovieDetailClient />;
}
// app/movies/[id]/MovieDetailClient.tsx(クライアントコンポーネント)
'use client';
import { useParams, useRouter } from 'next/navigation';
// ... useEffect でデータ取得 など
サーバーコンポーネント(page.tsx)が generateStaticParams を担当し、クライアントコンポーネントが実際の UI を担当する。
完成後の動作確認
一通り動作することを確認した:
-
https://master.xxxx.amplifyapp.comにアクセス → ログイン画面 - admin でログイン → 映画一覧(空)
- 「+ 映画を追加」→ 監督を追加 → 映画を登録
- 映画詳細ページ → 視聴ログを追加・評価
- 映画を編集・削除
まとめ
| ハマりポイント | 原因 | 解決策 |
|---|---|---|
| Amplify SSR がモノレポで動かない |
deploy-manifest.json 生成問題 |
output: 'export' 静的エクスポートに切り替え |
| Mixed Content エラー | Amplify(HTTPS)+ ALB(HTTP) | CloudFront を追加して HTTPS 終端 |
| ECS でコンテナが起動しない | M2 Mac(ARM64)ビルドが ECS(AMD64)と非互換 | docker buildx --platform linux/amd64 |
| 動的ルートの 404 | Amplify リライトルールの順序が不正 | 具体的なルールを汎用ルールより前に配置 |
_shell 永続スピナー |
params.id が '_shell' から更新されない |
window.location.pathname で実 URL から直接 ID を取得 |
| Turbopack ビルド失敗 | Google Fonts の @vercel/turbopack-next モジュール未解決 |
next build --webpack で Webpack に切り替え |
今回一番の学びは「フレームワークの抽象化が漏れる瞬間」を体験したこと。Next.js の useParams() は「URL のパラメータを返す」という単純な仕様に見えるが、静的エクスポートでプリレンダリングされたシェル HTML が配信される場面では、ハイドレーション中に古いパラメータが残り続けるケースがあった。フレームワークの内部動作を理解していないと、こういうバグは原因の見当もつかない。
window.location.pathname という一見ローレベルな解決策に辿り着くには、Next.js がどうハイドレーションするかを理解する必要があった。
次のステップ
- CI/CD(GitHub Actions + OIDC) — push するだけで ECR push + ECS デプロイを自動化
- HTTPS(ACM + ALB リスナー 443) — CloudFront を外して ALB を直接 HTTPS 化
- Terraform リモートステート(S3 + DynamoDB) — 複数端末からの操作に対応