Sekrab Garage

Angular SSR update, and deprecated tokens

Upgrading to Angular version 19

Angular December 19, 24
Subscribe to Sekrab Parts newsletter.

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.

anchorUpdating 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>)

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.

anchorCurrent 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.

anchorUpdating APP_INITIALIZER

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

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

Becomes (Angular docs for provideAppInitializer):

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

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;
            })
        );
    }
}

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),

This works as expected.

anchorUpdating 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');
    },
  }

Becomes

provideEnvironmentInitializer(() => {
  console.log('environment');
})

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]
}

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)

anchorServer 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",

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

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

// tsconfig.app.json
"files": [
  "src/main.ts",
  "src/main.server.ts",
  "src/server.ts"
],

anchorOutputPath

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

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.

anchorFull 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",
			}
		}
  }
}

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

anchorServer 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.

"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,

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/

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.

anchorSSR 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"
},

The output looks like this

|-browser/
|--main-xxxxx.js
|--index.csr.html
|-server/
|--main.server.mjs
|--index.server.html
|--assets-chunks/
|--server.mjs

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.

anchorIsolating 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:

"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
}

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';

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;

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 
  });
});

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"
}

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', {
    // ...
	});

});

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

anchorRequest 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,
    },
  );
}

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
  }
];

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.

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,
});

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

the StackBlitz project includes the custom tokens.

anchorBonus

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"
				}
			]
		},
	]
}

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

anchorConclusion

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.

So, you want to support Palestine?