
Points where I got stuck when referencing Next.js environment variables on ECS
Introduction
Hello everyone, I'm Asano from the Cloud Business Division Consulting Department.
I hadn't tried hosting Next.js on ECS before, so I gave it a try and encountered several issues related to environment variable references, which I'd like to summarize here.
As a note, the stumbling points below are not specific to ECS; they just happened to occur on ECS in this case.
Operating Environment
Main Framework
- Next.js: 15.5.2
- Build option: Turbopack
- React: 19.1.0
Docker Environment
-
Base image: node:20-alpine
-
Execution method: Server mode (non-standalone)## ① Browser environment variables are inlined at build time
Next.js handles two types of environment variables: -
- Environment variables accessible only on the server side: Variables without the "NEXT_PUBLIC_" prefix are only available in the Node.js environment and cannot be accessed from the browser.
-
- Browser environment variables: Variables with the "NEXT_PUBLIC_" prefix can be used from both server-side and browser-side. Be careful not to include highly confidential information as users can view these values if they want to.
For browser environment variables (type 2), the values are inlined from dynamic values (process.env.NEXT_PUBLIC_HOGEHOGE) to static values in the JS bundle delivered to the client at build time, so we need to pass environment variables at the "npm run build" stage. Therefore, even if you set "NEXT_PUBLIC_HOGEHOGE" in your ECS environment variables, it will be evaluated as undefined in your code because that information isn't available when building in the Dockerfile.
Here's a specific example.
The Dockerfile doesn't pass environment variables during the build process.
So even if you set environment variables in the task definition with CDK as shown below, it doesn't matter because they've already been converted to static values during the build.
# Base image
FROM node:20-alpine
RUN apk add --no-cache curl netcat-openbsd bind-tools
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 80
# Start command
CMD ["npm", "start"]
// Task definition
const frontendTaskDef = new ecs.FargateTaskDefinition(this, 'FrontendTaskDef', {
memoryLimitMiB: 512,
cpu: 256,
executionRole: taskExecutionRole,
taskRole: taskRole
});
frontendTaskDef.addContainer('frontend-container', {
image: ecs.ContainerImage.fromRegistry(`${cdk.Aws.ACCOUNT_ID}.dkr.ecr.${cdk.Aws.REGION}.amazonaws.com/${props.frontendRepo}:latest`),
memoryLimitMiB: 512,
portMappings: [
{
containerPort: 80,
protocol: ecs.Protocol.TCP
}
],
environment: {
'NODE_ENV': 'production',
'PORT': '80',
'NEXT_PUBLIC_CLIENT_URL': 'https://hogehoge.com' // Defining browser environment variable
},
logging: ecs.LogDrivers.awsLogs({
logGroup: props.frontendLogGroup,
streamPrefix: 'frontend'
})
});
Even if you write the following in your code, the value of {process.env.NEXT_PUBLIC_CLIENT_URL} will be empty.
'use client'
export default function ClientPage() {
return (
<div className="mt-8 p-4 bg-green-50 border border-green-200 rounded-lg">
<h3 className="text-lg font-semibold text-green-800 mb-2">クライアント側環境変数テスト</h3>
<div className="bg-green-100 p-3 rounded">
<p className="text-green-700 mb-1"><strong>NEXT_PUBLIC_CLIENT_URL:</strong></p>
<p className="text-green-800 font-mono text-sm">
{process.env.NEXT_PUBLIC_CLIENT_URL}
</p>
</div>
</div>
)
}
```Actual Display

Other articles and the official documentation also mention inlining. It's important to check things properly.
Official Documentation "Environment Variables"
https://nextjsjp.org/docs/app/guides/environment-variables
### Solutions
#### Pass environment variables in the Dockerfile
Among various solutions, simply passing values to `.env.production` in your Dockerfile will allow proper inlining with appropriate values during the build.
You could also fetch values from ssm parameter store or similar services and pass them.
```Dockerfile:Dockerfile
# Create .env.production during build (example)
RUN echo "NEXT_PUBLIC_CLIENT_URL=https://hogehoge.com" >> .env.production
Make it a variable for dynamic lookup
Directly quoting the official documentation, dynamic lookup values are excluded from build-time inlining. By crafting your code as shown below, you can avoid passing values at build time and have them evaluated at runtime instead.
// This won't be inlined because it uses a variable
const varName = 'NEXT_PUBLIC_ANALYTICS_ID'
setupAnalyticsService(process.env[varName])
// This won't be inlined because it uses a variable
const env = process.env
setupAnalyticsService(env.NEXT_PUBLIC_ANALYTICS_ID)
② Server Components in "Static Rendering" mode will also inline server-side environment variables at build time
The phenomenon described above can also occur with environment variables that don't include "NEXT_PUBLIC_".
The default rendering mode for Server Components when using SSR is "Static Rendering", so code like the following has no effect as it gets compiled into static HTML files at build time:
// Example in Static Rendering (inlined at build time)
'use server'
export default function StaticServerComponent() {
// Value gets inlined at build time
const apiUrl = process.env.API_BASE_URL // At build time: undefined
return (
<div>
<p>API URL: {apiUrl}</p> {/* Always "undefined" */}
</div>
)
}
Official Documentation "Server Components"
Solution#### Changing to Dynamic Rendering
When using environment variables in Server Components, you should explicitly switch from "Static Rendering" to "Dynamic Rendering".
This can be forced by using the following dynamic functions:
- cookies()
- headers()
- noStore()
Example:
// Force Server Component to use Dynamic Rendering
import { unstable_noStore as noStore } from 'next/cache'
// Server Component - direct Django API call
export default async function ServerComponentTest() {
// Force Dynamic Rendering (prevents execution at build time)
noStore()
// Environment variables will now be processed at runtime during the request
const serverComponentApiUrl = process.env.API_BASE_URL
.
.
.
}
Changing to Dynamic Rendering means it's no longer a completely static file, so the cache efficiency decreases slightly, but you can still update only the data that needs to be refreshed with each request while leveraging the cache for everything else, thus maintaining the benefits of using Server Components.
In Conclusion
Since Next.js processes code differently depending on whether it's CSR, SSR, ISR, etc., if you don't properly understand the processing order and specifications, you'll run into problems like this one.
The key point in this issue—"being inlined at build time"—isn't limited to environment variables. I realized it could potentially lead to other problems, like when you want to output dynamic values but only get fixed values.
When you understand the specifications, it makes perfect sense, but if you proceed based on intuition, you might end up wondering "why isn't this displaying?" So make sure to thoroughly check the official documentation! That's all for today.