DEV Community

Cover image for Upgrading to Angular version 19
Ayyash
Ayyash

Posted on • Originally published at garage.sekrab.com

Upgrading to Angular version 19

There are two ways to update, using ng update directly, or creating a new application after updating the global @angular/cli. They produce slightly different results. Mainly the builder used. The new changes touch the angular.json more than anything else. Some of the new options are not yet documented.

Updating to Angular 19

The resulting sever code can be found on StackBlitz

Optionally start with npm install -g @angular/cli to update the global builder to the new version.

Use ng update @angular/core@19 @angular/cli@19. Or create a new application with ng new appname --ssr . The difference is the @angular/builder, the ng update command prompts you with this extra option:

Select the migrations that you'd like to run 
❯◉ [use-application-builder] Migrate application projects to the new build system.
(<https://angular.dev/tools/cli/build-system-migration>)
Enter fullscreen mode Exit fullscreen mode

The new builder does not have the server separate builder. The same ng build will be responsible for building the client side, and the server side.

Another option prompted is updating the APP_INITIALIZER to the new provideAppInitializer.

Current project changes

  • This will remove all standalone: true because it is the default in the new version.
  • APP_INITLAIZER is deprecated (see below).
  • The builder @angular-devkit/build-angular:server is deprecated, let’s not use it
  • @angular-devkit/build-angular has changed to @angular/build
  • Server side rendering implementation changed (we’ll dig deeper into this one).
  • the tsconfig.app.json now adds the server related files.
  • Watch out for deleted files, it may not be a great idea.

The documentation of the new CLI options, and how to transfer to the new builder can be found on the official Angular website. But not everything is well documented.

Updating APP_INITIALIZER

This provider is now deprecated. The new version is a function, so:

// old
  {
    provide: APP_INITIALIZER,
    useFactory: configFactory,
    multi: true,
    deps: [ConfigService]
  },
Enter fullscreen mode Exit fullscreen mode

Becomes (Angular docs for provideAppInitializer):

// new
provideAppInitializer(() => {
  const initializerFn = (configFactory)(inject(ConfigService));
  return initializerFn();
}),
Enter fullscreen mode Exit fullscreen mode

The new provider expects a function of type **EnvironmentProviders .** The above configFactory was a function that expected ConfigService to be injected as a dependency. That was the auto generated code from the following:

// the configFactory, and the ConfigService
export const configFactory = (config: ConfigService) => () => {
  return config.loadAppConfig();
};

@Injectable({
  providedIn: 'root'
})
export class ConfigService {
    // ...
    loadAppConfig(): Observable<boolean> {
        // return an http call to get some configuration json
        return this.http.get(this._getUrl).pipe(
            map((response) => {
                return true;
            })
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

But wait. We can write this better. Since we have injection context, we’ll just inject the ConfigService directly.

// a better way
export const configFactory =  () => {
  // inject, and return the Observerable function
  const config = inject(ConfigService);
  return config.loadAppConfig();
};

// then just use directly
provideAppInitializer(configFactory),
Enter fullscreen mode Exit fullscreen mode

This works as expected.

Updating ENVIRONMENT_INITIALIZER

The other deprecated token is ENVIRONMENT_INITIALIZER. Read Angular documentation of the alternative (provideEnvironmentInitializer). Here is an example of before and after of the simplest provider.

// before
  {
    provide: ENVIRONMENT_INITIALIZER,
    multi: true,
    useValue() {
      console.log('environment');
    },
  }
Enter fullscreen mode Exit fullscreen mode

Becomes

provideEnvironmentInitializer(() => {
  console.log('environment');
})
Enter fullscreen mode Exit fullscreen mode

In a more complicated scenario, the changes are just as simple as in the APP_INITIALIZER. Here is an example of a provider that detects scroll events of the router.

// before, route provider:
// factory
const appFactory = (router: Router) => () => {
    // example
  router.events.pipe(
    filter(event => event instanceof Scroll)
  ).subscribe({
    next: (e: Scroll) => {
      // do something with scroll
      console.log(e.position);
    }
  });
};

// provided:
{
  provide: ENVIRONMENT_INITIALIZER,
  multi: true,
  useFactory: appFactory,
  deps: [Router]
}

Enter fullscreen mode Exit fullscreen mode

This becomes:

// new, reduced appFactory with simple inject
const appFactory = ()  => {
  const router: Router = inject(Router);

  router.events.pipe(
    filter(event => event instanceof Scroll)
  ).subscribe({
    next: (e: Scroll) => {
      // do something with scroll
      console.log(e.position);
    }
  });
};

// then simply use it:
provideEnvironmentInitializer(appFactory)
Enter fullscreen mode Exit fullscreen mode

Server side rendering, generation, and hydration

The documentation is thorough about the three options: rendering (produces a NodeJs version to be hosted using Express), generation (produces HTML static files to be hosted by an HTML host), and hydration (produces both and allows prerendering for selective routes).

What we want to do here, is move our current application as is, this isn’t the place for new options. So here is what we produced for our SSR custom solution.

The current server builder @angular-devkit/build-angular:server is no longer in use, thus the old way of creating a single configuration won’t work.

Note: current Angular documentation covers this but not everything

The following configuration, changed:

// angular.json, was
"builder": "@angular-devkit/build-angular:browser",
"outputPath": "../dist/public/",
"resourcesOutputPath": "assets/",
"main": "src/main.ts",
Enter fullscreen mode Exit fullscreen mode

Becomes

// angular.json, is
"builder": "@angular/build:application", // changed builder
"outputPath": {
  "base": "../dist/public/", // example
  "browser": "browser", // sub folder for browser
  "media": "assets", // rename to assets to keep everything the same
  "server": "server" // sub folder to server, can be empty
},
"browser": "src/main.ts", // instead of "main"
"server": "src/main.server.ts", // new... to explain
"ssr": {
  "entry": "server.ts" // new
},
"prerender": false, // not needed
Enter fullscreen mode Exit fullscreen mode

The tsconfig.app.json now includes the new server files

// tsconfig.app.json
"files": [
  "src/main.ts",
  "src/main.server.ts",
  "src/server.ts"
],
Enter fullscreen mode Exit fullscreen mode

OutputPath

First, the outputPath. It’s now specific to generate the following folder structure upon ng build

Here is a link to the official documentation of the outputPath.

|- dist
|----public
|-------browser
|---------assets
|-------server
Enter fullscreen mode Exit fullscreen mode

A single build creates both NodeJs and client-side. This is sort of a bummer, considering I have always separated them. Let’s try to get as close as possible to a working example.

Full client-side only

The config to create a similar output as before is as follows

// angular.json
"architect": {
    "build": {
        //...
        "configurations": {
            "production": {
                "outputPath": {
                    "base": "dist",
                    "browser": "", // reduce to keep everything on root
                    "media": "assets"
                },
                "index": "src/index.html",
                "browser": "src/main.ts",
            }
        }
  }
}
Enter fullscreen mode Exit fullscreen mode

This will create an output that has index.html on the root, and the assets in their assets folder. Pretty straight forward.

Server side rendered

To create a folder with browser, and server subfolders, no prerendering, and simply using the example out of the box, we need to add server entry, then another ssr entry.

Angular documentation

"outputPath": {
  "base": "ssr",
  "browser": "browser", // cannot be empty here
  "media": "assets",
  "server": "server" // can be empty
},
"index": "src/index.html",
"browser": "src/main.ts",
// The full path for the server entry point to the application, relative to the current workspace.
"server": "src/main.server.ts", 
// if "entry" is used, it should point to the Express server that imports the bootstrapped application from the main.server.ts
"ssr": true,
Enter fullscreen mode Exit fullscreen mode

The main.server.ts must have the exported bootstrapped application. ssr has to be true.

The generated output contains the following

|-browser/
|--main-xxxxx.js
|--index.csr.html
|-server/
|--main.server.mjs
|--index.server.html
|--assets-chunks/
Enter fullscreen mode Exit fullscreen mode

This does not produce a server, you need to write your own server, and then map those folders to the expected routes. But we need the CommonEngine at least.

A note about the CommonEngine

The CommonEngine is the currently working NodeJs engine, but there is another one AngularNodeAppEngine that is still in developer preview.

SSR entry server

The configuration is slightly different, and it includes the NodeJs CommonEngine server.

"outputPath": {
  "base": "ssr",
  "browser": "browser",
  "media": "assets",
  "server": "server"
},
"index": "src/index.html",
"browser": "src/main.ts",
"server": "src/main.server.ts", // has the bootstrapped app
"ssr": {
    // The server entry-point that when executed will spawn the web server.
    // this has the CommonEngine
    "entry": "src/server.ts"
},
Enter fullscreen mode Exit fullscreen mode

The output looks like this

|-browser/
|--main-xxxxx.js
|--index.csr.html
|-server/
|--main.server.mjs
|--index.server.html
|--assets-chunks/
|--server.mjs
Enter fullscreen mode Exit fullscreen mode

The server.mjs contains the Express listener so we can node server.mjs to start the server. (see the Angular documentation link above.)

Running the server with JavaScript disabled works. The browser folder is necessary only for running Angular in browser, but the site works fine without it (I have not tested with multiple routes).

Removing browser/index.csr.html actually did nothing! Hmm. Maybe the file is needed for generating prerendered files.

Isolating the server

We begin with exporting the CommonEngine in server.ts without a listener, and create our own Express listener. Using the same code we generated in the last post, and since the application bootstrapper is in the same file, here is the configuration to start with:

// angular.json
"ssr": {
  "outputPath": {
    "base": "../garage.host/ssr", // a new folder in host
    "server": "", // simpler
    "browser": "browser",
    "media": "assets"
  },
  // this has the application bootstrapper as well
  "server": "server.ts",
  "ssr": true
}
Enter fullscreen mode Exit fullscreen mode

The changes we need to make are:

  • add server.ts to the list of files in tsconfig.app.json.
  • remove import zone.js from our server file
  • change the CommonEngine source from @angular/ssr to @angular/ssr/node
// server.ts in our new server
// chagen to "node" sub folder
import { CommonEngine, CommonEngineRenderOptions } from '@angular/ssr/node';
// remove: import 'zone.js';
Enter fullscreen mode Exit fullscreen mode

Then ng build --configuration=ssr

The first error I receive is

[ERROR] No matching export in server.ts for import "default”

Obviously, the Angular builder expects something specific. So let’s export a default bootstrap application from our server.

// in server.ts, lets have a default export, this may be good enough
const _app = () => bootstrapApplication(AppComponent, {
  providers: [
    provideServerRendering(),
    ...appProviders
  ]}
);
// make it the default
export default _app;
Enter fullscreen mode Exit fullscreen mode

The output contains two folders, and main.server.mjs in the root folder. It has crExpressEgine that we created. (Did you catch the typo in crExpressEgine? Yeah well, it’s too late to fix it.)

Our Express can still import it and use it as an engine. It would look like this:


// our server.js in another folder

// the ssr engine comes from the outout sever/main.server.mjs
const ssr = require('./ssr/main.server.mjs');
const app = express();

// the dist folder is the browser
const distFolder = join(process.cwd(), './ssr/browser');
// use the engine we exported
app.engine('html', ssr.crExpressEgine);
app.set('view engine', 'html');
app.set('views', distFolder);

// ...
app.get('*'), (req, res) => {
  const { protocol, originalUrl, headers } = req;

  // serve the main index file generated in browser
  res.render(`index.html`, {
    // set the URL here
    url: `${protocol}://${headers.host}${originalUrl}`,
    // pass providers here, if any, for example "serverUrl"
    providers: [
      {
        provide: 'serverUrl',
        useValue: res.locals.serverUrl // something already saved
      }
    ],
    // we can also pass other options
    // document: use this to generate different DOM content
    // turn off inlinecriticalcss
    // inlineCriticalCss: false 
  });
});
Enter fullscreen mode Exit fullscreen mode

So the only change is how we use the root and the browser folders. And of course, the EJS cannot be “required.” We can build the server in typescript. Or turn it into an es-script. We start with package.json

// package.json on root folder of the express server
{
    "type" :"module"
}
Enter fullscreen mode Exit fullscreen mode

Then we change all require statements to imports

// new esscript server
import express from 'express';
import { join } from 'path';

import { crExpressEgine } from './ssr/server/main.server.mjs';
const app = express();

const distFolder = join(process.cwd(), './ssr/browser');
app.engine('html', crExpressEgine);
app.set('view engine', 'html');
app.set('views', distFolder);

app.use( express.static(distFolder));

app.get('*'), (req, res) => {
    // This is the browser index, so if you have index.xxx.html use it
    res.render('index.html', {
    // ...
    });

});
Enter fullscreen mode Exit fullscreen mode

Running the server in Node (node server), and browsing with JavaScript disabled, it looks like it’s working.

Request and Response tokens

Previously we needed to recreate the Request and Response tokens to continue to use them. In Angular 19, tokens are back. Well. Not so fast. If you see in the server.ts an implementation of CommonEngine it will not have the Request token implemented. But AngularNodeAppEngine I can see the tokens provided:

// Angular source code for the new Enginer: AngularNodeAppEngine
if (renderMode === RenderMode.Server) {
  // Configure platform providers for request and response only for SSR.
  platformProviders.push(
    {
      provide: REQUEST,
      useValue: request,
    },
    {
      provide: REQUEST_CONTEXT,
      useValue: requestContext,
    },
    {
      provide: RESPONSE_INIT,
      useValue: responseInit,
    },
  );
}
Enter fullscreen mode Exit fullscreen mode

So we need to change the RenderMode to Server.

// in the app.routes.server.ts, the out of the box file
export const serverRoutes: ServerRoute[] = [
  {
    path: '**',
    // this needs to be Server to get access to tokens
    renderMode: RenderMode.Server
  }
];
Enter fullscreen mode Exit fullscreen mode

The AngularNodeAppEngine is in developer preview mode. So we don’t have to use it. We’ll just continue to provide the tokens as we did before. Personally I don’t like to have to configure the Server routes to get access to the server REQUEST.

Official documentation of this engine.

The token that I previously created, compared to the officially new token in Angular 19:

// our REQUEST token, very simple
export const REQUEST: InjectionToken<any> = new InjectionToken('REQUEST Token');

// official Angular 19 token
export const REQUEST = new InjectionToken<Request | null>('REQUEST', {
  providedIn: 'platform',
  factory: () => null,
});
Enter fullscreen mode Exit fullscreen mode

That’s garnish. The other two tokens are: REQUEST_CONTEXT and RESPONSE_INIT. Meh!

the StackBlitz project includes the custom tokens.

Bonus

The hiccup in all of this is that if you do your own prerendering like I do, you would want the browser folder to be served alone. But since the new outputPath does not allow that, so you’d just need to map your routes to this inner folder. For example, in firebase config, it would look like this

// firebase.json
{
    "hosting": [
        {
            "target": "web",
            // the inner most browser folder
            "public": "ssr/browser",
            "rewrites": [
                {
                    "source": "/",
                    "destination": "/index.html"
                }
            ]
        },
    ]
}
Enter fullscreen mode Exit fullscreen mode

The prerender script (if you have one like mine) should write inside the browser folder.

Conclusion

With the latest update in Angular 19 SSR builder, there are few changes to make on current projects, which the following specs should be met:

  • Uses an isolated Express server that serves the site independently. Thus the generated server.ts which contains a listener needs to be adapted to remove the listener
  • Uses an Express Node server, this may need to update to ES Module.
  • Localization is done natively, single build that serves multiple languages, thus the server and the index.html are created in a separate build (Not covered in this article).
  • I have come to realize that prerendering isn’t really meaningful unless you have dynamic data to generate. An AppShell is something I also never made use of. Thus the partial hydration to me is a buzz word.
  • Never buy into occupation.

Top comments (0)