This article will teach you what you need to know to successfully implement multi-language Angular applications rendered server-side.

The Background

What does i18n stand for and why is there an “18” in the middle? Even as an engineer with more than ten years of experience in the field I had no idea until I looked it up. It’s the number of letters between “i” and “n” in the word “internationalization.” So, i18n is internationalization. Pretty neat. One of the definitions of i18n is:

The design and development of a product, application or document content that enables easy localization for target audiences that vary in culture, region, or language.

By following the i18n definition link above, we can see that there are multiple areas of development that i18n touches on. However, the area we’ll concentrate on in this article is:

Separating localizable elements from source code or content, such that localized alternatives can be loaded or selected based on the user’s international preferences as needed.

In essence, whatever should be displayed in different languages needs to be separated out from the meat of the code to enable its maintainability.

In the article, we will explore how to implement our translation strings in a maintainable manner, enable the application to load only necessary resources, and allow browser memorization of the selected language. Then we will enable Server-Side Rendering (SSR) and solve issues encountered during enabling SSR in the Angular application.

The article is split up in the following parts:

Part 1. Setting the Scene

Part 2. Adding SSR to the App

Part 3. Solution 1 — Fix via Providing a Separate I18nModule for the Server

Part 4. Solution 2 — Provide Everything in a Single Module

Part 5. Improve Performance with TransferState

Part 6. Are We There Yet?

In the first part of this article, we will follow simple instructions for setting up an Angular application and adding i18n capabilities to it. Beginner-level developers may want to take a deep dive into Part 1. More advanced developers may glance at the code in the following sections and proceed to “Part 2. Adding SSR to the App” to find out what obstacles adding SSR will create and how to solve them.


Setting the Scene

For the purposes of this article, we’ll work with a bare-bones Angular application generated with AngularCLI. To follow along with the article, we will generate an app using the command (assuming the Angular CLI installed globally):

ng new ssr-with-i18n

For the sake of the example let’s add a couple of components:

ng g c comp-a
ng g c comp-b

Now, we will replace the contents of app.component.html with these two components:

<h1>Welcome to {{ title }}!</h1>

<app-comp-a></app-comp-a>
<app-comp-b></app-comp-b>

*** The code up to this point is available here.

Let’s Add i18n

As with most things in coding, there are many ways to skin a cat. Originally, I wanted to use the framework-independent library i18next with an Angular wrapper: angular-i18next. However, there is currently an unfortunate limitation with angular-i18next: it’s not capable of switching language on the fly, which is a show-stopper for me.

In this article, we’ll use a popular library: ngx-translate.

Note: The concepts of organizing modules and code described in this article do not apply just to ngx-translate. An application can use the new and shiny transloco library, which was released the date of writing this article (8/15/2019). The reader might even be trying to solve an issue that has nothing to do with translations. Therefore, this article is helpful for anybody who’s trying to solve a SSR related issue.

Using ngx-translate will allow us to store our strings in separate JSON files (a file per language) where each string will be represented by a key-value pair. The key is a string identifier and the value is the translation of the string.

  1. Install dependencies

In addition to the core library, we’ll install the http-loader library which will enable loading translations on-demand.

npm install @ngx-translate/core @ngx-translate/http-loader --save

2.  Add the code

The directions for the ngx-translate package suggest adding relevant code directly to the AppModule. However, I think we can do better. Let’s create a separate module that will encapsulate i18n related logic.

ng g m i18n --module app

This will add a new file: /i18n/i18n.module.ts and reference it in the app.module.ts.

Modify the i18n.module.ts file according to the documentation. The full code file is below:

import { NgModule } from '@angular/core';
import { HttpClient, HttpClientModule } from '@angular/common/http';
import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core';
import { TranslateHttpLoader } from '@ngx-translate/http-loader';

@NgModule({
  imports: [
    HttpClientModule,
    TranslateModule.forRoot({
      loader: {
        provide: TranslateLoader,
        useFactory: translateLoaderFactory,
        deps: [HttpClient]
      }
    }),
  ],
  exports: [TranslateModule]
})
export class I18nModule {
  constructor(translate: TranslateService) {
    translate.addLangs(['en', 'ru']);
    const browserLang = translate.getBrowserLang();
    translate.use(browserLang.match(/en|ru/) ? browserLang : 'en');
  }
}

