Best Practices for Angular Internationalization with SSR

Robert Isaac
13 min readMay 7, 2023

--

Angular Internationalization

This is updated version of https://medium.com/javascript-in-plain-english/angular-localization-with-ssr-best-practice-ae75f22e2d05

the original one was created 2 years ago with Angular 11, since then a lot has changed and the best practice has changed with it, so in this one I will show the best practice according to Angular 16 (if you are using older version please refer to the old article, since the code here won’t work with older version)

I’m going to walk you through how to manage Internationalization in Angular project with server-side rendering, and give you best practice to get the best out of it.

Here is a list of tasks that will be done in this article

  1. Create a new project.
  2. Initialize the project with simple components.
  3. Add translation.
  4. Add more languages.
  5. Fix application flickering.
  6. save selected language.
  7. RTL support (optional).

1. Create a new project and add server-side rendering

Create a new Angular project with the new standalone flag ng new angular-internationalization — routing=true — style=scss — standalone=true

Then add SSR with the following:

note: for Angular 17 you will be asked if you want to enable SSR, reply yes and skip this step

ng add @nguniversal/express-engine

To verify that everything is fine, run npm run dev:ssr and after it loads, open it in the browser, make sure it’s working and go to the source code view-source:http://localhost:4200. And then make sure that the content is loaded as you see in the below image

left is before SSR, right is after SSR

2. Initialize the project with simple two components, and a header (using bootstrap)

Then we should create two new simple mock components and a shared module to be imported inside them.

Run the following commands to generate them:

ng g c home
ng g c feature

This will create 2 standalone components home and feature, let’s lazy load them in app.routes.ts

import { Routes } from '@angular/router';

export const routes: Routes = [
{
path: 'home',
loadComponent: () => import('./home/home.component'),
},
{
path: 'feature',
loadComponent: () => import('./feature/feature.component'),
},
];

we need to make the home and feature the default export to make it work to look like export default class HomeComponent

Then we should just add a simple header, for that I am using Bootstrap.

First, we need to install ngx-bootstrap and bootstrap.

npm i bootstrap ngx-bootstrap

Then modify the angular.json to add Bootstrap CSS files to it. Here’s how it should look (notice that we include it inside both build and test):

"styles": [
"node_modules/bootstrap/dist/css/bootstrap.min.css",
"src/styles.scss"
]

and for the header component generate it using

ng g c core/components/header

I used the following HTML code for it but any code will work, no problem:

<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container">
<a [routerLink]="'/home'" class="navbar-brand">Localize</a>
<button
(click)="isCollapsed = !isCollapsed"
[attr.aria-expanded]="!isCollapsed"
aria-controls="navbarSupportedContent"
aria-label="Toggle navigation"
class="navbar-toggler"
type="button">
<span class="navbar-toggler-icon"></span>
</button>
<div [collapse]="isCollapsed" [isAnimated]="true" class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav">
<li class="nav-item">
<a
[routerLinkActiveOptions]="{exact: true}"
[routerLink]="'/home'"
aria-current="page"
class="nav-link"
routerLinkActive="active">homepage</a>
</li>

<li class="nav-item">
<a
[routerLinkActiveOptions]="{exact: true}"
[routerLink]="'/feature'"
class="nav-link"
routerLinkActive="active">feature</a>
</li>
</ul>
</div>
</div>
</nav>

and for the TS it’s just simply

import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { CollapseModule } from 'ngx-bootstrap/collapse';

@Component({
selector: 'app-header',
standalone: true,
imports: [CommonModule, RouterLink, CollapseModule, RouterLinkActive],
templateUrl: './header.component.html',
styleUrls: ['./header.component.scss']
})
export class HeaderComponent {
isCollapsed = true;
}

And for the app.component.html, I removed the initial code and added the header. Here’s how it looks now:

<app-header/>
<div class="container">
<router-outlet/>
</div>

then finally to provide animation in app.config.ts so it looks like

import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';

import { routes } from './app.routes';
import { provideAnimations } from '@angular/platform-browser/animations';

export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideAnimations(),
]
};

Test that everything is working fine by checking the routing in the navbar is navigating between the modules

3. Add ngx-translate and translate the content

pre-step is to add environments files, as we will need it to load the translation files

simply run ng g environments then add property appUrl to have our Angular application url, e.g. appUrl: 'http://localhost:4200/'

