01 · The Build System & Configurations¶
Every Angular app you've built in this course was compiled by the same machinery:
@angular/build:application (esbuild + the Angular compiler) for builds, and
@angular/build:dev-server (Vite-based) for ng serve. Level 4 starts here because
almost every production concern — environments, deployment paths, i18n, SSR, CSP,
budgets — is ultimately an option on those builders. Knowing where they live saves hours.
angular.json in one picture¶
angular.json
└── projects
└── reading-list
└── architect ← "targets"
├── build builder: @angular/build:application
│ ├── options ← apply to every build
│ └── configurations
│ ├── production ← default for `ng build`
│ └── development
├── serve builder: @angular/build:dev-server
│ └── configurations ← point at build:production / build:development
└── test builder: @angular/build:unit-test
ng build runs the build target with its defaultConfiguration (production in a new
project). ng build -c development picks the other one, and configurations can be
combined: -c production,staging applies both in order, later ones overriding earlier.
The build target has a lot of options — in Angular 22's schema they include assets,
baseHref, budgets, define, externalDependencies, fileReplacements, i18n
settings (localize), optimization, outputHashing, outputMode, prerender,
security, server, serviceWorker, sourceMap, ssr, statsJson,
subresourceIntegrity and more. You rarely need most of them, but it's worth skimming
the list once so you know they exist.
Production vs development: what actually changes¶
We built the reading-list app both ways:
ng build Initial total 261.39 kB (73.12 kB estimated transfer)
ng build -c development Initial total 1.47 MB plus a .js.map file per chunk
The generated development configuration sets optimization: false,
extractLicenses: false and sourceMap: true. The production build minifies, tree-shakes
more aggressively, removes development-only checks (ngDevMode code such as the NG0100
"expression changed" check and most warnings you've seen in this course), hashes file
names for caching, and enforces budgets. Never deploy a development build, and never
judge performance from one.
Environments¶
Per-environment values (API base URLs, feature flags, analytics IDs) are handled with file replacement. The CLI sets it up:
CREATE src/environments/environment.ts (31 bytes)
CREATE src/environments/environment.development.ts (31 bytes)
UPDATE angular.json (2270 bytes)
It added this to the development configuration:
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.development.ts"
}
]
Fill the two files with the same shape:
export const environment = {
production: true,
apiUrl: 'https://api.example.com',
};
export const environment = {
production: false,
apiUrl: 'http://localhost:3000',
};
and always import the base file: import { environment } from '../environments/environment';.
The production bundle contained only api.example.com; the development bundle contained
only localhost:3000. The other file's contents never reached the output.
For more environments (staging, QA), add a configuration with its own
fileReplacements and build with -c staging.
Environments are public
Everything in an environment file ends up in JavaScript anyone can read. Put URLs and flags there, never secrets (Level 3, lesson 09).
Compile-time constants with define¶
For values injected by CI — a version number, a commit hash — define replaces an
identifier with a literal at build time:
import { Component } from '@angular/core';
import { environment } from '../environments/environment';
declare const BUILD_VERSION: string;
@Component({
selector: 'app-build-info',
template: `<small>v{{ version }} · API {{ apiUrl }}</small>`,
})
export class BuildInfo {
protected readonly version = BUILD_VERSION;
protected readonly apiUrl = environment.apiUrl;
}
The value must be a string containing a JavaScript expression — note the inner quotes.
In the production bundle the identifier was gone and the literal "1.4.0" was inlined.
You can pass it from the command line in CI: ng build --define "BUILD_VERSION='2.0.0'"
inlined "2.0.0" in our test.
The dev server and a backend¶
During development your API usually runs on another port. Rather than enabling CORS on the API, proxy API calls through the dev server so the browser sees one origin:
{
"/api": {
"target": "http://localhost:3000",
"secure": false,
"changeOrigin": true
}
}
"serve": {
"builder": "@angular/build:dev-server",
"options": { "proxyConfig": "proxy.conf.json" }
}
Now http.get('/api/books') in the app is forwarded to localhost:3000/api/books. The
dev server also has options for port, host, ssl, custom response headers,
allowedHosts and hmr (hot module replacement, which in recent versions updates
component templates and styles without a full reload).
Other options you will reach for¶
| Option | Use it for |
|---|---|
baseHref |
deploying under a sub-path (/app/) — Level 4, lesson 07 |
outputHashing |
all (default in production) for cache-busting file names |
sourceMap |
{ "scripts": true, "hidden": true } to generate maps for error tracking without linking them from the bundle |
subresourceIntegrity |
add integrity hashes to script tags |
allowedCommonJsDependencies |
silence the CommonJS warning for a dependency you've accepted |
externalDependencies |
leave an import unbundled (import maps, micro-frontends) |
security.autoCsp |
generated strict CSP — Level 3, lesson 09 |
How It Actually Works¶
The CLI reads angular.json, finds the target, merges options with the selected
configurations (left to right), merges command-line flags on top, validates the result
against the builder's JSON schema, and calls the builder. That's why an unknown option
in angular.json fails immediately with a schema error, and why ng build --help lists
exactly the options in the schema.
Inside the application builder, fileReplacements and define are applied before
bundling: the TypeScript/esbuild module resolution is told "when anything asks for
environment.ts, load environment.development.ts", and esbuild's define feature
substitutes identifiers during parsing. The two behave differently for dead-code
elimination, and we checked:
if (!environment.production) console.log('debug-marker-xyz');
if (ENABLE_DEBUG) console.log('define-marker-abc'); // built with --define ENABLE_DEBUG=false
define substitutes the literal false while parsing, so the minifier sees
if (false) and deletes the branch. environment.production is a property of an
exported object; the bundler doesn't constant-fold it, so the check (and its string)
ships — it's just never true. Use define for anything that must not reach production
at all (debug tooling, internal endpoints); use environments for values.
The dev server wraps the same compilation in watch mode and serves output from memory
through Vite's server, applying your proxy rules to matching request paths before
falling back to the SPA's index.html.
Common mistakes¶
- Importing
environment.development.tsdirectly, which bypasses replacement. - Secrets in environments — they're public.
- Different shapes in environment files, so a property is
undefinedin one build. Share a type (export interface Env) to prevent it. - Forgetting the inner quotes in
define.--define BUILD_VERSION=1.4.0failed with✘ [ERROR] Invalid define value (must be an entity name or JS literal): 1.4.0;--define "BUILD_VERSION='2.0.0'"worked and inlined"2.0.0". - Expecting
environment.productionchecks to strip code. They don't (above). - Defining a constant only on the command line. We built once with
--define ENABLE_DEBUG=falseand later without it; the second build compiled fine (thedeclare constsatisfies TypeScript) but the app crashed on load withReferenceError: ENABLE_DEBUG is not defined. Put everydefineinangular.jsonwith a default, and override it in CI. - Measuring performance with
ng serveor a development build.
Exercise¶
- Add a
stagingconfiguration with its own environment file and build it withng build -c staging. Confirm withgrepwhich API URL is in the output. - Add a
defineforBUILD_COMMITset fromgit rev-parse --short HEADin a small npm script, and show it in the footer. - Create a
proxy.conf.jsonthat forwards/apito a local JSON server and remove any hard-codedlocalhostURL from your services. - Put
if (!environment.production) console.log('debug')in a component, build for production, and search the output fordebug— it's there. Replace the check with adefinedENABLE_DEBUGflag set tofalseand confirm the string disappears.