export function translateLoaderFactory(httpClient: HttpClient) {
  return new TranslateHttpLoader(httpClient);
}

Nothing fancy is going on. We just added the  TranslateModule and configured it to use the HttpClient to load translations. We exported TranslateModule as well to make the pipe transform available in the AppModule and in HTML templates. In the constructor, we specified available languages and used a function provided by ngx-translate to get and use the browser’s default language.

By default, the TranslateHttpLoader will load translations from the /assets/i18n/ folder, so let’s add a couple of files there.

{
  "compA": "Component A works",
  "compB": "Component B works"
}
/assets/i18n/en.json
{
  "compA": "Компонент А работает",
  "compB": "Компонент Б работает"
}
/assets/i18n/ru.json

Note: we are using a single file per language. In more complex applications there’s nothing limiting us from creating files based on locale, e.g. en-US.json, en-Gb.json. These will be treated essentially as separate translations.

With this configuration, we should be able to update our component templates to use the translation strings instead of hard-coded text.

// comp-a.component.html
<p>{{'compA' | translate}}</p>

// comp-b.component.html
<p>{{'compB' | translate}}</p>

Run the application and notice that it’s using the translations from the en.json file. Let’s add a component that will let us switch between the two languages.

ng g c select-language --inlineStyle --inlineTemplate

Update the contents of the select-language.component.ts file.

import { Component } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';

@Component({
  selector: 'app-select-language',
  template: `
    <select #langSelect (change)="translate.use(langSelect.value)">
      <option
        *ngFor="let lang of translate.getLangs()"
        [value]="lang"
        [attr.selected]="lang === translate.currentLang ? '' : null"
      >{{lang}}</option>
    </select>
  `,
})
export class SelectLanguageComponent {
  constructor(public translate: TranslateService) { }
}

The ngx-translate library allows us to switch languages via a simple translate.use() API call. It also allows us to determine the currently selected language by querying the translate.currentLang property.

Insert the new component in the app.component.html file after the h1 tag.

<h1>Welcome to {{ title }}!</h1>
<app-select-language></app-select-language>
<app-comp-a></app-comp-a>
<app-comp-b></app-comp-b>

Run the application and see that the language can now be switched on the fly. Selecting a different language will request the appropriate .json file.

Now, if we select the language ru and refresh the browser, we’ll see that the page still loads with the language en selected. The browser does not have a mechanism for remembering the selected language. Let’s fix that.

Memorizing the Selected Language

The Angular community has made many plugins enhancing the functionality of the ngx-translate package. One of them is exactly what we need —ngx-translate-cache. By following instructions, we’ll (1) install the package

npm install ngx-translate-cache --save

and (2) use it inside of the I18nModule.

import { TranslateCacheModule, TranslateCacheSettings, TranslateCacheService } from 'ngx-translate-cache';

@NgModule({
  imports: [
    TranslateModule.forRoot(...), // unchanged
    TranslateCacheModule.forRoot({
      cacheService: {
        provide: TranslateCacheService,
        useFactory: translateCacheFactory,
        deps: [TranslateService, TranslateCacheSettings]
      },
      cacheMechanism: 'Cookie'
    })
  ]
})
export class I18nModule {
  constructor(
    translate: TranslateService,
    translateCacheService: TranslateCacheService
  ) {
    translateCacheService.init();
    translate.addLangs(['en', 'ru']);
    const browserLang = translateCacheService.getCachedLanguage() || translate.getBrowserLang();
    translate.use(browserLang.match(/en|ru/) ? browserLang : 'en');
  }
}

export function translateCacheFactory(
  translateService: TranslateService,
  translateCacheSettings: TranslateCacheSettings
) {
  return new TranslateCacheService(translateService, translateCacheSettings);
}