then we will add ngx-translate as a dependency:

npm i @ngx-translate/core @ngx-translate/http-loader

Add a folder inside assets called i18n and a file inside it for each language you use for now I will just add en.json with this.

{
"HOMEPAGE_WORKS": "homepage works",
"FEATURE_WORKS": "feature works",
"HOMEPAGE": "homepage",
"FEATURE": "feature"
}

Then start translating the content using translate pipe like that {{'HOMEPAGE' | translate}}, where HOMEPAGE is key in the JSON to be replaced with the value it has in each language, and we will do that in all of our HTML files.

Then we will add TranslateModule in the component import array.

then to create the loader translate-loader-factory.ts

import { HttpClient } from '@angular/common/http';
import { TranslateHttpLoader } from '@ngx-translate/http-loader';
import { environment } from '../../../environments/environment';

export function translateLoaderFactory(http: HttpClient) {
return new TranslateHttpLoader(http, `${environment.appUrl}assets/i18n/`, '.json');
}

Then inside app.config.ts add new http client and translate providers

provideHttpClient(),
importProvidersFrom(
TranslateModule.forRoot({
defaultLanguage: 'en',
loader: {
provide: TranslateLoader,
useFactory: translateLoaderFactory,
deps: [HttpClient],
},
}),
),

To verify that everything is working fine till now, make sure that all words are translated correctly, so for example home route shows “homepage” and not “HOMEPAGE” or “homepage works”

4. Add more languages

Now I will add two more languages Arabic (RTL) and French, and will show you how to switch between them.

First of all, you need to add a switch link to them in the header.

First, I will make the header TS file like that (new addition in bold).

import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { CollapseModule } from 'ngx-bootstrap/collapse';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { BsDropdownModule } from 'ngx-bootstrap/dropdown';

@Component({
selector: 'app-header',
standalone: true,
imports: [CommonModule, RouterLink, CollapseModule, RouterLinkActive, BsDropdownModule, TranslateModule],
templateUrl: './header.component.html',
styleUrls: ['./header.component.scss']
})
export class HeaderComponent {
private readonly translateService = inject(TranslateService);
protected readonly locales = ['en', 'fr', 'ar'];
protected isCollapsed = true;

protected changeLanguage(locale: string): void {
this.translateService.use(locale);
}
}

Then I will change the HTML to

<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container">
<a [routerLink]="'/home'" class="navbar-brand">Localize</a>
<button
(click)="isCollapsed = !isCollapsed"
[attr.aria-expanded]="!isCollapsed"
aria-controls="navbarSupportedContent"
aria-label="Toggle navigation"
class="navbar-toggler"
type="button">
<span class="navbar-toggler-icon"></span>
</button>
<div [collapse]="isCollapsed" [isAnimated]="true" class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav">
<li class="nav-item">
<a
[routerLinkActiveOptions]="{exact: true}"
[routerLink]="'/home'"
aria-current="page"
class="nav-link"
routerLinkActive="active">{{'HOMEPAGE' | translate}}</a>
</li>

<li class="nav-item">
<a
[routerLinkActiveOptions]="{exact: true}"
[routerLink]="'/feature'"
class="nav-link"
routerLinkActive="active">{{'FEATURE' | translate}}</a>
</li>
</ul>

<ul class="navbar-nav language-dropdown">
<li class="nav-item dropdown" dropdown>
<a
aria-expanded="false"
class="nav-link dropdown-toggle"
data-bs-toggle="dropdown"
dropdownToggle
id="navbarDropdown"
role="button">
{{'CHANGE_LANGUAGE' | translate}}
</a>
<ul *dropdownMenu aria-labelledby="navbarDropdown" class="dropdown-menu">
<li *ngFor="let locale of locales">
<a
(click)="changeLanguage(locale)"
class="dropdown-item"
role="button"
>
{{locale}}
</a>
</li>
</ul>
</li>
</ul>
</div>
</div>
</nav>

And I will add this simple SCSS code to make the language menu on the right side.

.language-dropdown {
margin-left: auto;
}

Then I will add "CHANGE_LANGUAGE": "change language" in en.json file and will add a new file for each language:

fr.json

{
"HOMEPAGE": "page d'accueil",
"FEATURE": "FONCTIONNALITÉ",
"HOMEPAGE_WORKS": "page d'accueil fonctionne",
"FEATURE_WORKS": "fonctionnalité fonctionne",
"CHANGE_LANGUAGE": "changer de langue"
}

