⚡️

Next.jsをSSGしてAWS CDKで静的サイトホスティングしてみた

に公開

本記事のサマリ

Next.jsの静的サイト生成(SSG)機能とAWS CDKを組み合わせて、S3 + CloudFrontによる静的サイトホスティング環境を構築した記録です。インフラをコードで管理しているので、これをボイラーテンプレートとしてLPを量産出来ますね!

なぜこの構成を選んだのか

静的サイトのホスティング方法を検討する際、いくつかの選択肢がありました。Vercelのようなプラットフォームサービスも魅力的でしたが、今後LPを量産する予定があり、使い回しの効くAWS CDK(インラフコード管理できるやつ)でサクッとAWSで構築することにいたしました。

Next.jsのSSGの強み

Next.jsのSSG機能は、React アプリケーションをビルド時に静的HTMLファイルに変換してくれます。これにより、サーバーサイドでの動的な処理が不要になり、CDNでの高速配信が可能になる点が魅力でした。

https://nextjs.org/docs/pages/building-your-application/rendering/static-site-generation

特に、getStaticPropsgetStaticPathsを使った静的生成は、コンテンツが更新頻度の低いサイトにとって理想的です。ビルド時にデータを取得してHTMLを生成するため、実行時のパフォーマンスが格段に向上します。

AWS CDKでインフラを管理する理由

手動でAWSコンソールを操作してS3やCloudFrontを設定することも可能ですが、CDKを選んだのはインフラの再現性と保守性を考慮してのことです。TypeScriptでインフラを定義できるため、アプリケーションコードと同じ感覚で開発できる点も大きなメリットでした。

https://docs.aws.amazon.com/cdk/v2/guide/home.html

S3 + CloudFrontの利点

この構成の魅力は、なんといってもコストパフォーマンスの良さです。トラフィックが少ない段階では月10円とかからず運用でき、アクセスが増加してもCloudFrontのエッジロケーションによる高速配信が期待できます。また、AWSの他サービスとの連携も考慮すると、将来的な拡張性も確保されます。

Next.jsの設定

SSG用の設定で重要なポイントがいくつかあります。特に、通常のNext.jsアプリケーションとは異なる設定が必要な部分について説明します。

const nextConfig = {
  output: 'export',
  trailingSlash: true,
  eslint: {
    ignoreDuringBuilds: true,
  },
  typescript: {
    ignoreBuildErrors: true,
  },
  images: {
    unoptimized: true,
  },
}

export default nextConfig

output: 'export' の設定

この設定が静的エクスポートの鍵です。Next.js 13.3以降で導入された設定で、従来の next exportコマンドに代わる機能です。これにより npm run build実行時に out/ディレクトリに静的ファイルが生成されます。

trailingSlash: true の意味

これは少し悩ましい設定でした。実際に動かしてみて、この設定の重要性を痛感することになりました。

trailingSlash: false (デフォルト)の場合

Next.jsのデフォルトでは、trailingSlash: falseになっています。この場合、生成される静的ファイルは以下のような構造になります:

out/
├── index.html
├── about.html
└── contact.html

一見問題なさそうですが、S3 + CloudFrontでホスティングする際に厄介な問題が発生します。

たとえば、/aboutにアクセスした場合、S3は aboutというファイルを探しますが、実際のファイル名は about.htmlです。CloudFrontのデフォルトルートオブジェクトは index.htmlしか設定できないため、/aboutへのアクセスが404エラーになってしまいます。

また、/about/(末尾スラッシュあり)でアクセスすると、今度はS3が about/index.htmlを探しますが、このファイルは存在しません。結果として、URLの書き方によってページが表示されたりされなかったりする、非常に不安定な状態になります。

trailingSlash: true にした場合

trailingSlash: trueを設定すると、Next.jsは以下のようなディレクトリ構造で静的ファイルを生成します:

out/
├── index.html
├── about/
│   └── index.html
└── contact/
    └── index.html

