← Back to blog

Running Angular with Different Environments Per Run

Your Angular app needs to talk to different backends depending on the situation. Local development, staging, production, and sometimes no backend at all — just mocked JSON files. The default environment.ts approach works until it doesn’t. Let’s fix that.

The Problem with Build-Time Environments

Angular’s built-in environment files (environment.ts, environment.prod.ts) are baked in at build time. You set a fileReplacements entry in angular.json, and the CLI swaps one file for another during the build.

export const environment = {
  production: false,
  apiUrl: 'http://localhost:3000/api',
};

This works for two environments. Maybe three. But then you need a staging config, a QA config, a mock config. Suddenly you’re managing five environment files that are 90% identical. And every config change requires a full rebuild.

Build-time environments
One build per environment. Slow to switch. Hard to scale beyond 2-3 configs. Config changes need rebuilds.
Runtime environments
One build, many configs. Switch instantly via CLI flags or env vars. Add new environments without touching code.

The better approach: load configuration at runtime.

Runtime Configuration with Environment Variables

The idea is simple. Pass environment variables when you run ng serve, and inject them into your app at startup.

First, create a config loader that reads from a JSON file served alongside your app.

export interface AppConfig {
  apiUrl: string;
  useMocks: boolean;
  logLevel: 'debug' | 'info' | 'warn' | 'error';
}

let config: AppConfig;

export async function loadConfig(): Promise<AppConfig> {
  const res = await fetch('/assets/config.json');
  config = await res.json();
  return config;
}

export function getConfig(): AppConfig {
  return config;
}

Wire it up in your app.config.ts using APP_INITIALIZER:

export const appConfig: ApplicationConfig = {
  providers: [
    {
      provide: APP_INITIALIZER,
      useFactory: () => () => loadConfig(),
      multi: true,
    },
  ],
};

Now your app reads config at startup from /assets/config.json. No rebuild needed.

Switching Configs Per Run

Create separate config files for each environment:

src/assets/
├── config.json          # default (local dev)
├── config.staging.json
├── config.mock.json
└── config.prod.json

Then use a simple script to swap the active config before running:

import { cpSync } from 'fs';

const env = process.argv[2] || 'dev';
const source = `src/assets/config.${env}.json`;
cpSync(source, 'src/assets/config.json');
console.log(`Using config: ${env}`);

Add npm scripts to make this easy:

{
  "scripts": {
    "start": "ng serve",
    "start:staging": "ts-node scripts/set-env.ts staging && ng serve",
    "start:mock": "ts-node scripts/set-env.ts mock && ng serve"
  }
}

Now npm run start:mock swaps the config and starts the dev server. One command.

npm run start:mock
Copy config.mock.json
ng serve
App reads config.json at startup

Setting Up a Local Mock Server

When the real backend isn’t ready — or you just want to work offline — you need a local mock server. json-server is the fastest way to get one running.

Install it:

npm install -D json-server

Create a mocks/ directory at the project root with your mock data:

mocks/
├── db.json
├── routes.json
└── collections/
    ├── users.json
    └── products.json

Your db.json is the single source of truth for the mock API:

{
  "users": [
    { "id": 1, "name": "Alice", "email": "alice@example.com", "role": "admin" },
    { "id": 2, "name": "Bob", "email": "bob@example.com", "role": "user" }
  ],
  "products": [
    { "id": 1, "name": "Widget", "price": 29.99, "stock": 150 },
    { "id": 2, "name": "Gadget", "price": 49.99, "stock": 42 }
  ]
}

Use routes.json to map your real API paths to json-server’s flat structure:

{
  "/api/v1/users": "/users",
  "/api/v1/users/:id": "/users/:id",
  "/api/v1/products": "/products"
}

Start the mock server:

npx json-server mocks/db.json --routes mocks/routes.json --port 3100

You now have a REST API at http://localhost:3100 that supports GET, POST, PUT, PATCH, and DELETE out of the box. No code required.

Structuring Mock Data

Keep your mock data realistic but minimal. A few tips that save headaches down the road.

Separate concerns by domain. Don’t dump everything into one giant db.json. Use a build script to merge individual collection files:

import { readFileSync, writeFileSync, readdirSync } from 'fs';

const collectionsDir = 'mocks/collections';
const db: Record<string, unknown> = {};

for (const file of readdirSync(collectionsDir)) {
  const name = file.replace('.json', '');
  db[name] = JSON.parse(readFileSync(`${collectionsDir}/${file}`, 'utf-8'));
}

writeFileSync('mocks/db.json', JSON.stringify(db, null, 2));

This way each team member can edit their own collection file without merge conflicts on one massive JSON file.

Key insight: Your mock data should match the real API contract exactly — same field names, same types, same nesting. If you let them drift, you'll ship bugs that only appear when you switch to the real backend.

Combining Everything

Here’s the full workflow. Your config.mock.json points to the local mock server:

{
  "apiUrl": "http://localhost:3100/api/v1",
  "useMocks": true,
  "logLevel": "debug"
}

Your config.json (default dev) points to the real backend:

{
  "apiUrl": "https://api-dev.yourapp.com/v1",
  "useMocks": false,
  "logLevel": "debug"
}

Add a combined npm script that starts both the mock server and Angular:

{
  "scripts": {
    "mock:build": "ts-node scripts/build-mock-db.ts",
    "mock:server": "json-server mocks/db.json --routes mocks/routes.json --port 3100",
    "start:mock": "npm run mock:build && ts-node scripts/set-env.ts mock && concurrently \"npm run mock:server\" \"ng serve\""
  }
}

One command — npm run start:mock — and you have Angular running against a local mock backend. No network dependency. No waiting for the backend team.

Using the Config in Services

Inject the config wherever you need it. Here’s a typical service pattern:

@Injectable({ providedIn: 'root' })
export class ApiService {
  private http = inject(HttpClient);
  private baseUrl = getConfig().apiUrl;

  getUsers() {
    return this.http.get<User[]>(`${this.baseUrl}/users`);
  }

  getProduct(id: number) {
    return this.http.get<Product>(`${this.baseUrl}/products/${id}`);
  }
}

The service doesn’t know or care whether it’s talking to a real server or json-server. Same code, different config. That’s the point.

The Practical Takeaway

Stop creating a new environment.*.ts file every time you need a new config. Load config at runtime from a JSON file, swap it with a script, and treat it like data instead of code.

For mocking, json-server gives you a full REST API from a JSON file in under five minutes. Pair it with runtime config and you can develop your entire frontend without the backend running.

The workflow is: one build artifact, many configs, mock server when you need it. Your Angular app shouldn’t need to know where it’s deployed. It just reads the config it’s given and gets to work.


Sources: