Documentation Next.js

はじめに

この記事では、Next.jsアプリケーションを大規模トラフィックに対応させるためのスケーリング戦略について解説します。VercelやAWSなどのクラウドプラットフォームを活用した具体的な実装方法から、ISR(インクリメンタル静的再生成)やキャッシング戦略まで、実践的なコード例とともに紹介します。

スケーリング(拡張性)とは、アプリケーションがユーザー数やリクエスト数の増加に応じて、安定したパフォーマンスを維持できる能力のことです。適切なスケーリング戦略を採用することで、アクセス集中時でも快適なユーザー体験を提供できます。

スケーリングの基本概念

垂直スケーリングと水平スケーリング

スケーリングには主に2つのアプローチがあります。

スケーリング方式説明メリットデメリット
垂直スケーリングサーバーのスペック(CPU、メモリ)を増強設定が簡単物理的な限界がある
水平スケーリングサーバーの台数を増やす理論上無限に拡張可能複雑な設計が必要

Next.jsアプリケーションでは、サーバーレスアーキテクチャを採用することで、水平スケーリングを自動的に行えます。

レンダリング戦略の選択

Next.jsでは、ページごとに最適なレンダリング戦略を選択できます。

// app/products/page.tsx
// 静的生成(SSG)- ビルド時に生成、最も高速
export default async function ProductsPage() {
  // ビルド時にデータを取得
  const products = await fetch('https://api.example.com/products', {
    cache: 'force-cache' // 静的にキャッシュ
  }).then(res => res.json())

  return (
    <div>
      <h1>製品一覧</h1>
      {products.map((product: { id: string; name: string }) => (
        <div key={product.id}>{product.name}</div>
      ))}
    </div>
  )
}
// app/dashboard/page.tsx
// サーバーサイドレンダリング(SSR)- リクエストごとに生成
export const dynamic = 'force-dynamic' // SSRを強制

export default async function DashboardPage() {
  // リクエストごとに最新データを取得
  const data = await fetch('https://api.example.com/dashboard', {
    cache: 'no-store' // キャッシュしない
  }).then(res => res.json())

  return (
    <div>
      <h1>ダッシュボード</h1>
      <p>最終更新: {new Date().toLocaleString()}</p>
    </div>
  )
}

Vercelによるスケーリング

Vercelは、Next.jsの開発元が提供するホスティングサービスで、Next.jsアプリケーションに最適化されています。

Vercelの主な特徴

  • オートスケーリング: トラフィックに応じてサーバーレスファンクションが自動的にスケール
  • グローバルCDN: 世界中のエッジロケーションからコンテンツを配信
  • ゼロコンフィグデプロイ: Git連携で自動デプロイ

Edge Functionsの活用

Edge Functionsを使用すると、ユーザーに最も近いエッジロケーションでコードを実行できます。

// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

// Edge Runtimeで実行されるミドルウェア
export const config = {
  matcher: '/api/:path*',
}

export function middleware(request: NextRequest) {
  // 地域に基づいたルーティング
  const country = request.geo?.country || 'JP'

  // レスポンスヘッダーにキャッシュ設定を追加
  const response = NextResponse.next()
  response.headers.set('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=300')

  // 国別のコンテンツリダイレクト
  if (country !== 'JP' && request.nextUrl.pathname === '/') {
    return NextResponse.redirect(new URL(`/${country.toLowerCase()}`, request.url))
  }

  return response
}

Vercelでのキャッシュ設定

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  // 画像最適化の設定
  images: {
    domains: ['example.com', 'cdn.example.com'],
    // Vercelの画像最適化を使用
    loader: 'default',
  },

  // カスタムヘッダーでキャッシュを制御
  async headers() {
    return [
      {
        // 静的アセットに長期キャッシュを設定
        source: '/_next/static/:path*',
        headers: [
          {
            key: 'Cache-Control',
            value: 'public, max-age=31536000, immutable',
          },
        ],
      },
      {
        // APIルートには短いキャッシュ
        source: '/api/:path*',
        headers: [
          {
            key: 'Cache-Control',
            value: 'public, s-maxage=60, stale-while-revalidate=300',
          },
        ],
      },
    ]
  },
}

