AmplifyなしでNext.jsアプリをSST+Lambdaに載せ、既存モノレポと共存させた話
背景
社内で開発しているweb系のSaaSの公開Web機能は長らく React SPA で運用していましたが、SEOと初期表示を強化するため Next.js 15(App Router)で書き直した web-nextjs/ を新設しました。既存マイクロサービス群(API サービスや認証サービスなど)は Serverless Framework で AWS Lambda + VPC + Aurora/MySQL に載っているため、Next.js も Amplify ではなく同じ土俵 に揃える必要がありました。最終的に serverless-open-next から SST 3.x へ移行し、他サービスと同じ VPC・Secrets・CI/CD フローで動かせるようにした実装ログです。
やりたかったこと
- Next.js を AWS Lambda 上に載せ、既存 VPC から RDS へダイレクト接続
- UI/管理画面/バックエンドが参照する
APP_REVALIDATION_URLなどの環境変数を共通化 -
*.preview.example-app.jpのようなワークスペース別カスタムサブドメインを HTTP レベルでリライト - Amplify 固有のビルドや IaC を増やさず、既存 serverless サービスと整合
SST での Next.js 配置
web-nextjs/sst.config.ts で sst.aws.Nextjs コンポーネントを 1 ファイルに集約しました。ステージ別の公開 API、リバリデーション URL、プレビュー送信元などを TypeScript で切り替えつつ、VPC や Secrets もここで束ねています。
// web-nextjs/sst.config.ts より抜粋
const web = new sst.aws.Nextjs("WebsiteNextjs", {
path: ".",
vpc: {
securityGroups: ["sg-xxxxxxxxxxxxxxxxx"],
privateSubnets: [
"subnet-aaaaaaaaaaaaaaaaa",
"subnet-bbbbbbbbbbbbbbbbb",
"subnet-ccccccccccccccccc",
],
},
environment: {
NEXT_PUBLIC_API_ENDPOINT: "https://api.example.com/dev",
APP_REVALIDATION_URL: "https://preview.example-app.jp/api/revalidate",
RDS_DATABASE: "webapp",
RDS_HOST: new sst.Secret("DbHost").value,
RDS_USER: new sst.Secret("DbUser").value,
RDS_PASSWORD: new sst.Secret("DbPassword").value,
},
server: {
memory: "1536 MB",
timeout: "120 seconds",
runtime: "nodejs18.x",
},
defaults: {
behavior: {
cachePolicy: "Managed-CachingOptimized",
responseHeadersPolicy: "Managed-SecurityHeadersPolicy",
},
},
});
SST の Secrets は Parameter Store 管理に寄せられるので、deploy-preview.sh で RevalidateSecret などを強制チェック ➜ npx sst deploy --stage preview を実行する構成にしています。CloudFront ドメインと https://preview.example-app.jp のカスタムドメインを同時に案内できるよう、スクリプト末尾で .sst/outputs.json を拾っています(web-nextjs/deploy-preview.sh)。
DB へ直接つなぐ SSR/ISR
Next.js 側では API 経由ではなく mysql2/promise で Aurora に直結し、HTML 生成を 1 ホップに抑えています。
-
src/lib/db/connection.ts: 環境変数から接続情報を解決し、beginTransaction()済みのコネクションを返す -
src/lib/services/page-service.ts: 複数 DAO をまとめ、1 回の接続で関連情報をすべて組み立てる -
getPageBySlug(src/lib/api/page.ts)でUSE_MOCK_DATA判定→DB→mock フォールバックを制御
// src/lib/api/page.ts より抜粋
type PageDataFetcher = (pageSlug: string) => Promise<PageData | null>;
export const getPageBySlug: PageDataFetcher = async (pageSlug) => {
if (process.env.USE_MOCK_DATA === "true") {
const { getIcons } = await import("../utils/icons");
return { ...mockPageData, icons: getIcons(mockPageData.setting) };
}
const { connect } = await import("../db/connection");
const { PageService } = await import("../services/page-service");
const conn = await connect();
try {
const pageData = await PageService.getPageBySlug(conn, pageSlug);
await conn.commit();
return pageData;
} finally {
await conn.end();
}
};
SST の VPC 設定を既存 API サービスと揃えたことで、Lambda から RDS(webapp スキーマ)へ直接入れます。Amplify Hosting を使わずに済み、既存の DB フレーバーや監査の仕組みをそのまま流用できました。
ISR の設計
App Router の generateStaticParams を空配列にし、初回アクセスでオンデマンド生成 ➜ export const revalidate = 600; で 10 分キャッシュという方針です(src/app/[pageSlug]/page.tsx)。サイトが存在しなければ notFound()、プレビュー時は draftMode() を見て PreviewMessageReceiver に切り替えており、管理画面プレビュー用メッセージを即座に反映できます。
再生成 API は src/app/api/revalidate/route.ts に実装しています。REVALIDATE_SECRET を Bearer 認証で受け取り、revalidatePath(``/${pageSlug}``) を実行。バックエンド側は APP_REVALIDATION_URL を叩いて更新後に自動再生成するので、モノレポ全体で再生成 URL が共有されているのがポイントです。
カスタムドメインとマルチテナント
NEXT_PUBLIC_SITE_ORIGIN を https://preview.example-app.jp のように固定し、src/middleware.ts で *.preview.example-app.jp を /{pageSlug} へ書き換えています。これで workspace-a.preview.example-app.jp に来たリクエストを /:pageSlug ルートに内部 rewrite し、App Router 側は通常のダイナミックパラメータで処理できます。matcher で _next/* と API は除外しているので、静的アセット配信は CloudFront/S3 に任せられます。
リクエストと再生成のフロー
Next.js 側のルーティングと再生成は下記の流れで動いています。
利用者ブラウザ
│ HTTPS
▼
Amazon CloudFront
│ origin request (Lambda@Edge なし)
▼
SST が作成した Next.js Lambda
│ SSR 時に mysql2/promise で Aurora へ接続
▼
RDS/Aurora (webapp スキーマ)
- 初回アクセス時は
generateStaticParamsが空のため即座にオンデマンド描画され、export const revalidate = 600に従いキャッシュされます。 - 管理画面から公開内容を更新するとバックエンドが
APP_REVALIDATION_URLを叩き、/api/revalidateがrevalidatePathを実行します。 - ISR のバックグラウンドリフレッシュで新しい HTML/JSON が Lambda 内で再生成され、CloudFront キャッシュも自動で入れ替わります。
このフローにより、カスタムサブドメインでも API サーバーを経由せずに常に最新の SSR コンテンツを返せます。
デプロイと運用フロー
-
pnpm run deploy:previewで下記を自動実行- Secrets の存在チェック (
npx sst secret list --stage preview) -
.next/.open-next/node_modulesをクリーンにして再インストール npx sst deploy --stage preview
- Secrets の存在チェック (
- デプロイ完了後、CloudFront と
https://preview.example-app.jpを案内 - Route53 側では
preview.example-app.jp/*.preview.example-app.jpを CloudFront へエイリアス - ACM(us-east-1) の
*.example-app.jpワイルドカード証明書を使い回し
SST コンソールで Lambda/CloudFront/S3 をまとめて確認できるので、serverless + open-next で散らばっていた設定が 1 か所に集約されました。以前必要だった OpenNext v3 のハック(server-function ディレクトリ写経など)も不要になりました。
SST 移行でハマったところ
-
VPC 内からの接続確認: Lambda が RDS へ到達できないと
mysql2/promiseがタイムアウトするだけなので、sst dev --stage preview --outputsで実際のセキュリティグループとサブネットを表示し、手元の Serverless Framework サービスと同じ設定であることを都度突き合わせました。USE_MOCK_DATAをtrueにすると SSR はモック経由で動くので、切り分け時に助かります。 -
Secrets の整合: Serverless Framework 時代は SSM パラメータを直接参照していましたが、SST は
sst secretで管理するため、deploy-preview.sh冒頭でnpx sst secret listを叩いて存在チェックする仕組みを追加しました。ここを怠るとデプロイ完了後に Lambda がprocess.env.RDS_HOSTを拾えず即エラーになります。 -
CloudFront 反映待ち: 初回デプロイは 15〜20 分ほどディストリビューションがアクティブにならないので、記事・ドキュメント上でも「焦らず待つ」ことを明示しました。以降は
npx sst deploy --stage previewの diff デプロイで 5 分以内に収まります。
運用で気を付けていること
-
npx sst secret list --stage previewで再生成トークンや DB 認証情報を定期棚卸しし、退職者のアクセスキーを回収したタイミングで Secrets もローテーションしています。 - Route53/ACM 周りは CloudFormation に委ねていますが、Certificate Manager はバージニア北部に集約しているため、証明書入れ替えは事前に
sst deploy --stage preview --verboseで DryRun し、差分リソースを確認してから本番環境にも適用しています。 - middleware ベースのマルチテナントなので、
preview.example-app.jp直下で 404 が出たら/:tenantUidへの rewrite が効いていないサインです。CloudFront キャッシュ削除 →npx sst consoleでログ確認 →src/middleware.tsの記述確認、の順に切り分けると早いです。
CI/CD への組み込み
モノレポ全体では CodeBuild ベースのジョブで次の順に実行しています。
-
pnpm install(ルート)→pnpm --filter web-nextjs type-checkで TypeScript をノーエラーに保つ -
pnpm --filter web-nextjs testとpnpm --filter web-nextjs lint - ビルド成果物は
sst buildではなくnpx sst deploy --stage preview --chromelessを採用し、CloudFormation 変換とデプロイを一気に行う
SST はスタック出力を .sst/outputs.json に書き出してくれるので、CI から Slack 通知するときもその JSON を読み取り、preview.example-app.jp と CloudFront ドメインを添えて共有しています。Serverless Framework 時代に書いていた serverless deploy --conceal 向けのハックは不要になり、CI スクリプトが 30 行ほど短くなりました。
CodeBuild の buildspec 例
buildspec_preview.yml の抜粋は次のとおりです。monorepo 直下で workspace を指定しつつ、SST デプロイと出力収集を一気に行います。
version: 0.2
phases:
install:
runtime-versions:
nodejs: 20
commands:
- pnpm install
- pnpm --filter web-nextjs install
build:
commands:
- pnpm --filter web-nextjs lint
- pnpm --filter web-nextjs test
- npx sst deploy --stage preview --chromeless --outputs-file sst-outputs.json
artifacts:
files:
- "sst-outputs.json"
--outputs-file で吐き出された JSON を CodeBuild のアーティファクトとして残しておくと、Slack 通知や次工程(例えば CDN Invalidation)で URL が必要になったときに即利用できます。
まとめ
- Next.js 15 (App Router) を SST の
aws.Nextjsで Lambda/CloudFront 化し、Amplify なしでデプロイ - VPC/Subnet/SG/Secrets を monorepo 既存サービスと揃え、RDS へ直接接続する SSR/ISR を構築
-
APP_REVALIDATION_URL・NEXT_PUBLIC_SITE_ORIGINなどを共通.env/sst.config.tsに寄せ、管理画面や API から再生成を自動化 -
middleware.tsで Workspace サブドメインを App Router のダイナミックルートへ rewrite し、1 アプリでマルチテナント配信 -
deploy-preview.shで Secrets チェック〜SST デプロイまでを自動化し、CloudFront/Route53/ACM の組をテンプレ化
同じ課題(モノレポ内で Next.js だけ Amplify 管理になる問題)を抱えている場合、SST への移行で IaC と VPC の整合性を保ちながら Next.js を統合できるはずです。
Discussion