この構造であれば、/about/にアクセスしたときに、S3は about/index.htmlを自動的に返してくれます。これはS3の静的Webサイトホスティング機能の仕様で、ディレクトリへのアクセスに対して自動的に index.htmlを探してくれるためです。

さらに、CloudFrontも同様の挙動をサポートしているため、CDN経由でも正しくページが表示されます。

SEOとURL正規化の観点

もう一つ重要な点として、/about/about/が別々のURLとして認識されてしまう問題があります。SEO的には、同じコンテンツに対して複数のURLが存在することは避けるべきです。

trailingSlash: trueを設定することで、すべてのリンクが一貫して /path/の形式になり、この問題を回避できます。Next.js側でリンク生成時に自動的にスラッシュを付与してくれるため、コード内で意識する必要もありません。

実際に遭遇した問題

最初は trailingSlashの設定を意識せずにデプロイしたところ、トップページは表示されるのに、他のページが404になるという現象に遭遇しました。ブラウザのデベロッパーツールで確認すると、CloudFrontが about.htmlではなく aboutというファイルを探していることが分かり、この設定の重要性に気づいた次第です。

結論として、S3 + CloudFrontで静的サイトをホスティングする場合は、trailingSlash: trueを設定することを強くおすすめします。

画像最適化の無効化

images: { unoptimized: true }の設定は、実は必須です。Next.jsの画像最適化機能はサーバーサイドでの処理を前提としており、静的エクスポートでは使用できません。この設定を忘れるとビルド時にエラーが発生します。

AWS CDKの構成

今回のCDK構成では、Node.js 22.16.0とAWS CDK 2.199.0を使用しました。

ファイル構成は以下のように整理しました:

  • constants.ts: ドメイン名やリージョンなどの定数を管理
  • cert-stack.ts: SSL証明書(ACM)の作成と管理
  • s3-stack.ts: S3バケットとCloudFrontディストリビューションの構成
  • formation.ts: メインのスタック定義とデプロイ順序の制御

この分離により、証明書とホスティング環境を独立して管理できるようになりました。特に、SSL証明書は他のプロジェクトでも再利用できる点がメリットです。

S3 + CloudFrontの構成で気をつけたポイント

SSL証明書の作成リージョン

これは前提情報として大事な情報なのですが...
CloudFrontで使用するSSL証明書は、必ずus-east-1リージョンで作成する必要があります。

https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/cnames-and-https-requirements.html

他のリージョンで作成した証明書はCloudFrontから参照できないため、スタックの分離設計が活かされます。証明書用のスタックだけus-east-1リージョンにデプロイし、その他のリソースは好きなリージョンで構築できます。

OAI(Origin Access Identity)の設定

S3バケットへの直接アクセスを制限し、CloudFront経由でのみアクセスを許可する設定です。これにより、意図しないトラフィック増加や不正アクセスを防ぐことができます。

CloudFrontのキャッシュ設定

静的サイトの場合、HTMLファイルとアセットファイル(CSS、JS、画像など)で異なるキャッシュ戦略が必要です。HTMLファイルは比較的短時間でキャッシュを無効化し、アセットファイルは長時間キャッシュする設定が一般的です。

ビルドとデプロイのフロー

実際の運用で重要なのは、効率的なデプロイフローです。package.jsonのスクリプト設定から、この点について説明します。

{
  "scripts": {
    "build": "tsc",
    "watch": "tsc -w",
    "cdk": "cdk",
    "deploy": "cdk deploy --all"
  }
}

