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?

Next.js SPA を S3+CloudFront で CloudFront Functions 不要な方法でデプロイしてみた

Last updated at Posted at 2025-07-06

TL;DR

  • CloudFront Functions や Lambda@Edge を使わずに SPA ルーティングを実現できた
    • trailingSlash false + html ファイルを 拡張子なしでデプロイ
    • trailingSlash ありでアクセスされた場合は、404.html でクライアントサイドで trailingSlash なしに遷移させることでトレイリングスラッシュ問題も解決できた
    • nested route でも対応可能
      • blog - 一覧ページ
      • blog/post-1 - 詳細ページ
      • blog/post-2 - 詳細ページ
      • blog/nested/ - nested 一覧ページ
      • blog/nested/nested-post-1 - nested 詳細ページ
  • Functions コストなし

📦 GitHub: https://github.com/thu-san/nextjs-s3-cloudfront-spa
🚀 Live Demo: https://d1ntce1o7au93y.cloudfront.net/

はじめに

こんにちは!Next.js の SPA を AWS にデプロイする時に、CloudFront Functions の設定が面倒だなーと思っていたところ、拡張子なしの html ページ と 404 エラーページを使った hack っぽい回避策を思いつきました。

結論から言うと、trailingSlash: falseで HTML ファイルを拡張子なしでデプロイし、トレイリングスラッシュ付きのアクセスは 404 ページでクライアント側で遷移させることで、Function 系のサービスを一切使わずに SPA ルーティングが実現できました!

なぜこの方法を試したのか

最初は普通に CloudFront Functions を使おうと思っていたのですが...

CloudFront Functions を使わないメリット

  • 管理が楽 - Function のコードを管理する必要がない
  • 開発が簡単 - AWS側の設定が少ない
  • デプロイがシンプル - 静的ファイルをアップロードするだけ
  • コストゼロ - Function 系サービスを使わないので追加料金なし

個人プロジェクトで余計な複雑さを避けたかったので、別の方法を探すことにしました。

HTML ファイルを拡張子なしでデプロイする仕組み

思いついたのは、S3 に HTML ファイルを拡張子なしでアップロードする方法です。例えば:

  • /aboutaboutというファイル(中身は HTML)
  • /contactcontactというファイル(中身は HTML)

トレイリングスラッシュ付きでアクセスされた場合(/about/)は 404 エラーになるので、404 ページで JavaScript を使ってトレイリングスラッシュなしの URL に遷移させます。

実装してみた

1. Next.js の設定

まずは Next.js を静的サイトとして出力できるように設定します。

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'export',
  trailingSlash: false, // これ重要!拡張子なしでHTMLを出力
  images: {
    unoptimized: true,
  },
};

module.exports = nextConfig;

trailingSlash: falseにすることで、HTML ファイルが拡張子なしで出力されます。

2. トレイリングスラッシュ対応の 404.html

not-found.tsx を作成

'use client';

import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';

