Skip to content

5. Internationalization

An internationalized application displays its text in the user’s language. The case study is in French and English. The principle is simple: the views no longer contain text, but keys ([MENU.HOME]), and a dictionary for each language associates a text with each key. The selected language is stored in a cookie.

5.1. Example 22: A Bilingual Application

22-i18n/
├── i18n/
│   ├── en.json
│   └── fr.json
├── src/
│   ├── app.module.ts
│   ├── helpers.ts
│   ├── i18n.ts
│   ├── language.controller.ts
│   ├── main.ts
│   └── pages.controller.ts
├── views/
│   ├── partials/
│   │   └── navbar.hbs
│   ├── home.hbs
│   ├── layout.hbs
│   └── products.hbs
├── nest-cli.json
└── tsconfig.json

5.1.1. The Dictionaries

The file [i18n/fr.json]:

{
  "MENU": {
    "HOME": "Accueil",
    "PRODUCTS": "Produits"
  },
  "HOME": {
    "TITLE": "Bienvenue",
    "HELLO": "Bonjour {name} !",
    "DATE": "Nous sommes le {date}."
  },
  "PRODUCTS": {
    "TITLE": "Nos produits",
    "NAME": "Produit",
    "PRICE": "Prix",
    "COUNT": "{n} produit(s) au catalogue."
  }
}

The file [i18n/en.json]:

{
  "MENU": {
    "HOME": "Home",
    "PRODUCTS": "Products"
  },
  "HOME": {
    "TITLE": "Welcome",
    "HELLO": "Hello {name}!",
    "DATE": "Today is {date}."
  },
  "PRODUCTS": {
    "TITLE": "Our products",
    "NAME": "Product",
    "PRICE": "Price",
    "COUNT": "{n} product(s) in the catalogue."
  }
}
  • A dictionary is a JSON file, organized by page or by topic: The key [HOME.HELLO] refers to the text [HELLO] in section [HOME];
  • Line 8: A text may contain parameters, enclosed in curly braces, which are replaced during translation. Word order varies from one language to another: this is why a text is not created by simply concatenating translated segments.

5.1.2. The translation module

The file [src/i18n.ts]:

import { readFileSync } from 'fs';
import { join } from 'path';

export type Language = 'fr' | 'en';
export const LANGUAGES: Language[] = ['fr', 'en'];
export const DEFAULT_LANGUAGE: Language = 'en';
// the "locale" used by Intl for dates and numbers
export const LOCALES: Record<Language, string> = { fr: 'fr-FR', en: 'en-US' };

// a dictionary is a nested JSON object: { "MENU": { "HOME": "Home" } }
type Dictionary = { [key: string]: string | Dictionary };

// the dictionaries are read ONCE, at startup
const FOLDER = join(__dirname, '..', 'i18n');
const dictionaries: Record<Language, Dictionary> = {
  fr: JSON.parse(readFileSync(join(FOLDER, 'fr.json'), 'utf-8')),
  en: JSON.parse(readFileSync(join(FOLDER, 'en.json'), 'utf-8')),
};

export function isLanguage(value: unknown): value is Language {
  return typeof value === 'string' && (LANGUAGES as string[]).includes(value);
}

// translate('en', 'HOME.HELLO', { name: 'Marie' }) -> "Hello Marie!"
export function translate(language: Language, key: string, params: Record<string, unknown> = {}): string {
  const text = lookup(dictionaries[language], key) ?? key;
  return text.replace(/\{(\w+)\}/g, (whole, name: string) => (name in params ? String(params[name]) : whole));
}

// 'HOME.HELLO': we go down into HOME, then into HELLO
function lookup(dictionary: Dictionary, key: string): string | null {
  let node: string | Dictionary | undefined = dictionary;
  for (const part of key.split('.')) {
    if (typeof node !== 'object') {
      return null;
    }
    node = node[part];
  }
  return typeof node === 'string' ? node : null;
}

Let’s comment on this code:

  • lines 4–6: the supported languages;
  • line 8: the “locale” for each language, used by [Intl] to format dates and numbers ([fr-FR]: 23/09/2026, 12,50 €; [en-US]: 9/23/2026, €12.50);
  • lines 14–18: the dictionaries are read once, when the module loads, and then kept in memory;
  • lines 20–22: a check for a type such as [TypeScript] ([value is Language]): after a positive test, the compiler knows that the value is a [Language];
  • lines 25–28: the translation. The key is looked up in the dictionary; if it is not found, the key itself is displayed: any omission is immediately visible on the screen. The regular expression [/\{(\w+)\}/g] finds each parameter [{name}] and replaces it with its value;
  • lines 31–40: The key [HOME.HELLO] is split into [HOME] and then [HELLO], and the process continues down into the object JSON.

