We generally achieve the philosophy of "Build once, deploy many" through controlling the env. But in Next.js, environment variables are divided into two types: one is the environment variables that can be used on the client side, starting with NEXT_PUBLIC_
, and the other is the environment variables that can only be used on the server side. The former will be injected into the client code during Next.js build, causing the original code to be replaced. This means that controlling the env cannot achieve build once, deploy many. Once we need to deploy to different environments and modify the env, we need to rebuild.
In today's article, we will explore how to achieve build once, deploy many through Next.js Runtime Env.
Next.js Runtime Env#
The protagonist today is the library next-runtime-env
, which allows us to use Runtime Env in Next.js. We can use it to achieve build once, deploy many.
npm i next-runtime-env
Change the way of using client-side environment variables:
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())
Then add environment variable injection script to 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>
)
}
That's it.
Now let's try it out. We have a page that directly renders the response data of API_URL
.
'use client'
export default function Home() {
const [json, setJson] = useState(null)
useEffect(() => {
fetchJson().then((r) => setJson(r))
}, [])
return JSON.stringify(json)
}
Now we use next build
to build the project, then after the build, modify the NEXT_PUBLIC_API_URL
in .env
, and then use next start
to start the project, and observe whether the actual requested interface changes with the modification of .env
.
Now our NEXT_PUBLIC_API_URL=https://jsonplaceholder.typicode.com/todos/2
, after starting the project, the browser requests https://jsonplaceholder.typicode.com/todos/2
.
When we modify NEXT_PUBLIC_API_URL
in .env
to https://jsonplaceholder.typicode.com/todos/3
, and then restart the project, the browser requests https://jsonplaceholder.typicode.com/todos/3
.
This way, we have achieved build once, deploy many, just by modifying the env.
Understanding Runtime Env in Depth#
In fact, the implementation principle of next-runtime-env
is very simple. <PublicEnvScript />
actually injects a <script />
similar to this in <head>
.
<script data-testid="env-script">window['__ENV'] = {"NEXT_PUBLIC_API_URL":"https://jsonplaceholder.typicode.com/todos/3"}</script>
Since the script in <head />
will be executed before the page hydration, we can use window['__ENV']
to get the environment variables on the client side, and next-runtime-env
provides env()
to achieve this. And this environment variable is dynamic on the server side, so the value on the server side is always obtained through process.env[]
.
The following simplified code shows the implementation of 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];
}
Building an Artifact without Environment Variable Dependencies#
In a project, there are generally a large number of environment variables, and some of them will only be used on the client side. During the project build process, the environment variables must be injected correctly, otherwise it will cause the project to fail to build.
For example, the common API_URL
variable is the address for requesting the interface. In the build process, if it is empty, it will cause the interface request in pre-rendering to fail and cause the build to fail. For example, in the Route Handler, we have such a function.
import { NextResponse } from 'next/server'
import { fetchJson } from '../../../lib/api'
export const GET = async () => {
await fetchJson()
return NextResponse.json({})
}
When API_URL
is empty, fetchJson
will throw an error, causing the build to fail.
✓ 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
Just use noStore()
or change the dynamic method to solve this problem.
import { unstable_noStore } from 'next/cache'
import { NextResponse } from 'next/server'
import { fetchJson } from '../../../lib/api'
export const dynamic = 'force-dynamic' // Method 2
export const GET = async () => {
unstable_noStore() // Method 1
await fetchJson()
return NextResponse.json({})
}
So, if you encounter similar problems in other page builds, just modify this part.
During the build, we did not inject any environment variables. Before starting the built service, remember to create a .env
file in the current directory and fill in the variable values correctly to ensure the normal operation of the project.
Building an Artifact without Environment Variable Dependencies through Dockerfile#
Based on the previous section, further encapsulate the entire build process and use Docker to complete the entire build and then publish it to Docker Hub, truly achieving build once, deploy many.
Create a Dockerfile
file.
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
# and other docker env inject
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;
The above dockerfile is modified based on the official version and has been used in Shiro.
Since the Next.js standalone build does not include the sharp dependency, we first globally install sharp in the Docker build, and then inject the environment variable of the installation location of sharp.
This way, the Docker image built does not depend on environment variables, and the standalone build makes the Docker image take up less space.
Through the path mapping of the Docker container, we only need to map the .env
in the current directory to /app/.env
inside the container.
Here is a simple Docker compose example.
version: '3'
services:
shiro:
container_name: shiro
image: innei/shiro:latest
volumes:
- ./.env:/app/.env # Map .env file
restart: always
ports:
- 2323:2323
Great, anyone can simply pull the built image through Docker pull and then modify the local .env
to run their own environment project.
This article is also updated to xLog by Mix Space
The original link is https://innei.in/posts/tech/nextjs-runtime-env-and-build-once-deploy-many