Mert Özen Further With Every Line
Frontend Angular — #19

Angular Build and Deploy: ng build, Environment Files, Budgets and SPA Routing

Mert Özen Oct 31, 2026 7 min 2 views
Angular Build and Deploy: ng build, Environment Files, Budgets and SPA Routing

Production bundles with ng build in Angular 12: the dist output, environment files via fileReplacements, budgets, base href, the SPA routing 404 on Nginx/IIS, source maps and bundle analysis.

Version note: The code and commands in this post are for Angular 12.

From Rehearsal to Opening Night

So far we have run everything with ng serve. ng serve is a great development tool: it compiles the code in memory, refreshes the browser the moment you save, and keeps error messages readable. But it is like a theater rehearsal. The actors are on stage and the lights are on, but the seats are empty and nobody has bought a ticket.

Opening night needs a different kind of preparation. The code has to be minified, unnecessary parts thrown out, and the files turned into plain HTML, JavaScript and CSS that any web server can serve. In this post we will produce that bundle with ng build, get to know its settings, and then put it on a real server.

ng build and the dist Folder

Run this in the project folder:

ng build

Angular 12 brought an important change: ng build now runs with the production configuration by default. The ng build --prod flag you see in older projects was deprecated in Angular 12; you no longer need it. If you want a build with development settings, you have to ask for it explicitly:

ng build --configuration development

When the build finishes, a dist folder appears at the project root. Let's look inside:

dist/magaza/
  3rdpartylicenses.txt
  assets/
  favicon.ico
  index.html
  main.3c1a2f9e8b7d6c5a4b3e.js
  polyfills.8d4e1b2c3a5f6e7d9c0b.js
  runtime.a7b6c5d4e3f2a1b0c9d8.js
  styles.0f1e2d3c4b5a69788796.css

The contents of this folder are exactly what you deploy. main is your code, polyfills holds Zone.js, which we saw earlier, plus browser patches, and runtime is a small loader that pulls the pieces together. 3rdpartylicenses.txt collects the licenses of the packages you use.

Those long strings in the file names are hashes generated from the content. If your code changes, the hash changes too, and the browser treats the new file as a brand-new file. That way the user's browser doesn't keep serving an old JavaScript file from its cache. This is called cache busting.

Production and Development Configurations

To see where this behavior comes from, let's look at a shortened version of the build section in angular.json:

"build": {
  "builder": "@angular-devkit/build-angular:browser",
  "options": {
    "outputPath": "dist/magaza",
    "index": "src/index.html",
    "main": "src/main.ts",
    "assets": ["src/favicon.ico", "src/assets"],
    "styles": ["src/styles.css"]
  },
  "configurations": {
    "production": {
      "budgets": [ ... ],
      "fileReplacements": [ ... ],
      "outputHashing": "all"
    },
    "development": {
      "buildOptimizer": false,
      "optimization": false,
      "vendorChunk": true,
      "extractLicenses": false,
      "sourceMap": true,
      "namedChunks": true
    }
  },
  "defaultConfiguration": "production"
}

options holds the shared settings that apply to every build. configurations are profiles layered on top of them. The defaultConfiguration: "production" line decides which profile is used when you don't specify one. In the serve section that value is development, which is why ng serve is fast and readable.

The production profile also has settings that aren't written out but are on by default: minifying the code, removing unused code (tree-shaking), and collecting licenses into a separate file. The development profile turns them off one by one. That's why the same application can weigh a few megabytes in development and a few hundred kilobytes in production.

Environment Files and fileReplacements

Earlier, in the HttpClient post, we read the API URL from an environment file. Now let's see how those files get swapped:

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

// src/environments/environment.prod.ts
export const environment = {
  production: true,
  apiUrl: 'https://api.magaza.com'
};

In your code you always import environment.ts and never touch environment.prod.ts. The swap is done by this setting in the production profile:

"fileReplacements": [
  {
    "replace": "src/environments/environment.ts",
    "with": "src/environments/environment.prod.ts"
  }
]

During the build, the CLI quietly puts environment.prod.ts in place of environment.ts. Like stage scenery: the play is the same, only the backdrop has changed when the curtain rises. This is also what makes the if (environment.production) { enableProdMode(); } block in main.ts work, turning off Angular's extra development-mode checks.

If you have more than two environments, say a test server, you create environment.staging.ts, add a staging profile under configurations with its own fileReplacements, and build with ng build --configuration staging. One small but important warning: environment files are baked into the bundle, and anyone can read them in the browser. Secret keys don't go there.

Budgets: A Guard for Bundle Size

With the strict mode default of ng new, the budgets section in the production profile looks like this:

