Next.js の API Route で動的コンテンツを配信して静的部分を CloudFront でキャッシュしてみた

2023.07.18

西田@CX事業本部です。今回はNext.js を使って、ほぼほぼ静的サイトだけど一部だけ動的なサイトを低いコストでキャッシュさせる方法を考えてみました

作成するアプリ

今回ホストするサンプルアプリは、簡易なECサイトをイメージした Next.js を使って作成された、WEBアプリケーションです。商品一覧と商品の詳細ページがあり、ヘッダ部分にログインしたユーザーの情報が動的に表示される仕様です

ヘッダ部分に動的に変更されキャッシュできない情報が表示されています。そのほかの部分は静的に生成され、キャッシュすることが可能なアプリケーションを想定しています

WEBアプリケーションが提供するパスは以下です

Path 画面
/ 商品一覧
/products/1 商品詳細
/api/ ユーザー名とカート数(動的コンテンツ)

全体構成

今回作成するアプリケーションの全体構成です

Cloudfront を前段に配置し、動的部分を提供する API Routes (/api) を配信する Next.js のプロセスを App Runner で実行し、それ以外(/*)のコンテンツを Next.js の Static Exports 機能で出力されたファイルをS3にアップロードしてホストしています

アプリケーション全体のソースコードは IaC や CI/CD を含め Github に Push しています。詳細が気になった方はご参考ください。このブログではハマったところや、ポイントとなるところをピックアップして解説します

ページの静的部分

全体のソースコードは こちら から確認できます。ポイントとなったところを解説していきます

静的ページの配信

静的ファイルは Next.js の Static Exports 機能を使って生成し、生成されたファイルを S3 の静的ウェブホスティングの機能を使って配信します。

next.config.js の output オプションに export を指定することでビルド時に静的なファイルを生成できるようになります。

next.confg.js

const nextConfig = {
	output: 'export'
  ... 省略
}

next.config.js の output に export を指定した状態で npm run buildを実行すると、プロジェクト直下の out ディレクトリに静的なファイルが生成されます。これをS3にアップロードし静的なコンテンツをデプロイします

tree -L 2 -I node_modules .
.
├── README.md
├── next-env.d.ts
├── next.config.js
├── out <--- ! ココ
│   ├── 404.html
│   ├── _next
│   ├── favicon.ico
│   ├── images
│   ├── index.html
│   ├── index.txt
│   ├── next.svg
│   ├── products
│   └── vercel.svg
├── package-lock.json
├── package.json
├── postcss.config.js
├── public
│   ├── images
│   ├── next.svg
│   └── vercel.svg
├── src
│   └── app
├── tailwind.config.js
└── tsconfig.json

ただし、Next.js のデフォルト設定では、htmlファイルが /ディレクトリ/1.htmlという形式で出力されてしまいます

out
├── 404.html
├── index.html
├── products
│   ├── 1.html
│   ├── 2.html

出力されたファイルをそのままS3にアップロードしてしまうと、アプリケーション内のリンクが /products/1 とファイル名が省略されたパスで指定されてるのでそのままブラウザからアクセスすると Not Found となってしまいます。この問題を解決するために、今回は S3 の静的WEBホスティングのインデックスドキュメントを使います。

next.config.jstrailingSlashにtrueを設定し

const nextConfig = {
	output: 'export',
	trailingSlash: true,
  ... 省略
}

以下のように、各パスに index.html ファイルが出力されるようにします

out
├── 404
│   └── index.html
├── 404.html
├── index.html
└── products
    ├── 1
    │   └── index.html
    └── 2
        └── index.html

この状態でアップロードすると S3 の静的WEBホスティングのインデックスドキュメント の機能で、index.html が省略できるので、 /products/1/ とアクセスされた場合に /products/1/index.html が表示されるようになります

動的にパスが決まるページを静的に生成

商品詳細ページは /products/${id} というパスの中に商品のIDが含まれるため、ビルド時に作成したいページの id をNext.jsに伝える必要があります

Next.js に存在する idを伝えるには generateStaticParams を使います。Next.jsは generateStaticParams で返却された id(slug) の数だけhtmlファイルを静的に生成します

下記は簡易的にデータをTypeScript内で宣言してそのidを generateStaticParams を使って Next.js に伝えてる例です。本来は、microCMS などのCMSからビルド時にデータを取得します

interface Product {
  id: number;
  name: string;
  price: number;
  description: string;
  image: string;
}

export const products: Product[] = [
  {
    id: 1,
    name: "ドギーマン",
    price: 100,
    description: "めっちゃ美味しい",
    image: "/images/dog-01.jpg",
  },
  {
    id: 2,
    name: "ドギーマン2",
    price: 200,
    description: "めっちゃ美味しいし栄養ある",
    image: "/images/dog-02.jpg",
  },
];

export async function generateStaticParams() {
  const params = items.map((item) => {
    return {
      slug: item.id.toString(),
    };
  });

  return params;
}

画像の最適化

Next.js の Imageコンポーネントは画像をクライアントに合わせて最適化しています。この機能はNext.js が、クライアントから画像をリクエストされた時に受け取ったリクエスト情報を元に、画像を最適化しそれを配信しています。そのため、Static Exports 機能を使った場合はこの機能が使えませんが、Next Export Optimize Images を利用することで、あらかじめ最適化済みのファイルを出力しておくことが可能です

インストール

npm install -d next-export-optimize-images

.next.config.js で設定を withExportImages 関数でラップします

const withExportImages = require('next-export-optimize-images')

module.exports = withExportImages({
  output: 'export',
  // write your next.js configuration values.
})

package.json を編集しbuild 後に next-export-optimize-images コマンドを呼び出すようにします

{
  "build": "next build && next-export-optimize-images",
}

この状態でビルドを行うと、 out 配下に最適な画像を配信するための画像が出力されるようになります

tree out/_next/static/chunks/images/ -L 3 

out/_next/static/chunks/images/
├── images
│   ├── dog-01_1080.jpg
│   ├── dog-01_1200.jpg
│   ├── dog-01_128.jpg
│   ├── dog-01_16.jpg
│   ├── dog-01_1920.jpg

そして、出力されたHTMLの img タグの srcSet プロパティに、最適な画像が配信されるよう、viewportの大きさに合わせたサイズの画像のパスが指定されます

<img srcSet="/_next/static/chunks/images/images/dog-01_384.jpg 384w..." ...省略  />

ページの動的部分

動的な情報は Next.js の API Routes で配信し、クライアントサイドで、それをfetchしてレンダリングさせています。

クライアントサイドで fetch するために useEffect を使う

クライアントサイドで fetch を実行させるために useEffect を使って API Routes のAPIを呼び出しています

const [name, setName] = useState("");
  const [cart, setCart] = useState(0);

  useEffect(() => {
    fetch("/api").then(async (res) => {
      const body = await res.json();
      setName(body.name);
      setCart(body.cart);
    });
  }, []);
// ... 省略

Next.js のビルド時のオプションを動的部分と静的部分で切り替える

動的なコンテンツを配信する Next.js は standalone モードでビルドしています。ただし、今回は静的ページは Static Exports 機能を使ってビルドしてるので、動的な部分と静的なページをビルドするときに next.config.jsoutput オプションを、それぞれをビルドするときに切り替える必要があります。今回は環境変数で切り替えるようにしました

next.confg.jsSTANDALONE 環境変数に “true” と設定されていれば output オプションに “standalone” を設定するようにしておき

const nextConfig = withExportImages({
  output: process.env.STANDALONE === "true" ? "standalone" : "export",
	// ... 省略
});

DockerfileSTANDALONE 環境変数を設定するようにしてます

ENV STANDALONE true

API Routes を実行する Next.js の実行環境

動的な情報を返却する Next.js が動く Node.js のプロセスは AWS App Runner 上で実行します

AWS App Runner は主に以下の特徴を持つサービスです

  • トラフィックに応じて自動でスケール
  • ALB 等のWEBアプリケーションを配信するときに必要な構成の大部分を隠蔽してくれる
  • コンテナが待機状態の時には、CPUの利用料金がかからない(メモリはかかる)

App RunnerでNext.js (13.4.9) を実行するにはいくつか調整する必要があったので説明します

HOSTNAME 環境変数の指定が必要

Next.js 13系の一部のバージョンを App Runner で動かした際に、HOSTNAMEを指定しないと、なぜか通信できないIPに対して通信をしてしまい、うまく動作しませんでした

参考: Deploying to AWS App Runner results in EADDRNOTAVAIL in multiple AWS regions

この不具合の回避のために Dockerfile で HOSTNAME 環境変数を設定してます

# -- 省略...
ENV HOSTNAME 0.0.0.0

M1(2) MACでビルドする場合は platform の指定が必要

執筆時点では App Runner は arm アーキテクチャのCPUには対応していません。筆者は M2 MAC で開発を行っているので、 イメージファイルのビルド時に platform の指定が必要でした。CDKで build 時に platform オプションを指定するには以下のようにします

const imageAssets = new cdk.aws_ecr_assets.DockerImageAsset(
  this,
  "ApiStack",
  {
    directory: path.join(__dirname, "../../web"),
    platform: cdk.aws_ecr_assets.Platform.LINUX_AMD64,
  }
);

また、筆者の環境(Rancher Desktop)ではうまく Build ができなかったので、 finch を使ってビルドしました。cdk 実行時に CDK_DOCKER 環境変数で docker コマンドを変更できるので finch と指定して実行してビルドすることができました

CDKでApp Runner を CloudFront の Origin に設定する

App Runner を CloudFront の Origin に CDK で設定するのに、AppRunner の Service の serviceUrl パラメーター を利用しています。このパラメーターには App Runner が提供しているサービスのドメインが格納されていて、Cloudfront の /api/ 配下のビハイビアを、このドメインのオリジンにトラフィックを向けることで、動的コンテンツを配信しています

serviceUrlパラメーターは、そのままコンストラクト時に参照するとトークンが取れるだけで、実際のドメイン名は取得できません。そのためパラメーターを一度SSMに格納し、それをCloudfront の Origin を設定するときに使用してます

const apiAppRunner = new apprunner.Service(this, "ApiService", {
	// ... 省略
});

new cdk.aws_ssm.StringParameter(this, "/staticweb/apiurl", {
  parameterName: "/staticweb/apiurl",
  stringValue: apiAppRunner.serviceUrl,
});
const apiUrl = ssm.StringParameter.fromStringParameterAttributes(
  this,
  "apiUrlSsm",
  {
    parameterName: "/staticweb/apiurl",
  }
).stringValue;

const distribution = new cloudfront.Distribution(this, "webDistribution", {
	// ... 省略
  additionalBehaviors: {
    "/api/*": {
      allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL,
      viewerProtocolPolicy:
        cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
      cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED,
      origin: new cloudfront_origins.HttpOrigin(apiUrl),
    },
  },
});

最後に

今回は一つの Next.js の静的な部分をS3に動的な部分をApp Runner で動かしてみました。Next.js は Vercel 等でも簡単にデプロイできますが、AWSで完結しないといけない、利用料金を抑えたいといった場合に今回の方法が参考になるかもしれません

この記事が誰かの役に立てば幸いです