module.exports = nextConfig

AWSを使ったスケーリング

AWSでは、より細かいインフラ制御が可能です。

AWS構成の概要

┌─────────────────────────────────────────────────────────┐
│                    CloudFront (CDN)                      │
│                   グローバルエッジ配信                     │
└─────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────┐
│               Application Load Balancer                  │
│                   ロードバランシング                       │
└─────────────────────────────────────────────────────────┘

              ┌─────────────┼─────────────┐
              ▼             ▼             ▼
        ┌─────────┐   ┌─────────┐   ┌─────────┐
        │ Lambda  │   │ Lambda  │   │ Lambda  │
        │ (SSR)   │   │ (SSR)   │   │ (SSR)   │
        └─────────┘   └─────────┘   └─────────┘


                    ┌─────────────┐
                    │     S3      │
                    │ (静的ファイル) │
                    └─────────────┘

AWS Lambda + API Gatewayでのデプロイ

AWS CDKを使用した構成例です。

// cdk/lib/nextjs-stack.ts
import * as cdk from 'aws-cdk-lib'
import * as lambda from 'aws-cdk-lib/aws-lambda'
import * as apigateway from 'aws-cdk-lib/aws-apigateway'
import * as s3 from 'aws-cdk-lib/aws-s3'
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront'
import { Construct } from 'constructs'

export class NextJsStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props)

    // 静的アセット用S3バケット
    const staticBucket = new s3.Bucket(this, 'StaticAssets', {
      bucketName: 'nextjs-static-assets',
      publicReadAccess: false,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
    })

    // SSR用Lambda関数
    const ssrFunction = new lambda.Function(this, 'SSRHandler', {
      runtime: lambda.Runtime.NODEJS_20_X,
      handler: 'index.handler',
      code: lambda.Code.fromAsset('.next/standalone'),
      memorySize: 1024,
      timeout: cdk.Duration.seconds(30),
      environment: {
        NODE_ENV: 'production',
      },
    })

    // API Gateway
    const api = new apigateway.LambdaRestApi(this, 'NextJsApi', {
      handler: ssrFunction,
      proxy: true,
    })

    // CloudFront Distribution
    const distribution = new cloudfront.CloudFrontWebDistribution(this, 'Distribution', {
      originConfigs: [
        {
          // 静的アセットはS3から配信
          s3OriginSource: {
            s3BucketSource: staticBucket,
          },
          behaviors: [
            {
              pathPattern: '/_next/static/*',
              defaultTtl: cdk.Duration.days(365),
              compress: true,
            },
          ],
        },
        {
          // 動的コンテンツはAPI Gatewayから配信
          customOriginSource: {
            domainName: `${api.restApiId}.execute-api.${this.region}.amazonaws.com`,
            originPath: '/prod',
          },
          behaviors: [
            {
              isDefaultBehavior: true,
              defaultTtl: cdk.Duration.seconds(0),
              allowedMethods: cloudfront.CloudFrontAllowedMethods.ALL,
            },
          ],
        },
      ],
    })
  }
}

環境変数の管理

// lib/config.ts
// AWS Systems Manager Parameter Storeから設定を取得
import { SSMClient, GetParameterCommand } from '@aws-sdk/client-ssm'

const ssmClient = new SSMClient({ region: process.env.AWS_REGION })

// 設定のキャッシュ
const configCache = new Map<string, { value: string; timestamp: number }>()
const CACHE_TTL = 5 * 60 * 1000 // 5分

export async function getConfig(parameterName: string): Promise<string> {
  // キャッシュをチェック
  const cached = configCache.get(parameterName)
  if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
    return cached.value
  }

  // Parameter Storeから取得
  const command = new GetParameterCommand({
    Name: parameterName,
    WithDecryption: true,
  })

  const response = await ssmClient.send(command)
  const value = response.Parameter?.Value || ''

  // キャッシュに保存
  configCache.set(parameterName, { value, timestamp: Date.now() })

  return value
}

ISR(インクリメンタル静的再生成)

ISRは、静的生成のパフォーマンスとSSRの柔軟性を両立する機能です。

ISRの仕組み

初回リクエスト


