Angular App Configuration, Environment Variables and provideAppInitializer

Angular Tutorial: Beginner to Advanced · 15. Architecture, configuration and errors · 5 min read · Updated

The same app runs on your laptop, on a test server and in production, and each place needs different settings: the API address, a feature flag, an analytics key. This lesson shows the safe ways to do it.

What you will learn

  • ApplicationConfig: where app-wide providers live
  • Environment files (build time) versus a runtime config.json
  • provideAppInitializer, which replaces the legacy APP_INITIALIZER
  • The Location service and path versus hash URLs

Application configuration

In a standalone app, bootstrapApplication(App, appConfig) receives one object with a providers array. That array is your app configuration: router, HTTP client, error handler and your own services all go there.

// File: src/app/app.config.ts (the shape you already know)
export const appConfig: ApplicationConfig = {
  providers: [provideRouter(routes), provideHttpClient()],
};

Everything in this lesson is just "more providers".

Option 1: environment files (build time)

Settings that are fixed when you build can live in environment files. Generate them with ng generate environments. You get:

  • src/environments/environment.ts (production values)
  • src/environments/environment.development.ts (used by ng serve)
// File: src/environments/environment.ts
export const environment = {
  production: true,
  apiUrl: 'https://api.example.com',
};
// File: use it anywhere
import { environment } from '../environments/environment';
const url = environment.apiUrl + '/posts';

The build swaps the file through fileReplacements in angular.json. The logic is simple: one import, different file per build.

Option 2: runtime config (one build, many servers)

With environment files you rebuild for every server. If you want one build that you can copy to test and production, load a JSON file when the app starts. Put config.json in the public folder and it is served as /config.json:

{ "apiUrl": "https://api.example.com", "featureBanner": true }

provideAppInitializer

An app initializer is a function that Angular runs before the app is shown. If it returns a Promise or an Observable, Angular waits for it. This is the perfect place to load config.json.

The old way was the APP_INITIALIZER token with multi: true. It still works, but it is deprecated. The modern way is provideAppInitializer:

// src/app/config/app-config.service.ts
import { Injectable, signal } from '@angular/core';

export interface RuntimeConfig {
  apiUrl: string;
  featureBanner: boolean;
}

@Injectable({ providedIn: 'root' })
export class AppConfigService {
  private readonly state = signal<RuntimeConfig>({ apiUrl: 'http://localhost:3000', featureBanner: false });
  readonly config = this.state.asReadonly();

  async load(fetcher: () => Promise<RuntimeConfig> = () => fetch('/config.json').then((r) => r.json())) {
    try {
      this.state.set(await fetcher());
    } catch {
      // keep the safe defaults if the file is missing
    }
  }
}
// src/app/config/config.providers.ts
import { EnvironmentProviders, inject, provideAppInitializer } from '@angular/core';
import { AppConfigService } from './app-config.service';

export function provideRuntimeConfig(): EnvironmentProviders {
  // the function runs in an injection context, so inject() works
  return provideAppInitializer(() => inject(AppConfigService).load());
}

Add provideRuntimeConfig() to the providers array in app.config.ts. Because load() returns a Promise, the app waits for the config before routing starts.

Logic: defaults first, file overrides, failure keeps the defaults. Your app never starts half configured.

Compare with the legacy form so you can read old code:

// File: legacy, deprecated
providers: [
  {
    provide: APP_INITIALIZER,
    multi: true,
    deps: [AppConfigService],
    useFactory: (config: AppConfigService) => () => config.load(),
  },
]

Keep initializers fast. A slow one delays the first screen for every visitor.

Test it

// src/app/config/app-config.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { provideAppInitializer, inject } from '@angular/core';
import { AppConfigService } from './app-config.service';

describe('AppConfigService', () => {
  it('uses defaults until loaded, then the file values', async () => {
    const service = TestBed.inject(AppConfigService);
    expect(service.config().featureBanner).toBe(false);
    await service.load(async () => ({ apiUrl: 'https://api.test', featureBanner: true }));
    expect(service.config().apiUrl).toBe('https://api.test');
    expect(service.config().featureBanner).toBe(true);
  });

  it('keeps defaults when loading fails', async () => {
    const service = TestBed.inject(AppConfigService);
    await service.load(async () => {
      throw new Error('missing');
    });
    expect(service.config().apiUrl).toBe('http://localhost:3000');
  });

  it('runs the initializer before the app is used', async () => {
    let loaded = false;
    TestBed.configureTestingModule({
      providers: [
        provideAppInitializer(() => {
          inject(AppConfigService);
          loaded = true;
        }),
      ],
    });
    TestBed.inject(AppConfigService);
    await TestBed.inject(AppConfigService).load(async () => ({ apiUrl: 'x', featureBanner: false }));
    expect(loaded).toBe(true);
  });
});

The Location service

Location (from @angular/common) talks to the browser address bar and history without the router:

// src/app/config/back-button.ts
import { Location } from '@angular/common';
import { Component, inject } from '@angular/core';

@Component({
  selector: 'app-back-button',
  template: `
    <button type="button" (click)="back()">Back</button>
    <small>Current path: {{ path }}</small>
  `,
})
export class BackButton {
  private readonly location = inject(Location);
  path = this.location.path();
  back() {
    this.location.back();
  }
}

Useful methods: back(), forward(), path(), go(path). In normal apps prefer the Router for navigation and use Location for "go back".

Path URLs versus hash URLs

The router has two location strategies:

  • PathLocationStrategy (default): clean URLs like /learn/angular. The server must return index.html for unknown paths (see the troubleshooting lesson).
  • HashLocationStrategy: URLs like /#/learn/angular. Works on any static host with no server rules, but looks older and is poor for SEO.
// File: enable hash URLs
provideRouter(routes, withHashLocation())

If the app lives in a sub-folder, set the base with <base href="/my-app/"> in index.html, or provide APP_BASE_HREF.

Common mistakes

  • Putting secrets in environment files.
  • Awaiting a slow request in an initializer and blocking the whole app.
  • Forgetting that config.json can be cached by the browser or CDN. Use a short cache time for it.
  • Using HashLocationStrategy only to avoid fixing the server rule, then losing clean URLs.

Try it yourself

  1. Add config.json to public and show apiUrl in a component through AppConfigService.
  2. Change the file and reload without rebuilding.
  3. Switch the router to hash URLs and see how the address bar changes.

Quick check

  1. Why do environment files need a rebuild per server?
  2. What replaces APP_INITIALIZER?
  3. When would you choose hash URLs?

Next, continue with Angular Error Handling: Global ErrorHandler, Custom Handlers and HTTP Errors.

Frequently asked questions

Is it safe to put an API key in config.json?

No. Anything the browser downloads is public. Only put values that are safe to show, such as the public API address. Keep secret keys on your server.

Is APP_INITIALIZER removed?

It still works but is deprecated. Use provideAppInitializer in new code.