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 ファイルを拡張子なしでアップロードする方法です。例えば:
-
/about
→about
というファイル(中身は HTML) -
/contact
→contact
というファイル(中身は 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're looking for doesn't exist. This error page
is served by Next.js for routes that don'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'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
これにより:
-
/about
→about
ファイルが返される(正常) -
/about/
→ ファイルが存在しないので 404 → 404.html でトレイリングスラッシュを削除してリダイレクト
メリット
- コスト 0 円 - Function 系サービスを使わないので追加料金なし
- 設定がシンプル - CloudFront Functions のコード管理が不要
- メンテナンスフリー - 一度設定すれば触る必要なし
デメリット
- ちょっとハック感 - 正攻法ではない感じ
まとめ
CloudFront Functions を使わなくても、工夫次第で SPA のルーティングは実現できました!
もし「Function 系の設定めんどくさいなー」と思っている方がいたら、ぜひ試してみてください。
完全なサンプルコードはGitHubで公開しています。
質問やもっと良い方法があれば、コメントで教えてください!