1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

DjangoアプリをNext.js + DRF + JWT + AWSに移行してみた #6 Amplify静的エクスポート&デプロイ完結編

1
Last updated at Posted at 2026-05-24

はじめに

前回の記事では 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')でないと useEffectuseRouter が使えない。

これを両立するための構成:

// 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 を担当する。


完成後の動作確認

一通り動作することを確認した:

  1. https://master.xxxx.amplifyapp.com にアクセス → ログイン画面
  2. admin でログイン → 映画一覧(空)
  3. 「+ 映画を追加」→ 監督を追加 → 映画を登録
  4. 映画詳細ページ → 視聴ログを追加・評価
  5. 映画を編集・削除

まとめ

ハマりポイント 原因 解決策
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 がどうハイドレーションするかを理解する必要があった。


次のステップ

  1. CI/CD(GitHub Actions + OIDC) — push するだけで ECR push + ECS デプロイを自動化
  2. HTTPS(ACM + ALB リスナー 443) — CloudFront を外して ALB を直接 HTTPS 化
  3. Terraform リモートステート(S3 + DynamoDB) — 複数端末からの操作に対応

参考リンク

1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?