はじめに
Next.js App Routerでは、Server ComponentsとClient Componentsで環境変数の扱いが異なります。この記事では、安全で効率的な環境変数管理のベストプラクティスを解説します。
環境変数の種類
| 種類 | プレフィックス | アクセス可能な場所 |
|---|
| サーバー専用 | なし | Server Components, API Routes, Server Actions |
| 公開用 | NEXT_PUBLIC_ | すべて(クライアント含む) |
環境変数ファイル
ファイルの優先順位
# 優先順位(高い順)
.env.$(NODE_ENV).local # .env.development.local, .env.production.local
.env.local # ローカル環境用(gitignore推奨)
.env.$(NODE_ENV) # .env.development, .env.production
.env # デフォルト
環境別ファイル例
# .env(デフォルト値)
NEXT_PUBLIC_APP_NAME=MyApp
NEXT_PUBLIC_API_URL=https://api.example.com
DATABASE_URL=postgresql://localhost:5432/myapp
# .env.development
NEXT_PUBLIC_API_URL=http://localhost:3001
DATABASE_URL=postgresql://localhost:5432/myapp_dev
LOG_LEVEL=debug
# .env.production
NEXT_PUBLIC_API_URL=https://api.production.example.com
# 本番のDATABASE_URLはVercelなどで設定
# .env.local(gitignore必須)
DATABASE_URL=postgresql://user:password@localhost:5432/myapp
STRIPE_SECRET_KEY=sk_test_xxxx
OPENAI_API_KEY=sk-xxxx
.gitignore設定
# .gitignore
.env*.local
.env.local
.env.development.local
.env.test.local
.env.production.local
型安全な環境変数管理
Zodによるバリデーション
// lib/env.ts
import { z } from 'zod';
const envSchema = z.object({
// サーバー専用(必須)
DATABASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
OPENAI_API_KEY: z.string().startsWith('sk-'),
// サーバー専用(オプション)
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
REDIS_URL: z.string().url().optional(),
// 公開用
NEXT_PUBLIC_APP_NAME: z.string().default('MyApp'),
NEXT_PUBLIC_API_URL: z.string().url(),
NEXT_PUBLIC_ENABLE_ANALYTICS: z
.string()
.transform((val) => val === 'true')
.default('false'),
// ビルド情報
VERCEL_GIT_COMMIT_SHA: z.string().optional(),
VERCEL_ENV: z.enum(['production', 'preview', 'development']).optional(),
});
// 型エクスポート
export type Env = z.infer<typeof envSchema>;
// バリデーション関数
function validateEnv(): Env {
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error('❌ Invalid environment variables:');
console.error(parsed.error.flatten().fieldErrors);
throw new Error('Invalid environment variables');
}
return parsed.data;
}
// シングルトンでエクスポート
export const env = validateEnv();
使用例
// Server Component
import { env } from '@/lib/env';
export default async function Page() {
// 型安全にアクセス
const apiKey = env.STRIPE_SECRET_KEY; // string型
const logLevel = env.LOG_LEVEL; // 'debug' | 'info' | 'warn' | 'error'
return <div>App: {env.NEXT_PUBLIC_APP_NAME}</div>;
}
クライアント用環境変数
// lib/env-client.ts
import { z } from 'zod';
const clientEnvSchema = z.object({
NEXT_PUBLIC_APP_NAME: z.string().default('MyApp'),
NEXT_PUBLIC_API_URL: z.string().url(),
NEXT_PUBLIC_ENABLE_ANALYTICS: z
.string()
.transform((val) => val === 'true')
.default('false'),
});
export type ClientEnv = z.infer<typeof clientEnvSchema>;
export const clientEnv: ClientEnv = {
NEXT_PUBLIC_APP_NAME: process.env.NEXT_PUBLIC_APP_NAME || 'MyApp',
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || '',
NEXT_PUBLIC_ENABLE_ANALYTICS:
process.env.NEXT_PUBLIC_ENABLE_ANALYTICS === 'true',
};
Server Componentsでの使用
// app/settings/page.tsx
import { env } from '@/lib/env';
// Server Componentでは直接process.envにアクセス可能
async function getConfig() {
const response = await fetch(env.NEXT_PUBLIC_API_URL + '/config', {
headers: {
Authorization: `Bearer ${env.STRIPE_SECRET_KEY}`,
},
});
return response.json();
}
export default async function SettingsPage() {
const config = await getConfig();
return (
<div>
<h1>{env.NEXT_PUBLIC_APP_NAME}の設定</h1>
{/* ... */}
</div>
);
}
Server Actionsでの使用
// actions/payment.ts
'use server';
import { env } from '@/lib/env';
import Stripe from 'stripe';
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
apiVersion: '2024-06-20',
});
export async function createPaymentIntent(amount: number) {
// Server Actionではサーバー専用の環境変数にアクセス可能
const paymentIntent = await stripe.paymentIntents.create({
amount,
currency: 'jpy',
});
return { clientSecret: paymentIntent.client_secret };
}
Route Handlersでの使用
// app/api/webhook/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { env } from '@/lib/env';
export async function POST(request: NextRequest) {
const signature = request.headers.get('stripe-signature');
// サーバー専用の環境変数を使用
const webhookSecret = env.STRIPE_WEBHOOK_SECRET;
// Webhook処理...
return NextResponse.json({ received: true });
}
Vercelでの環境変数設定
環境ごとの設定
# Vercel CLI
vercel env add DATABASE_URL production
vercel env add DATABASE_URL preview
vercel env add DATABASE_URL development
# 一覧表示
vercel env ls
# 取得
vercel env pull .env.local
vercel.json設定
{
"env": {
"NEXT_PUBLIC_APP_NAME": "MyApp"
},
"build": {
"env": {
"NEXT_PUBLIC_BUILD_ID": "@build-id"
}
}
}
ランタイム環境変数
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
// ビルド時に埋め込まれる環境変数
env: {
NEXT_PUBLIC_BUILD_TIME: new Date().toISOString(),
},
// 実験的機能: サーバーランタイムで読み込まれる環境変数
experimental: {
serverActions: {
bodySizeLimit: '2mb',
},
},
};
module.exports = nextConfig;
セキュリティベストプラクティス
機密情報の保護
// ❌ 危険な例
const apiKey = process.env.NEXT_PUBLIC_API_KEY; // クライアントに露出
// ✅ 安全な例
// Server Component / Server Action / Route Handler内
const apiKey = process.env.API_KEY; // サーバーのみ
環境変数の露出チェック
// scripts/check-env-exposure.ts
const dangerousPatterns = [
/NEXT_PUBLIC_.*SECRET/i,
/NEXT_PUBLIC_.*KEY/i,
/NEXT_PUBLIC_.*PASSWORD/i,
/NEXT_PUBLIC_.*TOKEN/i,
];
function checkEnvExposure() {
const exposed: string[] = [];
for (const key of Object.keys(process.env)) {
if (key.startsWith('NEXT_PUBLIC_')) {
for (const pattern of dangerousPatterns) {
if (pattern.test(key)) {
exposed.push(key);
}
}
}
}
if (exposed.length > 0) {
console.error('⚠️ 機密情報が公開される可能性があります:');
exposed.forEach((key) => console.error(` - ${key}`));
process.exit(1);
}
console.log('✅ 環境変数のセキュリティチェック完了');
}
checkEnvExposure();
CI/CDでの設定
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check env exposure
run: npx tsx scripts/check-env-exposure.ts
- name: Deploy to Vercel
uses: amondnet/vercel-action@v20
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.ORG_ID }}
vercel-project-id: ${{ secrets.PROJECT_ID }}
vercel-args: '--prod'
開発環境のセットアップスクリプト
// scripts/setup-env.ts
import fs from 'fs';
import readline from 'readline';
const requiredEnvVars = [
{ key: 'DATABASE_URL', description: 'PostgreSQL接続URL' },
{ key: 'STRIPE_SECRET_KEY', description: 'Stripeシークレットキー' },
{ key: 'OPENAI_API_KEY', description: 'OpenAI APIキー' },
];
async function setupEnv() {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const question = (prompt: string): Promise<string> => {
return new Promise((resolve) => {
rl.question(prompt, resolve);
});
};
let envContent = '';
for (const { key, description } of requiredEnvVars) {
const value = await question(`${description} (${key}): `);
if (value) {
envContent += `${key}=${value}\n`;
}
}
fs.writeFileSync('.env.local', envContent);
console.log('✅ .env.local を作成しました');
rl.close();
}
setupEnv();
デバッグ用ユーティリティ
// lib/debug-env.ts
export function logEnvStatus() {
if (process.env.NODE_ENV !== 'development') return;
const serverOnlyVars = [
'DATABASE_URL',
'STRIPE_SECRET_KEY',
'OPENAI_API_KEY',
];
const publicVars = Object.keys(process.env).filter((key) =>
key.startsWith('NEXT_PUBLIC_')
);
console.log('📦 Server-only environment variables:');
serverOnlyVars.forEach((key) => {
const value = process.env[key];
console.log(` ${key}: ${value ? '✅ Set' : '❌ Missing'}`);
});
console.log('\n🌍 Public environment variables:');
publicVars.forEach((key) => {
console.log(` ${key}: ${process.env[key]}`);
});
}
まとめ
| 変数の種類 | 用途 | 保存場所 |
|---|
| DATABASE_URL | DB接続 | .env.local / Vercel |
| API_SECRET | 外部API | .env.local / Vercel |
| NEXT_PUBLIC_* | クライアント設定 | .env / Vercel |
参考文献