We flipped a feature flag. The flag was an environment variable on the App Runner service, NEXT_PUBLIC_NEW_CHECKOUT=true, changed through CDK, deployed cleanly, service healthy. The old checkout stayed. We checked the environment on the running instance: the variable was there, set to true. We restarted the service. Still the old checkout. It took a second deploy, with a code change that touched nothing related, for the new checkout to appear.
The explanation is one sentence long, and every Next.js developer has read it: NEXT_PUBLIC_* variables are inlined into the JavaScript bundle at build time. We had read it too. What we hadn't internalised is what it means when your build and your runtime are two different places, which is exactly what containers give you. This article is the rulebook we wrote afterwards.
Three kinds of configuration, not two
Next.js documentation talks about build-time and runtime. On a container platform there are really three kinds, and the third is the one that bites.
| Kind | Example | Where it lives | When it can change |
|---|---|---|---|
| Build-time, public | NEXT_PUBLIC_API_URL |
Inlined into client chunks under .next/static |
Only by rebuilding the image |
| Runtime, server-only | DATABASE_URL, API keys |
process.env in server components, route handlers, middleware |
On the next request after the env var changes |
| Runtime, public | A feature flag the browser needs | Nowhere, by default. You have to build the path yourself | Whenever you want, if you build it |
The first kind is what NEXT_PUBLIC_ gives you: fast, static, and frozen at next build. The second kind works the way everyone expects; a server component that reads process.env.DATABASE_URL reads it at request time, and a change on the App Runner service takes effect at the next instance start. The third kind is the trap: a value the browser needs, that you'd like to change without a rebuild. Next.js has no built-in answer, so people reach for NEXT_PUBLIC_ and get the first kind by accident.
The rule: one image, all environments
The decision that fixed the class of bug, not just the instance: the same image runs in dev, staging and production. No environment-specific builds. If the image is the same, then by construction nothing that differs between environments can be build-time, and the question "is this build-time or runtime?" answers itself for every new variable.
What that leaves as build-time is very short: the git SHA, the build date, and third-party SDKs that insist on a static key at bundle time. Everything else, including everything public, is runtime. And because the image is the same, promoting a build from staging to production is a tag change on the App Runner service, not a rebuild. That's the feature that pays for the discipline.
Runtime public config, done properly
The browser still needs some values. Instead of NEXT_PUBLIC_, the root layout, which is a server component, reads the environment at request time and hands the browser exactly the keys it should have:
// app/layout.tsx (server component)
import { PublicConfigProvider } from '@/lib/public-config';
const publicConfig = () => ({
apiUrl: process.env.API_URL!,
newCheckout: process.env.FEATURE_NEW_CHECKOUT === 'true',
release: process.env.RELEASE_SHA ?? 'dev',
});
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<PublicConfigProvider value={publicConfig()}>{children}</PublicConfigProvider>
</body>
</html>
);
}
// lib/public-config.tsx
'use client';
import { createContext, useContext } from 'react';
const Ctx = createContext<ReturnType<typeof publicConfig> | null>(null);
export const PublicConfigProvider = Ctx.Provider;
export const usePublicConfig = () => {
const c = useContext(Ctx);
if (!c) throw new Error('PublicConfigProvider missing');
return c;
};
Three properties of this pattern matter. The allow-list is explicit: publicConfig() names every key that reaches the browser, so a secret can't leak by being prefixed wrong. The values are read per request, so a change on the service is live at the next instance start with no rebuild. And the layout must not be statically prerendered for this to work, which in the App Router means the layout, or something in it, is dynamic; we read headers() in the layout anyway for the locale, which already makes it dynamic. If your layout is static, wrap the read in unstable_noStore() or connection() depending on your Next.js version.
Middleware gets the same treatment: it reads process.env at the edge of every request, which is runtime by nature, so the preview token and the maintenance flag live there without any of this ceremony.
What stays build-time, and how we don't get burned again
Two things in our stack genuinely need a value at bundle time: the error-tracking SDK wants its DSN when the client bundle initialises, and the build SHA that we show in the footer and send with every API call. Both are the same in every environment (one error-tracking project with the environment set at runtime as a tag; the SHA is the SHA), so they don't break the one-image rule.
For those we pass Docker build arguments, not environment variables, and the Dockerfile makes the distinction visible:
FROM node:22-alpine AS builder
ARG RELEASE_SHA # build-time only, baked into the bundle
ENV NEXT_PUBLIC_RELEASE_SHA=$RELEASE_SHA
COPY . .
RUN npm ci && npm run build
FROM node:22-alpine AS runner
ENV NODE_ENV=production # runtime; App Runner overrides the rest
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
CMD ["node", "server.js"]
An ARG in the builder stage cannot be set on the App Runner service, so nobody can be tempted to "just change it in the console". If a value is an ARG, it's a rebuild. If it's read by publicConfig(), it's a service setting. There is no third place.
The check that would have caught it
Our deploy script now verifies what the browser will actually get, after every deploy:
url=$(aws apprunner describe-service --service-arn "$ARN" --query 'Service.ServiceUrl' --output text)
sha=$(curl -sf "https://$url/api/health" | jq -r .release)
[ "$sha" = "$GIT_SHA" ] || { echo "running $sha, expected $GIT_SHA"; exit 1; }
# the bundle the browser downloads must carry the same release
chunk=$(curl -sf "https://$url/" | grep -o '/_next/static/chunks/main-app-[a-z0-9]*\.js' | head -1)
curl -sf "https://$url$chunk" | grep -q "$GIT_SHA" || { echo "client bundle is stale"; exit 1; }
Two lines of grep against the served JavaScript. It fails the deploy when the server says one release and the client bundle says another, which is precisely the state we were in for a full day without knowing.
The wider lesson
The bug wasn't a Next.js bug and it wasn't an App Runner bug. It was a boundary we hadn't drawn: which values are properties of the artifact and which are properties of the environment. Vercel hides that boundary from you by rebuilding on every environment change, and it's a fine trade if you're on Vercel. On containers you own the boundary, so you have to draw it on purpose. One image for all environments is the simplest line to draw, and the deploy-time grep is the cheapest way to prove you stayed on the right side of it.
If you're moving a Next.js app from Vercel to containers and want to skip the day we lost, talk to us.