デプロイは以下の順序で実行します:

  1. Next.jsアプリケーションのビルド(npm run build
  2. CDKスタックのデプロイ(npm run deploy
  3. 生成された静的ファイルをS3にアップロード

この際、--allオプションを使用することで、依存関係のあるスタックが正しい順序でデプロイされます。

ハマりどころと解決策

CloudFrontのキャッシュ問題

デプロイ後にすぐに変更が反映されない問題がありました。CloudFrontは標準で24時間のキャッシュを持つため、HTMLファイルの更新が見えない状況が発生します。

解決策として、デプロイ時にCloudFrontのインバリデーション(キャッシュクリア)を自動実行するように設定しました。ただし、インバリデーションには少額ですが料金が発生するため、頻繁な更新が想定される場合は注意が必要です。

getServerSidePropsは使えない制限

SSGでは当然ですが、リクエスト時の動的な処理は行えません。認証が必要なページや、リアルタイムデータを表示するページは別の手法を検討する必要があります。

この制限は事前に分かっていたものの、実装中に「やっぱりこの機能だけ動的にしたい」という要望が出てくることがあります。その場合は、クライアントサイドでのAPI呼び出しやISR(Incremental Static Regeneration)の検討が必要になります。

トレイリングスラッシュとルーティング

Next.jsの trailingSlash: true設定と、CloudFrontのデフォルトルートオブジェクト設定の組み合わせで、微妙なルーティング問題が発生しました。特に、/about/about/が混在するとSEO的にも良くない状況になります。

この問題は、CloudFrontのビヘイビア設定で、適切なリダイレクトルールを設定することで解決できました。

この構成のメリット・デメリット

メリット

  • 低コスト: トラフィックが少ない段階では月額数十円程度で運用可能
  • 高速: CloudFrontによる世界中での高速配信
  • スケーラブル: アクセス増加に自動対応
  • 保守性: インフラがコードで管理されるため、環境の再現や移行が容易

デメリット

  • 学習コスト: CDKとAWSサービスの理解が必要
  • 動的機能の制限: SSRやAPIルートは使用不可
  • デプロイ時間: CloudFrontの更新に時間がかかる場合がある

どういうケースに向いているか

この構成が特に効果を発揮するのは、以下のようなケースです:

  • コーポレートサイトやブログなど、更新頻度が低いサイト
  • 高いパフォーマンスが求められるランディングページ
  • グローバルにユーザーがいるWebサイト
  • AWSの他サービスとの連携を予定しているプロジェクト

逆に、頻繁にコンテンツが更新されるサイトや、複雑な認証機能が必要なアプリケーションには正直向いてません...

CI/CDパイプラインの構築

LP量産を見据えて、CI/CDパイプラインによる自動デプロイ環境を整備しました。mainブランチへのpushで自動的にビルドとデプロイが実行される仕組みです。

デプロイフローは以下のようになります:

  1. ソースコードのチェックアウト
  2. Node.js環境のセットアップ(v22.16.0)
  3. 依存関係のインストール(yarn)
  4. 環境変数の設定
  5. Next.jsアプリケーションのビルド
  6. CDKによるAWSへのデプロイ

CI/CD設定のポイント

環境変数の管理

環境変数は機密情報と非機密情報で適切に管理する必要があります。リポジトリのSettings画面から、適切な権限設定で管理することが重要です。

手動デプロイの仕組み

自動デプロイだけでなく、手動でワークフローを実行できる仕組みも用意しておくと便利です。急ぎのデプロイや、プルリクエストをマージせずに動作確認したい場合に活用できます。

自動承認の設定

CDKのデプロイ時に、IAMロールやセキュリティグループの変更があっても自動的に承認される設定が必要です。CI/CD環境では必須の設定ですが、セキュリティ面での影響を理解した上で使用する必要があります。

CDKスタックの実装

実際のCDKスタック構成を見ていきます。

constants.ts(定数管理)

環境ごとの設定を一元管理するファイルです。

export const APP_NAME = 'my-landing-page';
export const DOMAIN_NAME = 'example.com';
export const SUBDOMAIN = 'www';
export const FULL_DOMAIN = `${SUBDOMAIN}.${DOMAIN_NAME}`;

export const CDK_DEFAULT_ACCOUNT = process.env.CDK_DEFAULT_ACCOUNT || '';
export const CDK_DEFAULT_REGION = process.env.CDK_DEFAULT_REGION || 'ap-northeast-1';
export const SSL_CERTIFICATE_ARN = process.env.SSL_CERTIFICATE_ARN || '';

cert-stack.ts(SSL証明書スタック)

前述の通り、CloudFront用の証明書はus-east-1リージョンで作成する必要があります。

import * as cdk from 'aws-cdk-lib';
import * as acm from 'aws-cdk-lib/aws-certificatemanager';
import * as route53 from 'aws-cdk-lib/aws-route53';
import { Construct } from 'constructs';
import { DOMAIN_NAME, FULL_DOMAIN } from './constants';

export class CertStack extends cdk.Stack {
  public readonly certificate: acm.Certificate;

  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, {
      ...props,
      env: {
        account: props?.env?.account,
        region: 'us-east-1', // CloudFront用証明書は必ずus-east-1
      },
    });

    // Route53ホストゾーンの参照
    const hostedZone = route53.HostedZone.fromLookup(this, 'HostedZone', {
      domainName: DOMAIN_NAME,
    });

    // SSL証明書の作成
    this.certificate = new acm.Certificate(this, 'SiteCertificate', {
      domainName: FULL_DOMAIN,
      validation: acm.CertificateValidation.fromDns(hostedZone),
    });
  }
}