Now, if we select the language ru and refresh the browser we’ll see that it remembered our choice. Notice, that we selected 'Cookie' as a place to store the selected language. The default selection for this option is 'LocalStorage' . However, LocalStorage is not accessible on the server. A big part of this article has to do with enabling SSR, so we’re being a little bit proactive here and storing the language selection in the place where a server can read it.

Up until now there really was nothing special about this article. We simply followed instructions posted in relevant packages and encapsulated the i18n related logic in a separate module. Adding SSR has some tricky parts, so let’s take a closer look.

*** The code up to this point is available here.


Adding SSR to the App

Angular CLI is amazing! In particular, its schematics feature allows us to add new capabilities to the app using a simple command. In this case, we’ll run the following command to add SSR capabilities.

ng add @nguniversal/express-engine --clientProject ssr-with-i18n

Running this command updated and added a few files.

If we look at the package.json file, we’ll see that now we have a few new scripts that we can execute. The two most important are: (1) build:ssr and (2) serve:ssr . Let’s run these commands and see what happens.

Both commands run successfully. However, when we load the website in the browser, we get an error.

TypeError: Cannot read property 'match' of undefined
    at new I18nModule (C:\Source\Random\ssr-with-i18n\dist\server\main.js:113153:35)

A little bit of investigation reveals that the failing code is:

browserLang.match(/en|ru/)

The browserLang variable is undefined, which means that the following line of code didn’t work:

const browserLang = translateCacheService.getCachedLanguage() || translate.getBrowserLang();

This happens because we’re trying to access browser-specific APIs during the server-side rendering. Even the name of the function — getBrowserLang suggests that this function won’t work on the server. We’ll come back to this issue, but for the time being, let’s patch it by hard-coding the value of the browserLang variable:

const browserLang = 'en';

Build and serve the application again. This time there is no error. In fact, if we look at the network tab of the Developer Tools we’ll see that SSR worked! However, the translations didn’t come through.

Let’s see why this is happening. Notice the factory function used in the TranslateModule to load translations: translateLoaderFactory . This function makes use of the HttpClient and knows how to load the JSON files containing translations from the browser. However, the factory function is not smart enough to know how to load these files while in the server environment.

This brings us to the two issues we need to solve:

PROBLEM 1. Being able to determine the correct language to load in both the client and the server environments (instead of hard-coding the value to en ).

PROBLEM 2. Based on the environment, use the appropriate mechanism to load the JSON file containing translations.

Now that the issues are identified, let’s examine different ways to solve these issues.

Evaluating Existing Options

There are a few ways that we can make everything work. There is a closed issue in the ngx-translate repository related to enabling SSR — issue #754. A few solutions to PROBLEMS 1 and 2 can be found there.

Existing Solution 1. Fix via HttpInterceptor

One of the latest comments on issue #754 suggests using a solution found in the article “Angular Universal: How to add multi language support?" to address the PROBLEM 2. Unfortunately, PROBLEM 1 is not addressed in the article. The author suggests a fix using the HttpInterceptor, which patches the requests to retrieve the JSON files while on the server.

Even though the solution works, it feels a bit awkward to me to create an interceptor that will patch the path of the request. In addition, why should we be making an extra request (even though it’s local) when the server has access to the files through the file system? Let’s see what other options are available.

Existing Solution 2. Fix via Importing JSON Files Directly

A few recent comments on the same issue #754 suggest importing the contents of JSON files straight into the file which defines our module. Then we can check which environment we’re running in and either use the default TranslateHttpLoader or a custom one, which uses the imported JSON. This approach suggests a way to handle PROBLEM 2 by checking the environment where the code is running: if (isPlatformBrowser(platform)). We’ll use a similar platform check later in the article.

import { PLATFORM_ID } from "@angular/core";
import { isPlatformBrowser } from '@angular/common';
import * as translationEn from './assets/i18n/en.json';
import * as translationEs from './assets/i18n/es.json';

const TRANSLATIONS = {
  en: translationEn,
  es: translationEs,
};

export class JSONModuleLoader implements TranslateLoader {
  getTranslation(lang: string): Observable<any> {
    return of(TRANSLATIONS[lang]);
  }
}

