私たちは一般的に、env を制御することで "Build once, deploy many" の哲学を実現します。しかし、Next.js では、環境変数は 2 種類に分かれます。一つはクライアント側で使用できる NEXT_PUBLIC_
で始まる環境変数、もう一つはサーバー側でのみ使用できる環境変数です。前者は Next.js のビルド時にクライアントコードに注入され、元のコードが置き換えられるため、env を制御しても一度のビルドで複数のデプロイを実現することはできません。一度異なる環境にデプロイする必要があり、env を変更する場合は再ビルドが必要です。
今日の記事では、Next.js の Runtime Env を使用して一度のビルドで複数のデプロイを実現する方法について探ります。
Next.js Runtime Env#
今日の主役は next-runtime-env
というライブラリで、これを使うことで Next.js で Runtime Env を利用できます。これを通じて、一度のビルドで複数のデプロイを実現できます。
npm i next-runtime-env
クライアント側の環境変数の使用方法を変更します:
import { env } from 'next-runtime-env'
const API_URL = process.env.NEXT_PUBLIC_API_URL
const API_URL = env('NEXT_PUBLIC_API_URL')
export const fetchJson = () => fetch(API_URL as string).then((r) => r.json())
次に、 app/layout.tsx
に環境変数注入スクリプトを追加します。
import { PublicEnvScript } from 'next-runtime-env'
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en">
<head>
<PublicEnvScript />
</head>
<body className={inter.className}>{children}</body>
</html>
)
}
これで完了です。
では、試してみましょう。以下のようなページがあり、上記の API_URL
の応答データを直接レンダリングします。
'use client'
export default function Home() {
const [json, setJson] = useState(null)
useEffect(() => {
fetchJson().then((r) => setJson(r))
}, [])
return JSON.stringify(json)
}
今、 next build
でプロジェクトをビルドし、その後 .env
の NEXT_PUBLIC_API_URL
を変更し、 next start
でプロジェクトを起動して、実際のリクエストのインターフェースが .env
の変更に応じて変わるかどうかを観察します。
現在の NEXT_PUBLIC_API_URL=https://jsonplaceholder.typicode.com/todos/2
で、プロジェクトを起動すると、ブラウザがリクエストするのは https://jsonplaceholder.typicode.com/todos/2
です。
.env
の NEXT_PUBLIC_API_URL
を https://jsonplaceholder.typicode.com/todos/3
に変更し、プロジェクトを再起動すると、ブラウザがリクエストするのは https://jsonplaceholder.typicode.com/todos/3
です。
これで、一度のビルドで複数のデプロイを実現しました。env を変更するだけで済みます。
Runtime Env の詳細#
実際、 next-runtime-env
の実装原理は非常にシンプルで、 <PublicEnvScript />
は実際には <head>
に <script />
を注入しています。以下のように。
<script data-testid="env-script">window['__ENV'] = {"NEXT_PUBLIC_API_URL":"https://jsonplaceholder.typicode.com/todos/3"}</script>
<head />
内のスクリプトはページの水合前に実行されるため、クライアント側で window['__ENV']
を通じて環境変数を取得できます。 next-runtime-env
が提供する env()
はこのように実現されています。この環境変数はサーバーサイドでは常に動的で、サーバーサイドの値は常に process.env[key]
を通じて取得されます。
以下の簡略なコードは env()
の実装を示しています。
export function env(key: string): string | undefined {
if (isBrowser()) {
if (!key.startsWith('NEXT_PUBLIC_')) {
throw new Error(
`Environment variable '${key}' is not public and cannot be accessed in the browser.`,
);
}
return window['__ENV'][key];
}
return process.env[key];
}
環境変数に依存しない成果物の構築#
プロジェクトには一般的に大量の環境変数が存在し、一部の環境変数はクライアント側でのみ使用されます。プロジェクトのビルドプロセス中に、環境変数を正しく注入しないと、プロジェクトがビルドできなくなります。
例えば、一般的な API_URL
変数はリクエストインターフェースのアドレスであり、ビルド中に値がないと、プリレンダリング中のインターフェースリクエストエラーが発生し、ビルドが失敗します。例えば、ルートハンドラー内に以下のような関数があります。
import { NextResponse } from 'next/server'
import { fetchJson } from '../../../lib/api'
export const GET = async () => {
await fetchJson()
return NextResponse.json({})
}
API_URL
が空の場合、 fetchJson
はエラーを報告し、ビルドが失敗します。
✓ Collecting page data
Generating static pages (0/6) [ ]
Error occurred prerendering page "/feed". Read more: https://nextjs.org/docs/messages/prerender-error
TypeError: Failed to parse URL from
これは、Next.js がデフォルトでルートハンドラーをプリレンダリングするためで、プリレンダリング中に fetchJson
が実行され、 API_URL
が空であるため、リクエストが失敗します。
noStore()
を使用するか、動的な方法を変更することでこの問題を解決できます。
import { unstable_noStore } from 'next/cache'
import { NextResponse } from 'next/server'
import { fetchJson } from '../../../lib/api'
export const dynamic = 'force-dynamic' // 方法 2
export const GET = async () => {
unstable_noStore() // 方法 1
await fetchJson()
return NextResponse.json({})
}
他のページのビルド中に同様の問題が発生した場合も、この部分を変更すれば解決できます。
ビルド時に、環境変数を一切注入しなかった場合、ビルド後のサービスを起動する前に、必ず現在のディレクトリに .env
ファイルを作成し、変数の値を正しく記入することを忘れないでください。そうしないと、プロジェクトが正常に動作しません。
Dockerfile を使用して環境変数に依存しないイメージを構築#
前のセクションを基に、全体のビルドプロセスをさらに封装し、Docker を使用してビルドを完了し、Docker Hub に公開することで、真の意味で一度のビルドで複数のデプロイを実現します。
Dockerfile
ファイルを作成します。
FROM node:18-alpine AS base
RUN npm install -g --arch=x64 --platform=linux sharp
FROM base AS deps
RUN apk add --no-cache libc6-compat
RUN apk add --no-cache python3 make g++
WORKDIR /app
COPY . .
RUN npm install -g pnpm
RUN pnpm install
FROM base AS builder
RUN apk update && apk add --no-cache git
WORKDIR /app
COPY --from=deps /app/ .
RUN npm install -g pnpm
ENV NODE_ENV production
RUN pnpm build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
# その他のdocker環境注入
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/.next/server ./.next/server
EXPOSE 2323
ENV PORT 2323
ENV NEXT_SHARP_PATH=/usr/local/lib/node_modules/sharp
CMD node server.js;
上記の dockerfile は公式バージョンを基に修正され、Shiroで実際に使用されています。
Next.js のスタンドアロンビルドには sharp 依存関係が含まれていないため、Docker ビルド中にまず sharp をグローバルにインストールし、その後 sharp のインストール位置の環境変数を注入しました。
このように構築された Docker イメージは環境変数に依存せず、スタンドアロンビルドにより Docker イメージの占有スペースが小さくなります。
Docker コンテナのパスマッピングを通じて、現在のディレクトリにある .env
をコンテナ内部の /app/.env
にマッピングするだけで済みます。
ここで、簡単な Docker コンポーズの例を書きます。
version: '3'
services:
shiro:
container_name: shiro
image: innei/shiro:latest
volumes:
- ./.env:/app/.env # .envファイルをマッピング
restart: always
ports:
- 2323:2323
これで完成です。今後、誰でも Docker pull を通じて構築されたイメージを取得し、ローカルの .env
を変更することで、自分の環境のプロジェクトを実行できるようになります。
この記事は Mix Space によって xLog に同期更新されました。元のリンクは https://innei.in/posts/tech/nextjs-runtime-env-and-build-once-deploy-many