"budgets": [
  {
    "type": "initial",
    "maximumWarning": "500kb",
    "maximumError": "1mb"
  },
  {
    "type": "anyComponentStyle",
    "maximumWarning": "2kb",
    "maximumError": "4kb"
  }
]

initial is the total size of all the files downloaded when the application starts. anyComponentStyle is the limit for a single component's stylesheet. If the warning threshold is exceeded, the build completes but you see a yellow warning in the terminal. If the error threshold is exceeded, the build fails. The message looks something like this:

Warning: initial exceeded maximum budget. Budget 500.00 kB was not met by 84.31 kB with a total of 584.31 kB.

A budget is like a speed limiter on a car. Nobody ships a 3 MB bundle on purpose, but a small library added every week will get you there one day. When you see this warning, your first reaction shouldn't be to raise the limit but to find the cause. The lazy loading we saw earlier is one of the most effective ways to shrink the initial bundle.

base href: When the App Lives in a Subfolder

Open src/index.html and you will see this line inside head:

<base href="/">

This line tells the browser and the Angular router "start all relative URLs from here." If your application will be served from the root of the domain, at https://magaza.com/, it can stay as is. But if you are putting it in a subfolder like https://example.com/magaza/, the JavaScript files are looked up in the wrong place and you get a blank white page. For that, you change the base href at build time:

ng build --base-href /magaza/

Don't forget the trailing slash; /magaza and /magaza/ are not the same thing. You can also put the same setting in angular.json under the relevant configuration as "baseHref": "/magaza/".

Static Hosting and the 404 Problem

You copied the dist folder to a server, the home page opened, you navigated to /products/42 from the menu, and everything was fine. Then you refreshed the page and got a 404. Almost everyone who deploys an Angular application for the first time has this moment.

Here is why: when you navigated from the menu, nothing went to the server; the router we saw earlier changed the URL inside the browser. When you refresh, though, the browser really asks the server for a file called /products/42. There is no such file on the server's disk, so it returns 404. The fix is to tell the server "for every URL you can't find, serve index.html." The Angular router handles the rest.

Nginx

server {
  listen 80;
  server_name magaza.com;
  root /var/www/magaza;
  index index.html;

  location / {
    try_files $uri $uri/ /index.html;
  }
}

try_files first looks for the requested file, then for a folder with the same name; if neither exists, it returns index.html. Real files like main.3c1a....js are served normally, and every other URL falls through to Angular.

IIS

On IIS, the URL Rewrite module does the same job. Put a web.config file under src:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <system.webServer>
    <rewrite>
      <rules>
        <rule name="Angular Routes" stopProcessing="true">
          <match url=".*" />
          <conditions logicalGrouping="MatchAll">
            <add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
            <add input="{REQUEST_FILENAME}" matchType="IsDirectory" negate="true" />
          </conditions>
          <action type="Rewrite" url="/" />
        </rule>
      </rules>
    </rewrite>
  </system.webServer>
</configuration>

Don't forget to add "src/web.config" to the assets array in angular.json so that the file gets copied to dist. If you have no access to the server configuration at all, as a last resort you can run the router in hash mode with RouterModule.forRoot(routes, { useHash: true }); URLs become /#/products/42 and never reach the server. It works, but the URLs aren't very pretty.

Source Maps and Bundle Analysis

When you get an error in production, the console shows minified code on a single line, something like main.3c1a....js:1. A source map is the map that links minified code back to the TypeScript lines you wrote. It is off by default in the production profile; you can turn it on when you need it:

ng build --source-map

If you put source map files on a public server, you are publishing your source code too. They are usually either not uploaded at all or sent only to an error tracking tool.

When you wonder why the bundle is so big, you run a bundle analysis:

ng build --stats-json
npx webpack-bundle-analyzer dist/magaza/stats.json

--stats-json produces a stats.json file that describes what went into the build. webpack-bundle-analyzer opens that file in the browser as a colorful map; each box is a package and the size of the box is its size. Most of the time the culprit jumps right out: a huge date library added for a single function, or an icon set imported in its entirety.

A Small Experiment

Take one of the examples you built in the previous posts and run ng build --configuration development first, then ng build. Compare the size and file names of the two dist folders. Then put a different apiUrl in environment.prod.ts and confirm the production build uses the right URL.

Next, open the dist contents locally with a simple static server: npx serve dist/magaza. In a project with routing, go to a subpage and refresh it; see the 404 with your own eyes. Then restart the server with npx serve -s dist/magaza. The -s flag redirects every not-found URL to index.html, doing the same job as the try_files setting in Nginx. Check that the problem is gone.

With that, we have finished the Angular 12 fundamentals: from the first component to testing, from build to deploy, we walked the whole path of an application together. But Angular doesn't stand still; a new major version comes out every six months. In the next part of the series we start following Angular version by version: we will move the project to Angular 13 with ng update and see what that version changed.