React Router v7のSSRビルドをLambdaとCloudFrontにデプロイする方法

React Router v7のSSRビルドをLambdaとCloudFrontにデプロイする方法


はじめに

React Router v7のSSR(Server-Side Rendering)アプリケーションをAWS上で動かしてみます。

React Routerはフレームワードモードを前提とします。

今回は小規模なため、運用の手軽さとコストの観点から、この構成でいきます。

  • HTML生成(SSR) -> Lambda
  • 静的ファイル -> S3 + CloudFront

SPAであればS3とCloudFrontでよかったのですが、SSRではサーバーコードを実行する場所が必要ですね。

構成図

architecture

環境

  • Node.js 22.14.0
  • React Router 7.x
  • Vite 7.x
  • React 19.x

1. React Router Appの作成

公式の手順に従います。

npx create-react-router@latest my-react-router-app
cd my-react-router-app
npm i

2. SSRの設定

react-router.config.tsは、初期状態でssrでビルドをする設定になっていますが、念のため確認。

react-router.config.ts
export default {
  ssr: true,
} satisfies Config;

Vite側の設定ファイルには、SSRの設定を追記します。

vite.config.ts
export default defineConfig(({ command }) => ({
  plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
  ssr:
    command === "build"
      ? {
        // SSRビルドで依存関係をバンドルに含める
          noExternal: true,
        }
      : undefined,
}));

3. Lambdaハンドラーの実装

lambdaハンドラーを追加します。

.mjs(Module形式)にしていますが、packge.jsonに`“type”:“module”を指定した状態でハンドラーと一緒にアップロードすれば、.jsでも可能です。

lambda/index.mjs
// Lambda handler for React Router SSR
import { createRequestHandler } from "react-router";

// Lambda handler
export const handler = async (event) => {
  console.log("Event:", JSON.stringify(event, null, 2));

  try {
    // ビルドされたサーバーコードを動的にインポート(初回のみ)
    const build = await import("./build/server/index.js");
    console.log("Build loaded, keys:", Object.keys(build));

    // API Gateway v2.0形式のイベントまたはLambda関数URLイベントに対応
    const request = createLambdaRequest(event);
    console.log("Request URL:", request.url);

    // React Routerのリクエストハンドラーを作成
    const handleRequest = createRequestHandler(build);

    // リクエストを処理
    const response = await handleRequest(request);
    console.log("Response status:", response.status);

    // Lambdaレスポンス形式に変換
    return createLambdaResponse(response);
  } catch (error) {
    console.error("Error handling request:", error);
    console.error("Error stack:", error.stack);
    return {
      statusCode: 500,
      headers: { "Content-Type": "text/plain" },
      body: `Internal Server Error: ${error.message}`,
    };
  }
};

// Lambda EventをWeb Request APIのRequestに変換
function createLambdaRequest(event) {
  const {
    rawPath,
    rawQueryString,
    requestContext,
    headers,
    body,
    isBase64Encoded,
  } = event;

  // URLを構築
  const url = `https://${headers.host}${rawPath}${rawQueryString ? `?${rawQueryString}` : ""}`;

  // ヘッダーを構築
  const requestHeaders = new Headers();
  for (const [key, value] of Object.entries(headers)) {
    requestHeaders.set(key, value);
  }

  // ボディを処理
  const requestBody = body
    ? isBase64Encoded
      ? Buffer.from(body, "base64").toString("utf-8")
      : body
    : undefined;

  // Requestオブジェクトを作成
  return new Request(url, {
    method: requestContext.http.method,
    headers: requestHeaders,
    body: requestBody,
  });
}

// Web APIのResponseをLambda Responseに変換
async function createLambdaResponse(response) {
  const headers = {};
  response.headers.forEach((value, key) => {
    headers[key] = value;
  });

  const body = await response.text();

  return {
    statusCode: response.status,
    headers,
    body,
  };
}

4. AWS側の準備

IaCでも可ですが、学習のため手動で設定・確認します。

S3

クライアント用アセットとLambdaデプロイパッケージのアップロード先バケットをそれぞれ用意します。

バケット内でフォルダを分けてCloudFront側で設定をしておけばバケット1つでも可能です。

alt text

Lambda

Node.jsの関数を作成します。

画像がないですが、Node v24で作成しました。

関数URL

Lambda呼び出し用に関数URLを作成します。

設定>関数URLから作成可能です。

alt text

認証タイプはAWS_IAMを選択して保存を押下します。

alt text

作成した関数URLは後程CloudFrontの設定で使用します。

一般設定

タイムアウトは初期設定では3秒となっていますが、延ばしておきます。

メモリは最低の128MBでも動きましたが、割とギリギリでしたので、増やしておくと安心です。

alt text

CloudFront

ディストリビューションを作成します。

無料プランなんて増えたんですね。

alt text

画面に沿って作成します。WAFは検証ではなしでOKです。

Originはひとまずクライアント用アセットに作成したS3バケットを指定しておきます。(後程追加で設定します)

オリジンの追加

S3は作成時にオリジンに追加済みなので、次はLambdaの関数URLをオリジンとして追加します。

Lambdaの設定>関数URLに記載されている関数URLをコピーします。

alt text

CloudFrontに戻り、オリジンのタブからオリジンを作成を押下します。

alt text

Origin domainに関数URLを貼り付けます。

alt text

そのままページ下部の「オリジンを作成」を押下し、作成します。 alt text

作成したら、オリジンに作成したオリジンが追加されていることを確認し、選択->「編集」を押下します。

alt text

Origin access controlを設定します。

「Create new OAC」を押下し作成し、作成したものを指定します。

alt text

