Best Practices for Angular Localization with SSR

Robert Isaac
16 min readMay 3, 2021

--

If you are using Angular 16 or later please check the updated version https://robert-isaac.medium.com/best-practices-for-angular-internationalization-with-ssr-384a98ee672a

I have worked before on making many websites with multiple languages, but when you want to index all of them in search engines, have a dedicated link for each language, and optimize the loading time, things start to get complicated.

So in this article, I’m going to walk you through how to manage localization 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 and add server-side rendering to it.
  2. Initialize the project with two simple modules, and a header (using bootstrap).
  3. Add ngx-translate and translate the content.
  4. Add more languages.
  5. Fix application flickering using transfer state, also this will optimize loading time.
  6. Add ngx-translate-router to save selected language and have a dedicated path for each language.
  7. RTL support (only if one of the languages you want to support is RTL)

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

Create a new Angular project normally with ng new. Here are my answers:

> ng new
? What name would you like to use for the new workspace and initial project? angular-localization
? Do you want to enforce stricter type checking and stricter bundle budgets in the workspace?
This setting helps improve maintainability and catch bugs ahead of time.
For more information, see https://angular.io/strict Yes
? Would you like to add Angular routing? Yes
? Which stylesheet format would you like to use? SCSS [ https://sass-lang.com/documentation/syntax#scss ]

Then add SSR with the following:

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
left is before SSR, right is after SSR

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

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

Run the following commands to generate them:

ng g m shared
ng g m home --route=home -m=app
ng g m feature --route=feature -m=app

This will create 3 modules, shared, home and feature and for the last two will add a component with routing to it and lazy loading the module in the app routing module.

We just need to make sure that empty route redirects to home. Here is the final look at routes in app-routing.module.ts:

const routes: Routes = [
{
path: '',
redirectTo: 'home',
pathMatch: 'full',
},
{
path: 'home',
loadChildren: () => import('./home/home.module').then(m => m.HomeModule),
},
{
path: 'feature',
loadChildren: () => import('./feature/feature.module').then(m => m.FeatureModule),
},
];

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

First, we need to install ngx-bootstrap and bootstrap (will use @next here to install Bootstrap 5 for RTL support, if you don’t care about RTL you can just install Bootstrap 4).

npm i bootstrap@next 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 in app.module.ts, add CollapseModule and BrowserAnimationsModule (needed for CollapseModule) to its import array, so that it looks like this:

imports: [
BrowserModule.withServerTransition({appId: 'serverApp'}),
AppRoutingModule,
BrowserAnimationsModule,
CollapseModule.forRoot(),

],

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 I just added this line 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></app-header>
<div class="container">
<router-outlet></router-outlet>
</div>

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

We will start with adding 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 AppModule and AppServerModule but we will use custom loader for each, in AppModule it will use HTTP request and in AppServerModule it will use node fs to load it.

I prefer to add the files inside a new folder src/app/core/utils but you are free to add them anywhere.

Here is the loader for each:

translate-browser.loader.ts

import { Observable } from 'rxjs';
import { TranslateLoader } from '@ngx-translate/core';
import { TranslateHttpLoader } from '@ngx-translate/http-loader';
import { HttpClient } from '@angular/common/http';

export class TranslateBrowserLoader implements TranslateLoader {
constructor(
private http: HttpClient,
) {
}

public getTranslation(lang: string): Observable<unknown> {
return new TranslateHttpLoader(this.http).getTranslation(lang);
}
}

export function translateBrowserLoaderFactory(
httpClient: HttpClient,
): TranslateBrowserLoader {
return new TranslateBrowserLoader(httpClient);
}

translate-server.loader.ts

import { join } from 'path';
import { Observable } from 'rxjs';
import { TranslateLoader } from '@ngx-translate/core';
import * as fs from 'fs';

export class TranslateServerLoader implements TranslateLoader {
constructor(
private prefix: string = 'i18n',
private suffix: string = '.json',
) {
}

public getTranslation(lang: string): Observable<any> {
return new Observable((observer) => {
const assetsFolder = join(
process.cwd(),
'dist',
'localize', // Your project name here
'browser',
'assets',
this.prefix,
);

const jsonData = JSON.parse(
fs.readFileSync(`${assetsFolder}/${lang}${this.suffix}`, 'utf8'),
);

observer.next(jsonData);
observer.complete();
});
}
}

export function translateServerLoaderFactory(): TranslateLoader {
return new TranslateServerLoader();
}

Then inside AppModule import use it like that

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

And don’t forget to import HttpClientModule too.

Then inside AppServerModule import add

TranslateModule.forRoot({
defaultLanguage: 'en',
loader: {
provide: TranslateLoader,
useFactory: translateServerLoaderFactory,
deps: []
}
}),

Finally, import and export TranslateModule in the SharedModule

To verify that everything is working fine till now, make sure that all words are translated correctly, so for example home route shows “homepage works!” and not “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, OnInit } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';