ar.json

{
"HOMEPAGE": "الصفحة الرئيسية",
"FEATURE": "ميزة",
"HOMEPAGE_WORKS": "الصفحة الرئيسية تشتغل",
"FEATURE_WORKS": "الميزة تشتغل",
"CHANGE_LANGUAGE": "غير اللغة"
}

Now, when you click on another language, it should load the website in it. If that’s the case then woohoo! You have added a localization to your application successfully.

While the website is now working, you must have noticed 3 issues:

  1. The application is flickering after it has loaded
  2. When you refresh the page it always return to English
  3. For Arabic, the website is showing in LTR

These are the issues I’m going to fix next.

5. Fix application flickering using hydration; also this will optimize loading time.

TL;DR: the core issue that makes the website flicker is because in SSR the content is rendered correctly as a static HTML, but when the Angular starts in the browser, it replaces the static HTML content with the interactive one, but to work, the translate pipe needs the translation file like en.json and the content will be empty till that file loads. Then the content return again.

Solution: The solution is to avoid waiting for the HTTP request to en.json file and send the translation file with the SSR content, also instead of rendering the application from scratch to just attach the events to it, read more about it in Angular official documentation.

While this seems a complicated task, thankfully Angular has a built-in solution and that’s the provideClientHydration() , once you have provided it in the app.config.ts go again to the source code of the SSR content you will notice added script with the empty object as in the below image:

at left after providing hydration, at right before

To double-check that the issue no longer exists, you can check the network tab on your browser, you won’t find any request to the en.json file.

6. Add ngx-translate-router to save selected language and have a dedicated path for each language.

Now another issue we are facing is that when we change the language, our new language choice doesn’t get saved and upon refreshing, the user gets our default language defined in our AppModule again.

While there are many solutions to this issue, I prefer using ngx-translate-router, because it handles a lot out of the box, and having a dedicated link for each content is important when people are sharing pages with each other or saving it to bookmark, or even sending it to their other device to find that it doesn’t open the same language he was reading in.

That’s enough about the problem, let’s focus on the solution.

The first thing you need to do is install it as a dependency using the following command:

npm i @gilsdav/ngx-translate-router @gilsdav/ngx-translate-router-http-loader

Then we will create a file containing our locales called locales.json with the following content

{
"locales": [
"en",
"ar",
"fr"
],
"prefix": "ROUTES."
}

Then we will create new loaders beside the translate loader file

localize-loader-factory.ts

import { TranslateService } from '@ngx-translate/core';
import { Location } from '@angular/common';
import { LocalizeRouterSettings } from '@gilsdav/ngx-translate-router';
import { HttpClient } from '@angular/common/http';
import { LocalizeRouterHttpLoader } from '@gilsdav/ngx-translate-router-http-loader';
import { environment } from '../../../environments/environment';

export function localizeLoaderFactory(translate: TranslateService, location: Location, settings: LocalizeRouterSettings, http: HttpClient) {
return new LocalizeRouterHttpLoader(translate, location, settings, http, `${environment.appUrl}assets/i18n/locales.json`);
}

then in askdjalkjsd

import { APP_INITIALIZER, ApplicationConfig, importProvidersFrom } from '@angular/core';
import { provideRouter, withDisabledInitialNavigation } from '@angular/router';
import { routes } from './app.routes';
import { provideAnimations } from '@angular/platform-browser/animations';
import { provideClientHydration } from '@angular/platform-browser';
import { HttpClient, provideHttpClient } from '@angular/common/http';
import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core';
import { translateLoaderFactory } from '../utils/translate-loader-factory';
import { LocalizeParser, LocalizeRouterModule, LocalizeRouterSettings } from '@gilsdav/ngx-translate-router';
import { Location } from '@angular/common';
import { localizeLoaderFactory } from '../utils/localize-loader-factory';
import { initializeDirectionFactory } from '../utils/initialize-direction.factory';

export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes, withDisabledInitialNavigation()),
provideAnimations(),
provideClientHydration(),
provideHttpClient(),
importProvidersFrom([
TranslateModule.forRoot({
defaultLanguage: 'en',
loader: {
provide: TranslateLoader,
useFactory: translateLoaderFactory,
deps: [HttpClient],
},
}),
LocalizeRouterModule.forRoot(routes, {
parser: {
provide: LocalizeParser,
useFactory: localizeLoaderFactory,
deps: [TranslateService, Location, LocalizeRouterSettings, HttpClient],
},
initialNavigation: true,
}),
]),
{
provide: APP_INITIALIZER,
useFactory: initializeDirectionFactory,
multi: true,
},
],
};

