If a UI must talk to a different API in each region, building a separate bundle per region is slow, and the artefact you tested is not the artefact you deploy. The better pattern is to build once and read configuration at runtime. This post shows the code, the hosting rules that keep it correct, and the failure modes to plan for.
Versions in use on this date. Angular 19 (released 19 November 2024, with the 19.1 minor release following in January 2025), built on Node.js 22 LTS. Angular 19 projects use the application builder, which copies everything in the public/ folder into the build output unchanged (this folder replaced src/assets in new projects from Angular 18).
Architecture
flowchart TB
CI[CI pipeline<br/>one build of the Angular app] --> ART[Build artefact<br/>same files for every region]
ART --> IN[S3 bucket<br/>ap-south-1]
ART --> EU[S3 bucket<br/>eu-west-1]
ART --> US[S3 bucket<br/>us-east-1]
CFG[Per-region config.json<br/>generated at deploy] --> IN
CFG --> EU
CFG --> US
IN --> CFIN[CloudFront<br/>in region]
EU --> CFEU[CloudFront<br/>eu region]
US --> CFUS[CloudFront<br/>us region]
B[Browser] -->|1 load index.html and bundles| CFIN
B -->|2 fetch config.json no cache| CFIN
B -->|3 call API from config| API[Regional API]
The bundles are identical in every region and cached for a long time. Only config.json differs, and it is never cached.
The configuration file
Create public/config.json. It ships with local-development defaults, so ng serve works without any extra step:
{
"apiUrl": "http://localhost:3000",
"region": "local",
"features": { "newDashboard": false }
}
Define a typed token and load the file in main.ts before the application starts:
// app-config.ts
import { InjectionToken } from '@angular/core';
export interface AppConfig { apiUrl: string; region: string; features: Record<string, boolean>; }
export const APP_CONFIG = new InjectionToken<AppConfig>('APP_CONFIG');
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { AppComponent } from './app/app.component';
import { APP_CONFIG, AppConfig } from './app/app-config';
import { apiBaseInterceptor } from './app/api-base.interceptor';
async function loadConfig(): Promise<AppConfig> {
const res = await fetch('/config.json', { cache: 'no-store' });
if (!res.ok) throw new Error(`config.json returned ${res.status}`);
const cfg = await res.json();
if (!/^https:\/\//.test(cfg.apiUrl) && cfg.region !== 'local') throw new Error('apiUrl must be https');
return cfg;
}
loadConfig()
.then((cfg) => bootstrapApplication(AppComponent, {
providers: [
{ provide: APP_CONFIG, useValue: cfg },
provideHttpClient(withInterceptors([apiBaseInterceptor])),
],
}))
.catch((err) => {
document.body.innerHTML = '<p>The application could not start. Please try again later.</p>';
console.error(err);
});
An interceptor then prefixes relative API calls, so no component needs to know the URL:
// api-base.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { APP_CONFIG } from './app-config';
export const apiBaseInterceptor: HttpInterceptorFn = (req, next) => {
const { apiUrl } = inject(APP_CONFIG);
return req.url.startsWith('/api/') ? next(req.clone({ url: apiUrl + req.url })) : next(req);
};
Fetching before bootstrapApplication is the simplest approach. Angular 19 also offers provideAppInitializer(...), which runs a function (and waits for its promise) during application initialisation; use it when the config must be available through Angular’s DI to services that start early.
Hosting rules that make it safe
| File | Cache-Control | Reason |
|---|---|---|
index.html |
no-cache |
Always revalidate, so users receive new bundle names |
main-*.js, chunk-*.js, styles-*.css (hashed names) |
public, max-age=31536000, immutable |
The hash changes with content, so they can be cached for a year |
config.json |
no-cache, and a CloudFront behaviour with the managed CachingDisabled policy |
A cached copy would send users to the wrong API |
On CloudFront, add a separate cache behaviour for the path /config.json with the CachingDisabled managed policy, and keep the default caching behaviour for everything else. Set response headers (HSTS, X-Content-Type-Options, a Content-Security-Policy whose connect-src lists only the regional API) with a response headers policy.
Failure modes to plan for
- Config fetch fails. The code above shows a plain error page instead of a blank screen. Monitor
config.jsonwith a synthetic check per region, because every user depends on it. - Wrong file in a region. Add a post-deploy check that fetches
https://<region host>/config.jsonand comparesregionandapiUrlwith the expected values. - CORS. Each regional API must allow the UI origin of its own region; a missing header looks like a configuration bug but is not.
- Stale browsers. Users with a tab open before a release keep the old bundles until reload, so keep the API backwards compatible for at least one release.
Choosing between build-time and runtime config
Angular’s fileReplacements in angular.json swaps environment files at build time, which forces a rebuild per region and produces different artefacts. Use runtime config for URLs, region names and feature flags. Never place secrets in any file shipped to a browser: everything in config.json is public, so API keys and tokens belong on the server side.