┌─────────────────────────────────────┐
│ キャッシュされたページを即座に返却     │
│ (stale-while-revalidate)            │
└─────────────────────────────────────┘

    ▼ (バックグラウンド)
┌─────────────────────────────────────┐
│ 新しいページを再生成                  │
│ キャッシュを更新                      │
└─────────────────────────────────────┘


次回リクエストから新しいページを配信

ISRの実装例

// app/posts/[slug]/page.tsx
import { notFound } from 'next/navigation'

interface Post {
  slug: string
  title: string
  content: string
  updatedAt: string
}

// ISRでページを生成(60秒ごとに再検証)
export const revalidate = 60

// 静的パラメータの生成
export async function generateStaticParams() {
  const posts = await fetch('https://api.example.com/posts').then(res => res.json())

  // 最初のビルドでは最新100件のみ生成
  return posts.slice(0, 100).map((post: Post) => ({
    slug: post.slug,
  }))
}

async function getPost(slug: string): Promise<Post | null> {
  const res = await fetch(`https://api.example.com/posts/${slug}`, {
    next: { revalidate: 60 } // 60秒ごとに再検証
  })

  if (!res.ok) return null
  return res.json()
}

export default async function PostPage({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug)

  if (!post) {
    notFound()
  }

  return (
    <article>
      <h1>{post.title}</h1>
      <p>最終更新: {new Date(post.updatedAt).toLocaleDateString('ja-JP')}</p>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  )
}

オンデマンド再検証

特定のイベント(記事更新など)でページを即座に再生成できます。

// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'

export async function POST(request: NextRequest) {
  // シークレットトークンで認証
  const authHeader = request.headers.get('authorization')
  if (authHeader !== `Bearer ${process.env.REVALIDATION_TOKEN}`) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const { path, tag } = await request.json()

  try {
    if (path) {
      // 特定のパスを再検証
      revalidatePath(path)
      return NextResponse.json({ revalidated: true, path })
    }

    if (tag) {
      // タグに関連するすべてのデータを再検証
      revalidateTag(tag)
      return NextResponse.json({ revalidated: true, tag })
    }

    return NextResponse.json({ error: 'Path or tag required' }, { status: 400 })
  } catch (err) {
    return NextResponse.json({ error: 'Error revalidating' }, { status: 500 })
  }
}
// 使用例: CMSからのWebhook
// curl -X POST https://example.com/api/revalidate \
//   -H "Authorization: Bearer your-token" \
//   -H "Content-Type: application/json" \
//   -d '{"path": "/posts/my-article"}'

キャッシング戦略

多層キャッシングの実装

// lib/cache.ts
import { unstable_cache } from 'next/cache'

interface CacheOptions {
  tags?: string[]
  revalidate?: number
}

// Next.jsのキャッシュを使用したデータ取得
export function cachedFetch<T>(
  fetchFn: () => Promise<T>,
  cacheKey: string[],
  options: CacheOptions = {}
) {
  return unstable_cache(
    fetchFn,
    cacheKey,
    {
      tags: options.tags,
      revalidate: options.revalidate || 3600, // デフォルト1時間
    }
  )
}

// 使用例
export const getProducts = cachedFetch(
  async () => {
    const res = await fetch('https://api.example.com/products')
    return res.json()
  },
  ['products'],
  { tags: ['products'], revalidate: 300 } // 5分ごとに再検証
)

Redisを使用した分散キャッシュ

// lib/redis-cache.ts
import { Redis } from 'ioredis'

const redis = new Redis(process.env.REDIS_URL!)

interface CacheEntry<T> {
  data: T
  timestamp: number
}

export async function getCachedData<T>(
  key: string,
  fetchFn: () => Promise<T>,
  ttlSeconds: number = 3600
): Promise<T> {
  // Redisからキャッシュを取得
  const cached = await redis.get(key)

  if (cached) {
    const entry: CacheEntry<T> = JSON.parse(cached)
    return entry.data
  }

  // キャッシュがない場合はデータを取得
  const data = await fetchFn()

  // Redisにキャッシュを保存
  const entry: CacheEntry<T> = {
    data,
    timestamp: Date.now(),
  }
  await redis.setex(key, ttlSeconds, JSON.stringify(entry))

  return data
}

