1. Next.jsをSSRモードで静的エクスポートし、S3+CloudFrontで公開したらページリロードで403エラーが発生
Next.jsで開発したブログを静的エクスポートし、AWS S3 + CloudFrontという構成で公開した際、ホームページは問題なく表示されるのに、サブページ(例: /posts/20250515/
)を直接リロードしたり、URLを直打ちしたりすると403 Forbiddenエラーが発生するという問題に遭遇しました。
CloudFront Functionsを使った解決策を説明します。
2. サブページだけが403 Forbidden
私のブログの構成は以下の通りです。
- フレームワーク: Next.jsバージョン15 (App Router)
- ビルド: 静的エクスポート (
next build
) - ホスティング: AWS S3 (静的ウェブサイトホスティングは無効、オブジェクトは非公開)
- CDN: AWS CloudFront (OACでS3バケットにアクセス)
この構成で、以下のような現象が発生しました。
https://untilcoffeecools.com/
(ホームページ): 正常に表示https://untilcoffeecools/posts/20250515/
(記事ページなど):- ホームページからの遷移: 正常に表示
- ページリロード、またはURL直打ち: 403 Forbiddenエラー
また、S3バケット内には、Next.jsのビルドによって posts/20250515/index.html
のような形でHTMLファイルが正しく配置されていました。
3. なぜ403エラーが発生したのか? S3オリジンの挙動
CloudFrontのオリジン設定とS3の挙動に原因がありました。 前提として、CloudFrontのオリジンとしてS3バケットを指定する場合、主に以下の2つのエンドポイントがあります。
- S3静的ウェブサイトホスティングエンドポイント:
- 例:
your-bucket-name.s3-website-us-east-1.amazonaws.com
- このエンドポイントをオリジンにすると、S3側でインデックスドキュメント(例:
index.html
)の解決やリダイレクトルールを処理してくれます。しかし、OAI/OACを使ったアクセス制限と併用するには追加設定が必要だったり、HTTPSを強制できなかったりする制約があります。
- 例:
- S3 REST APIエンドポイント:
- 例:
your-bucket-name.s3.us-east-1.amazonaws.com
- OAI/OACを使ってS3バケット内のオブジェクトを非公開にしつつCloudFrontからのみアクセスさせる場合、通常こちらを使用します。本ブログはこの形式
- 例:
問題は、CloudFrontがS3 REST APIエンドポイントをオリジンにしている場合、/posts/20250515/
のような末尾にスラッシュが付いたパスへのリクエストがあった際に、S3が自動的にその「ディレクトリ」内にある index.html
を探してくれないことです。
CloudFrontはリクエストされたURIをそのままS3に伝えます。例えば、/posts/20250515/
というリクエストは、S3に対して posts/20250515/
というキーのオブジェクトを要求します。S3上にそのような名前のオブジェクト(0バイトのフォルダプレースホルダーなど)が存在しないか、アクセス権の問題で、結果として403 Forbiddenエラーが返されていました。
ホームページ (/
) が表示されたのは、CloudFrontの「Default Root Object」設定(通常 index.html
)が機能し、ルートパスへのアクセスを index.html
に解決してくれていたためでした。
4. 解決策: CloudFront FunctionsでURIを書き換える
この問題を解決するため、CloudFrontがS3にリクエストを送信する前に、リクエストURIをS3が理解できる形(具体的なファイルパス)に書き換えます。 ようやく本題の CloudFront Functions が出てきます。
CloudFront Functionsを使って、ビューワーリクエスト(ユーザーのリクエストがCloudFrontエッジに到達した直後)のタイミングで、JavaScriptコードを実行してリクエストを操作します。
基本的には1だけでいいですが、ついでなので2の処理を行う関数を作成します。
- リクエストURIが
/
で終わる場合 (例:/posts/20250515/
) →index.html
を末尾に追加 (例:/posts/20250515/index.html
) - リクエストURIに拡張子が含まれず、
/
で終わらない場合 (例:/posts
) → 末尾に/index.html
を追加 (例:/posts/index.html
)
5. 既存のCloudFront Functionに追加する
私の環境では、既にViewer Requestイベントに対して別のCloudFront Functionが設定していました。(2025/05/07の記事)
CloudFront Functionsの制約として、1つのイベントタイプ(例: Viewer Request)に対して関連付けられる関数は1つだけということらしいので、1つのCloudFront Functionにまとめました。
以下が、追加した分のCloudFront Functionコードです。
function handler(event) {
// --- 既存のリダイレクト処理ここまで ---
// --- 上記のリダイレクトが発生しなかった場合(つまりカスタムドメインでアクセスされた場合)のみ、以下のURI書き換え処理を実行 ---
var uri = request.uri;
// URIのパス部分(クエリ文字列は除く)を取得
var path = uri.split('?')[0];
// URIにクエリ文字列がある場合のクエリ文字列部分
var queryString = uri.includes('?') ? '?' + uri.split('?')[1] : '';
// パスが '/' で終わる場合 (例: /posts/20250515/)
if (path.endsWith('/')) {
request.uri = path + 'index.html' + queryString;
}
// パスに '.' (拡張子を示すものと仮定) が含まれず、'/' で終わらない場合 (例: /posts)
// かつ、空のパスでない場合(ルートへのアクセスは Default Root Object が処理するため)
else if (path !== '/' && !path.substring(path.lastIndexOf('/') + 1).includes('.')) {
request.uri = path + '/index.html' + queryString;
}
return request;
}
6. まとめ
個人ブログ程度の規模なら無料で使えてAWSは太っ腹