s3-stack.ts(S3 + CloudFrontスタック)

静的サイトホスティングのメインとなるスタックです。

import * as cdk from 'aws-cdk-lib';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
import * as origins from 'aws-cdk-lib/aws-cloudfront-origins';
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment';
import * as acm from 'aws-cdk-lib/aws-certificatemanager';
import { Construct } from 'constructs';
import { APP_NAME, FULL_DOMAIN } from './constants';

interface S3StackProps extends cdk.StackProps {
  certificate: acm.ICertificate;
}

export class S3Stack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: S3StackProps) {
    super(scope, id, props);

    // S3バケットの作成
    const siteBucket = new s3.Bucket(this, 'SiteBucket', {
      bucketName: `${APP_NAME}-site-bucket`,
      websiteIndexDocument: 'index.html',
      websiteErrorDocument: 'index.html', // SPAの場合はindex.htmlにフォールバック
      publicReadAccess: false, // OAI経由のみアクセス許可
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      removalPolicy: cdk.RemovalPolicy.DESTROY, // 開発環境用。本番はRETAIN推奨
      autoDeleteObjects: true, // 開発環境用。本番はfalse推奨
    });

    // CloudFront OAI(Origin Access Identity)の作成
    const originAccessIdentity = new cloudfront.OriginAccessIdentity(
      this,
      'OriginAccessIdentity',
      {
        comment: `OAI for ${APP_NAME}`,
      }
    );

    // S3バケットポリシーでOAIからのアクセスを許可
    siteBucket.grantRead(originAccessIdentity);

    // CloudFrontディストリビューションの作成
    const distribution = new cloudfront.Distribution(this, 'SiteDistribution', {
      defaultBehavior: {
        origin: new origins.S3Origin(siteBucket, {
          originAccessIdentity,
        }),
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
      },
      domainNames: [FULL_DOMAIN],
      certificate: props.certificate,
      defaultRootObject: 'index.html',
      errorResponses: [
        {
          httpStatus: 404,
          responseHttpStatus: 200,
          responsePagePath: '/index.html',
          ttl: cdk.Duration.minutes(5),
        },
      ],
    });

    // Next.jsのビルド成果物をS3にデプロイ
    new s3deploy.BucketDeployment(this, 'DeployWebsite', {
      sources: [s3deploy.Source.asset('../front/out')], // Next.jsのoutディレクトリ
      destinationBucket: siteBucket,
      distribution, // デプロイ時に自動でCloudFrontキャッシュを無効化
      distributionPaths: ['/*'],
    });