// 使用例
export async function getUserProfile(userId: string) {
  return getCachedData(
    `user:${userId}`,
    async () => {
      const res = await fetch(`https://api.example.com/users/${userId}`)
      return res.json()
    },
    600 // 10分間キャッシュ
  )
}

画像と静的ファイルの最適化

next/imageの活用

// components/OptimizedImage.tsx
import Image from 'next/image'

interface OptimizedImageProps {
  src: string
  alt: string
  priority?: boolean
}

export function OptimizedImage({ src, alt, priority = false }: OptimizedImageProps) {
  return (
    <div className="relative aspect-video">
      <Image
        src={src}
        alt={alt}
        fill
        sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
        priority={priority} // LCP(Largest Contentful Paint)対象の画像に設定
        placeholder="blur"
        blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAIAAoDASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAAAAUH/8QAIRAAAQMDBAMAAAAAAAAAAAAAAQACAwQFEQYSITEHE0H/xAAVAQEBAAAAAAAAAAAAAAAAAAADBf/EABkRAAIDAQAAAAAAAAAAAAAAAAABAgMRIf/aAAwDAQACEQMRAD8At4o="
        className="object-cover"
      />
    </div>
  )
}

静的ファイルの圧縮設定

// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
})

/** @type {import('next').NextConfig} */
const nextConfig = {
  // 圧縮を有効化
  compress: true,

  // 画像最適化
  images: {
    formats: ['image/avif', 'image/webp'],
    deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
    imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
    minimumCacheTTL: 60 * 60 * 24 * 365, // 1年間キャッシュ
  },

  // 出力の最適化
  output: 'standalone', // Dockerデプロイに最適

  // Webpack設定
  webpack: (config, { isServer }) => {
    // 不要なモジュールを除外
    if (!isServer) {
      config.resolve.fallback = {
        fs: false,
        net: false,
        tls: false,
      }
    }
    return config
  },
}

module.exports = withBundleAnalyzer(nextConfig)

パフォーマンスモニタリング

Web Vitalsの計測

// app/layout.tsx
import { SpeedInsights } from '@vercel/speed-insights/next'
import { Analytics } from '@vercel/analytics/react'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="ja">
      <body>
        {children}
        {/* Vercel Speed Insights */}
        <SpeedInsights />
        {/* Vercel Analytics */}
        <Analytics />
      </body>
    </html>
  )
}
// components/WebVitals.tsx
'use client'

import { useReportWebVitals } from 'next/web-vitals'

export function WebVitals() {
  useReportWebVitals((metric) => {
    // Google Analyticsに送信
    if (typeof window !== 'undefined' && window.gtag) {
      window.gtag('event', metric.name, {
        value: Math.round(
          metric.name === 'CLS' ? metric.value * 1000 : metric.value
        ),
        event_label: metric.id,
        non_interaction: true,
      })
    }

    // コンソールにも出力(開発時)
    if (process.env.NODE_ENV === 'development') {
      console.log(metric)
    }
  })

  return null
}

パフォーマンス目標値

メトリクス良好要改善不良
LCP(Largest Contentful Paint)≤ 2.5秒≤ 4.0秒> 4.0秒
FID(First Input Delay)≤ 100ms≤ 300ms> 300ms
CLS(Cumulative Layout Shift)≤ 0.1≤ 0.25> 0.25
TTFB(Time to First Byte)≤ 800ms≤ 1800ms> 1800ms

まとめ

Next.jsアプリケーションのスケーリング戦略では、以下のポイントが重要です。

  1. 適切なレンダリング戦略の選択: ページの特性に応じてSSG、SSR、ISRを使い分ける
  2. クラウドプラットフォームの活用: Vercelの自動最適化やAWSの柔軟なインフラを活用
  3. 多層キャッシング: CDN、アプリケーション、データベースの各層でキャッシュを設定
  4. 画像と静的ファイルの最適化: next/imageの活用と適切なキャッシュヘッダーの設定
  5. パフォーマンスモニタリング: Web Vitalsを継続的に計測し、改善を続ける

これらの戦略を組み合わせることで、大規模トラフィックにも対応できる高パフォーマンスなNext.jsアプリケーションを構築できます。

参考文献

円