するとCloudFrontが関数にアクセスできるようにするため、関数ポリシーを更新してくださいという内容のメッセージが表示されます。

コマンドが2つ書いてあります。

aws lambda add-permission \
--statement-id "AllowCloudFrontServicePrincipal" \
--action "lambda:InvokeFunctionUrl" \
--principal "cloudfront.amazonaws.com" \
--source-arn "arn:aws:cloudfront::xxx:distribution/xxx" \
--region "ap-northeast-1" \
--function-name xxx
aws lambda add-permission \
--statement-id "AllowCloudFrontServicePrincipalInvokeFunction" \
--action "lambda:InvokeFunction" \
--principal "cloudfront.amazonaws.com" \
--source-arn "arn:aws:cloudfront::xxx:distribution/xxx" \
--region "ap-northeast-1" \
--function-name xxx

このコマンドをAWS CLIで実行する必要があるのですが、ローカルから実行するのも面倒なので、Cloud Shellから実行します。

alt text

コマンド実行後、Lambda関数の権限を確認すると、リソースベースのポリシーステートメントに2行追加されていることが確認できます。

alt text

関数URLでOACを作成しましたが、S3のほうも念のため確認してください。

ディストリビューション作成時に自動で設定されているはずです。

設定されていない場合は、OACを指定して、CloudFrontからコピー可能なバケットポリシーをコピペでS3バケットのバケットポリシーに設定します。

(以前は毎回自分でやらないといけなかったような・・?)

ビヘイビアの設定

S3に向けるパターンとLambdaに向けるパターンができるようにします。

ビヘイビアのタブの「ビヘイビアを作成」を押下し、作成します。

パスパターンに「/assets/*」を指定し、オリジンにはS3を指定します。

alt text

後はそのままデフォルトのままで、ビヘイビアを作成します。

(ビューワリクエストの関数など指定が必要な場合は適宜指定)

ビヘイビアが2つになったことを確認し、次はパスパターン「デフォルト」のビヘイビアを編集します。

パスパターンはそのままにオリジンをLambdaに変更し、変更を保存します。

alt text

再びビヘイビア一覧に戻り、優先順位を確認します。

優先順位0にパスパターン「/assets/*」が来るようにしておきます。

alt text

5. デプロイ手順

GitHub Actionsでの例です。

-- チェックアウトやenv作成などの前処理は省略

      # --- AWS 認証(OIDC で AssumeRole)---
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ env.AWS_ROLE_ARN }}
          aws-region: ${{ env.AWS_REGION }}

      # --- クライアントアセットをS3にアップロード ---
      - name: Upload client assets (immutable)
        run: |
          aws s3 sync "$CLIENT_DIR" "s3://${S3_BUCKET_APP}/" \
            --delete \
            --cache-control "public, max-age=31536000, immutable"

      # --- Lambdaデプロイパッケージを作成 ---
      - name: Prepare Lambda deployment package
        run: |
          # Lambda用のディレクトリを作成
          rm -rf lambda-package
          mkdir -p lambda-package/build

          # サーバーコードをコピー(依存関係はバンドル済み)
          cp -r $SERVER_DIR lambda-package/build/server

          # Lambda handlerをコピー
          cp lambda/index.mjs lambda-package/

          # ESM対応のためpackage.jsonを作成し、@react-router/nodeをインストール
          cd lambda-package
          echo '{"type": "module"}' > package.json
          npm install react-router

          # ZIPファイルを作成
          zip -r ../lambda-deployment.zip .
          cd ..

      # --- LambdaにSSRサーバーをデプロイ ---
      - name: Upload Lambda package to S3
        run: |
          aws s3 cp lambda-deployment.zip "s3://${S3_BUCKET_LAMBDA}/lambda/lambda-deployment.zip"

      - name: Deploy Lambda function
        run: |
          aws lambda update-function-code \
            --function-name "$LAMBDA_FUNCTION_NAME" \
            --s3-bucket "$S3_BUCKET_LAMBDA" \
            --s3-key "lambda/lambda-deployment.zip" \
            --region "$AWS_REGION"

          # デプロイ完了を待機
          aws lambda wait function-updated \
            --function-name "$LAMBDA_FUNCTION_NAME" \
            --region "$AWS_REGION"

      # --- CloudFront ---
      - name: Create CloudFront invalidation
        run: |
          aws cloudfront create-invalidation \
            --distribution-id "$CLOUDFRONT_DISTRIBUTION_ID" \
            --paths "/${S3_PREFIX}/*"

6. 結果確認

画面

CloudFrontディストリビューションのドメインにアクセスすると、ページが表示されます。

alt text

ここでErrorが表示されてしまう場合は、何かが間違っています。

  • handlerのパス、lambdaが想定する構成とS3が異なる
  • ファイル名間違い
  • 権限周り

モニタリング

Lambdaメモリ128MBで初回アクセスの場合3.3秒かかっていますね。

alt text

2回目やメモリを増やした場合は、短くなっているようです。

コールドスタート時は時間がかかりそうです。

alt text

7. 注意点

実務でこの構成を利用する場合、注意や検討が必要な部分がありそうです。

  • コールドスタート対策
  • バンドルサイズ
  • 環境変数をクライアントコードに露出させない

まとめ

Vite SSRをLambda + CloudFrontで動かすことで、スケーラブルかつ低コストなSSR環境を構築できます。

静的アセットはS3、動的なSSRはLambdaという役割分担により、それぞれの強みを活かした構成になります。

SSRデプロイの方法はこれだけではないので、各々の環境や要件によって対応を変えていく必要がありそうです。

他の方法も時間があるときに試しておきたいと思います。