@Component({
selector: 'app-header',
templateUrl: './header.component.html',
styleUrls: ['./header.component.scss']
})
export class HeaderComponent implements OnInit {
isCollapsed = true;
locales = ['en', 'fr', 'ar'];

constructor(
private translateService: TranslateService,
) { }

ngOnInit(): void {
}

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
class="dropdown-item"
role="button"
(click)="changeLanguage(locale)"
>
{{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": "غير اللغة"
}

Finally, add BsDropdownModule.forRoot() in AppModule import array.

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 transfer state; 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.

While this seems a complicated task, thankfully Angular has a built-in solution and that’s the ServerTransferStateModule , once you have imported it in the AppServerModule 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 adding the module, at right before

Now let’s fill that object with our translating data. For that, we will use the TransferState service:

First, edit the app.server.module.ts to:

import { NgModule } from '@angular/core';
import { ServerModule, ServerTransferStateModule } from '@angular/platform-server';
import { AppModule } from './app.module';
import { AppComponent } from './app.component';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { translateServerLoaderFactory } from './core/utils/translate-server.loader';
import { TransferState } from '@angular/platform-browser';

@NgModule({
imports: [
AppModule,
ServerModule,
ServerTransferStateModule,
TranslateModule.forRoot({
defaultLanguage: 'en',
loader: {
provide: TranslateLoader,
useFactory: translateServerLoaderFactory,
deps: [TransferState]
}
}),
],
bootstrap: [AppComponent],
})
export class AppServerModule {}

Then modify translate-server.loader.ts to be

import { join } from 'path';
import { Observable } from 'rxjs';
import { TranslateLoader } from '@ngx-translate/core';
import * as fs from 'fs';
import { makeStateKey, StateKey, TransferState } from '@angular/platform-browser';

export class TranslateServerLoader implements TranslateLoader {
constructor(
private transferState: TransferState,
private prefix: string = 'i18n',
private suffix: string = '.json',
) {
}

public getTranslation(lang: string): Observable<any> {
return new Observable((observer) => {
const assetsFolder = join(
process.cwd(),
'dist',
'localize', // Your project name here
'browser',
'assets',
this.prefix,
);

const jsonData = JSON.parse(
fs.readFileSync(`${assetsFolder}/${lang}${this.suffix}`, 'utf8'),
);

// Here we save the translations in the transfer-state
const key: StateKey<number> = makeStateKey<number>(
'transfer-translate-' + lang,
);
this.transferState.set(key, jsonData);


observer.next(jsonData);
observer.complete();
});
}
}

export function translateServerLoaderFactory(transferState: TransferState): TranslateLoader {
return new TranslateServerLoader(transferState);
}

Now, let’s save and look again at the content of the source code

As you see, the translation data is now returned with our SSR, but we are still not using it.

Now let’s modify our app.module.ts to:

import { NgModule } from '@angular/core';
import { BrowserModule, BrowserTransferStateModule, TransferState } from '@angular/platform-browser';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { HeaderComponent } from './core/components/header/header.component';
import { CollapseModule } from 'ngx-bootstrap/collapse';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { HttpClient, HttpClientModule } from '@angular/common/http';
import { translateBrowserLoaderFactory } from './core/utils/translate-browser.loader';
import { BsDropdownModule } from 'ngx-bootstrap/dropdown';

@NgModule({
declarations: [
AppComponent,
HeaderComponent
],
imports: [
BrowserModule.withServerTransition({appId: 'serverApp'}),
AppRoutingModule,
BrowserAnimationsModule,
HttpClientModule,
BrowserTransferStateModule,
CollapseModule.forRoot(),
BsDropdownModule.forRoot(),
TranslateModule.forRoot({
defaultLanguage: 'en',
loader: {
provide: TranslateLoader,
useFactory: translateBrowserLoaderFactory,
deps: [HttpClient, TransferState]
}
}),
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }

To provide TransferState service to our translate-browser.loader.ts do this:

import { Observable } from 'rxjs';
import { TranslateLoader } from '@ngx-translate/core';
import { TranslateHttpLoader } from '@ngx-translate/http-loader';
import { HttpClient } from '@angular/common/http';
import { makeStateKey, StateKey, TransferState } from '@angular/platform-browser';

export class TranslateBrowserLoader implements TranslateLoader {
constructor(
private http: HttpClient,
private transferState: TransferState,
) {
}

public getTranslation(lang: string): Observable<unknown> {
const key: StateKey<number> = makeStateKey<number>(
'transfer-translate-' + lang,
);
const data = this.transferState.get(key, null);

// if none found, http load as fallback
if (data) {
return new Observable((observer) => {
observer.next(data);
observer.complete();
});
} else {

return new TranslateHttpLoader(this.http).getTranslation(lang);
}
}
}

export function translateBrowserLoaderFactory(
httpClient: HttpClient,
transferState: TransferState,
): TranslateBrowserLoader {
return new TranslateBrowserLoader(httpClient, transferState);
}

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 two new loaders beside the translate loader files

localize-server.loader.ts

import { LocalizeParser, LocalizeRouterSettings } from '@gilsdav/ngx-translate-router';
import { Routes } from '@angular/router';
import * as fs from 'fs';
import { TranslateService } from '@ngx-translate/core';
import { Location } from '@angular/common';
import { join } from 'path';
import { makeStateKey, StateKey, TransferState } from '@angular/platform-browser';

export class LocalizeServerLoader extends LocalizeParser {

isLoaded = false;
constructor(
translate: TranslateService,
location: Location,
settings: LocalizeRouterSettings,
private transferState: TransferState,
) {
super(translate, location, settings);
}

/**
* Gets config from the server
*/
public load(routes: Routes): Promise<any> {
// this because we load LocalizeRouterModule with forRoot twice once in AppModule and another in AppServerModule
// so this will be called twice, so we need to ignore the second one
if (this.isLoaded) {
return Promise.resolve();
} else {
this.isLoaded = true;
}
return new Promise((resolve: any) => {
const assetsFolder = join(
process.cwd(),
'dist',
'localize', // Your project name here
'browser',
'assets',
'locales.json',
);
const data: any = JSON.parse(fs.readFileSync(assetsFolder, 'utf8'));

// Here we save the locales in the transfer-state
const key: StateKey<number> = makeStateKey<number>(
'transfer-locales',
);
this.transferState.set(key, data);

this.locales = data.locales;
this.prefix = data.prefix;
this.init(routes).then(resolve);
});
}
}

export function localizeServerLoaderFactory(
translate: TranslateService,
location: Location,
settings: LocalizeRouterSettings,
transferState: TransferState,
): LocalizeServerLoader {
return new LocalizeServerLoader(translate, location, settings, transferState);
}

localize-browser.loader.ts

import { LocalizeParser, LocalizeRouterSettings } from '@gilsdav/ngx-translate-router';
import { Routes } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { Location } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { makeStateKey, StateKey, TransferState } from '@angular/platform-browser';
import { LocalizeRouterHttpLoader } from '@gilsdav/ngx-translate-router-http-loader';

export class LocalizeBrowserLoader extends LocalizeParser {
private translateService: TranslateService;
private LocalLocation: Location;
private localizeRouterSettings: LocalizeRouterSettings;

constructor(
translateService: TranslateService,
location: Location,
settings: LocalizeRouterSettings,
private data: any,
) {
super(translateService, location, settings);
this.translateService = translateService;
this.LocalLocation = location;
this.localizeRouterSettings = settings;
}

public load(routes: Routes): Promise<any> {
return new Promise((resolve: any) => {
this.locales = this.data.locales;
this.prefix = this.data.prefix;
this.init(routes).then(resolve);
});
}
}

export function localizeBrowserLoaderFactory(
translate: TranslateService,
location: Location,
settings: LocalizeRouterSettings,
httpClient: HttpClient,
transferState: TransferState,
): LocalizeParser {
const key: StateKey<number> = makeStateKey<number>(
'transfer-locales',
);
const data = transferState.get(key, null);
if (data) {
return new LocalizeBrowserLoader(translate, location, settings, data);
} else {
return new LocalizeRouterHttpLoader(
translate,
location,
settings,
httpClient,
);
}
}

One last step before importing it is to modify app-routing.module.ts to make it export routes as it’s needed when we import LocalizeRouterModule. Andd set the initialNavigation option to disabled as the initialNavigation will be handled by the module. Here is how it looks now:

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';

export const routes: Routes = [
{path: '', redirectTo: 'home', pathMatch: 'full'},
{path: 'home', loadChildren: () => import('./home/home.module').then(m => m.HomeModule)},
{path: 'feature', loadChildren: () => import('./feature/feature.module').then(m => m.FeatureModule)},
];

@NgModule({
imports: [RouterModule.forRoot(routes, {
initialNavigation: 'disabled',
})],
exports: [RouterModule],
})
export class AppRoutingModule {
}

Then we will import the module using this loader inside app.server.module.tslike this:

LocalizeRouterModule.forRoot(routes, {
parser: {
provide: LocalizeParser,
useFactory: localizeServerLoaderFactory,
deps: [TranslateService, Location, LocalizeRouterSettings, TransferState],
},
initialNavigation: true,
}),

And inside app.module.ts like this:

LocalizeRouterModule.forRoot(routes, {
parser: {
provide: LocalizeParser,
useFactory: localizeBrowserLoaderFactory,
deps: [TranslateService, Location, LocalizeRouterSettings, HttpClient, TransferState],
},
initialNavigation: true,
}),

But make sure to add import { Location } from ‘@angular/common’; as the IDE may not auto-import it.

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, OnInit } from '@angular/core';
import { LocalizeRouterService } from '@gilsdav/ngx-translate-router';
import { NavigationEnd, Router } from '@angular/router';
import { filter } from 'rxjs/operators';

@Component({
selector: 'app-header',
templateUrl: './header.component.html',
styleUrls: ['./header.component.scss']
})
export class HeaderComponent implements OnInit {
isCollapsed = true;
locales = this.localizeRouterService.parser.locales;
currentUrl = '';

constructor(
private localizeRouterService: LocalizeRouterService,
private router: Router,
) { }

ngOnInit(): void {
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 the app.component.ts.

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

@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent {
constructor(
private translateService: TranslateService,
@Inject(DOCUMENT) private document: Document,
) {
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);
}
}
}

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-localization

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 as it’s my first article on medium.

More content at plainenglish.io

--

--

Robert Isaac

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