export function translateLoaderFactory(http: HttpClient, platform: any) {
  if (isPlatformBrowser(platform)) {
    return new TranslateHttpLoader(http);
  } else {
    return new JSONModuleLoader();
  }
}

// module imports:
TranslateModule.forRoot({
  loader: {
    provide: TranslateLoader,
    useFactory: translateLoaderFactory,
    deps: [HttpClient, PLATFORM_ID]
  }
})

Please don’t do this! By importing JSON files like shown above, they will end up in the browser bundle. The whole purpose of using HttpLoader is that it will load the required language file on demand making the browser bundle smaller.

With this method, the translations for all the languages will be bundled together with the run-time JavaScript compromising performance.

Although both existing solutions provide a fix for PROBLEM 2, they have their shortcomings. One results in unnecessary requests being made and another one compromises performance. Neither of them provides a solution for PROBLEM 1.

A Better Way — Prerequisites

In the upcoming sections I’ll provide two separate solutions to the identified PROBLEMS. Both of the solutions will require the following prerequisites.

Prerequisite 1. We need to install and use a dependency called cookie-parser.

Prerequisite 2. Understand the Angular REQUEST injection token

The ngx-translate-cache library is in charge of creating a cookie in the client when a user selects the language. By default (although it can be configured) the cookie is named lang. In the upcoming solutions, we’ll need a way to access this cookie on the server. By default, we can access the information we need from the req.headers.cookie object in any of the Express request handlers. The value would look something like this:

lang=en; other-cookie=other-value

This property has all the information we need, but we need to parse the lang out. Although it’s simple enough, there is no need to reinvent the wheel since cookie-parser is an Express middleware that does exactly what we need.

Install the required dependencies.

npm install cookie-parser
npm install @types/cookie-parser -D

Update the server.ts file to use the installed cookie-parser.

import * as cookieParser from 'cookie-parser';
app.use(cookieParser());

Under the hood, the cookie-parser will parse the Cookies and store them as a dictionary object under req.cookies.

{
  "lang": "en",
  "other-cookie": "other-value"
}

Prerequisite 2. The Angular REQUEST Injection Token

Now that we have a convenient way of accessing Cookies from the request object, we need to have access to the req object in the context of the Angular application. This can easily be done using the REQUEST injection token.

import { REQUEST } from '@nguniversal/express-engine/tokens';
import { Request } from 'express';

export class AnyModule {
  constructor(@Inject(REQUEST) private req: Request) {
    console.log(req.cookies.lang); // 'en' | 'ru'
  }
}

Here’s the obvious fact: The REQUEST injection token is available under @nguniversal/express-engine/tokens. Here is a not so obvious fact: the type for the req object is the Request provided by type definitions of the express library.

This is important and might trip us over. If this import is forgotten, the typescript will assume a different Request type from the Fetch API that’s available under lib.dom.d.ts. As a result, TypeScript will not have knowledge of req.cookies object and will underline it with red.

Now We Are Ready for the Solutions

Please make a mental snapshot of the PART 2 Checkpoint below. We will use this code as a starting point for the next two parts of this series where we’ll explore how to fix the two PROBLEMS outlined previously.

*** The code up to this point is available here.


Solution 1 — Fix via Providing a Separate I18nModule for the Server

Currently, our application looks like this:

The diagram above shows the path of code execution when the code runs in the browser (green) and when it runs in the server (blue). Notice that in the client-side path, the file that bootstraps the whole application (main.ts) imports the AppModule directly. On the server-side path, the main file imports a separate module, the AppServerModule, which in turn imports the AppModule. Also, notice that the I18nModule is a dependency of AppModule , which means that the code of I18nModule will be executed in both the client and in the server.

In the solution below we’ll make the browser side look more like the server side. We’ll introduce a new module — the AppBrowserModule . That will be the module to be bootstrapped. We’ll also rename the I18nModule to I18nBrowserModule and move it into the imports of the AppBrowserModule . Finally, we’ll introduce a new module, the I18nServerModule, that will use file system access to load JSON files. This module will be imported inside of the AppServerModule. See the resulting structure below:

