Build once, deploy everywhere - Next.js Runtime Env

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 // [!code --]
const API_URL = env('NEXT_PUBLIC_API_URL') // [!code ++]

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({
}: Readonly<{
  children: React.ReactNode
}>) {
  return (
    <html lang="en">
        <PublicEnvScript /> // [!code ++]
      <body className={inter.className}>{children}</body>

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=, after starting the project, the browser requests


When we modify NEXT_PUBLIC_API_URL in .env to, and then restart the project, the browser requests


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":""}</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:

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++


COPY . .

RUN npm install -g pnpm
RUN pnpm install

FROM base AS builder

RUN apk update && apk add --no-cache git

COPY --from=deps /app/ .
RUN npm install -g pnpm

ENV NODE_ENV production
RUN pnpm build

FROM base AS runner

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


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'

    container_name: shiro
    image: innei/shiro:latest
      - ./.env:/app/.env # Map .env file
    restart: always
      - 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

Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.