はじめに Edge RuntimeとNode.js Runtimeの比較 Middleware(エッジで常に実行) 基本構造 認証チェック 地理情報に基づくルーティング A/Bテスト レート制限 Edge API Routes 基本的なEdge API Route 地理情報API ストリーミングレスポンス LLMストリーミング(AI SDK) Edge対応のデータフェッチ KVストア(Vercel KV) Edge Config パフォーマンス最適化 レスポンスキャッシュ 条件付きリクエスト Edge Runtimeの制限事項 使用できないAPI 代替パターン ユースケース別推奨ランタイム まとめ 参考文献 はじめに
Next.js App RouterはEdge Runtimeをサポートしており、Middlewareやルートハンドラーをエッジサーバーで実行できます。これにより、ユーザーに近い場所で処理を行い、レイテンシを大幅に削減できます。
Edge RuntimeとNode.js Runtimeの比較
特徴 Edge Runtime Node.js Runtime 実行環境 V8 Isolates Node.js コールドスタート 〜0ms 〜250ms 最大実行時間 30秒(Vercel) 10秒〜5分 メモリ 128MB 1024MB〜 Node.js API 一部のみ フルサポート npm packages Web 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
まとめ
機能 実装場所 特徴 Middleware middleware.ts 全リクエストに適用 Edge API route.ts + runtime: ‘edge’ 低遅延API ストリーミング ReadableStream リアルタイム応答 キャッシュ Vercel KV / Edge Config グローバル分散
参考文献