Documentation Next.js

はじめに

Next.js App RouterはEdge Runtimeをサポートしており、Middlewareやルートハンドラーをエッジサーバーで実行できます。これにより、ユーザーに近い場所で処理を行い、レイテンシを大幅に削減できます。

Edge RuntimeとNode.js Runtimeの比較

特徴Edge RuntimeNode.js Runtime
実行環境V8 IsolatesNode.js
コールドスタート〜0ms〜250ms
最大実行時間30秒(Vercel)10秒〜5分
メモリ128MB1024MB〜
Node.js API一部のみフルサポート
npm packagesWeb API互換のみすべて

Middleware(エッジで常に実行)

基本構造

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

export function middleware(request: NextRequest) {
  // すべてのリクエストに対して実行される
  console.log('Request path:', request.nextUrl.pathname);

  return NextResponse.next();
}

export const config = {
  // マッチャーでMiddlewareを適用するパスを指定
  matcher: [
    // 静的ファイルと_nextを除外
    '/((?!_next/static|_next/image|favicon.ico).*)',
  ],
};

認証チェック

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { verifyToken } from '@/lib/auth';

const protectedPaths = ['/dashboard', '/settings', '/api/protected'];

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // 保護されたパスかチェック
  const isProtectedPath = protectedPaths.some((path) =>
    pathname.startsWith(path)
  );

  if (!isProtectedPath) {
    return NextResponse.next();
  }

  // トークン検証
  const token = request.cookies.get('auth-token')?.value;

  if (!token) {
    const loginUrl = new URL('/login', request.url);
    loginUrl.searchParams.set('redirect', pathname);
    return NextResponse.redirect(loginUrl);
  }

  try {
    const payload = await verifyToken(token);

    // ユーザー情報をヘッダーに追加(Server Componentで使用)
    const response = NextResponse.next();
    response.headers.set('x-user-id', payload.userId);
    response.headers.set('x-user-role', payload.role);

    return response;
  } catch (error) {
    // トークンが無効
    const response = NextResponse.redirect(new URL('/login', request.url));
    response.cookies.delete('auth-token');
    return response;
  }
}

export const config = {
  matcher: ['/dashboard/:path*', '/settings/:path*', '/api/protected/:path*'],
};

地理情報に基づくルーティング

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

export function middleware(request: NextRequest) {
  const country = request.geo?.country || 'US';
  const city = request.geo?.city || 'Unknown';

  // 国別リダイレクト
  if (country === 'JP' && !request.nextUrl.pathname.startsWith('/ja')) {
    return NextResponse.redirect(new URL('/ja' + request.nextUrl.pathname, request.url));
  }

  // 地理情報をヘッダーに追加
  const response = NextResponse.next();
  response.headers.set('x-country', country);
  response.headers.set('x-city', city);

  return response;
}

A/Bテスト

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

const COOKIE_NAME = 'ab-test-variant';

export function middleware(request: NextRequest) {
  // 既存のバリアントを確認
  let variant = request.cookies.get(COOKIE_NAME)?.value;

  // 新規ユーザーにランダムにバリアントを割り当て
  if (!variant) {
    variant = Math.random() < 0.5 ? 'control' : 'experiment';
  }

  // バリアントに基づいてリライト
  const url = request.nextUrl.clone();

  if (url.pathname === '/' && variant === 'experiment') {
    url.pathname = '/home-experiment';
  }

  const response = NextResponse.rewrite(url);

  // Cookieを設定
  response.cookies.set(COOKIE_NAME, variant, {
    maxAge: 60 * 60 * 24 * 30, // 30日
    httpOnly: true,
  });

  return response;
}

export const config = {
  matcher: ['/'],
};

レート制限

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, '10 s'), // 10秒間に10リクエスト
  analytics: true,
});

export async function middleware(request: NextRequest) {
  // API ルートのみレート制限
  if (!request.nextUrl.pathname.startsWith('/api')) {
    return NextResponse.next();
  }

  const ip = request.ip ?? '127.0.0.1';
  const { success, limit, reset, remaining } = await ratelimit.limit(ip);

  if (!success) {
    return NextResponse.json(
      { error: 'Too many requests' },
      {
        status: 429,
        headers: {
          'X-RateLimit-Limit': limit.toString(),
          'X-RateLimit-Remaining': remaining.toString(),
          'X-RateLimit-Reset': reset.toString(),
        },
      }
    );
  }

  const response = NextResponse.next();
  response.headers.set('X-RateLimit-Limit', limit.toString());
  response.headers.set('X-RateLimit-Remaining', remaining.toString());

  return response;
}

export const config = {
  matcher: ['/api/:path*'],
};

Edge API Routes

基本的なEdge API Route

// app/api/hello/route.ts
import { NextRequest, NextResponse } from 'next/server';

export const runtime = 'edge';

