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ではサーバーコードを実行する場所が必要ですね。
構成図

環境
- 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でビルドをする設定になっていますが、念のため確認。
export default {
ssr: true,
} satisfies Config;
Vite側の設定ファイルには、SSRの設定を追記します。
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 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つでも可能です。

Lambda
Node.jsの関数を作成します。
画像がないですが、Node v24で作成しました。
関数URL
Lambda呼び出し用に関数URLを作成します。
設定>関数URLから作成可能です。

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

作成した関数URLは後程CloudFrontの設定で使用します。
一般設定
タイムアウトは初期設定では3秒となっていますが、延ばしておきます。
メモリは最低の128MBでも動きましたが、割とギリギリでしたので、増やしておくと安心です。

CloudFront
ディストリビューションを作成します。
無料プランなんて増えたんですね。

画面に沿って作成します。WAFは検証ではなしでOKです。
Originはひとまずクライアント用アセットに作成したS3バケットを指定しておきます。(後程追加で設定します)
オリジンの追加
S3は作成時にオリジンに追加済みなので、次はLambdaの関数URLをオリジンとして追加します。
Lambdaの設定>関数URLに記載されている関数URLをコピーします。

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

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

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

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

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

すると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から実行します。

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

関数URLでOACを作成しましたが、S3のほうも念のため確認してください。
ディストリビューション作成時に自動で設定されているはずです。
設定されていない場合は、OACを指定して、CloudFrontからコピー可能なバケットポリシーをコピペでS3バケットのバケットポリシーに設定します。
(以前は毎回自分でやらないといけなかったような・・?)
ビヘイビアの設定
S3に向けるパターンとLambdaに向けるパターンができるようにします。
ビヘイビアのタブの「ビヘイビアを作成」を押下し、作成します。
パスパターンに「/assets/*」を指定し、オリジンにはS3を指定します。

後はそのままデフォルトのままで、ビヘイビアを作成します。
(ビューワリクエストの関数など指定が必要な場合は適宜指定)
ビヘイビアが2つになったことを確認し、次はパスパターン「デフォルト」のビヘイビアを編集します。
パスパターンはそのままにオリジンをLambdaに変更し、変更を保存します。

再びビヘイビア一覧に戻り、優先順位を確認します。
優先順位0にパスパターン「/assets/*」が来るようにしておきます。

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ディストリビューションのドメインにアクセスすると、ページが表示されます。

ここでErrorが表示されてしまう場合は、何かが間違っています。
例
- handlerのパス、lambdaが想定する構成とS3が異なる
- ファイル名間違い
- 権限周り
モニタリング
Lambdaメモリ128MBで初回アクセスの場合3.3秒かかっていますね。

2回目やメモリを増やした場合は、短くなっているようです。
コールドスタート時は時間がかかりそうです。

7. 注意点
実務でこの構成を利用する場合、注意や検討が必要な部分がありそうです。
- コールドスタート対策
- バンドルサイズ
- 環境変数をクライアントコードに露出させない
まとめ
Vite SSRをLambda + CloudFrontで動かすことで、スケーラブルかつ低コストなSSR環境を構築できます。
静的アセットはS3、動的なSSRはLambdaという役割分担により、それぞれの強みを活かした構成になります。
SSRデプロイの方法はこれだけではないので、各々の環境や要件によって対応を変えていく必要がありそうです。
他の方法も時間があるときに試しておきたいと思います。
