はじめに
この記事では、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アプリケーションのスケーリング戦略では、以下のポイントが重要です。
- 適切なレンダリング戦略の選択: ページの特性に応じてSSG、SSR、ISRを使い分ける
- クラウドプラットフォームの活用: Vercelの自動最適化やAWSの柔軟なインフラを活用
- 多層キャッシング: CDN、アプリケーション、データベースの各層でキャッシュを設定
- 画像と静的ファイルの最適化: next/imageの活用と適切なキャッシュヘッダーの設定
- パフォーマンスモニタリング: Web Vitalsを継続的に計測し、改善を続ける
これらの戦略を組み合わせることで、大規模トラフィックにも対応できる高パフォーマンスなNext.jsアプリケーションを構築できます。