notice

  1. import { Location } from ‘@angular/common’; as the IDE may not auto-import it.
  2. withDisabledInitialNavigation() inside provideRouter
  3. and of course adding LocalizeRouterModule

Now everything is okay. But you will find that all links are not working (because it should go to en/home or ar/home not just home ). To fix this, we need to call localize pipe on all absolute routes to append current locale at the begging of the route and update it when locale change.

Also, you will find that changing the language doesn’t change the URL, and refreshing loads our default still. To fix this, we will make the language to be anchor tags to direct to the current URL in the other language, and localize module will reload the translations automatically, so no need to call this.translateService.use(locale); anymore.

Here is the complete code for header TS now:

import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { NavigationEnd, Router, RouterLink, RouterLinkActive } from '@angular/router';
import { CollapseModule } from 'ngx-bootstrap/collapse';
import { TranslateModule } from '@ngx-translate/core';
import { BsDropdownModule } from 'ngx-bootstrap/dropdown';
import { filter } from 'rxjs';
import { LocalizeRouterModule, LocalizeRouterService } from '@gilsdav/ngx-translate-router';

@Component({
selector: 'app-header',
standalone: true,
imports: [CommonModule, RouterLink, CollapseModule, RouterLinkActive, BsDropdownModule, TranslateModule, LocalizeRouterModule],
templateUrl: './header.component.html',
styleUrls: ['./header.component.scss'],
})
export class HeaderComponent {
private readonly localizeRouterService = inject(LocalizeRouterService);
private readonly router = inject(Router);
protected readonly locales = ['en', 'fr', 'ar'];
protected isCollapsed = true;
protected currentUrl = '';

constructor() {
this.setCurrentUrl();

this.router.events.pipe(
filter(event => event instanceof NavigationEnd),
).subscribe(() => {
this.setCurrentUrl();
});
}

private setCurrentUrl(): void {
this.currentUrl = this.router.url
.replace('/' + this.localizeRouterService.parser.currentLang, '')
.split('?')[0];
}
}

And its HTML now:

<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container">
<a [routerLink]="'/home' | localize" class="navbar-brand">Localize</a>
<button
(click)="isCollapsed = !isCollapsed"
[attr.aria-expanded]="!isCollapsed"
aria-controls="navbarSupportedContent"
aria-label="Toggle navigation"
class="navbar-toggler"
type="button">
<span class="navbar-toggler-icon"></span>
</button>
<div [collapse]="isCollapsed" [isAnimated]="true" class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav">
<li class="nav-item">
<a
[routerLinkActiveOptions]="{exact: true}"
[routerLink]="'/home' | localize"
aria-current="page"
class="nav-link"
routerLinkActive="active">{{'HOMEPAGE' | translate}}</a>
</li>

<li class="nav-item">
<a
[routerLinkActiveOptions]="{exact: true}"
[routerLink]="'/feature' | localize"
class="nav-link"
routerLinkActive="active">{{'FEATURE' | translate}}</a>
</li>
</ul>

<ul class="navbar-nav language-dropdown">
<li class="nav-item dropdown" dropdown>
<a
aria-expanded="false"
class="nav-link dropdown-toggle"
data-bs-toggle="dropdown"
dropdownToggle
id="navbarDropdown"
role="button">
{{'CHANGE_LANGUAGE' | translate}}
</a>
<ul *dropdownMenu aria-labelledby="navbarDropdown" class="dropdown-menu">
<li *ngFor="let locale of locales">
<a
class="dropdown-item"
queryParamsHandling="merge"
routerLink="/{{locale}}/{{currentUrl}}"
routerLinkActive="active">
{{locale}}
</a>
</li>
</ul>
</li>
</ul>
</div>
</div>
</nav>

And wohoo! We have finally finished it.

Now when you refresh, it should keep your selected language, even if we removed it from the URL and went straight to http://localhost:4200/, and opening http://localhost:4200/ar/home should open the home in Arabic.

7. RTL support