Below is the code of the new I18nServerModule.

import { Inject, NgModule } from '@angular/core';
import { REQUEST } from '@nguniversal/express-engine/tokens';
import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core';
import { Request } from 'express';
import { readFileSync } from 'fs';
import { join } from 'path';
import { Observable, of } from 'rxjs';

@NgModule({
  imports: [
    TranslateModule.forRoot({
      loader: {
        provide: TranslateLoader,
        useFactory: translateFSLoaderFactory
      }
    })
  ]
})
export class I18nServerModule {
  constructor(translate: TranslateService, @Inject(REQUEST) req: Request) {
    translate.addLangs(['en', 'ru']);
    const language: 'en' | 'ru' = req.cookies.lang || 'en';
    translate.use(language.match(/en|ru/) ? language : 'en');
  }
}

export class TranslateFSLoader implements TranslateLoader {
  constructor(private prefix = 'i18n', private suffix = '.json') { }
  public getTranslation(lang: string): Observable<any> {
    const path = join(__dirname, '../browser/assets/', this.prefix, `${lang}${this.suffix}`);
    const data = JSON.parse(readFileSync(path, 'utf8'));
    return of(data);
  }
}

export function translateFSLoaderFactory() {
  return new TranslateFSLoader();
}

There are two main things happening in the code above.

First, we make use of the REQUEST injection token provided by Angular to get a hold of the full request object. We use the token to access the cookies object to find out what language the user selected in the browser. Knowing the language, we call the use method of the TranslateService class so that our website gets rendered in that language.

Second, the action above will trigger our custom loading mechanism defined in the TranslateFsLoader class. In the class, we simply use standard node API to read files from the file system (fs).

Solution 1 Summary

This solution completely separates the compilation path for the server from the compilation path for the browser. PROBLEM 1 is solved due to the translate.getBrowserLang() existing only in the I18nBrowserModule, which will never run in the server environment.

PROBLEM 2 is similarly solved by each I18n Module — the Server and the Client modules — using their own Translation Loader mechanism — the TranslateFsLoader and TranslateHttpLoader respectively.

I like this option because it comes with a clear separation between the code that runs on the server and the code that runs in the browser. Introducing the AppBrowserModule establishes the architecture for handling cases when the server- and client-side logic significantly differs. Perhaps this approach is best suited for larger applications.

However, there is one more approach to tackle this task. Keep reading!

*** The code up to this point is available here.


Solution 2 — Provide Everything in a Single Module

Now that we looked at Solution 1, let’s examine another way. In contrast to Solution 1, this solution does not require the creation of new modules. Instead, all code will be placed inside of the I18nModule. This can be achieved using the isPlatformBrowser function provided by the Angular framework.

Let’s come back to the PART 2 Checkpoint.

git checkout step-2

Now we’ll make the I18nModule aware of the platform it’s running in and use the appropriate Loader depending on the environment — either the TranslateFsLoader created in the previous Part or the TranslateHttpLoader provided by the ngx-translate library.

Add the PLATFORM_ID to the deps of the translateLoaderFactory. This will allow us to select the loader in the factory depending on the current platform.

export function translateLoaderFactory(httpClient: HttpClient, platform: any) {
  return isPlatformBrowser(platform)
    ? new TranslateHttpLoader(httpClient)
    : new TranslateFSLoader();
}

Now, the factory function will use the appropriate Loader depending on the platform. Similar adjustments need to be done to the constructor of the I18nModule.

@NgModule({...})
export class I18nModule {
  constructor(
    translate: TranslateService,
    translateCacheService: TranslateCacheService,
    @Optional() @Inject(REQUEST) private req: Request,
    @Inject(PLATFORM_ID) private platform: any
  ) {
    if (isPlatformBrowser(this.platform)) {
      translateCacheService.init();
    }
    translate.addLangs(['en', 'ru']);
    const browserLang = isPlatformBrowser(this.platform)
      ? translateCacheService.getCachedLanguage() || translate.getBrowserLang() || 'en'
      : this.getLangFromServerSideCookie() || 'en';
    translate.use(browserLang.match(/en|ru/) ? browserLang : 'en');
  }
  
