🗻

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.tssst.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.shRevalidateSecret などを強制チェック ➜ 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 回の接続で関連情報をすべて組み立てる
  • getPageBySlugsrc/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_ORIGINhttps://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 スキーマ)
  1. 初回アクセス時は generateStaticParams が空のため即座にオンデマンド描画され、export const revalidate = 600 に従いキャッシュされます。
  2. 管理画面から公開内容を更新するとバックエンドが APP_REVALIDATION_URL を叩き、/api/revalidaterevalidatePath を実行します。
  3. ISR のバックグラウンドリフレッシュで新しい HTML/JSON が Lambda 内で再生成され、CloudFront キャッシュも自動で入れ替わります。

このフローにより、カスタムサブドメインでも API サーバーを経由せずに常に最新の SSR コンテンツを返せます。

デプロイと運用フロー

  1. pnpm run deploy:preview で下記を自動実行
    • Secrets の存在チェック (npx sst secret list --stage preview)
    • .next/.open-next/node_modules をクリーンにして再インストール
    • npx sst deploy --stage preview
  2. デプロイ完了後、CloudFront と https://preview.example-app.jp を案内
  3. Route53 側では preview.example-app.jp / *.preview.example-app.jp を CloudFront へエイリアス
  4. 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_DATAtrue にすると 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 ベースのジョブで次の順に実行しています。

  1. pnpm install(ルート)→ pnpm --filter web-nextjs type-check で TypeScript をノーエラーに保つ
  2. pnpm --filter web-nextjs testpnpm --filter web-nextjs lint
  3. ビルド成果物は 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_URLNEXT_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