Now this is the last part of this long article. If none of the languages you are using is RTL, you can safely ignore this section. If not, this section will be very important to you

Now you remember that I mentioned using Bootstrap 5 for its RTL support, and here we will start with that.

Go to angular.json and update the styles list to be like this (but only in build this time):

"styles": [
{
"input": "node_modules/bootstrap/dist/css/bootstrap.rtl.min.css",
"inject": false
},
{
"input": "node_modules/bootstrap/dist/css/bootstrap.min.css",
"inject": false
},
"src/styles.scss"
],

This will tell the Angular compiler to copy these files to dist, but won’t do anything with them. We will choose which one to use in runtime.

Then, to determine which locale is RTL and which isn’t, I will be adding a new key to the translation files called DIR to hold rtl or rtl . There are so many ways to determine this, but in our case it’s rtl in ar.json but ltr in the other two.

Then, we need to see which files to load depending on the DIR also to set the dir in the native html tag to the correct value, we will handle it all in a new file initialize-direction.factory.ts

import { inject, Injectable } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { DOCUMENT } from '@angular/common';

export function initializeDirectionFactory() {
const initializeDirectionService = inject(InitializeDirectionService);
return () => initializeDirectionService.initializeDirection();
}

@Injectable({providedIn: 'root'})
class InitializeDirectionService {
translateService = inject(TranslateService);
document = inject(DOCUMENT);

initializeDirection() {
this.translateService.stream('DIR').subscribe(dir => {
this.directionChanged(dir);
});
}

private directionChanged(dir: string): void {
const htmlTag = this.document.getElementsByTagName('html')[0] as HTMLHtmlElement;
htmlTag.dir = dir === 'rtl' ? 'rtl' : 'ltr';
this.changeCssFile(dir);
}

private changeCssFile(dir: string): void {
const headTag = this.document.getElementsByTagName('head')[0] as HTMLHeadElement;
const existingLink = this.document.getElementById('bootstrap-css') as HTMLLinkElement;
const bundleName = dir === 'rtl' ? 'bootstrap.rtl.min.css' : 'bootstrap.min.css';

if (existingLink) {
existingLink.href = bundleName;
} else {
const newLink = this.document.createElement('link');
newLink.rel = 'stylesheet';
newLink.id = 'bootstrap-css';
newLink.href = bundleName;
headTag.appendChild(newLink);
}
}

}

then add it as APP_INITIALIZER in the app.config.ts

{
provide: APP_INITIALIZER,
useFactory: initializeDirectionFactory,
multi: true,
},

You will notice after that change that everything is working fine except for one small thing — in the Arabic version the locale dropdown is beside the rest of the menu, and you will find in real projects many cases like that.

The issue is that margin-left: auto; in the header.component.scss needs to be margin-right: auto; in the case of RTL. But how to do that in Angular?

I have a trick to fix this, to wrap the whole style inside :host then add [dir=rtl] & selector and inside it the RTL style. So, for example, the header.component.scss should look like:

:host {
.language-dropdown {
margin-left: auto;

[dir=rtl] & {
margin-left: unset;
margin-right: auto;
}
}
}

To understand how this works, let’s look at the generated CSS code

[_nghost-serverApp-c22] .language-dropdown[_ngcontent-serverApp-c22] {
margin-left: auto;
}
[dir=rtl] [_nghost-serverApp-c22] .language-dropdown[_ngcontent-serverApp-c22] {
margin-left: unset;
margin-right: auto;
}

You see, [dir=rtl] is not appended with [_ngcontent-serverApp-c22] like the other selectors. That’s because I’m saying that’s before the :host selector.

To make it clearer, let’s try it without the :host.

.language-dropdown[_ngcontent-serverApp-c22] {
margin-left: auto;
}
[dir=rtl][_ngcontent-serverApp-c22] .language-dropdown[_ngcontent-serverApp-c22] {
margin-left: unset;
margin-right: auto;
}

Now you see [dir=rtl] is appended with [_ngcontent-serverApp-c22] which will make this always not matching any element (unless there is an element with dir=rtl inside the header component itself).

And finally, we are done.

You can see the whole project in my GitHub repository here:

https://github.com/robertIsaac/angular-internationalization

Thank you so much for reading this article. I hope it matched your expectations. If you have any feedback, I will appreciate it very much.

--

--

Robert Isaac

I am a Senior Front-End Developer who fall in love with Angular and TypeScript.