5.1.3. The helpers

The file [src/helpers.ts]:

import { HelperOptions } from 'handlebars';
import { Language, DEFAULT_LANGUAGE, LOCALES, isLanguage, translate } from './i18n';

interface Hbs {
  registerHelper(name: string, fn: (...args: any[]) => unknown): void;
}

// the language of the page: res.locals.lang, merged into the root model
function languageOf(options: HelperOptions): Language {
  const lang = options.data?.root?.lang;
  return isLanguage(lang) ? lang : DEFAULT_LANGUAGE;
}

export function registerHelpers(hbs: Hbs): void {
  // {{t 'KEY'}} or {{t 'KEY' name=...}}: translation, with named parameters
  hbs.registerHelper('t', (key: string, options: HelperOptions) => translate(languageOf(options), key, options.hash));

  // {{longDate when}}: "mercredi 23 septembre 2026" / "Wednesday, September 23, 2026"
  hbs.registerHelper('longDate', (when: string, options: HelperOptions) =>
    new Intl.DateTimeFormat(LOCALES[languageOf(options)], { dateStyle: 'full' }).format(new Date(when)),
  );

  // {{money price}}: "12,50 €" / "€12.50"
  hbs.registerHelper('money', (amount: number, options: HelperOptions) =>
    new Intl.NumberFormat(LOCALES[languageOf(options)], { style: 'currency', currency: 'EUR' }).format(amount),
  );

  hbs.registerHelper('eq', (a: unknown, b: unknown) => a === b);
}
  • lines 9–12: The page’s language is in the root template ([options.data.root.lang]), where the [main.ts] middleware placed it via [res.locals];
  • line 16: the helper [t] translates a key. The named parameters of the helper ([options.hash]) are the text parameters: [{{t 'HOME.HELLO' name=name}}];
  • lines 19–21 and 24–26: dates and amounts are formatted according to the language.

5.1.4. Language Selection

The file [src/main.ts]:

import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import * as cookieParser from 'cookie-parser';
import { NextFunction, Request, Response } from 'express';
import * as hbs from 'hbs';
import { dirname, join } from 'path';
import { AppModule } from './app.module';
import { registerHelpers } from './helpers';
import { isLanguage, DEFAULT_LANGUAGE } from './i18n';

const ROOT = join(__dirname, '..');

async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule);
  app.useStaticAssets(join(dirname(require.resolve('bootstrap/package.json')), 'dist'), { prefix: '/bootstrap/' });
  app.setBaseViewsDir(join(ROOT, 'views'));
  app.setViewEngine('hbs');
  registerHelpers(hbs);
  await new Promise<void>((done) => hbs.registerPartials(join(ROOT, 'views', 'partials'), () => done()));
  app.use(cookieParser());
  // the language of each page: that of the [language] cookie, English otherwise
  app.use((req: Request, res: Response, next: NextFunction) => {
    const cookie = req.cookies?.language;
    res.locals.lang = isLanguage(cookie) ? cookie : DEFAULT_LANGUAGE;
    next();
  });
  await app.listen(3000);
  console.log('Server started on http://localhost:3000');
}

bootstrap();

Lines 22–26: For each request, the language is read from the [language] cookie (English by default) and stored in [res.locals.lang], and thus in the template for each view.

The file [src/language.controller.ts]:

import { Controller, Get, NotFoundException, Param, Req, Res } from '@nestjs/common';
import { Request, Response } from 'express';
import { isLanguage } from './i18n';

@Controller('language')
export class LanguageController {
  // GET /language/fr: stores the choice in a cookie, then goes back to the previous page
  @Get(':language')
  change(@Param('language') language: string, @Req() req: Request, @Res() res: Response): void {
    if (!isLanguage(language)) {
      throw new NotFoundException();
    }
    res.cookie('language', language, { maxAge: 365 * 24 * 3600 * 1000, sameSite: 'lax', httpOnly: true });
    res.redirect(originPage(req));
  }
}

// the page we come from (Referer header), of which we keep only the path:
// redirecting to a full address taken from the request would allow
// sending the user to another site ("open redirect" vulnerability)
function originPage(req: Request): string {
  const referer = req.get('referer');
  if (!referer) {
    return '/';
  }
  try {
    const url = new URL(referer);
    return url.pathname + url.search;
  } catch {
    return '/';
  }
}
  • lines 8–15: [GET /language/en] stores the selection in a cookie valid for one year, then redirects to the page the user came from. An unknown language results in a 404 error;
  • lines 21–32: The original page is specified by the [Referer] header, which the browser adds when a link is clicked. We retain only the path: redirecting to a full URL from the request would allow a malicious site to create a link that, after passing through our site, sends the user to the malicious site (a vulnerability known as [open redirect]).

