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.
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.
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_MODEis 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 exposesNEXT_PUBLIC_*. A variable namedAPI_MODEalone isundefinedin 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. -
replaymode 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.
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.
Related
- Abstracting Network Layers for Frontend Apps — the client factory this configuration feeds
- Recording and Replaying Real API Traffic — where the replay mode’s fixtures come from
- Environment-Variable-Driven Route Switching — the same idea applied at the gateway rather than in the client
← Back to Network Layer Abstraction