Swapping Mock and Live APIs with Env Vars

Your feature code is dotted with if (process.env.USE_MOCKS) branches, two of them disagree, and nobody is sure which backend the running app is actually talking to. This page replaces all of that with a single validated mode variable resolved in one place, building on the network layer abstraction pattern.

Context: why scattered flags rot

A boolean flag read at the point of use has three failure modes, and a codebase of any size hits all three.

It duplicates the decision. Twenty call sites each decide independently what “mock mode” means, and they drift — one checks USE_MOCKS, another checks NODE_ENV !== 'production', a third was written before either existed.

It prevents the modes you actually need. A boolean has two states, but the real world has at least four: mocks, a shared staging API, a service running on your own machine, and a recorded fixture set replayed offline. Squeezing that into on/off means the other two get a second flag, and now the combinations are undefined.

It hides the answer. Nothing in the running application tells you which backend it chose, so the standard debugging session begins with ten minutes of establishing where the data came from.

Twenty decisions versus one On the left, four feature modules each read an environment variable directly and each reach a different conclusion, so two point at the mock and two at the live API. On the right, the same four modules import one resolved config module, which reads the variable once, validates it against a closed set of modes and exposes a single base URL, so all four agree by construction. Each module decides for itself orders.ts → mock payments.ts → live profile.ts → mock search.ts → live two backends, one running app One module decides for all orders.ts payments.ts profile.ts search.ts config/api.ts reads API_MODE once validates, resolves, logs one backend, by construction

Solution

1. Name the modes and validate at startup

// src/config/api.ts
export const API_MODES = ['mock', 'local', 'staging', 'replay'] as const;
export type ApiMode = (typeof API_MODES)[number];

export interface ApiConfig {
  mode: ApiMode;
  baseUrl: string;
  timeoutMs: number;
  /** Whether the mock layer should be started at all. */
  useMockLayer: boolean;
  /** In CI, an unhandled request must fail rather than reach the network. */
  onUnhandledRequest: 'error' | 'warn';
}

const RAW = import.meta.env.VITE_API_MODE ?? process.env.API_MODE;

function assertMode(value: unknown): ApiMode {
  if (typeof value === 'string' && (API_MODES as readonly string[]).includes(value)) {
    return value as ApiMode;
  }
  // Fail closed: an unset or misspelled mode must never silently mean "live".
  throw new Error(
    `API_MODE must be one of ${API_MODES.join(' | ')} — received ${JSON.stringify(value)}`
  );
}

const BY_MODE: Record<ApiMode, Omit<ApiConfig, 'mode'>> = {
  mock:    { baseUrl: 'https://api.example.com', timeoutMs: 8_000,  useMockLayer: true,  onUnhandledRequest: 'error' },
  local:   { baseUrl: 'http://localhost:4000',   timeoutMs: 8_000,  useMockLayer: false, onUnhandledRequest: 'warn'  },
  staging: { baseUrl: 'https://api.staging.example.com', timeoutMs: 15_000, useMockLayer: false, onUnhandledRequest: 'warn' },
  replay:  { baseUrl: 'https://api.example.com', timeoutMs: 8_000,  useMockLayer: true,  onUnhandledRequest: 'error' },
};

const mode = assertMode(RAW);
export const apiConfig: ApiConfig = { mode, ...BY_MODE[mode] };

Throwing on an unrecognised value is the single most important line. The alternative — defaulting to live — means a typo in a CI variable produces a green build that silently hammered a real API, which is the failure this whole pattern exists to prevent.

Note that in mock mode the base URL is still the production hostname. That is deliberate: the mock layer intercepts by URL, so keeping the real hostname means the code under test constructs exactly the URLs it would in production, and a URL-building bug cannot hide behind a localhost rewrite.

2. Resolve the client once

// src/api/client.ts
import { apiConfig } from '../config/api';

export const apiClient = {
  async get<T>(path: string, init?: RequestInit): Promise<T> {
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), apiConfig.timeoutMs);
    try {
      const res = await fetch(`${apiConfig.baseUrl}${path}`, {
        ...init,
        signal: controller.signal,
        headers: { Accept: 'application/json', ...init?.headers },
      });
      if (!res.ok) throw new ApiError(res.status, await res.json().catch(() => ({})));
      return (await res.json()) as T;
    } finally {
      clearTimeout(timer);
    }
  },
};

export class ApiError extends Error {
  constructor(public status: number, public body: unknown) {
    super(`API error ${status}`);
  }
}

Feature code now imports apiClient and never learns which mode it is in — the property the network layer abstraction guide argues for.

