AI Integration Containerization

Demo Mode vs Production Mode in a Next.js App — One Docker Image, Two UIs

Apr 14, 2026 5 min read

Next.js `NEXT_PUBLIC_*` env vars are baked in at build time, which makes them useless for switching between a developer demo UI and a production customer UI at runtime. This article shows how ShopAgent serves both views from a single Docker image using a server-side `SHOP_MODE` read, a React context provider, and a simple toggle that never leaks into real customer deployments.

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:

  1. In layout.tsx (a Server Component), read SHOP_MODE from process.env at request time. This is a plain server-side env var — no NEXT_PUBLIC_ prefix — so it is never baked into the bundle.
  2. Pass the server-resolved value as a prop to a Client Component provider.
  3. The provider stores the mode in React state. On mount, it checks localStorage for a user override.
  4. 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 ⚡ DEMO toggle badge with ⌃⇧D hint

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.