5.1.5. The Views

The [views/layout.hbs] file:

<!doctype html>
<html lang="{{lang}}">
<head>
  <meta charset="utf-8">
  <title>{{t pageTitle}}</title>
  <link rel="stylesheet" href="/bootstrap/css/bootstrap.min.css">
</head>
<body>
  <div class="container py-3">
    {{> navbar}}
    {{{body}}}
  </div>
</body>
</html>
  • line 2: the page’s [lang] attribute reflects the selected language (useful for screen readers and spell-checkers);
  • line 5: the page title is a key, provided by the action ([pageTitle]), and translated by the view: [{{t pageTitle}}]. Without apostrophes, the helper’s argument is a template variable, not a literal string.

The file [views/partials/navbar.hbs]:

<nav class="navbar navbar-expand bg-light rounded mb-3 px-3">
  <div class="navbar-nav me-auto">
    <a class="nav-link" href="/">{{t 'MENU.HOME'}}</a>
    <a class="nav-link" href="/products">{{t 'MENU.PRODUCTS'}}</a>
  </div>
  {{!-- the current language is highlighted --}}
  <div class="btn-group btn-group-sm">
    <a class="btn {{#if (eq lang 'fr')}}btn-primary{{else}}btn-outline-primary{{/if}}" href="/language/fr">FR</a>
    <a class="btn {{#if (eq lang 'en')}}btn-primary{{else}}btn-outline-primary{{/if}}" href="/language/en">EN</a>
  </div>
</nav>

Lines 8–9: two links (changing the language does not modify any application data: a GET is appropriate), with the current language highlighted.

The file [views/home.hbs]:

1
2
3
<h1>{{t 'HOME.TITLE'}}</h1>
<p>{{t 'HOME.HELLO' name=name}}</p>
<p>{{t 'HOME.DATE' date=(longDate today)}}</p>

Line 3: a sub-expression: the date is first formatted by [longDate], then passed as a [date] parameter to the helper [t].

The file [views/products.hbs]:

<h1>{{t 'PRODUCTS.TITLE'}}</h1>
<table class="table table-sm w-auto">
  <thead><tr><th>{{t 'PRODUCTS.NAME'}}</th><th class="text-end">{{t 'PRODUCTS.PRICE'}}</th></tr></thead>
  <tbody>
    {{#each products}}
      {{!-- inside the loop, the helper finds the language in the ROOT model --}}
      <tr><td>{{name}}</td><td class="text-end">{{money price}}</td></tr>
    {{/each}}
  </tbody>
</table>
<p>{{t 'PRODUCTS.COUNT' n=count}}</p>

The [src/pages.controller.ts] file:

import { Controller, Get, Render } from '@nestjs/common';

@Controller()
export class PagesController {
  @Get()
  @Render('home')
  home(): object {
    // pageTitle is a KEY: the view translates it
    return { pageTitle: 'HOME.TITLE', name: 'Marie', today: '2026-09-23' };
  }

  @Get('products')
  @Render('products')
  products(): object {
    const products = [
      { name: 'Keyboard', price: 25 },
      { name: 'Mouse', price: 12.5 },
      { name: 'Screen', price: 1149 },
    ];
    return { pageTitle: 'PRODUCTS.TITLE', products, count: products.length };
  }
}

5.1.6. Execution

In French (after clicking FR; without a cookie, the page is in English, the default language):

Image

Image

Click on EN:

curl -i -H "Referer: http://localhost:3000/products" http://localhost:3000/language/en
1
2
3
4
5
6
7
HTTP/1.1 302 Found
Set-Cookie: language=en; Max-Age=31536000; Path=/; Expires=Thu, 23 Sep 2027 14:16:03 GMT; HttpOnly; SameSite=Lax
Location: /products
Content-Type: text/plain; charset=utf-8
Content-Length: 31

Found. Redirecting to /products

The server sets the cookie and redirects to the original page, which is now in English:

Image

Image

The case study proceeds exactly this way, using more comprehensive dictionaries ([i18n/fr.json] and [i18n/en.json]): same helper [t], same controller [/language/:language], same cookie. There is only one difference: the error messages generated by the business and web layers are also keys, translated at the last moment by the view.