はじめに
最近 Next.js の SSG (Static Site Generator: 静的サイト生成) の機能が強化されにわかに盛り上がっています。
SSG の用途では今までは Gatsby.js や React から Vue に浮気して Nuxt.js を選択したほうが早いという印象でしたが、 Next.js で何でもできるようになってきて個人的には嬉しいです。
SSG によって生成されたファイルは単なる HTML/CSS/JS なため、 AWS S3 などに静的にホスティングすることが可能です。
これ自体は create-react-app などで作った SPA でも同じなのですが、 SSG の場合はページ毎に予めレンダリングされた HTML を取得できるため、ロードが早かったりページ別に SEO 対策がやりやすいといったメリットがあります。
今回、 Next.js で SSG したサイトを CloudFront + S3 で配信しようと思ったところ、意外とすんなりいかなかったのでハマったところをメモしておきます。
何が問題なのか
Next.js の SSG では例えば /about というパスは /about.html として生成されます。
そのため、そのまま S3 にホスティングして CloudFront で /about にアクセスしてもページが見つかりません。
正確には index.html からの遷移の場合はクライアント側でのルーティングとなるので表示できるのですが、その後リロードするとうまく表示されません。
これではせっかく SSG したのに意味がありませんね...
解決方法
Lambda@Edge を使い、拡張子がついていないアクセスの場合 .html を付与して S3 へマッピングしました。
以下のコードを Origin Request ハンドラーとして登録します。
今回は TypeScript で書いて @aws-cdk/aws-lambda-nodejs モジュールでデプロイしましたが、 JavaScript や他の言語の場合は適宜読み替えてください。
(Node.js 12.x ランタイムなら Handler の型情報を消せば動くかな...?)
import { Handler } from "aws-lambda";
export const handler: Handler = async (event) => {
const { request } = event.Records[0].cf;
// "/" へのリクエストはそのまま処理する
if (request.uri === "/") {
return request;
}
// ファイル名 ("/" で区切られたパスの最後) を取得
const filename = request.uri.split("/").pop();
if (!filename) {
// ファイル名が空 (つまり "/" で終わる) の場合、末尾の "/" を除去してリダイレクト
return {
status: "302",
statusDescription: "Found",
headers: {
location: [
{
key: "Location",
value: request.uri.replace(/\/+$/, "") || "/",
},
],
},
};
} else if (!filename.includes(".")) {
// ファイル名に拡張子がついていない場合、 ".html" をつける
request.uri = request.uri.concat(".html");
}
return request;
};
Lmabda@Edge の全般の注意点として、 us-east-1 リージョンにデプロイする必要があることと、 Lambda のバージョンは $LATEST ではなく特定のバージョンを指定する必要があるので気をつけてください。
ハマったこと
exportTrailingSlash: true ではダメだった
next.config.js で exportTrailingSlash: true を設定すると、例えば <Link href="/about"> で作成されるリンクが /about/ に変換され、出力先が /about/index.html に変わります。
初めはこれでいけるかなと思ったのですが、 CloudFront の DefaultRootObject は / にしか対応しておらず、 /about/ へのアクセスは /about/index.html にマッピングしてくれないということがわかりました。
https://qiita.com/onooooo/items/6839b5871b35451a0235
https://dev.classmethod.jp/articles/directory-indexes-in-s3-origin-backed-cloudfront/
どちらにしろ Lambda@Edge を使うのであれば exportTrailingSlash: true を使う必要はなく、今回の解決方法に至りました。
request.headers.host は S3 へのパスだった
はじめ、リダイレクト時のヘッダーをこのように書いていました。
[{
key: "Location",
value:
"https://" +
request.headers.host[0].value +
(request.uri.replace(/\/+$/, "") || "/"),
}]
これで試してみたところ、 https://<bucket-name>.s3.<region>.amazonaws.com のパスにリダイレクトされてしまうという問題がありました。
event の内容を確認したところ、 request.headers.host には S3 の URL が格納されており、 CloudFront のドメイン名は config.distributionDomainName に入っているということを発見しました。
よくよく調べると最近は RFC 上も Location ヘッダーは必ずしもドメインから始める必要がないようなので、ドメインを省略しました。
まとめ
初回のデプロイに思ったより手間取りましたが、やり方さえわかってしまえばあとは S3 にアップするだけですので、単純なサイトであれば SSG にしてしまったほうが楽ですし、コスト的にもパフォーマンス的にも有利です。
SSG ではまだ試してみていないので推測ですが、 Vercel (Zeit Now) や Netlify であればもっと簡単にデプロイできそうな気がします。
しかし CloudFront + S3 の鉄板構成を抑えておくと、既に AWS を活用している場合や細かくカスタマイズしたい場合に役立つのではないでしょうか。