  getLangFromServerSideCookie() {
    if (this.req) {
      return this.req.cookies.lang;
    }
  }
}

If we try to build the application now we’ll get an error.

Module not found: Error: Can't resolve 'fs' in 'C:\ssr-with-i18n\src\app\i18n'
Module not found: Error: Can't resolve 'path' in 'C:\ssr-with-i18n\src\app\i18n'

That’s because the fs and the path dependencies, which are strictly Node dependencies, are now referenced in the file that’s compiled for the client-side environment.

We, as developers, know that these server-side dependencies won’t be used because they are behind appropriate if statements, but the compiler does not know that.

There is an easy fix for this issue as well. We can let our compiler know not to include these dependencies in the client-side bundle using a new browser field of the package.json file.

Add the following to the package.json file.

"browser": {
  "fs": false,
  "path": false
}

Now, everything will compile and run exactly the same as with the previous solution.

Solution 2 Summary

Both PROBLEM 1 and PROBLEM 2 are solved by separating the browser-specific code from the server-specific code via an `if` statement that evaluates the current platform:

isPlatformBrowser(this.platform)

Now that there is only a single path of compilation for both platforms, fs and path dependencies that are strictly node dependencies cause a compilation-time error when the build process compiles a browser bundle. This is solved by specifying these dependencies in the browser field of the package.json file and setting their values to false.

I like this option because it’s simpler from the perspective of the consumer application. There’s no need to create additional modules.

*** The code up to this point is available here.


Improve Performance with TransferState

If we run our app in its current state and take a look at the network tab of the Browser Developer Tools, we’ll see that after initial load the app will make a request to load the JSON file for the currently selected language.

This does not make much sense since we’ve already loaded the appropriate language in the server.

Making an extra request to load language translations that are already loaded might seem like it’s not a big issue worth solving. There probably are areas of an application that will result in a bigger bang for the buck in terms of performance tuning. See this article for more on this topic. However, for bigger applications, translation files might also get bigger. Therefore, the time to download and process them will also increase, at which point this would be an issue to solve.

Thankfully, Angular Universal provides a tool to solve this issue with relatively little effort: TransferState. When this solution is in use, the server will embed the data with the initial HTML sent to the client. The client will then be able to read this data without the need to ask the server.

Overview of the Workflow

To make use of the TransferState feature, we need to:

1. Add the module provided by Angular for the server and for the client: ServerTransferStateModule and BrowserTransferStateModule

2. On the server: set the data that we want to transfer under a specific key using API: transferState.set(key, value)

3. On the client: retrieve the data using API: transferState.get(key, defaultValue)

Our implementation

First, let’s add the TransferState Modules to the imports:

import { BrowserTransferStateModule, TransferState } from '@angular/platform-browser';

@NgModule({
  imports: [
    BrowserTransferStateModule, // ADDED
    // ...
  ]
})
export class I18nModule {
  // ...
}
import { ServerTransferStateModule } from '@angular/platform-server';

@NgModule({
  imports: [
    ServerTransferStateModule, // ADDED
    // ...
  ],
  bootstrap: [AppComponent],
})
export class AppServerModule { }

Now let’s make appropriate changes to the I18nModule. The snippet below shows the new code.

// ADDED needed imports from @angular
import { makeStateKey, TransferState } from '@angular/platform-browser';
@NgModule({
  imports: [
    TranslateModule.forRoot({
      loader: {
        provide: TranslateLoader,
        useFactory: translateLoaderFactory,
        deps: [HttpClient, TransferState, PLATFORM_ID] // ADDED: dependency for the factory func
      }
    })
  ]
})
export class I18nModule {
  // ...
}

Second, the translateLoaderFactory will now look like this:

export function translateLoaderFactory(httpClient: HttpClient, transferState: TransferState, platform: any) {
  return isPlatformBrowser(platform)
    ? new TranslateHttpLoader(httpClient)
    : new TranslateFSLoader(transferState);
}

