Next.js で SSG したサイトを AWS CloudFront + S3 にデプロイする

## はじめに

最近 [Next.js](https://nextjs.org/) の **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` の型情報を消せば動くかな...?)

```OriginRequest.ts
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` に変わります。

https://nextjs.org/docs/api-reference/next.config.js/exportPathMap#adding-a-trailing-slash

初めはこれでいけるかなと思ったのですが、 **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 へのパスだった

はじめ、リダイレクト時のヘッダーをこのように書いていました。

```ts

[{
  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 を活用している場合や細かくカスタマイズしたい場合に役立つのではないでしょうか。