export async function GET(request: NextRequest) {
  return NextResponse.json({
    message: 'Hello from the Edge!',
    region: process.env.VERCEL_REGION || 'local',
    timestamp: new Date().toISOString(),
  });
}

地理情報API

// app/api/geo/route.ts
import { NextRequest, NextResponse } from 'next/server';

export const runtime = 'edge';

export async function GET(request: NextRequest) {
  const geo = request.geo;

  return NextResponse.json({
    country: geo?.country || 'Unknown',
    city: geo?.city || 'Unknown',
    region: geo?.region || 'Unknown',
    latitude: geo?.latitude || null,
    longitude: geo?.longitude || null,
  });
}

ストリーミングレスポンス

// app/api/stream/route.ts
import { NextRequest } from 'next/server';

export const runtime = 'edge';

export async function GET(request: NextRequest) {
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    async start(controller) {
      for (let i = 1; i <= 5; i++) {
        const chunk = encoder.encode(`data: Message ${i}\n\n`);
        controller.enqueue(chunk);
        await new Promise((resolve) => setTimeout(resolve, 1000));
      }
      controller.close();
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      Connection: 'keep-alive',
    },
  });
}

LLMストリーミング(AI SDK)

// app/api/chat/route.ts
import { OpenAIStream, StreamingTextResponse } from 'ai';
import OpenAI from 'openai';

export const runtime = 'edge';

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

export async function POST(request: Request) {
  const { messages } = await request.json();

  const response = await openai.chat.completions.create({
    model: 'gpt-4',
    stream: true,
    messages,
  });

  const stream = OpenAIStream(response);

  return new StreamingTextResponse(stream);
}

Edge対応のデータフェッチ

KVストア(Vercel KV)

// app/api/cache/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { kv } from '@vercel/kv';

export const runtime = 'edge';

export async function GET(request: NextRequest) {
  const key = request.nextUrl.searchParams.get('key');

  if (!key) {
    return NextResponse.json({ error: 'Key required' }, { status: 400 });
  }

  const value = await kv.get(key);

  return NextResponse.json({ key, value });
}

export async function POST(request: NextRequest) {
  const { key, value, ttl } = await request.json();

  if (ttl) {
    await kv.set(key, value, { ex: ttl });
  } else {
    await kv.set(key, value);
  }

  return NextResponse.json({ success: true });
}

Edge Config

// app/api/config/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { get } from '@vercel/edge-config';

export const runtime = 'edge';

export async function GET(request: NextRequest) {
  const featureFlags = await get('featureFlags');
  const maintenance = await get('maintenanceMode');

  return NextResponse.json({
    featureFlags,
    maintenance,
  });
}

パフォーマンス最適化

レスポンスキャッシュ

// app/api/cached/route.ts
import { NextRequest, NextResponse } from 'next/server';

export const runtime = 'edge';

export async function GET(request: NextRequest) {
  const data = await fetchExpensiveData();

  return NextResponse.json(data, {
    headers: {
      'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300',
    },
  });
}

条件付きリクエスト

// app/api/resource/route.ts
import { NextRequest, NextResponse } from 'next/server';

export const runtime = 'edge';

export async function GET(request: NextRequest) {
  const data = { id: 1, name: 'Resource', updatedAt: '2024-01-01' };
  const etag = `"${Buffer.from(JSON.stringify(data)).toString('base64')}"`;

  // ETagチェック
  const ifNoneMatch = request.headers.get('if-none-match');
  if (ifNoneMatch === etag) {
    return new Response(null, { status: 304 });
  }

  return NextResponse.json(data, {
    headers: {
      ETag: etag,
      'Cache-Control': 'public, max-age=0, must-revalidate',
    },
  });
}

Edge Runtimeの制限事項

使用できないAPI

// ❌ 使用不可
import fs from 'fs';           // ファイルシステム
import path from 'path';       // 一部のみ
import { spawn } from 'child_process'; // 子プロセス

// ✅ 使用可能
import { NextResponse } from 'next/server';
import crypto from 'crypto';   // Web Crypto API

代替パターン

// app/api/file/route.ts
// Edgeで使えない場合はNode.jsランタイムを使用
export const runtime = 'nodejs';

import { readFile } from 'fs/promises';
import { NextResponse } from 'next/server';

export async function GET() {
  const content = await readFile('./data/config.json', 'utf-8');
  return NextResponse.json(JSON.parse(content));
}

ユースケース別推奨ランタイム

ユースケース推奨ランタイム
認証・認可Edge
A/BテストEdge
リダイレクトEdge
地理情報取得Edge
AIストリーミングEdge
ファイル操作Node.js
データベース接続Node.js
重い計算処理Node.js

まとめ

機能実装場所特徴
Middlewaremiddleware.ts全リクエストに適用
Edge APIroute.ts + runtime: ‘edge’低遅延API
ストリーミングReadableStreamリアルタイム応答
キャッシュVercel KV / Edge Configグローバル分散

参考文献

円