TranslateFsLoader will now make use of TransferState:

import { makeStateKey, TransferState } from '@angular/platform-browser';

export class TranslateFsLoader implements TranslateLoader {
  constructor(
    // ADDED: inject the transferState service
    private transferState: TransferState,
    private prefix = 'i18n',
    private suffix = '.json'
  ) { }

  public getTranslation(lang: string): Observable<any> {
    const path = join(__dirname, '../browser/assets/', this.prefix, `${lang}${this.suffix}`);
    const data = JSON.parse(readFileSync(path, 'utf8'));
    // ADDED: store the translations in the transfer state:
    const key = makeStateKey<any>('transfer-translate-' + lang);
    this.transferState.set(key, data);
    return of(data);
  }
}

How does it exactly transfer the state? During the server-side rendering, the framework will include the data in the HTML <script> tag. See this in the image below.

Once the client-side bundle bootstraps, it will be able to access this data.

Now we need to allow the client-side Loader to make use of the transferred data. Currently, our loader factory function simply returns the TranslateHttpLoader. We’ll have to create a custom loader that will also be capable of handling the transfer state.

Let’s create a new file to hold the custom loader class. The new loader will look like the one below.

export class TranslateBrowserLoader implements TranslateLoader {
  constructor(
    private transferState: TransferState,
    private http: HttpClient,
    private prefix: string = 'i18n',
    private suffix: string = '.json',
  ) { }
  
  public getTranslation(lang: string): Observable<any> {
    const key = makeStateKey<any>('transfer-translate-' + lang);
    const data = this.transferState.get(key, null);
    
    // First we are looking for the translations in transfer-state, if none found, http load as fallback
    return data
      ? of(data)
      : new TranslateHttpLoader(this.http, this.prefix, this.suffix).getTranslation(lang);
  }
}

Update the translateLoaderFactory to use the new Loader:

export function translateLoaderFactory(httpClient: HttpClient, transferState: TransferState, platform: any) {
  return isPlatformBrowser(platform)
    ? new TranslateBrowserLoader(transferState, httpClient) // <- Changed
    : new TranslateFSLoader(transferState);
}

TransferState Summary

Using TransferState allowed us to avoid loading data from the browser that was already loaded on the server.

Now, if we run the application we’ll see that there is no unneeded request to the JSON file for the currently selected language in the network tab.

*** The code up to this point is available here.


Are We There Yet?

Whether you’ve chosen to go with the Solution 1 or 2, it seems like it’s all working now! Let’s close the Browser Developer Tools and enjoy the feeling of accomplishment after a lot of work.

Just to make sure everything is working correctly, let’s update our JSON files and add “!!!” at the end of all translation strings to celebrate. Build and start the application. Refresh the page and then… scratch your head. The “!!!” aren’t there. What happened?

While the Browser Developer Tools panel was open, the browser would clear cache on each page reload. It means the browser would download fresh JSON files each time. The moment we closed the Developer Tools panel, the browser started caching our assets. Even though we’ve changed the contents of the JSON files, the browser does not know about it.

But how does the browser know to load fresh JavaScript and CSS files every time? That’s because Angular’s build scripts append a unique set of characters (hash) to the file name.

This hash changes every time when the contents of the file change. We need to implement a similar feature for our JSON files.

Luckily, the implementation is straightforward. Let’s create /scripts folder and a new file there: hash-translations.js.

"use strict";
const fs = require('fs');
const path = require('path');
const md5 = require('md5');
const srcPath = 'src/assets/i18n';
const destPath = 'src/assets/i18n/autogen';

cleanDestinationDir();
const map = generateHashedFiles();
saveHashMapFile(map);

function cleanDestinationDir() {
  console.log("Cleaning existing destinaiton directory");
  if (fs.existsSync(destPath) && fs.statSync(destPath).isDirectory()) {
    const destFiles = fs.readdirSync(destPath);
    destFiles.forEach(function(fileName) {
      fs.unlinkSync(path.join(destPath, fileName));
    });
  } else {
    fs.mkdirSync(destPath);
  }
}

