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>)
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]
},
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.
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');
},
}
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)
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",
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"
],
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
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",
}
}
}
}
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.
"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.
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"
},
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.
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
}
The changes we need to make are:
- add
server.ts
to the list offiles
intsconfig.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.
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,
},
);
}
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
.
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,
});
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"
}
]
},
]
}
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)