3. Start the mock layer conditionally, and dynamically

// src/main.tsx
import { apiConfig } from './config/api';

async function startMocks(): Promise<void> {
  if (!apiConfig.useMockLayer) return;

  // Dynamic import: the bundler drops this whole subtree from builds where
  // useMockLayer can be statically proven false.
  const { worker } = await import('./mocks/browser');
  await worker.start({
    onUnhandledRequest: apiConfig.onUnhandledRequest,
    quiet: apiConfig.mode === 'replay',
  });
}

await startMocks();
renderApp();

4. Make the active mode impossible to miss

// src/config/announce.ts
import { apiConfig } from './api';

export function announceApiMode(): void {
  const line = `[api] mode=${apiConfig.mode}  base=${apiConfig.baseUrl}  mocks=${apiConfig.useMockLayer}`;
  if (apiConfig.mode === 'staging' || apiConfig.mode === 'local') {
    // Real data on the other end — say so loudly.
    console.warn(`%c${line}`, 'background:#7a5200;color:#fff;padding:2px 6px;border-radius:3px');
  } else {
    console.info(line);
  }
}

Ten seconds of work that removes a recurring ten-minute debugging detour. Pair it with a visible corner badge in non-production builds and the question stops being asked at all.

Four modes, one variable A table of four modes. Mock intercepts in-process against the production hostname and errors on unhandled requests. Local points at a service on port 4000 with no interception. Staging points at a shared remote API with a longer timeout. Replay uses the mock layer but serves recorded fixtures rather than authored handlers. A final column names when each mode is the right choice. API_MODE Talks to Mock layer Use it when mock nothing — intercepted in-process on, errors on miss everyday feature work and CI local localhost:4000 off the backend is on your machine staging api.staging.example.com off, longer timeout reproducing a real defect replay recorded fixtures on disk on, quiet offline work and demos Note that mock and replay both keep the production hostname, so URL construction is exercised exactly as it will be in production.

Verification

# A valid mode starts and announces itself
API_MODE=mock npm run dev 2>&1 | grep '\[api\] mode=mock'

# An invalid mode fails immediately rather than defaulting
API_MODE=mocks npm run dev; echo "exit=$?"      # expect a non-zero exit

# The production bundle contains no handler code
API_MODE=staging npm run build && \
  grep -rl 'msw' dist/assets | wc -l            # expect 0

The third check is the one that catches a static import creeping back in. If it returns anything other than zero, some module imports the mock entry point unconditionally and the bundler could not shake it out.

Gotchas and edge cases

  • Bundlers inline environment variables at build time. import.meta.env.VITE_API_MODE is replaced with a literal during the build, so changing the variable afterwards does nothing — a container that reads it at runtime will not behave as expected. Either rebuild per mode, or read a runtime config file the container can mount.

  • Only variables with the framework’s prefix reach the browser. Vite exposes VITE_*, Next.js exposes NEXT_PUBLIC_*. A variable named API_MODE alone is undefined in browser code, which — because the config module fails closed — surfaces as a clear startup error rather than a silent default. That is the behaviour you want, but it confuses people who expected the server-side name to work.

  • replay mode needs its fixtures committed or generated. A mode that depends on recorded responses fails on a fresh clone unless the recordings are in the repository or produced by a build step. Generate them from the same source as your handlers rather than checking in raw captures — see recording and replaying real API traffic.


Where the variable is actually read Three framework behaviours. Vite inlines variables at build time under a VITE prefix. Next.js exposes NEXT_PUBLIC variables to the browser and all variables on the server. A plain Node process reads them at runtime. Each determines whether a container can change the mode without a rebuild. Vite inlined at build time, VITE_ prefix a container cannot change it afterwards Next.js NEXT_PUBLIC_ in the browser, all on the server the server half is runtime-changeable Plain Node read at runtime fully changeable without a rebuild If the mode must be changeable per deployment, read it from a mounted runtime config rather than from a build-time variable.

Announcing the mode is not optional

The single cheapest addition to this whole arrangement is making the resolved mode visible, and it is the one most often skipped because it feels cosmetic.

It is not. The recurring cost of a configurable backend is the recurring question of which backend is answering, and that question is asked at the worst moments — mid-debugging, mid-demo, mid-incident. A startup log line and a visible badge in non-production builds cost a few minutes once and remove the question permanently.

The stronger version adds a response header naming the mode, so a captured response from anybody’s browser carries its own provenance. That turns “it works for me” into a comparison that can actually be made.

← Back to Network Layer Abstraction