function generateHashedFiles() {
  const map = {};
  const srcFiles = fs.readdirSync(srcPath);
  srcFiles.forEach(function(fileName) {
    if (fileName === 'autogen') { return; }
    const srcFile = path.join(srcPath, fileName);
    console.log('Reading source file: ', srcFile);
    const buf = fs.readFileSync(srcFile);
    const hash = md5(buf);
    const lang = fileName.split('.')[0];
    map[lang] = hash;
    const destFile = path.join(destPath, `${lang}.${hash}.json`);
    console.log('Writing new file:', destFile);
    fs.writeFileSync(destFile, buf);
  });
  return map;
}

function saveHashMapFile(map) {
  const mapFile = path.join(destPath, 'map.json');
  console.log('Writing map file: ', mapFile);
  fs.writeFileSync(mapFile, JSON.stringify(map, null, 2));
}

For this script to work, we need to install a new dependency.

npm install md5 -D

The script file above defines two important variables: the source directory and the destination directory.

First, the script will clean up the destination directory if it was not empty. Then the script will look at the source path specified, read the JSON files, and generate hashes using md5 based on the files’ contents.

Once the hashes are generated, the script will write a copy of each file in the destination directory, but this time with the hash in the file name.

Finally, the script will generate the map.json file and put it in the destination directory as well. This file will let us pick the correct hashed file based on the locale. The contents of this file will look like the following:

{
  "en": "[hash-for-file-1]",
  "ru": "[hash-for-file-2]"
}

Add an entry to the package.json file under the scripts field that will let us execute the file we created.

Also, update the start and build:ssr scripts to run this new script:

"hash:i18n": "node scripts/hash-translations.js",
"start": "npm run hash:i18n && ng serve",
"build:ssr": "npm run hash:i18n && npm run build:client-and-server-bundles && npm run compile:server"

Go ahead and run the new script to see the results. Note, that there is no need to check-in auto-generated files into the repository since they will be changing often. Add an entry to the .gitignore file.

src/assets/i18n/autogen/*

Finally, update the Translation Loaders to serve auto-generated files.

The path to the file that we need to load looks like this: ./assets/i18n/autogen/${lang}.${hash}.json. The ./assets/i18n/autogen/ part is the prefix. The .${hash}.json is the suffix. Both of these variables need to be customized in order to make use of the auto-generated files.

Here’s how we can handle the prefix change for both loaders.

export function translateLoaderFactory(httpClient: HttpClient, transferState: TransferState, platform: any) {
  const prefix = './assets/i18n/autogen/';
  return isPlatformBrowser(platform)
    ? new TranslateBrowserLoader(transferState, httpClient, prefix)
    : new TranslateFSLoader(transferState, prefix);
}

The suffix would have to be handled in the getTranslation method of each loader since we need access to the lang variable.

First, we need to get a hold of the auto-generated map.json file.

const i18nMap = require('../../assets/i18n/autogen/map.json');

We use a require syntax because this file might only be available during compilation.

The TranslateBrowserLoader will have the following changes:

const suffix = `.${i18nMap[lang]}${this.suffix}`;

return data
  ? of(data)
  : new TranslateHttpLoader(this.http, this.prefix, suffix).getTranslation(lang);

For the TranslateFsLoader , it is a one-line code change.

const path = join(__dirname, '../browser', this.prefix, `${lang}.${i18nMap[lang]}${this.suffix}`);

Compile and run the app. Everything will work as expected and the browser will load updated translation files when it needs to.

*** The final code is available here.


Article Summary

In this article, we built a maintainable solution for managing application translation strings via separate JSON files. We utilized a popular library — ngx-translate. We’ve also looked at the current solutions for integrating this functionality with Server-Side Rendered applications provided by the community. We talked about the weaknesses of these solutions and provided better options. Finally, we implemented a few of the advanced features, such as: (1) memorization of the selected language via Cookies, (2) utilizing State Transfer to avoid unnecessary HTTP requests to the server, and (3) breaking cache for the translation files.