export default function NotFound() {
  const pathname = usePathname();
  const router = useRouter();
  const [showNotFound, setShowNotFound] = useState(false);

  // Check if this is a trailing slash case that needs redirect
  const needsTrailingSlashRedirect =
    pathname && pathname.endsWith('/') && pathname !== '/';

  useEffect(() => {
    if (needsTrailingSlashRedirect) {
      // Get the full path including query and hash, remove trailing slash from pathname
      const fullPath = window.location.href.replace(window.location.origin, '');
      const newPath = fullPath.replace(/\/(\?|#|$)/, '$1');
      router.replace(newPath);
    } else {
      // Only show 404 if we're not redirecting
      setShowNotFound(true);
    }
  }, [needsTrailingSlashRedirect, router]);

  // Don't show anything until we know whether to redirect or show 404
  if (!showNotFound) {
    return null;
  }

  return (
    <div className="min-h-[60vh] flex items-center justify-center">
      <div className="text-center">
        <h1 className="text-6xl font-bold text-gray-900 mb-4">404</h1>
        <h2 className="text-3xl font-semibold text-gray-700 mb-6">
          Page Not Found
        </h2>
        <p className="text-lg text-gray-600 mb-8 max-w-md mx-auto">
          The page you&apos;re looking for doesn&apos;t exist. This error page
          is served by Next.js for routes that don&apos;t have a corresponding
          page component.
        </p>

        <div className="space-y-4">
          <Link
            href="/"
            className="inline-block bg-blue-600 text-white px-6 py-3 rounded-md hover:bg-blue-700 transition-colors"
          >
            Go Back Home
          </Link>

          <div className="mt-8 p-4 bg-gray-50 rounded-lg max-w-lg mx-auto">
            <p className="text-sm text-gray-600">
              <strong>Note:</strong> In production with static export, this 404
              page will be served by CloudFront&apos;s custom error pages
              configuration for missing files.
            </p>
          </div>

          <div className="mt-4 p-4 bg-blue-50 rounded-lg max-w-lg mx-auto">
            <p className="text-sm text-gray-600">
              <strong>Trailing Slash Handling:</strong> This page automatically
              redirects URLs with trailing slashes to their non-trailing slash
              equivalents before showing the 404 error.
            </p>
          </div>
        </div>
      </div>
    </div>
  );
}

3. CloudFront の設定

CloudFront で 404 と 403 エラーを 404.html にリダイレクトさせる

{
  "CustomErrorResponses": [
    {
      "ErrorCode": 404,
      "ResponsePagePath": "/404.html",
      "ResponseCode": 404,
      "ErrorCachingMinTTL": 10
    },
    {
      "ErrorCode": 403,
      "ResponsePagePath": "/404.html",
      "ResponseCode": 404,
      "ErrorCachingMinTTL": 10
    }
  ]
}

4. デプロイしてみる

S3 デプロイ時に html ファイルを拡張子なしでアップロードする

# Create a temporary directory inside the app folder
echo "📁 Preparing HTML files..."
mkdir -p tmp

# Copy all HTML files from out to the temporary directory, preserving structure
rsync -a --prune-empty-dirs --include='*/' --include='*.html' --exclude='*' out/ tmp/

# Create copies of HTML files without .html extension (keep both versions) - don't copy html files that have directory counterparts as extensionless routes (e.g., blog.html → /blog when blog/ exists)
find tmp -name "*.html" -type f -exec sh -c '[ ! -d "${1%.html}" ] && cp "$1" "${1%.html}"' sh {} \;

# Upload normal files first
echo "📤 Uploading static assets..."
aws s3 sync out s3://$S3_BUCKET_NAME --exclude "*.html"

# Upload html files without extension but set content-type to text/html and cache control
echo "📤 Uploading HTML files..."
aws s3 sync tmp s3://$S3_BUCKET_NAME --content-type 'text/html' --cache-control 'must-revalidate'

# Merge tmp to out folder
rsync -abviuzP tmp/ out/

# Sync again with delete flag to remove any obsolete files
echo "🧹 Cleaning up obsolete files..."
aws s3 sync out s3://$S3_BUCKET_NAME --delete

# Upload HTML files that have directory counterparts as extensionless routes (e.g., blog.html → /blog when blog/ exists)
find tmp -type d -exec sh -c '[ -f "$1.html" ] && aws s3 cp "$1.html" "s3://$2/${1#tmp/}" --content-type "text/html" --cache-control "must-revalidate"' sh {} $S3_BUCKET_NAME \;

# Clean up the temporary directory
rm -rf tmp

echo "✅ S3 deployment complete!"
echo "   Website URL: https://$CLOUDFRONT_DOMAIN_NAME"

# Optional: Invalidate CloudFront cache
if [ ! -z "$CLOUDFRONT_DISTRIBUTION_ID" ]; then
  echo "🔄 Creating CloudFront invalidation..."
  aws cloudfront create-invalidation \
    --distribution-id $CLOUDFRONT_DISTRIBUTION_ID \
    --paths "/*" \
    --query 'Invalidation.Id' \
    --output text
  echo "✅ CloudFront invalidation created"
fi

トレイリングスラッシュ問題の解決方法

拡張子なし HTML ファイルの仕組み

以下のよな構成で S3 にデプロイされる

s3/
├── index.html
├── index
├── about.html
├── about
├── contact.html
├── contact
├── 404.html
└── 404

これにより:

  • /aboutaboutファイルが返される(正常)
  • /about/ → ファイルが存在しないので 404 → 404.html でトレイリングスラッシュを削除してリダイレクト

メリット

  1. コスト 0 円 - Function 系サービスを使わないので追加料金なし
  2. 設定がシンプル - CloudFront Functions のコード管理が不要
  3. メンテナンスフリー - 一度設定すれば触る必要なし

デメリット

  1. ちょっとハック感 - 正攻法ではない感じ

まとめ

CloudFront Functions を使わなくても、工夫次第で SPA のルーティングは実現できました!

もし「Function 系の設定めんどくさいなー」と思っている方がいたら、ぜひ試してみてください。

完全なサンプルコードはGitHubで公開しています。


質問やもっと良い方法があれば、コメントで教えてください!

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?