NEXT_PUBLIC_* variables are baked in at build time, which means you cannot use them to switch modes at runtime. Here is the pattern we used to serve a developer view and a customer view from the same Docker image.
ShopAgent has two audiences. Developers and technical stakeholders want to see the Protocol Activity panel — the live trace of every MCP, A2A, UCP, AP2, A2UI, and AG-UI call with expandable JSON payloads. Customers and non-technical viewers want the clean shopping UI: friendly copy, no debug panels, no protocol labels.
The natural instinct in Next.js is to use an environment variable: NEXT_PUBLIC_SHOP_MODE=demo for the technical view, NEXT_PUBLIC_SHOP_MODE=production for the customer view. The problem: NEXT_PUBLIC_* variables are inlined at build time by webpack. Changing the variable requires a new Docker image build. You cannot flip modes at runtime without rebuilding.
We needed one image, two views, switchable at runtime. This post explains the pattern.
WHY NEXT_PUBLIC_* DOES NOT WORK FOR RUNTIME SWITCHING
When you build a Next.js app, any reference to process.env.NEXT_PUBLIC_SOMETHING is replaced at build time with the literal value. The built JavaScript bundle contains the string directly — there is no process.env lookup at runtime.
This means:
// In your component
if (process.env.NEXT_PUBLIC_SHOP_MODE === "demo") {
// This check is evaluated at BUILD TIME
// The result is baked into the bundle
}
If you build with NEXT_PUBLIC_SHOP_MODE=demo, the production customer view is not accessible from that image. If you build with NEXT_PUBLIC_SHOP_MODE=production, the demo view is not accessible. You would need a separate Docker image for each environment.
For a deployment where the same image needs to serve both views — and where a customer demo might need to be switched between modes mid-presentation — this does not work.
THE SOLUTION: SERVER-SIDE READ + CLIENT CONTEXT
The pattern we used:
- In
layout.tsx(a Server Component), readSHOP_MODEfromprocess.envat request time. This is a plain server-side env var — noNEXT_PUBLIC_prefix — so it is never baked into the bundle. - Pass the server-resolved value as a prop to a Client Component provider.
- The provider stores the mode in React state. On mount, it checks
localStoragefor a user override. - Any component in the tree that needs the mode calls
useShopMode().
The Docker Compose file sets SHOP_MODE=demo for developer deployments and SHOP_MODE=production for customer-facing deployments. The same image serves both.
READING SHOP_MODE SERVER-SIDE IN LAYOUT.TSX
// frontend/src/app/layout.tsx — Server Component
import { ShopModeProvider } from "@/components/ShopModeProvider";
import type { ShopMode } from "@/lib/useShopMode";
export const dynamic = "force-dynamic";
export default function RootLayout({ children }) {
// Read at request time — not baked into the bundle
const rawMode = process.env.SHOP_MODE ?? "demo";
const initialMode: ShopMode =
rawMode === "production" ? "production" : "demo";
return (
<html lang="en">
<body>
<ShopModeProvider initialMode={initialMode}>
{children}
</ShopModeProvider>
</body>
</html>
);
}
export const dynamic = "force-dynamic" ensures Next.js re-evaluates process.env.SHOP_MODE on every request rather than at build time. Without this, Next.js might statically prerender the layout and cache the result.
The initialMode prop carries the server-resolved value into the client. Once it crosses the server/client boundary as a prop, it becomes regular JavaScript data — not an environment variable — and can be stored in React state.
THE SHOPMODEPROVIDER
// frontend/src/components/ShopModeProvider.tsx
"use client";
const LS_KEY = "shopMode";
export function ShopModeProvider({ initialMode, children }) {
const canToggle = initialMode === "demo";
const [mode, setMode] = useState<ShopMode>(initialMode);
// On mount: check localStorage for a runtime override
useEffect(() => {
if (!canToggle) return;
try {
const stored = localStorage.getItem(LS_KEY) as ShopMode | null;
if (stored === "demo" || stored === "production") {
setMode(stored);
}
} catch {
// localStorage unavailable in SSR or private browsing
}
}, [canToggle]);
const toggleMode = () => {
if (!canToggle) return;
setMode(prev => {
const next = prev === "demo" ? "production" : "demo";
try { localStorage.setItem(LS_KEY, next); } catch {}
return next;
});
};
return (
<ShopModeContext.Provider value={{ mode, canToggle, toggleMode }}>
{children}
</ShopModeContext.Provider>
);
}
canToggle is only true when the server configured demo mode. In a production deployment (SHOP_MODE=production), canToggle is false. The toggle button does not render. The user cannot switch to the developer view — there is no DOM element to click and no keyboard shortcut registered.
The localStorage override is how a developer can switch modes during a live presentation without restarting Docker. Toggle once to switch to customer view, toggle again to return to demo mode. The preference persists across page reloads.
THE USESHOPMODE HOOK
// frontend/src/lib/useShopMode.ts
"use client";
export type ShopMode = "demo" | "production";
export const ShopModeContext = createContext<ShopModeContextValue>({
mode: "demo",
canToggle: true,
toggleMode: () => {},
});
export function useShopMode(): ShopModeContextValue {
return useContext(ShopModeContext);
}
Any component that needs to know the current mode calls useShopMode():
const { mode, canToggle, toggleMode } = useShopMode();
const isDemo = mode === "demo";
This is the only import needed in consuming components. The mechanism behind it — server read, client provider, localStorage — is invisible to the consumer.
THE TOGGLE AND KEYBOARD SHORTCUT
When canToggle is true, the header renders a toggle button and registers a keyboard shortcut:
// ShopModeProvider.tsx
useEffect(() => {
if (!canToggle) return;
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === "D") {
e.preventDefault();
toggleRef.current();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [canToggle]);
Ctrl+Shift+D (or Cmd+Shift+D on Mac) flips the mode. The keyboard shortcut is only registered when canToggle is true — in production deployments, the event listener is never added.
WHAT CHANGES BETWEEN MODES
Demo mode shows:
- Protocol Activity panel (live trace of every protocol call with timestamps and JSON payloads)
- Protocol step labels: "Searching catalog via MCP…", "Ranking via A2A…"
- Protocol badge strip in the header:
MCP · A2A · UCP · AP2 · A2UI · AG-UI - The
⚡ DEMOtoggle badge with⌃⇧Dhint
Production mode shows:
- Protocol Activity panel hidden entirely
- Customer-friendly step labels: "Finding products for you…", "Preparing your checkout…"
- Protocol badge strip hidden
- Clean shopping branding only
The step labels are driven by a map:
const STEP_LABELS = isDemo ? {
search_products: "Searching catalog via MCP…",
initiate_checkout: "Creating checkout session via UCP…",
complete_checkout: "Authorising payment via AP2…",
} : {
search_products: "Finding products for you…",
initiate_checkout: "Preparing your checkout…",
complete_checkout: "Placing your order…",
};
{STEP_LABELS[state.next_action] ?? "Working…"} is all the loading indicator needs.
THE TAKEAWAY
The key insight is that NEXT_PUBLIC_* variables are a build-time mechanism, not a runtime one. For runtime configuration — including anything that needs to change without a new Docker build — read from server-side process.env in a Server Component and pass the value as a prop to the client tree. From there it is just React state.
This pattern is not specific to mode switching. Any configuration that should be runtime-adjustable — feature flags, tenant-specific settings, A/B test variants — can follow the same shape: server reads env, passes as prop, client stores in context.
The ShopAgent demo is live at https://shop-agent.agilecreativeminds.nl. See the demo showcase or follow the demo walkthrough. Built by Agile Creative Minds.