    // CloudFront URLを出力
    new cdk.CfnOutput(this, 'DistributionDomainName', {
      value: distribution.distributionDomainName,
      description: 'CloudFront Distribution Domain Name',
    });

    new cdk.CfnOutput(this, 'SiteURL', {
      value: `https://${FULL_DOMAIN}`,
      description: 'Website URL',
    });
  }
}

formation.ts(メインスタック定義)

各スタックをまとめて、デプロイ順序を制御します。

#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { CertStack } from '../lib/cert-stack';
import { S3Stack } from '../lib/s3-stack';
import { CDK_DEFAULT_ACCOUNT, CDK_DEFAULT_REGION, APP_NAME } from '../lib/constants';

const app = new cdk.App();

// SSL証明書スタック(us-east-1)
const certStack = new CertStack(app, `${APP_NAME}-CertStack`, {
  env: {
    account: CDK_DEFAULT_ACCOUNT,
    region: 'us-east-1', // CloudFront用証明書は必ずus-east-1
  },
  crossRegionReferences: true, // クロスリージョン参照を有効化
});

// S3 + CloudFrontスタック(任意のリージョン)
const s3Stack = new S3Stack(app, `${APP_NAME}-S3Stack`, {
  certificate: certStack.certificate,
  env: {
    account: CDK_DEFAULT_ACCOUNT,
    region: CDK_DEFAULT_REGION,
  },
  crossRegionReferences: true,
});

// 依存関係の明示(証明書が先に作成される必要がある)
s3Stack.addDependency(certStack);

スタック設計のポイント

クロスリージョン参照

crossRegionReferences: trueを設定することで、us-east-1の証明書を他のリージョンのCloudFrontから参照できるようになります。CDK v2では必須の設定です。

エラーページの設定

errorResponses: [
  {
    httpStatus: 404,
    responseHttpStatus: 200,
    responsePagePath: '/index.html',
    ttl: cdk.Duration.minutes(5),
  },
]

この設定により、存在しないパスへのアクセスも index.htmlにフォールバックされます。Next.jsのクライアントサイドルーティングが正常に動作するために必要です。

自動キャッシュ無効化

BucketDeploymentdistributiondistributionPathsを指定することで、デプロイ時に自動的にCloudFrontのキャッシュがクリアされます。これにより、更新がすぐに反映されるようになります。

ハマりどころと解決策(追加)

CDK Bootstrap の罠

初回デプロイ時に cdk bootstrapを実行し忘れると、S3へのアセットアップロードでエラーが発生します。GitHub Actionsでは毎回bootstrapを実行していますが、既にbootstrap済みの環境では何もせずスキップされるので問題ありません。

クロスリージョン参照のエラー

証明書とCloudFrontが異なるリージョンにある場合、crossRegionReferences: trueを両方のスタックに設定する必要があります。片方だけの設定だと、デプロイ時にエラーになるので注意が必要です。

今後の改善点と拡張可能性

現在の構成でも実用的ですが、さらなる改善の余地があります。

複数環境への対応

dev/staging/prodなど、複数環境を管理する場合は、CDKのコンテキストや環境変数を活用して、環境ごとに異なる設定を適用できます。

const env = app.node.tryGetContext('env') || 'dev';
const config = {
  dev: { domainName: 'dev.example.com' },
  prod: { domainName: 'www.example.com' },
};

Route53によるDNS管理

現在は手動でDNS設定を行っていますが、CDKでRoute53のAレコードも管理することで、完全なInfrastructure as Codeが実現できます。

この構成は、モダンなWebアプリケーション開発とクラウドインフラの管理を統合的に行える点が最大の価値だと思います。初期の学習コストはありますが、長期的な保守性と運用効率を考慮すると、十分に投資価値のある選択肢です!
これでもりもりLPを作って、プロダクト開発していければと思います。

特にLP量産のようなユースケースでは、ボイラープレートとして活用できるため、2サイト目以降の構築が圧倒的に楽になります。

株式会社StellarCreate | Tech blog📚

Discussion