Mohammad Ashour, Author at Phrase https://phrase.com/blog/posts/author/mohammad/ Localization and translation software Thu, 14 Nov 2024 16:29:17 +0000 en-US hourly 1 https://wordpress.org/?v=6.6.2 https://phrase.com/wp-content/uploads/2023/09/phrase-favicon.png Mohammad Ashour, Author at Phrase https://phrase.com/blog/posts/author/mohammad/ 32 32 The Ultimate Guide to Laravel Localization https://phrase.com/blog/posts/laravel-i18n-frontend-best-practices/ https://phrase.com/blog/posts/laravel-i18n-frontend-best-practices/#comments Thu, 17 Oct 2024 11:24:20 +0000 https://phraseapp.com/blog/?p=3164 We dive deep into Laravel localization, covering routing, strings, and more.

The post The Ultimate Guide to Laravel Localization appeared first on Phrase.

]]>
I remember using Laravel years ago to kick off two tech startups. I loved Laravel’s elegance, developer experience, and down-to-earth community. This was in the days of Laravel 3. We’re at version 11 of the framework as I write this, and to say that its ecosystem has matured would be an understatement. There is a quality Laravel package (often first-party) for almost any common dev problem, from authentication to deployment. The care and commitment the community continues to pour into Laravel is inspiring.

When it comes to Laravel internationalization (i18n), a good built-in foundation is complemented by third-party packages. In this guide, we’ll use these features to localize a Laravel app, covering translations, localized routing, formatting, localizing models, and more.

🔗 Resource » Internationalization (i18n) and localization (l10n) allow us to make our apps available in different languages and regions, often for more profit. If you’re new to i18n and l10n, check out our guide to internationalization.

Our demo

Our starting point will be the fictional bookavel, a used bookstore app.

The image is a screenshot of a demo web app showcasing a book listing. It includes a header with the app name "bookavel" and two navigation links: "Books" and "About us." The section shown is titled "Recently added" and displays four books with their titles, authors, added dates, and prices in USD. The screenshot reflects the state of the app before localization is applied. The footer notes the app is a demo showcasing Laravel localization, created as a companion to a Phrase blog article.

We won’t cover any e-commerce functionality here, since we want to focus on i18n.

Packages and versions used

We’ll use the following technologies and packages when building this app.

  • PHP v8.3.10 — our programming language
  • Laravel v11.21.0 — the framework for web artisans
  • codezero/laravel-localized-routes v4.0.1 — allows us to localize our routes quickly
  • kkomelin/laravel-translatable-string-exporter v1.22.0 — provides translation extraction from our code into message files
  • tailwindcss v3.4.10 — for styling; largely optional for our purposes

🗒️ Note » We’ll cover the installation of laravel-localized-routes and laravel-translatable-string-exporter a bit later. All other packages are included with our starter app.

The starter app

Normally, we build demo apps from scratch in our tutorials. However, we have lots to cover here, so we’ll work from a pre-built starter for brevity. Here’s how to install the starter app:

🗒️ Heads up » You’ll need PHP 8+, Composer, and Node.js 20+ installed to continue.

  1. Clone the GitHub repo and switch to the start branch (alternatively, download the ZIP of the start branch and unzip it)
  2. If you’re using Laravel Herd, make sure to place the project directory inside your Herd directory
  3. From the project root, run composer install
  4. Run npm install
  5. Duplicate the .env.example file and rename the copy to .env
  6. Run php artisan key:generate
  7. Run php artisan migrate --seed and select “Yes” when asked to create the SQLite database
  8. Run php artisan serve to start the dev server (you don’t need to do this if you’re using Laravel Herd; just find the app under Sites and visit its URL)
  9. In a separate terminal tab, run npm run dev to start Vite

🗒️ Note » If you’re using Laravel Sail with Docker, we’ve provided a docker-compose.yml file in the repo. However, Sail/Docker instructions are a bit outside the scope of this article.

If all goes well, you should see the demo app in all its glory when you visit the local URL.

The image is a screenshot of a demo web app showcasing a book listing. It includes a header with the app name "bookavel" and two navigation links: "Books" and "About us." The section shown is titled "Recently added" and displays four books with their titles, authors, added dates, and prices in USD. The screenshot reflects the state of the app before localization is applied. The footer notes the app is a demo showcasing Laravel localization, created as a companion to a Phrase blog article.

The app is simple:

  • A Book model is used by a BookController to expose a book index (under /books) and a single book show (under /books/{id}).
  • The root route (/) exposes BookController@index, presenting the book listing above.
  • An /about route shows a simple text view.
// routes/web.php

<?php

use App\Http\Controllers\BookController;
use Illuminate\Support\Facades\Route;

Route::get('/', [BookController::class, 'index'])->name('root');
Route::resource('books', BookController::class)->only('index', 'show');

Route::get('/about', fn () => view('about'))->name('about');

This should be bread-and-butter Laravel for you. Let’s localize!

How do I localize a Laravel app?

Localizing a Laravel app involves the following steps.

  1. Replacing hard-coded strings with translated strings using the Laravel () function.
  2. Extracting these translated strings from code into translation files.
  3. Localizing routes.
  4. Building a language switcher UI.
  5. Handling dynamic values in translations.
  6. Working with plurals in translations.
  7. Formatting localized numbers and dates.
  8. Localizing database models.

We’ll go through these steps in detail. Let’s start with basic string translation.

How do I translate strings?

Laravel has solid built-in localization features; we can use them for basic translations. First, let’s configure some localization settings in our .env file.

We’ll support English – United States (en_US) and Arabic – Egypt (ar_EG) in this article. Let’s start by updating our active and fallback locales to be en_US.

# .env

  APP_NAME=bookavel
  APP_ENV=local
  APP_KEY=yourappkeyliveshere
  APP_DEBUG=true
  APP_TIMEZONE=UTC
  APP_URL=http://localhost

- APP_LOCALE=en
+ APP_LOCALE=en_US
- APP_FALLBACK_LOCALE=en
+ APP_FALLBACK_LOCALE=en_US
  APP_FAKER_LOCALE=en

  APP_MAINTENANCE_DRIVER=file
  # APP_MAINTENANCE_STORE=database

  BCRYPT_ROUNDS=12

  # ...

The APP_LOCALE environment variable determines the active locale. When a translation message is missing, the APP_FALLBACK_LOCALE is used. We’ll see how both these variables work momentarily.

A note on locales

A locale defines a language, a region, and sometimes more. Locales typically use IETF BCP 47 language tags, like en for English, fr for French, and es for Spanish. Adding a region with the ISO Alpha-2 code (e.g., BH for Bahrain, CN for China, US for the United States) is recommended for accurate date and number localization. So a complete locale might look like en_US for American English or zh_CN for Chinese as used in China.

🔗 Explore more language tags on Wikipedia and find country codes through the ISO’s search tool.

Now to add some translation strings. By default, Laravel expects translation files to sit in the lang directory under the project root. Let’s create this directory, along with a subdirectory for each locale.

.
└── lang
    ├── ar_EG
    │   └── # We'll place Arabic translation files here.
    └── en_US
        └── # We'll place English translation files here.

We can have as many locale subdirectories under lang as we want; Laravel will automatically pick them up. Under each locale subdirectory, we can have one or more translation files. Let’s create our first.

// lang/en_US/global.php

<?php

return [
  'app_name' => 'bookavel',
];
// lang/ar_EG/global.php

<?php

return [
  'app_name' => 'بوكاڤِل',
];

Translation files return PHP arrays with simple key/value pairs. To use them, we can call the __() translation function in our code. Let’s translate the app name in our header component to demonstrate.

{{-- resources/views/components/layout/header.blade.php --}}

  <header class="...">
    <x-layout.logo />
    
    <p class="...">
      <a href="{{ route('root') }}">
-       bookavel
+       {{ __('global.app_name') }}
      </a>
    </p>

    <nav class="...">
      <!-- ... -->
    </nav>
  </header>

The __() function takes a translation key in the form file.key.

🗒️ Note » We omit CSS styles here for brevity, unless they relate directly to i18n.

Since we set the APP_LOCALE value to en_US in our .env file, the lang/en_US/global.php file will be loaded and used. If we refresh our app now, it should look the same.

Now let’s set our APP_LOCALE to Arabic and reload the app.

# .env

  # ...
  APP_TIMEZONE=UTC
  APP_URL=http://localhost

- APP_LOCALE=en_US
+ APP_LOCALE=ar_EG
  APP_FALLBACK_LOCALE=en_US
  APP_FAKER_LOCALE=en_US

  # ...

The navigation bar of our app with the brand name shown in Arabic.
Et voila! Our app name in Arabic!

That’s basic string translation in a nutshell. We can use as many files as we want under our locale directories. Let’s add a couple for navigation links.

// lang/en_US/nav.php

<?php

return [
  'books' => 'Books',
  'about' => 'About us',
];
// lang/ar_EG/nav.php

<?php

return [
  'books' => 'الكتب',
  'about' => 'نبذة عنا',
];

In Blade templates, the @lang('foo.bar') directive is a shortcut for {{ ('foo.bar) }}.

{{-- resources/views/components/layout/header.blade.php --}}

  <header class="...">
    <x-layout.logo />
    
    <p class="...">
      <a href="{{ route('root') }}">
        {{ __('global.app_name') }}
      </a>
    </p>

    <nav class="...">
      <ul class="...">
        <li>
          <x-layout.nav-link :href="route('books.index')">
-           Books
+           @lang('nav.books')
          </x-layout.nav-link>
        </li>
        <li>
          <x-layout.nav-link :href="route('about')">
-           About us
+           @lang('nav.about')
          </x-layout.nav-link>
        </li>
      </ul>
    </nav>
  </header>

Our navigation links shown in Arabic.

Missing translations

If a translation is missing from our Arabic file, Laravel will fall back to the locale specified in APP_FALLBACK_LOCALE. Let’s test this by removing the Arabic translation for “about.”

// lang/ar_EG/nav.php

  <?php

  return [
    'books' => 'الكتب',
-   'about' => 'نبذة عنا',
  ];

Our navigation links shown in Arabic, except the "About us" links, which is in English.

The en_US value is used as a fallback for our missing translation.

What if the en_US value itself is missing? In that case, Laravel shows us the translation key.

// lang/en_US/nav.php

  <?php

  return [
    'books' => 'Books',
-   'about' => 'About us',
  ];

Our navigation links shown in Arabic, except the "About us" link, which reads "nav.about.

Translation strings as keys

Using translation strings as keys can clarify our views. To do this, we need to change how we define our translations. First, we’ll pass English translation strings (verbatim) to __() and @lang().

{{-- resources/views/components/layout/header.blade.php --}}

  <header class="...">
    <x-layout.logo />
    
    <p class="...">
      <a href="{{ route('root') }}">
-       {{ __('global.app_name') }}
+       {{ __('bookavel') }}
      </a>
    </p>

    <nav class="...">
      <ul class="...">
        <li>
          <x-layout.nav-link :href="route('books.index')">
-           @lang('nav.books')
+           @lang('Books')
          </x-layout.nav-link>
        </li>
        <li>
          <x-layout.nav-link :href="route('about')">
-           @lang('nav.about')
+           @lang('About')
          </x-layout.nav-link>
        </li>
      </ul>
    </nav>
  </header>

Instead of PHP files under locale directories, we use a single JSON translation file per locale.

// lang/ar_EG.json

{
  "bookavel": "بوكاڤِل",
  "Books": "الكتب",
  "About us": "نبذة عنا"
}

🗒️ Note » We don’t need to add an en_US.json file. Laravel’s fallback logic will ensure enUS translations are shown when en_US is the active locale.

When not given a file prefix like global. or nav., the () function will pull translations from the {locale}.json file (where {locale} is the active locale). If the file or translation isn’t found, Laravel will default to the given key itself.

The image shows a comparison between two versions of the same web app header, one for the `en-US` locale and the other for `ar-EG`. The `en-US` version displays the app name "bookavel" with navigation links "Books" and "About us." The `ar-EG` version shows the same content in Arabic, with the app name and navigation links translated. The text explains that the `en-US` version uses keys provided to the translation function `__()`, while the `ar-EG` version uses corresponding values from an `ar-EG.json` file.

🗒️ Note » We’ll use the translation values as keys (JSON) method moving forward. However, JSON files can be used along with PHP files. If you call ('foo.bar'), Laravel will look for a lang/{locale}/foo.php file with a bar key inside. A call like ('Baz') will cause Laravel to look for a lang/{locale}.json file with a Baz key inside. Both methods can work side-by-side.

🔗 Resource » Read more about Defining Translation Strings in the Laravel docs.

How do I extract translations from my code?

Instead of manually adding each translation to each locale file (and we might have many), we can use a CLI tool to do it automatically.

Konstantin Komelin’s laravel-translatable-string-exporter is a popular choice for string extraction. Using this package, a simple Artisan command will scan our code and place translated strings into locale files. Let’s install it as a dev dependency.

composer require kkomelin/laravel-translatable-string-exporter --dev

Now let’s hop into our book index view and mark a string for translation to see the new CLI tool in action.

{{-- resources/views/books/index.blade.php --}}

  <x-layout.main title="Books">
    <h1 class="...">
-     Recently added   
+     {{ __('Recently added') }}
    </h1>

    <section class="...">
      @foreach ($books as $book)
        {{-- ... --}}
      @endforeach
    </section>
  </x-layout.main>

Now to run the Artisan command.

php artisan translatable:export ar_EG

As you’ve probably guessed, the ar_EG argument tells translatable:export to dump new translations into our lang/ar_EG.json file.

🗒️ Note » We can have as many comma-separated locales as we want here. For example, php artisan translatable:export ar_EG,de_DE,hi_IN will export new translations to ar_EG.json, de_DE.json and hiI_N.json, respectively.

 // lang/ar_EG.json
 
  {
+     "Recently added": "Recently added",
      "Books": "الكتب",
      "About us": "بذة عنا",
      "bookavel": "بوكاڤِل"
  }

The extracted string duplicates the key as its value. We can replace this with the appropriate translation.

 // lang/ar_EG.json
 
  {
-   "Recently added": "Recently added",
+   "Recently added": "الجديد عندنا",
    "Books": "الكتب",
    "About us": "بذة عنا",
    "bookavel": "بوكاڤِل"
 }

🗒️ Note » For larger projects, we can upload our translation files to a software localization platform like Phrase Strings so that translators can work on them.

By default, translatable:export will scan all PHP and JS files under the app and resources directories for any calls to __(), _t(), or @lang(), pulling any keys out of them. You can configure this behavior by publishing the package’s config file. We’ll do this later.

🗒️ Heads up » We haven’t mentioned the trans() function. () is an alias of trans(). If you’re using trans() note that translatable:export doesn’t scan for it by default.

Moving translation strings into as many locale files as our app needs is now significantly streamlined.

🔗 Resource » Check out the laravel-translatable-string-exporter documentation for the details on how the package works.

How do I localize routes?

One of the most common ways to set the app’s active locale is via routes. This could be a locale route param: /en_US/foo and /ar_EG/foo, where the first route segment sets the active locale. Visiting en_US/foo shows the foo page in English; visiting /ar_EG/foo shows it in Arabic. We’ll use this localized routing strategy in this guide.

We’ll also grab a package to carry the heavy lifting for localized routing. We could roll our own, but CodeZero’s Laravel Localized Routes does a great job of it and saves us some time.

First, let’s install the package from the command line:

composer require codezero/laravel-localized-routes

🗒️ Note » When asked to trust the package to execute code, enter y for “yes”.

Next, let’s publish the package’s config file so we can modify it.

php artisan vendor:publish --provider="CodeZero\LocalizedRoutes\LocalizedRoutesServiceProvider" --tag="config"
// config/localized-routes.php

  <?php

  return [

    /**
     * The locales you wish to support.
     */
-   'supported_locales' => [],
+   'supported_locales' => ['en_US', 'ar_EG'],
  
    /**
     * The fallback locale to use when generating a route URL
     * and the provided locale is not supported.
     */
-   'fallback_locale' => null,
+   'fallback_locale' => 'en_US',

    /**
     * If you have a main locale, and you want to omit
     * its slug from the URL, specify it here.
     */
    'omitted_locale' => null,

    /**
     * Set this option to true if you want to redirect URLs
     * without a locale slug to their localized version.
     * You need to register the fallback route for this to work.
     */
-   'redirect_to_localized_urls' => false,
+   'redirect_to_localized_urls' => true,
 
    'redirect_status_code' => 301,

    // ...

  ];

Any locale not present in supported_locales will trigger a 404 Not Found if its slug is used.

In the above, redirect_to_localized_urls and fallback_locale work together; if we visit /about, we’re automatically redirected to /en_US/about.

But for any of this to work we need two more pieces: middleware and route config. Let’s add the middleware first:

// bootstrap/app.php

  <?php

  use Illuminate\Foundation\Application;
  use Illuminate\Foundation\Configuration\Exceptions;
  use Illuminate\Foundation\Configuration\Middleware;

  return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
      web: __DIR__ . '/../routes/web.php',
      commands: __DIR__ . '/../routes/console.php',
      health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware) {
+     $middleware->web(remove: [
+       \Illuminate\Routing\Middleware\SubstituteBindings::class,
+     ]);
+     $middleware->web(append: [
+       \CodeZero\LocalizedRoutes\Middleware\SetLocale::class,
+       \Illuminate\Routing\Middleware\SubstituteBindings::class,
+     ]);
    })
    ->withExceptions(function (Exceptions $exceptions) {
      //
    })->create();

The above ensures that the locale is set from the route before Laravel’s route model binding kicks in. This is important if you want to, say, translate article slugs. We won’t do this in this guide, but it’s good to know.

🔗 Resource » Read the Translate Parameters with Route Model Binding and Translate Hard-Coded URI Slugs sections of the docs for more info.

OK, now let’s update our routes to add localization:

// routes/web.php

  <?php

  use App\Http\Controllers\BookController;
+ use CodeZero\LocalizedRoutes\Controllers\FallbackController;
  use Illuminate\Support\Facades\Route;

+ Route::localized(function () {
    Route::get('/', [BookController::class, 'index'])->name('root');
    Route::resource('books', BookController::class)->only('index', 'show');

    Route::get('/about', fn () => view('about'))->name('about');
+ });

+ Route::fallback(FallbackController::class);

Wrapping our routes with Route::localized adds our locale slugs (en_US, ar_EG) before each route. We also specify the FallbackController::class to handle Laravel’s catch-all fallback route: This redirects / to /en_US.

If we visit the root (/) route of our app now, we should indeed be redirected to /en_US.

🗒️ Heads up » The Localized Routes package performs locale auto-detection based on the user’s browser settings. If your browser languages list a locale supported by your app, you might get redirected to that locale URI instead. We’ll tackle locale auto-detection in the next section.

Our app's home page shown with English nav links; the browser's address bar reads "localhost/en_US"

We can manually visit /ar_EG to see our home page translated into Arabic. Note how the Localized Link package sets the active app locale to match the URL slug behind the scenes. This means our ar_EG.json strings are used when we hit a route prefixed with ar_EG.

Our app's home page shown with Arabic nav links; the browser's address bar reads "localhost/ar_EG"

🗒️ Note » You can re-map the locale URI slugs to Custom Slugs if you prefer.

We can also run the route:list artisan command to see our new localized routes.

The image is a screenshot of console output from running the `php artisan route:list` command in a Laravel application. It displays a list of 10 routes for two locales: `ar_EG` and `en_US`. The routes include paths like `/about`, `/books`, and `/books/{book}`, mapped to the `BookController` methods (`index`, `show`). There is also a fallback route `{fallbackPlaceholder}` mapped to the `FallbackController`. Each route is prefixed by either `GET|HEAD`, indicating the HTTP methods supported, and is followed by the corresponding localized route name (e.g., `ar_EG.root`, `en_US.books.show`).

Localized links and URLs

The Localized Routes package works with named routes out of the box: If we’re currently on an en_US route then route('about') outputs '/en_US/about'.

// routes/web.php

<?php

// ...

Route::localized(function () {
  // Explicit naming with `name()`
  Route::get('/', [BookController::class, 'index'])->name('root');

  // Implicit naming with `resource()` generates names:
  // `books.index` and `books.show`
  Route::resource('books', BookController::class)->only('index', 'show');

  Route::get('/about', fn () => view('about'))->name('about');
});

// ...

Let’s take the books resource as an example:

  • If we’re on /en_US and call route('books.index') in one of our views, it will output `/en_US/books`.
  • If we’re on /ar_EG and call route('books.index') in one of our views, it will output `/ar_EG/books`.
  • If we’re on /en_US and call route('books.show', 1) in one of our views, it will output `/en_US/books/1`.
  • If we’re on /ar_EG and call route('books.show', 1) in one of our views, it will output `/ar_EG/books/1`.

If you want to force a route, you can call route('about', [], true, 'ar_EG'): This outputs '/ar_EG/about' even if we’re on an en_US page.

🔗 Resource » See the Generate URLs for a Specific Locale section of the docs.

🔗 Resource » URL slugs are one strategy for localized routes. Another is custom domains e.g. en.example.com, ar.example.com. The Localized Routes package supports the domain strategy: See the Custom Domains section of the docs for more info.

How do I detect the user’s locale?

CodeZero’s Localized Routes package has an array of locale detectors, and locale stores, set up for us by default.

// config/localized-routes.php

<?php

return [

  // ...

  /**
   * The detectors to use to find a matching locale.
   * These will be executed in the order that they are added to the array!
   */
  'detectors' => [
    // required for scoped config
    CodeZero\LocalizedRoutes\Middleware\Detectors\RouteActionDetector::class, 
    // required
    CodeZero\LocalizedRoutes\Middleware\Detectors\UrlDetector::class, 
    // required for omitted locale
    CodeZero\LocalizedRoutes\Middleware\Detectors\OmittedLocaleDetector::class,
    
    CodeZero\LocalizedRoutes\Middleware\Detectors\UserDetector::class,
    CodeZero\LocalizedRoutes\Middleware\Detectors\SessionDetector::class,
    CodeZero\LocalizedRoutes\Middleware\Detectors\CookieDetector::class,
    CodeZero\LocalizedRoutes\Middleware\Detectors\BrowserDetector::class,
    
    // required
    CodeZero\LocalizedRoutes\Middleware\Detectors\AppDetector::class, 
  ],

  // ...

  /**
   * The stores to store the first matching locale in.
   */
   'stores' => [
      CodeZero\LocalizedRoutes\Middleware\Stores\SessionStore::class,
      CodeZero\LocalizedRoutes\Middleware\Stores\CookieStore::class,
      
      // required
      CodeZero\LocalizedRoutes\Middleware\Stores\AppStore::class,
   ],
];

The detectors will run in order, and the first detector to find a locale will determine the active locale for the request, bypassing the rest of the detectors. Stores save a matched locale for a user’s subsequent site visits.

🔗 Resource » The Detectors section of the docs also has a table that further explains how they all work.

Before we proceed, let’s remove the detectors and stores we’re not using in this app.

// config/localized-routes.php

  <?php

  return [

    // ...

    /**
     * The detectors to use to find a matching locale.
     * These will be executed in the order that they are added to the array!
     */
    'detectors' => [
      // required for scoped config
      CodeZero\LocalizedRoutes\Middleware\Detectors\RouteActionDetector::class, 
      // required
      CodeZero\LocalizedRoutes\Middleware\Detectors\UrlDetector::class, 
-     // required for omitted locale
-     CodeZero\LocalizedRoutes\Middleware\Detectors\OmittedLocaleDetector::class,
    
-     CodeZero\LocalizedRoutes\Middleware\Detectors\UserDetector::class,
-     CodeZero\LocalizedRoutes\Middleware\Detectors\SessionDetector::class,
      CodeZero\LocalizedRoutes\Middleware\Detectors\CookieDetector::class,
      CodeZero\LocalizedRoutes\Middleware\Detectors\BrowserDetector::class,
    
      // required
      CodeZero\LocalizedRoutes\Middleware\Detectors\AppDetector::class, 
    ],

   // ...

    /**
     * The stores to store the first matching locale in.
     */
     'stores' => [
-       CodeZero\LocalizedRoutes\Middleware\Stores\SessionStore::class,
        CodeZero\LocalizedRoutes\Middleware\Stores\CookieStore::class,
      
        // required
        CodeZero\LocalizedRoutes\Middleware\Stores\AppStore::class,
     ],
  ];

We’re not using an omitted locale in this app. An omitted locale would be used for URLs without a locale slug. Since we’re forcing locale slugs in our app, we can safely remove the OmittedLocaleDetector here.

Since we don’t have authentication, and we’re not using Laravel’s User model, we can remove the UserDetector.

The SessionDetector is redundant since we’ll utilize the CookieDetector and CookieStore to save the user’s locale preference. We can safely remove the SessionDetector and SessionStore here.

New user visit

Now let’s go through the scenario of a fresh visit to our site, illustrating how the detector cascade would work with our current configuration.

  1. A new user visits our site’s root route (/).
  2. No locale slug is on the / route, so the RouteActionDetector and UrlDetector have nothing to detect; they cascade down.
  3. This is the user’s first visit, so we haven’t stored their locale preference with the CookieStore yet. The CookieDetector has nothing to detect; it cascades down.
  4. The BrowserDetector kicks in and tries to match one of the user’s configured browser locales with one of our supported locales. If it succeeds, the matched locale becomes the active locale for the request. If it fails, it cascades down.
  5. The AppDetector uses the locale configured in config/app.php as the final fallback.

Of particular interest to us is the BrowserDetector: It detects the locale based on the request’s Accept-Language HTTP header, which corresponds to the user’s configured language list in the browser.

🔗 Resource » Our guide, Detecting a User’s Locale in a Web App, goes into Accept-Language and browser locale detection in depth.

Rolling our own browser detector

CodeZero’s built-in BrowserDetector works reasonably well but doesn’t normalize locales during detection. For example, it doesn’t match ar-eg in an Accept-Language header to our configured ar_EG locale. The problem is that Laravel’s documentation instructs us to use ISO 15897 locale naming with underscores (ar_EG). It’s common for browsers to use a dash to separate locales and regions, e.g. ar-eg. So we’re between a rock and a hard place here.

The built-in BrowserDetector doesn’t attempt a best match, either. So it won’t match a user’s configured en-gb to our en_US locale, even though British people can read American English just fine.

Thankfully, it’s easy enough to roll our own detector that solves these problems. We’ll begin by installing the popular http-accept-language PHP package by Baguette HQ. This package will parse the Accept-Language header and provide it as a PHP array.

composer require zonuexe/http-accept-language

We can use this package to write our own browser detector that performs a best match against the Accept-Language header.

// app/Http/Middleware/LocaleDetectors/AcceptLanguageDetector.php

<?php

namespace App\Http\Middleware\LocaleDetectors;

use CodeZero\LocalizedRoutes\Middleware\Detectors\Detector;
use Illuminate\Support\Arr;
use Teto\HTTP\AcceptLanguage;

class AcceptLanguageDetector implements Detector
{
  /**
   * Detect the locale.
   *
   * @return string|array|null
   */
  public function detect()
  {
    // Get locales from the Accept-Language header
    $accept_locales = AcceptLanguage::get();
    
    // Get the languages parts only
    $languages = array_unique(Arr::pluck(
      $accept_locales, 'language'));

    $supported_locales =
      config('localized-routes.supported_locales');

    foreach ($languages as $language) {
      foreach ($supported_locales as $locale) {
        if (str_starts_with(
          $locale,
          strtolower($language))) {
          return $locale;
        }
      }
    }

    return null;
  }
}

We must implement CodeZero’s Detector interface, which defines a detect method for our custom detector to work with the Localized Routes package. In our detect(), we first grab the user’s configured locales using the http-accept-language parser package and do a best match against our app’s supported locales. Our detector will match the first case-insensitive language code regardless of region. If a user has set ar-SA (Arabic Saudi-Arabia) in their browser, they will be matched with our supported ar_EG and shown our site in Arabic.

Let’s wire up our new detector to see it in action.

// config/localized-routes.php

  <?php

  return [

    // ...
 
    'detectors' => [
      CodeZero\LocalizedRoutes\Middleware\Detectors\RouteActionDetector::class, 
      CodeZero\LocalizedRoutes\Middleware\Detectors\UrlDetector::class, 
      CodeZero\LocalizedRoutes\Middleware\Detectors\CookieDetector::class,
-     CodeZero\LocalizedRoutes\Middleware\Detectors\BrowserDetector::class
+     App\Http\Middleware\LocaleDetectors\AcceptLanguageDetector::class,
      CodeZero\LocalizedRoutes\Middleware\Detectors\AppDetector::class,
    ],

    // ...
    ],

  ];

We get our desired behavior by swapping out the built-in BrowserDetector with our own AcceptLanuageDetector. If a user has en, en-us, or en_GB as a preferred locale in her browser settings, she will be redirected from / to /en_US during her first site visit. If another user has ar, ar-ma, or ar_SA as a preferred locale, he will be redirected from / to /ar_EG.

Of course, we need a language switcher UI to let the user override the locale we’ve chosen for them. We also need to cover how the locale is stored in a cookie. We’ll do this shortly.

But first, a quick detour to take care of locale direction.

How do I work with right-to-left languages?

Before we get to the language switcher, let’s deal with right-to-left (RTL) languages. Our Arabic content flows left-to-right, yet Arabic is read right-to-left.

🔗 Resource » We dive into locale direction, writing modes, fonts, and more in our guide to CSS Localization.

We can deal with this by creating a helper function that returns the active locale’s direction, and use this direction in our pages’ <html dir> attribute. Let’s start by creating a config file to store our custom i18n settings, such as locale directions.

// config/i18n.php

<?php

return [
  'rtl_locales' => [
      'ar_EG',
  ],
];

Most locales are LTR (left-to-right), so we can list the RTL locales and default to LTR. Currently, we only have one RTL locale, ar_EG.

We can use this new config in a simple locale_dir() function, which we’ll place in a new i18n-functions file.

// app/i18n-functions.php

<?php

function locale_dir() : string
{
  $rtl_locales = config('i18n.rtl_locales');
  $locale = app()->getLocale();

  return in_array($locale, $rtl_locales)
    ? "rtl"
    : "ltr";
}

Simple enough. We need to remember to register our new file for autoloading, of course.

// composer.json

{
  "name": "laravel/laravel",

  // ...

  "autoload": {
    "psr-4": {
      "App\\": "app/",
      "Database\\Factories\\": "database/factories/",
      "Database\\Seeders\\": "database/seeders/"
    },
+   "files": [
+     "app/i18n-functions.php"
+   ]
  },
  "autoload-dev": {
    "psr-4": {
      "Tests\\": "tests/"
    }
  },

  // ...
}

For Composer to pick this up we must regenerate our autoload files.

composer dumpautoload

With this setup in place, we can call locale_dir() in our main layout.

//  resources/views/components/layout/main.blade.php

  <!DOCTYPE html>
  <html
    lang="{{ str_replace("_", "-", app()->getLocale()) }}"
+   dir="{{ locale_dir() }}"
  >
    <head>
      <!-- ... -->
    </head>

    <body class="...">
      <!-- ... -->
    </body>
  </html>
Our home page shown with Arabic content, laid out right-to-left.
Our Arabic routes now correctly show content laid out right-to-left.

🔗 Resource » We often must ensure our CSS works in both directions, which we cover in CSS Localization.

How do I build a language switcher?

OK, let’s return to setting the active locale. We’re attempting to detect the user’s locale from their browser settings. However, the user should be able to override this selection manually. This is often achieved with a locale switcher UI. Let’s build ours.

First, let’s add an array to our i18n.php config file that lists our supported locale codes with human-readable text for each.

// config/i18n.php

 <?php

 return [
+  'supported_locales' => [
+    'en_US' => 'English',
+    'ar_EG' => 'العربية (Arabic)'
+  ],
   'rtl_locales' => [
       'ar_EG',
   ]
 ];

We can now use this config in our new locale-switcher.blade.php component.

// resources/views/components/layout/locale-switcher.blade.php

<div {{ $attributes->merge(["class" => "relative"]) }}>
  <select
    class="..."
    autocomplete="off"
    onchange="window.location = this.options[this.selectedIndex].getAttribute('data-url')"
  >
    @foreach (config("i18n.supported_locales") as $locale => $name)
      <option
        value="{{ $locale }}"
        data-url="{{ Route::localizedUrl($locale) }}"
        {{ $locale == app()->getLocale() ? "selected" : "" }}
      >
        {{ $name }}
      </option>
    @endforeach
  </select>
  
  <div class="...">
    <x-layout.icon-chevron-down />
  </div>
</div>

🔗 Resource » You can get the complete demo code from our GitHub repo.

Our locale selector is a simple <select> element. When a user selects a new locale, it redirects them to the current page in the selected language. This is done using the Route::localizedUrl() macro from the Localized Routes package.

🗒️ Note » We set autocomplete="off" to work around an issue with Firefox; it doesn’t always select the correct option. See the Stack Overflow question, Firefox ignores option selected=”selected”, for more info.

Animation showing a user clicking on the locale switcher dropdown to switch between English and Arabic versions of a page.

A note on storage and detectors

Remember how we set up CookieDetector and CookieStore in config/localized-routes.php? These are important for our locale switcher. When a locale is resolved, CookieStore saves it in the user’s browser. Since CookieDetector is listed before AcceptLanguageDetector, it resolves the locale from the user’s cookie and stops the cascade.

When a user selects a new locale from the switcher, the URL updates, changing from something like /en_US/about to /ar-EG/about. This prompts the UrlDetector to recognize the new locale, and the CookieStore to save it. The next time the user visits, the CookieDetector will automatically load the locale the user chose last.

How do I add dynamic values to translation messages?

Let’s direct our attention to translation strings and formatting. Laravel’s translation functions let you insert dynamic values into your messages using the :variable format. For example, ('Hello, :username', ['username' => 'Adam']) will interpolate ‘Adam’ at runtime, rendering ‘Hello, Adam’ when the code runs. Let’s add a simple user notification and translate it to see how that works.

// resources/views/books/index.blade.php

  <x-layout.main title="Books">
+   <p class="...">
+       {{
+         __("👋 Hey :username, :book_title just came in :)", [
+           "username" => "Nunu",
+           "book_title" => "A Brief of History of Time",
+         ])
+       }}
+   </p>

    <h1 class="...">{{ __("Recently added") }}</h1>
  
    <!-- ... -->  
  
  </x-layout.main>

When we view our home page in English, we see the username and book_title variables interpolated correctly.

The upper part of the English home page, showing an alert that reads "Hey Nunu, A Brief History of Time just arrived :)"

We can extract this string into our ar_EG.json file as usual (or copy/paste it manually if preferred).

php artisan translatable:export ar_EG

We can then translate the new string, positioning :username and :book_title where appropriate in the translation message.

  {
    "About us": "بذة عنا",
    "bookavel": "بوكاڤِل",
    "Books": "الكتب",
    "Recently added": "الجديد عندنا",
+   "👋 Hey :username, :book_title just came in :)": "👋 أهلاً :username، لقد وصل للتو :book_title :)"
  }

The upper part of the Arabic home page, showing an alert that reads, in Arabic, "Hey Nunu, A Brief History of Time just arrived :)"

🔗 Resource » See the Laravel docs section, Replacing Parameters in Translation Strings, for more info.

How do I work with plurals?

Plurals often need special treatment in translation messages. It’s not just “one” and “other” forms like in English. Arabic has six plural forms, for example. Some languages only have one. So we need a way to provide different plural forms for each locale. Luckily, Laravel allows for this.

Let’s start simple. We’ll add a book counter next to our “Recently added” title.

// resources/views/books/index.blade.php

  <x-layout.main title="Books">
    <p class="...">
        {{ __("👋 Hey :username, :book_title just came in :)", ["username" => "Nunu", "book_title" => "A Brief of History of Time"]) }}
    </p>

    <h1 class="...">{{ __("Recently added") }}</h1>
+   <p>
+     {{ trans_choice("One book|:count books", $books->count()) }}
+   </p>
    
    <!-- ... -->
    
  </x-layout.main>

trans_choice is Laravel’s function for handling plural translations, taking a message and an integer. The message is split into plural forms using the pipe character (|). In English, when $books->count() equals 1, it will render “One book.” Any other value, like 3, will show “3 books.”

We can control this behavior further by specifying ranges.

{{
    trans_choice(
      "{0} No books|{1} One book|[2,*] :count books",
      $books->count(),
    )
}}

Now we’ll get these renders:

  • $books->count() == 0 renders 'No books'
  • $books->count() == 1 renders 'One book'
  • $books->count() == 38 renders '38 books'

Let’s tackle the Arabic translation. First, another quick detour.

Adding trans_choice to the extraction function list

The Translatable String Exporter package doesn’t scan for trans_choice out of the box, but we can tell it to. Let’s publish the package’s config to do that.

php artisan vendor:publish --provider="KKomelin\TranslatableStringExporter\Providers\ExporterServiceProvider"

The command should have created a laravel-translatable-string-exporter.php file in our config directory. We can add trans_choice under functions to tell the exporter to track it.

// config/laravel-translatable-string-exporter.php

  <?php
  return [
    'directories' => ['app', 'resources'],

    'excluded-directories' => [],

    'patterns' => [
      '*.php',
      '*.js',
    ],
  
    'allow-newlines' => false,

    'functions' => [
      '__',
      '_t',
      '@lang',
+     'trans_choice',
    ],

    'sort-keys' => true,
  
    // ...
  
  ];

Now let’s run our translation export command as usual.

php artisan translatable:export ar_EG

Our plural string should get added to lang/ar_EG.json. As mentioned earlier, Arabic has six translation forms, and we can add them separated by the | character.

  {
    "About us": "بذة عنا",
    "bookavel": "بوكاڤِل",
    "Books": "الكتب",
    "Recently added": "الجديد عندنا",
+   "{0} No books|{1} One book|[2,*] :count books": "لا توجد كتب|كتاب واحد|كتابين|:count كتب|:count كتاب|:count كتاب",
    "👋 Hey :username, :book_title just came in :)": "👋 أهلاً :username، لقد وصل للتو :book_title :)"
  }

🔗 Resource » The canonical source for languages’ plural forms is the CLDR Language Plural Rules listing.

🗒️ Note » Even though it’s not documented, Laravel’s trans_choice supports CLDR plural rules, so specifying six plural forms for Arabic works out of the box.

The image compares pluralization rules in English (en-US) and Arabic (ar-EG). On the left, the English rules show two forms: "one" for singular ("One book") and "other" for plural ("2 books"). On the right, the Arabic rules display multiple plural forms: "zero" for no books, "one" for one book, "two" for two books, "few" for numbers like 3, "many" for numbers like 11, and "other" for larger numbers like 100.

🔗 Resource » Read our Guide to Localizing Plurals for a deeper dive.

How do I format numbers?

Laravel has built-in number-formatting helpers that are locale-aware.

// In our views

{{ Number::format(1000) }}
// => '1,000'

{{ Number::currency(1234.56) }}
// => '$1,234.56'

We can add a locale param when calling these functions.

// In our views

{{ Number::format(1000, locale: 'ar_EG') }}
// => '١٬٠٠٠'

{{ Number::currency(1234.56, locale: 'ar_EG') }}
// => '١٬٢٣٤٫٥٦ US$'

🗒️ Note » Numbers and dates are often formatted differently in each region, so it’s a good idea to use region qualifiers when setting our locales e.g. using en_US instead of en.

However, instead of manually adding the active locale, we can write a custom middleware that sets the locale used by all number formatters.

php artisan make:middleware FormatSetLocale
// app/Http/Middleware/FormatSetLocale.php

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Number;
use Symfony\Component\HttpFoundation\Response;

class FormatSetLocale
{
  public function handle(Request $request, Closure $next) : Response
  {
    // Set the locale to use for all number formatters
    // as the active locale.
    Number::useLocale(app()->getLocale());
    
    return $next($request);
  }
}

We need to register this middleware after the SetLocale middleware to ensure the Localized Routes package has set the app locale before we use it.

// bootstrap/app.php

  <?php

+ use App\Http\Middleware\FormatSetLocale;
  use Illuminate\Foundation\Application;
  use Illuminate\Foundation\Configuration\Exceptions;
  use Illuminate\Foundation\Configuration\Middleware;

  return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
      web: __DIR__ . '/../routes/web.php',
      commands: __DIR__ . '/../routes/console.php',
      health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware) {
      $middleware->web(remove: [
        \Illuminate\Routing\Middleware\SubstituteBindings::class,
      ]);
      $middleware->web(append: [
        \CodeZero\LocalizedRoutes\Middleware\SetLocale::class,
+       FormatSetLocale::class,
        \Illuminate\Routing\Middleware\SubstituteBindings::class,
      ]);
    })
    ->withExceptions(function (Exceptions $exceptions) {
      //
    })->create();

Now we can call the Number functions without an explicit locale, and they will use the app’s active locale.

The image shows a table comparing number and currency formatting between the `en_US` and `ar_EG` locales. For `en_US`, formatting 1000 results in "1,000", while currency formatting for 1234.56 renders as "$1,234.56". In the `ar_EG` locale, formatting 1000 shows "١٠٠٠", and currency formatting for 1234.56 renders as "١٬٢٣٤٫٥٦ US$". The table illustrates differences in how numbers and currencies are displayed based on locale settings.

🔗 Resource » Our Concise Guide to Number Localization goes into numeral systems, separators, currency, and more.

How do I format dates?

Under the hood, Laravel uses the PHP Carbon library to represent and format datetimes. And, unlike numbers, Laravel’s Carbon instances will automatically use the app’s active locale.

// In our views
<?php $datetime = Illuminate\Support\Carbon::create(2024, 9, 24, 14, 37, 6); ?>

<p>{{ $datetime->isoFormat("MMMM Do YYYY, h:mm:ss a") }}</p>
<p>{{ $datetime->isoFormat("LL") }}</p>
<p>{{ $datetime->diffForHumans() }}</p>

When the active locale is en_US, the above will output:

The image shows three date and time formats in English. The first is "September 24th 2024, 2:37:06 pm" displaying the full date with time. The second is "September 24, 2024" showing the date only. The third is "52 minutes from now" expressing time in a relative format.

And when it’s ar_EG, we get:

The image shows three date and time formats in Arabic. The first is "٢:٣٧:٠٦ م, ٢٤ سبتمبر ٢٠٢٤" displaying the full date with time. The second is "٢٤ سبتمبر ٢٠٢٤" showing the date only. The third is "٥٢ دقيقة من الآن" expressing time in a relative format.

Some notes on date localization

  • The isoFormat() function is compatible with Moment.js formats.
  • See the Lang directory in the Carbon source code for all supported locales. Note that Carbon will fall back ie. if it can’t find ar_XX it will use ar.
  • The default timezone that Laravel sets for our app is UTC, which is often what we want. You can change this in config/app.php, however.
  • Laravel’s Eloquent models’ datetimes, e.g. timestamps, return Carbon instances.

🔗 Resource » Our Guide to Date and Time Localization dives into formatting, time zones, calendars, and more.

How do I localize my Eloquent models?

This guide is getting a bit long, and model/database localization could take up its own article. So while we won’t get into the details of model localization in this tutorial, we’ve created a separate branch in our GitHub repo that should help. Here’s how to use it:

1. Clone or download the model-l10n branch from our GitHub repo. (Alternatively, check out the branch if you’ve already cloned the repo).
2. Run composer install
3. Run php artisan migrate:refresh --seed
4. Run your dev server as usual

We’re using the spatie/laravel-translatable package, which is great for localizing Eloquent models in apps with a few locales. The laravel-translatable package requires refactors to both our migrations and our model classes.

🔗 Resource » Peruse the main → model-l10n diff to see this refactor.

🔗 Resource » See the official laravel-translatable docs for more info.

And with that, our demo is complete.

Animation showing a few pages of our fully localized app in English, and the same pages in Arabic.

🔗 Resource » Remember, you can get the entire demo code from our GitHub repo.

Power up your Laravel localization

We hope you enjoyed this journey through Laravel localization. When you’re all set to start the translation process, rely on the Phrase Localization Platform to handle the heavy lifting. A specialized software localization platform, the Phrase Localization Platform comes with dozens of tools designed to automate your translation process and native integrations with platforms like GitHub, GitLab, and Bitbucket.

With its user-friendly strings editor, translators can effortlessly access your content and transfer it into your target languages. Once your translations are set, easily integrate them back into your project with a single command or automated sync.

This way, you can stay focused on what you love—your code. Sign up for a free trial and see for yourself why developers appreciate using The Phrase Localization Platform for software localization.

The post The Ultimate Guide to Laravel Localization appeared first on Phrase.

]]>
https://phrase.com/blog/posts/laravel-i18n-frontend-best-practices/feed/ 4
Mastering Qwik Localization: A Comprehensive Guide https://phrase.com/blog/posts/qwik-localization/ Mon, 05 Aug 2024 16:01:39 +0000 https://phrase.com/?p=89840 We localize a Qwik app with the Qwik Speak i18n library, covering all the ins and outs of localization.

The post Mastering Qwik Localization: A Comprehensive Guide appeared first on Phrase.

]]>

As applications become more complex, loading more JavaScript upfront increases client bundle sizes and slows initial load times. Frameworks like Next.js tackle this with Server Components, which render on the server to reduce the client’s payload and browser workload. Qwik takes a radically different approach, serializing the application’s state on the server and resuming it on the client, which avoids the need for re-rendering and hydration in the browser.

While Qwik’s instantly interactive “Live HTML” and aggressive lazy-loading can speed up our apps, they make things like internationalization (i18n) a bit more tricky. Thankfully, the Qwik Speak library by Roberto Simonetti simplifies Qwik i18n while adhering to Qwik’s resumability and lazy-loading principles. In this guide, we’ll walk through a Qwik demo app and internationalize it using Qwik Speak. Let’s get started.

🔗 Resource » Internationalization (i18n) and localization (l10n) allow us to make our apps available in different languages and regions, often for more profit. If you’re new to i18n and l10n, check out our guide to internationalization.

Our demo

The fictitious Etttro is a mock second-hand marketplace for retro hardware.

Screenshot of the Etttro demo app's home page showing three latest products: a Commodore 64 for $149.99 dated 2024-06-01, a Nintendo Virtual Boy for $229 dated 2024-06-05, and a Sony Walkman for $199 dated 2024-06-10. The app has a dark green background and includes navigation links for "Latest products" and "About us."

We won’t cover any e-commerce or CRUD in this guide, keeping the app nice and lean to focus on the i18n.

Packages used

We’ll use the following NPM packages and walk through their installation when needed.

  • typescript@5.5 — our programming language
  • @builder.io/qwik@1.7 — the core Qwik library
  • @builder.io/qwik-city@1.7 — Qwik’s SSR framework
  • qwik-speak@0.23 — the i18n library
  • rtl-detect@1.1 — detects the layout direction of a language
  • tailwindcss@3.4 — for styling, optional for our purposes

Building the starter app

Let’s spin up a new Qwik app from the command line.

 npm create qwik@latest

When prompted to select a starter, we can pick the Basic App (Qwik City + Qwik) option. After installing the npm packages, we can install Tailwind CSS by running the following. (Again, this is optional, and we don’t focus much on styling in this guide).

 npm run qwik add tailwind

If you are coding along, note that we removed all non-Tailwind boilerplate styles in our demo. So our global.css looks like the following.

/* src/global.css */

@tailwind base;
@tailwind components;
@tailwind utilities;

We can remove most of the boilerplate code that some with the Qwik starter: everything in src/components, src/media can be deleted, and so can src/routes/demo and public/fonts.

Our root layout file can be simplified to look like the following.

// src/routes/layout.tsx

import { component$, Slot } from "@builder.io/qwik";
import type { RequestHandler } from "@builder.io/qwik-city";
import { routeLoader$ } from "@builder.io/qwik-city";
import Header from "~/components/layout/header";

export const onGet: RequestHandler = async ({
  cacheControl,
}) => {
  cacheControl({
    staleWhileRevalidate: 60 * 60 * 24 * 7,
    maxAge: 5,
  });
};

export const useServerTimeLoader = routeLoader$(() => {
  return {
    date: new Date().toISOString(),
  };
});

export default component$(() => {
  return (
    <>
      <Header />
      <main class="...">
        <Slot />
      </main>
    </>
  );
});

🔗 Resource » We omit styles for brevity. You can get all the starter code from our GitHub repo, including styles.

Let’s write the Header component.

// src/components/layout/header.tsx

import { component$ } from "@builder.io/qwik";
import { Link } from "@builder.io/qwik-city";

export default component$(() => {
  return (
    <header class="...">
      <nav class="...">
        <Link href="/">
          <span class="...">👾 Etttro</span>
        </Link>

        <ul class="...">
          <li>
            <Link href="/">Latest products</Link>
          </li>
          <li>
            <Link href="/about">About us</Link>
          </li>
        </ul>
      </nav>
    </header>
  );
});

Our app will have three pages:

  • A home page listing the latest products on offer.
  • A single product page that shows product details.
  • A simple about page.

We can create hard-coded product data to simulate data retrieval on the server.

// src/data/retro-hardware.ts

const retroHardware = [
  {
    id: 1,
    title: "Commodore 64",
    priceInCents: 14999,
    imageUrl: "commodore-64.jpg",
    publishedAt: "2024-06-01T10:00:00Z",
    description: "Classic Commodore 64 in working condition...",
  },
  {
    id: 2,
    title: "Virtual Boy",
    // ...
  },
  // ...
] as const;

export default retroHardware;

export type Product = (typeof retroHardware)[number];

🔗 Resource » Get the entire file from GitHub.

Our home page can now “pull in” this data and display it.

// src/routes/index.tsx

import { component$ } from "@builder.io/qwik";
import {
  Link,
  routeLoader$,
  type DocumentHead,
} from "@builder.io/qwik-city";
import retroHardware, {
  type Product,
} from "~/data/retro-hardware";

export const useProducts = routeLoader$<
  Readonly<Product[]>
>(() => {
  return retroHardware;
});

export default component$(() => {
  const productsS = useProducts();

  return (
    <>
      <h1 class="...">Latest products</h1>

      <section class="...">
        {productsS.value.map((product) => (
          <Link
            href={`products/${product.id}`}
            key={product.id}
          >
            <article class="...">
              <h3 class="...">{product.title}</h3>
              <img
                class="..."
                width={600}
                height={600}
                alt={product.title}
                src={`/product-img/${product.imageUrl}`}
              />
              <div class="...">
                <p>${product.priceInCents / 100.0}</p>
                <p>
                  {new Date(
                    product.publishedAt,
                  ).toLocaleDateString("en-US")}
                </p>
              </div>
              <p class="...">
                {product.description.slice(0, 65)}...
              </p>
            </article>
          </Link>
        ))}
      </section>
    </>
  );
});

export const head: DocumentHead = {
  title: "Etttro | Retro Hardware Marketplace",
  meta: [
    {
      name: "description",
      content: "Etttro is your community second-hand...",
    },
  ],
};

To get to the i18n as quickly as possible, we’ll skip the code for the single product and about pages, but you can get all the starter code from GitHub.

How do I localize my app with Qwik Speak?

Localizing a Qwik app with Qwik Speak involves the following steps.

1. Install and configure the Qwik Speak library.
2. Move hard-coded strings to translation files.
3. Use Qwik Speak’s t() function to display the localized strings.
4. Set up localized routing.
5. Build a language switcher UI.
6. Automatically extract strings out of our components for translation.
7. Handle dynamic values in translations.
8. Work with plurals in translations.
9. Format localized numbers and dates.

We’ll go through these steps in detail. Let’s start with installing the i18n library.

How do I install and configure Qwik Speak?

The usual npm install will do us here, except notice that Qwik Speak is installed as a development dependency.

npm install qwik-speak --save-dev

Next, let’s set up the qwikSpeakInline Vite plugin in vite.config.ts.

// vite.config.ts

  import { defineConfig, type UserConfig } from "vite";
  import { qwikVite } from "@builder.io/qwik/optimizer";
  import { qwikCity } from "@builder.io/qwik-city/vite";
+ import { qwikSpeakInline } from 'qwik-speak/inline';
  import tsconfigPaths from "vite-tsconfig-paths";
  import pkg from "./package.json";

  //...

  export default defineConfig(({ command, mode }): UserConfig => {
    return {
      plugins: [
        qwikCity(),
        qwikVite(),
+       qwikSpeakInline({
+         supportedLangs: ['en-US', 'ar-EG'],
+         defaultLang: 'en-US',
+         assetsPath: 'i18n'
+       }),
        tsconfigPaths(),
      ],
      optimizeDeps: {
        exclude: [],
      },
      // ...
    };
  });

// ...

Qwik Speak resolves its translations during SSR (server-side rendering) and inlines them into pre-compiled chunks of our app for the browser, keeping the client-side performant.

A Vite plugin is used to generate these translation chunks at compile time. This is the plugin we configured above. Here’s what we set:

  • supportedLangs — the locales that we guarantee translations for, set as language tags; here we’re supporting en-US (English as used in the United States) and ar-EG (Arabic as used in Egypt). Feel free to support any locales you want.
  • defaultLang — this needs to be one of our supportedLangs and is used as a fallback when we can’t resolve a locale for the user.
  • assetsPath — the directory, relative to the project root, where translation files will be stored; we set ours to i18n.

A note on locales

A locale defines a language, a region, and sometimes more. Locales typically use IETF BCP 47 language tags, like en for English, fr for French, and es for Spanish. Adding a region with the ISO Alpha-2 code (e.g., BH for Bahrain, CN for China, US for the United States) is recommended for accurate date and number localization. So a complete locale might look like en-US for American English or zh-CN for Chinese as used in China.

🔗 Explore more language tags on Wikipedia and find country codes through the ISO’s search tool.

🔗 Resource » Learn more about the Qwik Speak Inline Vite plugin from the official docs.

Next, we need to add two configuration files, speak-config.ts and speak-functions.ts. These go directly into the src directory.

// src/speak-config.ts

import type { SpeakConfig } from "qwik-speak";

export const config: SpeakConfig = {
  // Note that we expand on the locale config
  // here, setting a currency and time zone.
  defaultLocale: {
    lang: "en-US",
    currency: "USD",
    timeZone: "America/Los_Angeles",
  },
  supportedLocales: [
    {
      lang: "ar-EG",
      currency: "USD",
      timeZone: "Africa/Cairo",
    },
    {
      lang: "en-US",
      currency: "USD",
      timeZone: "America/Los_Angeles",
    },
  ],

  // Translations available in the whole
  // app. These map to files under our
  // `i18n/{lang}` directories.
  assets: ["app"],

  // Translations that require dynamic keys,
  // and cannot be set at compile-time.
  runtimeAssets: [],
};
// src/speak-functions.ts

import { server$ } from "@builder.io/qwik-city";
import type {
  LoadTranslationFn,
  Translation,
  TranslationFn,
} from "qwik-speak";

/**
 * Translation files are lazy-loaded via dynamic import
 * and will be split into separate chunks during build.
 * Assets names and keys must be valid variable names.
 */
const translationData = import.meta.glob<Translation>(
  "/i18n/**/*.json",
);

/**
 * Using server$, translation data is always accessed
 * on the server.
 */
const loadTranslation$: LoadTranslationFn = server$(
  async (lang: string, asset: string) =>
    await translationData[`/i18n/${lang}/${asset}.json`](),
);

export const translationFn: TranslationFn = {
  loadTranslation$: loadTranslation$,
};

The translation functions determine how our translation files are loaded on the server. Qwik Speak lets us write our translation functions any way we want. We’ll stick to the defaults for this guide.

🗒️ Note » The translation files are loaded from the same directory set when configuring the Vite plugin, /i18n. Each locale will have a subdirectory e.g. /i18n/en-US .

Let’s pull this config into the root of our app to provide it with the Qwik Speak context.

// src/root.tsx

  import { component$ } from "@builder.io/qwik";
  import {
    QwikCityProvider,
    RouterOutlet,
    ServiceWorkerRegister,
  } from "@builder.io/qwik-city";
  import { RouterHead } from "./components/router-head/router-head";
+ import { useQwikSpeak } from "qwik-speak";
+ import { config } from "./speak-config";
+ import { translationFn } from "./speak-functions";

  import "./global.css";

  export default component$(() => {
+   useQwikSpeak({ config, translationFn });

    return (
      <QwikCityProvider>
        <head>
          <meta charset="utf-8" />
          <RouterHead />
          <ServiceWorkerRegister />
        </head>
        <body lang="en" class="...">
          <RouterOutlet />
        </body>
      </QwikCityProvider>
    );
  });

One last piece of setup: We need to set the base URL of our app explicitly since Qwik Speak uses the base URL to load its translation chunks. This is configured in our app’s SSR entry point.

// src/entry.ssr.tsx

+ import { isDev } from "@builder.io/qwik/build";
  import {
    renderToStream,
+   type RenderOptions,
    type RenderToStreamOptions,
  } from "@builder.io/qwik/server";
  import { manifest } from "@qwik-client-manifest";
  import Root from "./root";
+ import { config } from "./speak-config";

+ export function extractBase({
+   serverData,
+ }: RenderOptions): string {
+   if (!isDev && serverData?.locale) {
+     return "/build/" + serverData.locale;
+   } else {
+     return "/build";
+   }
+ }

  export default function (opts: RenderToStreamOptions) {
+   // Let's set the lang attribute on the
+   // <html> tag while we're at it.
+   const lang =
+     opts.serverData?.locale || config.defaultLocale.lang;

    return renderToStream(<Root />, {
      manifest,
      ...opts,
+     base: extractBase,
      containerAttributes: {
+       lang,
        ...opts.containerAttributes,
      },
      serverData: {
        ...opts.serverData,
      },
    });
  }

serverData.locale will be set to the resolved locale by Qwik Speak. Right now it will always be the default locale, en-US. We’ll look at how we can change this in the following sections.

Alright, that’s about it for setup. Let’s see if this all works, shall we? We’ll add our first translations, starting with the app title. Let’s add two translation files, i18n/en-US/app.json and i18n/ar-EG/app.json.

// i18n/en-US/app.json

{
  "appTitle": "Ettro"
}
// i18n/ar-EG/app.json

{
  "appTitle": "إترو"
}

As per our config, Qwik Speak expects our translation files to be at i18n/{locale}/{asset}.json. The {asset}.json part is just the namespace file; app is the default namespace file. Now let’s use this translation in our header component.

// src/components/layout/header.tsx

  import { component$ } from "@builder.io/qwik";
  import { Link } from "@builder.io/qwik-city";
+ import { inlineTranslate } from "qwik-speak";

  export default component$(() => {
+   const t = inlineTranslate();

    return (
      <header class="...">
        <nav class="...">
          <Link href="/">
            <span class="...">
-             👾 Etttro
+             👾 {t("appTitle")}
            </span>
          </Link>

          <ul class="...">
            {/* ... */}
          </ul>
        </nav>
      </header>
    );
  });

The t() function tells Qwik Speak to swap in a translation at compile time. Again, app is the default namespace/asset file, so Qwik Speak knows to look in the app.json file under the resolved locale for the appTitle key. The resolved locale is currently the default, en-US, so if we reload our app it should look exactly as before.

Screenshot of the Etttro demo app's header showing the app title "Etttro" with a pixelated alien icon, and navigation links for "Latest products" and "About us" on a dark green background.

However, if we change the defaultLocale to Arabic in our speak-config.ts file, we should see our app title translated to Arabic.

// src/speak-config.ts

import type { SpeakConfig } from "qwik-speak";

export const config: SpeakConfig = {
  defaultLocale: {
-   lang: "en-US",
+   lang: "ar-EG",
    currency: "USD",
    timeZone: "America/Los_Angeles",
  },
  // ...
};

Screenshot of the Etttro demo app's header showing the app title in Arabic with a pixelated alien icon, and navigation links for "Latest products" and "About us" in English on a dark green background.

Alright! We got Qwik Speak working. Let’s set up localized routing next.

🔗 Resource » You can get the completed app code from our GitHub repo.

How do I configure localized routing?

We often want localized apps with routes like /foo for English resources and /ar-EG/foo for Arabic. The locale segment of each route would resolve to the active locale for the request, and we would serve content for that locale.

We can accomplish this by setting up some middleware and moving our routes to handle the dynamic locale segment.

First, let’s move our routes. We’ll create a directory called [...lang] under /src/routes and move all our app routes there.

# BEFORE
.
└── src/
    └── routes/
        ├── about/
        │   └── index.tsx
        ├── products/
        │   └── [id]/
        │       └── index.ts
        └── index.tsx


# AFTER
.
└── src/
    └── routes/
        └── [...lang]/
            ├── about/
            │   └── index.tsx
            ├── products/
            │   └── [id]/
            │       └── index.ts
            └── index.tsx

[...lang] is a catch-all dynamic route segment corresponding to the ar-EG part of /ar-EG/foo. We can now use this lang route param to resolve the active locale in our new plugin middleware:

// src/routes/plugin.ts

import type { RequestHandler } from "@builder.io/qwik-city";
import {
  setSpeakContext,
  validateLocale,
} from "qwik-speak";

import { config } from "../speak-config";

/**
 * This middleware function must only contain the logic to
 * set the locale, because it is invoked on every request
 * to the server.
 * Avoid redirecting or throwing errors here, and prefer
 * layouts or pages.
 */
export const onRequest: RequestHandler = ({
  params,
  locale,
}) => {
  let lang: string | undefined = undefined;

  if (params.lang && validateLocale(params.lang)) {
    lang = config.supportedLocales.find(
      (value) => value.lang === params.lang,
    )?.lang;
  } else {
    lang = config.defaultLocale.lang;
  }

  // Set Qwik locale.
  locale(lang);
};

We check for a lang route param and use Qwik Speak’s validateLocale function to ensure it’s in the correct format (e.g. en-US, ar-EG, it-IT). If invalid, lang will be set to the configured default locale, en-US. If all is well, we further check that lang corresponds to a locale our app supports.

Whatever lang ends up being, we set it as the app’s locale using Qwik’s locale() function. This function is used internally by Qwik Speak to determine the active locale.

🗒️ Note » If lang is undefined when we call locale(), Qwik Speak will resolve the active locale to the default locale, en-US.

We should see our app title in English if we visit our default routes e.g. / or /about. However, if we visit /ar-EG or /ar-EG/about, the app title should be in Arabic. We’ve successfully set the app locale using the route prefix.

🔗 Resource » Qwik Speak also supports domain-based routing, where our locales are determined by the top-level domain, e.g. example.ar or example.it. Also available is route rewriting, where sub-routes are translated, e.g. example.com/about is translated to example.com/ar-EG/عنا.

Forcing a locale prefix

Qwik Speak insists on leaving the default locale out of routes. We can, however, force a route prefix for the default locale. This will cause our English routes to always be /en-US/foo.

First, let’s handle the case where the lang route param doesn’t correspond to a locale supported by our app; we want to send the client a 404 error response.

// src/routes/layout.tsx

  import { component$, Slot } from "@builder.io/qwik";
  import type { RequestHandler } from "@builder.io/qwik-city";
  import { routeLoader$ } from "@builder.io/qwik-city";
  import Footer from "~/components/layout/footer";
  import Header from "~/components/layout/header";
+ import { config } from "~/speak-config";

  export const onGet: RequestHandler = async ({
    cacheControl,
+   params,
+   send,
  }) => {
    cacheControl({
      staleWhileRevalidate: 60 * 60 * 24 * 7,
      maxAge: 5,
    });

+   if (
+     !config.supportedLocales.find(
+       (loc) => loc.lang === params.lang,
+     )
+   ) {
+     send(404, "Not Found");
+   }
+ };

  export const useServerTimeLoader = routeLoader$(() => {
    return {
      date: new Date().toISOString(),
    };
  });

  export default component$(() => {
    return (
      <>
        <Header />
        <main class="...">
          <Slot />
        </main>
        <Footer />
      </>
    );
  });

Now let’s force a locale prefix from the home route. If we land on / let’s redirect to /en-US.

// src/routes/[...lang]/index.tsx

  import { component$ } from "@builder.io/qwik";
  import {
    routeLoader$,
    type DocumentHead,
+   type RequestHandler,
  } from "@builder.io/qwik-city";
+ import { config } from "~/speak-config";
// ...

+ export const onGet: RequestHandler = async ({
+   params,
+   redirect,
+ }) => {
+   if (!params.lang) {
+     throw redirect(301, `/${config.defaultLocale.lang}/`);
+   }
+ };

// ...

Now if a visitor lands on the / route, we redirect to the /en-US route. The rest of our locale-forcing logic will be covered in the next section.

How do I localize my links?

We can roll our own localized link component to ensure our links keep the active locale prefix. Qwik Speak has a handy localizePath function that can help you here if you didn’t force the locale prefix like we did above. We are forcing the locale prefix in this guide, so let’s write our own function to localize any path.

//  src/util/i18n/loc-path.ts

import { config } from "~/speak-config";

export default function locPath$(
  path: string,
  lang: string,
): string {
  if (path === "/") {
    return `/${lang}`;
  }

  const pathParts = path
    .split("/")
    .filter((segment) => segment);

  if (
    config.supportedLocales.find(
      (locale) => locale.lang === pathParts[0],
    )
  ) {
    pathParts[0] = lang;
  } else {
    pathParts.unshift(lang);
  }

  return `/${pathParts.join("/")}`;
}

Given a path and a locale code, lang, locPath$ will convert that path to one prefixed with lang. Some examples:

  • locPath$("/", "ar-EG")"/ar-EG"
  • locPath$("/en-US/foo", "ar-EG")"/ar-EG/foo"
  • locPath$("/ar-EG/foo", "en-US")"/en-US/foo"

We can now use locPath$ in a new LocLink component for localized links.

// src/components/i18n/loc-link.tsx

import { Slot, component$ } from "@builder.io/qwik";
import {
  Link,
  useLocation,
  type LinkProps,
} from "@builder.io/qwik-city";
import locPath$ from "~/util/i18n/loc-path";

type LocLinkProps = LinkProps & { href: string };

export default component$(
  ({ href, ...props }: LocLinkProps) => {
    const {
      params: { lang },
    } = useLocation();
    const localizedHref = locPath$(href, lang);

    return (
      <Link href={localizedHref} {...props}>
        <Slot />
      </Link>
    );
  },
);

We’re effectively extending Qwik’s built-in Link component, and localizing the given href prop. For example, if we’re given /foo as the href value, and the active locale is Arabic, we set the href on the Link to be /ar-EG/foo.

LocLink can be used as a drop-in replacement for Link.

// src/components/layout/header.tsx

  import { component$ } from "@builder.io/qwik";
- import { Link } from "@builder.io/qwik-city";
+ import LocLink from "../i18n/loc-link";
  import { inlineTranslate } from "qwik-speak";

  export default component$(() => {
    const t = inlineTranslate();

    return (
      <header class="...">
        <div class="...">
          <nav class="...">
-           <Link href="/">
+           <LocLink href="/">
              <span class="text-2xl font-thin">
                👾 {t("appTitle")}
              </span>
-           </Link>
+           </LocLink>
            <ul class="...">
              <li>
-               <Link href="/">
+               <LocLink href="/">
                  {t("nav.latestProducts")}
-               </Link>
+               </LocLink>
              </li>
              <li>
-               <Link href="/about">
+               <LocLink href="/about">
                  {t("nav.aboutUs")}
-               </Link>
+               </LocLink>
              </li>
            </ul>
          </nav>
        </div>
      </header>
    );
  });

Now our links will always include the locale prefix for the active locale.

An animation showing various links in the app being clicked. The browser address bar always shows the en-US/ locale route prefix whenever a link is clicked.
Note that the en-US prefix is always present in the browser address bar.

How do I build a language switcher?

We often want to allow our visitors to select their preferred locale. Qwik Speak doesn’t have a function to switch locales, so we need to reload the app when a new locale is selected. Let’s build a locale switcher <select> component to achieve this.

// src/components/i18n/locale-switcher.tsx

import { $, component$ } from "@builder.io/qwik";
import { useLocation } from "@builder.io/qwik-city";
import {
  useSpeakLocale,
  type SpeakLocale,
} from "qwik-speak";
import { config } from "~/speak-config";
import locPath$ from "~/util/i18n/loc-path";

const langNames: Record<SpeakLocale["lang"], string> = {
  "en-US": "English",
  "ar-EG": "العربية (Arabic)",
};

export default component$(() => {
  const { lang: activeLang } = useSpeakLocale();
  const loc = useLocation();

  const changeLocale$ = $((evt: Event) => {
    const selectedLang = (evt.target as HTMLSelectElement)
      .value;

    // Reload the whole app/page with the newly
    // selected locale.
    window.location.href = locPath$(
      loc.url.pathname,
      selectedLang,
    );
  });

  return (
    <select
      onChange$={changeLocale$}
      class="..."
    >
      {config.supportedLocales.map(({ lang }) => (
        <option
          key={lang}
          value={lang}
          selected={lang === activeLang}
        >
          {langNames[lang]}
        </option>
      ))}
    </select>
  );
});

The useSpeakLocale() function returns an object representing the active locale; a lang string on the object has the locale code (”en-US" | "ar-EG"). We use this to set the selected language option in the <select> dropdown.

When the visitor chooses a new language from the dropdown, we reload the app, swapping the newly selected language into the current URL. For example, let’s say the visitor is on a product details page in English and they switch to Arabic: We take the current URL, /en-US/products/1, use our locPath$ function to convert it to /ar-EG/products/1, and reload the app with this new URL.

An animation showing the locale switcher dropdown in action. The dropdown is clicked, revealing options for English and Arabic. When Arabic is selected, the current page is shown with Arabic translations. When English is selected, the page is shown with English translations.

🗒️ Note » If you’re coding along, remember to place the new LocaleSwitcher component in your Header component.

🔗 Resource » You can get all the code we cover in this guide from our GitHub repo.

How do I extract translations from code?

Qwik Speak features a handy CLI that can save us time by extracting translations from our code files. Let’s see it in action.

First, we’ll add a new NPM script to our package.json file.

// package.json

{
  "name": "my-qwik-basic-starter",
  "description": "Demo App with Routing built-in (recommended)",
  // ...
  "scripts": {
    //...
    "start": "vite --open --mode ssr",
    "qwik": "qwik",
+   "i18n:extract": "qwik-speak-extract --supportedLangs=en-US,ar-EG --assetsPath=i18n --unusedKeys=true"
  },
  "devDependencies": {
    //...
  }
}

The qwik-speak-extract command will search our code for calls to t(key). The command will extract all the keys found and place them in translation files under the --assetsPath directory. It will create one file for each entry we provide in --supportedLangs.

Let’s run the command to see what it does. Recall the translations in our Header component.

// src/components/layout/header.tsx

import { component$ } from "@builder.io/qwik";
import { inlineTranslate } from "qwik-speak";
import LocLink from "../i18n/loc-link";
import LocaleSwitcher from "../i18n/locale-switcher";

export default component$(() => {
  const t = inlineTranslate();

  return (
    <header class="...">
      <div class="...">
        <nav class="...">
          <LocLink href="/">
            <span class="...">
              {/* 👇 The extraction script will look
                     for `t(key)` calls. This will be
                     extracted into `app.json`. */}
              👾 {t("appTitle")}
            </span>
          </LocLink>
          <ul class="...">
            <li>
              <LocLink href="/">
                {/* 👇 This will be extracted into
                       `nav.json`.  */}
                {t("nav.latestProducts")}
              </LocLink>
            </li>
            <li>
              <LocLink href="/about">
                {t("nav.aboutUs")}
              </LocLink>
            </li>
          </ul>
        </nav>

        <LocaleSwitcher />
      </div>
    </header>
  );
});

Let’s remove all the translation JSON files we created earlier under the i18n directory, then run npm run i18n:extract from the command line.

If all goes well, we should see the files re-created by the extraction script.

.
└── i18n/
    ├── ar-EG/
    │   ├── app.json
    │   └── nav.json
    └── en-US/
        ├── app.json
        └── nav.json

All keys without namespaces, like appTitle, go into the default app.json file.

// i18n/en-US/app.json
{
  "appTitle": ""
}

// i18n/ar-EG/app.json
{
  "appTitle": ""
}

Keys with namespaces, like nav.latestProducts, go into files named after the given namespace.

// i18n/en-US/nav.json
{
  "nav": {
    "aboutUs": "",
    "latestProducts": ""
  }
}

// i18n/ar-EG/nav.json
{
  "nav": {
    "aboutUs": "",
    "latestProducts": ""
  }
}

The translation files work the same as they did before.

🗒️ Note »  qwik-speak-extract is smart enough not to override existing key/value pairs.

🗒️ Heads up » Every new file/namespace must be added to the src/speak-config.ts file’s assets array.

// src/speak-config.ts

import type { SpeakConfig } from "qwik-speak";

export const config: SpeakConfig = {
  // ...
  supportedLocales: [
    //...
  ],
  // Translations available in the whole app
- assets: ["app"],
+ assets: ["app", "nav"],
  runtimeAssets: [],
};

If we want to prune keys no longer in our codebase, we can use the --unusedKeys=true CLI flag as we have above. This will cause the command to remove the keys we’re no longer using.

🔗 Resource » If you don’t like your extracted translations to have blank values, you can provide default translations with your keys that will end up as values in your extracted files. Read about default values and all the extraction CLI options on the Qwik Speak Extract page of the official docs.

🗒️ Note » These files can be uploaded to a string management platform like Phrase Strings, where translators can work on various language files; an automated process can pull the translation files back into the project when ready.

Some notes on translation files

We won’t cover the following in detail here, but we found the following aspects of Qwik Speak translation files noteworthy.

Runtime translations

Qwik Speak translations are compiled on the server and baked into utilizing components as hard-coded strings. This makes our app very performant, as it avoids any translation logic running on the client. However, this means that Qwik Speak needs to statically analyze translation keys at compile time. What if we needed to calculate a translation key at runtime? This is where runtime translations come in. Read about runtime translations in the official docs.

Lazy-loaded translations

In large applications, we may need to delay loading specific translations until they are required. Qwik Speak allows this via lazy loading. The idea is to exclude specific translation files from the speak-config.js assets array and load them manually with useSpeak(). Read about lazy-loading in the official docs.

How do I deal with right-to-left languages?

Arabic, Hebrew, Persian (Farsi), Urdu, and others are laid out right to left. Modern browsers do a good job of accommodating right-to-left layouts. However, we should set the <html dir> attribute to rtl to get our pages flowing correctly.

Before that, let’s install a handy little package called rtl-detect that detects the direction of a given locale.

npm install --save-dev rtl-detect @types/rtl-detect

We can install rtl-detect as a dev dependency because we’ll only run it on the server.

Now let’s use the package to set the <html dir> attribute during SSR (server-side rendering).

// src/entry.ssr.tsx

  import { isDev } from "@builder.io/qwik/build";
  import {
    renderToStream,
    type RenderOptions,
    type RenderToStreamOptions,
  } from "@builder.io/qwik/server";
  import { manifest } from "@qwik-client-manifest";
+ import rtlDetect from "rtl-detect";
  import Root from "./root";
  import { config } from "./speak-config";

  export function extractBase(/* ... */) {
    // ...
  }

  export default function (opts: RenderToStreamOptions) {
    const lang =
      opts.serverData?.locale || config.defaultLocale.lang;

    return renderToStream(<Root />, {
      manifest,
      ...opts,
      base: extractBase,
      // Use container attributes to set attributes on
      // the html tag.
      containerAttributes: {
        lang,
+       // Use rtl-detect to determine the dir of
+       // the current locale and set it as the
+       // `<html dir>` attribute.
+       dir: rtlDetect.getLangDir(lang),
        ...opts.containerAttributes,
      },
      serverData: {
        ...opts.serverData,
      },
    });
  }

With this code in place, our Arabic pages will have a <html dir> value of rtl.

Screenshot of the browser inspector DOM view showing the HTML element with attributes: lang="ar-EG", and dir="rtl".

And, of course, this causes our page to be laid out from right to left.

Screenshot of the Etttro demo app's home page showing the latest products laid out right-to-left. The header is in Arabic with a language switcher button labeled "(Arabic) العربية". The products displayed are a Sony Walkman for $199 dated 2024-06-10, a Nintendo Virtual Boy for $229 dated 2024-06-05, and a Commodore 64 for $149.99 dated 2024-06-01. The app has a dark green background.

🔗 Resource » There’s more to layout localization than simply <html dir>. Check out our CSS Localization guide for more details.

How do I localize on the server?

Unlike components, we need to supply the current locale explicitly when using inlineTranslate in server contexts like endpoints and router loaders.

export const useMyTranslatedString = routeLoader$<string>(
  (requestEvent) => {
    const t = inlineTranslate();
    // We need to provide the locale explicitly
    // to `t()`. We can get the Qwik locale, from
    // `requestEvent.locale()`. We set this in our
    // plugin middleware.
    const translatedString = t(
      "myTranslation",
      {},
      requestEvent.locale(),
    );

    return translatedString;
  },
);

🔗 Resource » Read more about Server translation in the official docs.

🗒️ Note » If you want to localize your static-side generated (SSG) Qwik apps, check out the Static Site Generation (SSG) of the docs.

How do I localize page metadata?

Since document metadata is generated on the server, it can be localized similarly to other server-side contexts. Let’s provide the translations first.

// i18n/en-US/app.json

{
+ "app": {
+   "meta": {
+     "description": "Etttro is your community second-hand market for all retro electronics.",
+     "title": "Etttro | Retro Hardware Marketplace"
+   }
+ },
  "appTitle": "Etttro",
  // ...
}
// i18n/ar-EG/app.json

{
+ "app": {
+   "meta": {
+     "description": "إترو هو سوق مجتمعي لجميع الأجهزة الإلكترونية الرجعية المستعملة.",
+     "title": "إترو | سوق الأجهزة الرجعية"
+   }
+ },
  "appTitle": "إترو",
  // ...
}

Instead of the inline object, we need to use the function form of Qwik Speak’s head. This allows us to access route params, including the lang param, which we can pass to t().

// src/routes/[...lang]/index.tsx

import { component$ } from "@builder.io/qwik";
import {
  routeLoader$,
  type DocumentHead,
} from "@builder.io/qwik-city";
import { inlineTranslate } from "qwik-speak";

// ...

export default component$(() => {
  // ...
});

export const head: DocumentHead = ({ params }) => {
  const t = inlineTranslate();

  // We pass the `lang` route param to `t()` as
  // the third argument.
  return {
    title: t("app.meta.title", {}, params.lang),
    meta: [
      {
        name: "description",
        content: t("app.meta.description", {}, params.lang),
      },
    ],
  };
};
Screenshot of the browser DOM inspector showing the metadata of the HTML document. The title tag contains (in Arabic) "إتترو | سوق الأجهزة الرجعية". The meta description tag contains (in Arabic) "إتترو هو سوق مجتمعي لجميع الأجهزة الإلكترونية الرجعية المستعملة." Other visible elements include canonical link, viewport meta tag, and favicon link.
The title and meta description are translated to Arabic when our home page locale is ar-EG.

The title and meta description are translated to Arabic when our home page locale is ar-EG.

How do I work with basic translation messages?

We’ve covered basic translations a few times by now, so we’ll briefly refresh ourselves before we cover more advanced formatting topics.

Translated strings are added to our translation files, one for each locale.

// i18n/en-US/app.json
{
  //...
  "water": "Water"
}
// i18n/ar-EG/app.json
{
  //...
  "water": "ماء"
}

We use the t() function to pull translations into our components.

import { component$ } from "@builder.io/qwik";
import { inlineTranslate } from "qwik-speak";

export default component$(() => {
  const t = inlineTranslate();

  return <p>{t("water")}</p>
});

The above renders “Water” when the active locale is en-US and “ماء” when the active locale is ar-EG.

🗒️ Note » Instead of adding strings manually to translation files, we can use the CLI to extract them automatically. See the extraction section above for more.

How do I work with dynamic values in translation messages?

We sometimes need to inject runtime values into our translations. Qwik Speak’s translation message format allows for this using a {{variable}} placeholder syntax. Let’s add a user greeting to our home page to demonstrate.

// i18n/en-US/app.json
  {
    "appTitle": "Ettro",
    //...
+   "userGreeting": "Hello, {{username}}."
  }
// i18n/ar-EG/app.json
  {
    "appTitle": "إترو",
    //...
+   "userGreeting": "مرحبًا، {{username}}."
  }

Notice the {{username}} syntax in the above messages. This will be replaced by a username argument when we call t().

// src/routes/[...lang]/index.tsx

import { $, component$ } from "@builder.io/qwik";
// ...
import {
  inlineTranslate,
  type Translation,
} from "qwik-speak";
// ...

// ...

export default component$(() => {
  const t = inlineTranslate();
  const productsS = useProducts();

  return (
    <>
      <div class="...">
        <h1 class="...">
          {t("homePageTitle")}
        </h1>

+       <p>{t("userGreeting", { username: "Hannah" })}</p>
      </div>

      <section class="...">
        {/* ... */}
      </section>
    </>
  );
});

// ...

The second argument to t() is a map of key/value pairs that will replace the placeholders at runtime, matching each by key/name.

Screenshots of the user greeting in English and Arabic. The English greeting reads, "Hello, Hannah." The Arabic one reads, "مرحباً هنا".

🔗 Resource » Read more about Params interpolation in the official docs.

How do I work with localized plurals?

Plurals in translation messages are more than just switching between singular and plural; different languages have a variety of plural forms. For instance, English has two forms: “one message” and “many messages.” Meanwhile, some languages like Chinese have only one plural form, whereas Arabic and Russian each have six. To get it right, we must provide the different plural forms and an integer count to select the correct one.

Qwik Speak offers plural support, using the standard JavaScript Intl.PluralRules object under the hood. Let’s add a count to our “Latest products” header to showcase localized plurals.

First, we’ll add our messages. Plural translations have a special structure: each plural form is a key/value pair in a nested object:

// i18n/en-US/app.json

{
  "appTitle": "Ettro",
- "latestProducts": "Latest products",
+ "latestProducts": {
+   "one": "{{value}} Latest Product",
+   "other": "{{value}} Latest Products"
+ },
  // ...
  "userGreeting": "Hello, {{name}}."
}
// i18n/ar-EG/app.json
{
  "appTitle": "إترو",
- "latestProducts": "أحدث المنتجات",
+ "latestProducts": {
+   "zero": "لا توجد منتجات",
+   "one": "أحدث منتج",
+   "two": "أحدث منتجين",
+   "few": "أحدث {{value}} منتجات",
+   "many": "أحدث {{value}} منتج",
+   "other": "أحدث {{value}} منتج"
+ },
  // ...
  "userGreeting": "مرحبًا، {{name}}."
}

As we mentioned, English has two plural forms, one and other, while Arabic has six plural forms. At runtime, the {{value}} placeholder will be replaced by given the integer counter.

🔗 Resource » The CLDR Language Plural Rules page is the canonical, and handy, listing of all languages’ plural forms.

To use plural messages in our components, we call Qwik Speak’s inlinePlural instead of inlineTranslate.

import { $, component$ } from "@builder.io/qwik";
// ...
import {
+ inlinePlural,
  inlineTranslate,
  type Translation,
} from "qwik-speak";

//...

export default component$(() => {
  const t = inlineTranslate();
+ const p = inlinePlural();
  const productsS = useProducts();

  return (
    <>
      <div class="...">
        <h1 class="...">
-         {t("latestProducts")}
+         {/* The first argument is an integer
+             counter used to select the plural
+             form. The second argument is the
+             message key. */}
+         {p(productsS.value.length, "latestProducts")}
        </h1>

        <p>{t("userGreeting", { name: "Hannah" })}</p>
      </div>

      <section class="...">
        {/* ... */}
      </section>
    </>
  );
});

// ...

Now, depending on the number of products, the correct plural form is shown for the active locale.

Screenshot comparing plural forms in English (en-US) and Arabic (ar-EG) for the phrase "Latest Product." In English, "one" maps to "1 Latest Product" and "other" maps to "3 Latest Products." In Arabic, "zero" maps to "لا توجد منتجات," "one" maps to "أحدث منتج," "two" maps to "أحدث منتجين," "few" maps to "أحدث 3 منتجات," "many" maps to "أحدث 11 منتج," and "other" maps to "أحدث 100 منتج."

🗒️ Note » If using the CLI to extract messages, all forms will be extracted for all languages. We need to remove the unused forms for the language ourselves.

🗒️ Note » Unlike other i18n libraries, Qwik Speak doesn’t seem to provide a way to override a language’s standard plural forms. For example, if we wanted to handle the non-standard zero case in English, we would need to write conditional logic to handle this in our component code.

🔗 Resource » Read more about inlinePural in the official docs.

🔗 Resource » We go deep in Pluralization: A Guide to Localizing Plurals if you want to learn more.

How do I add HTML to my translation messages?

Adding HTML markup to translation messages requires using Qwik’s dangerouslySetInnerHTML prop. Since translations are effectively hard-coded, or inlined, at build time, there is little risk of the XSS (Cross-Site Scripting) attacks normally associated with rendering raw HTML. Let’s add a footer component to our app to demonstrate.

// i18n/en-US/app.json

{
  "appTitle": "Etttro",
+ "footerText": "<strong>Etttro</strong> is a demo app made with Qwik &amp; Qwik Speak.",
  //...
}
// i18n/ar-EG/app.json

{
  "appTitle": "إترو",
+ "footerText": "<strong>إترو</strong> هو تطبيق تجريبي تم تصميمه باستخدام Qwik &amp; Qwik Speak.",
  // ...
}

Our new footer component:

// src/components/layout/footer.tsx

import { component$ } from "@builder.io/qwik";
import { inlineTranslate } from "qwik-speak";

export default component$(() => {
  const t = inlineTranslate();

  return (
    <footer class="...">
      <div class="...">
        <p
          class="..."
          dangerouslySetInnerHTML={t("footerText")}
        ></p>
      </div>
    </footer>
  );
});

Again, note the dangerouslySetInnerHTML, which allows injecting raw innerHTML into a DOM element.

Screenshot of browser DOM inspector showing rendered HTML. A  paragraph with the class attribute "text-center" is shown containing the text "Etttro is a demo app made with Qwik & Qwik Speak," where "Etttro" is enclosed in a strong tag.
The HTML in the translation message is rendered as-is.

🗒️ Note » If you’re coding along, tuck the new Footer component in the root layout to see it render.

How do I format localized numbers?

i18n is more than just string translations. Working with numbers and dates is crucial for most apps, and each region handles number and date formatting differently.

A note on regional formatting

Number and date formatting are determined by region, not just language. For example, the US and Canada use English but have different date formats and measurement units. So it’s better to use a qualified locale (like en-US) instead of just a language code (en).

Using a language code alone, such as ar for Arabic, can lead to inconsistency. Different browsers might default to various regions, like Saudi Arabia (ar-SA) or Egypt (ar-EG), resulting in varied date formats due to distinct regional calendars.

Luckily, Qwik Speak provides a number formatting function built on the robust standard Intl.NumberFormat object. Let’s use it to format our product prices.

// src/routes/[...lang]/index.tsx

import { $, component$ } from "@builder.io/qwik";
// ...
import {
  inlinePlural,
  inlineTranslate,
+ useFormatNumber,
  type Translation,
} from "qwik-speak";
import LocLink from "~/components/i18n/loc-link";

// ...

export default component$(() => {
  const t = inlineTranslate();
  const p = inlinePlural();
+ const fn = useFormatNumber();
  const productsS = useProducts();

  return (
    <>
      <div class="...">
        {/* ... */}
      </div>

      <section class="...">
        {productsS.value.map((product) => (
          <LocLink
            href={`products/${product.id}`}
            key={product.id}
          >
            <article class="...">
              {/* ... */}
              <img
                width={600}
                height={600}
                alt={product.title}
                class="block aspect-square w-full"
                src={`/product-img/${product.imageUrl}`}
              />
              <div class="...">
-               <p>${product.priceInCents / 100.0}</p>
+               <p>
+                 {fn(product.priceInCents / 100.0, {
+                   style: "currency",
+                 })}
+               </p>
                {/* ... */}
              </div>
              <p class="...">
                {product.description.slice(0, 65)}...
              </p>
            </article>
          </LocLink>
        ))}
      </section>
    </>
  );
});

// ...

The useFormatNumber function returns a number formatting function, fn, which takes a number and formatting options. These options are the same ones the Intl.NumberFormat constructor accepts.

🔗 Resource » We can format numbers with different precisions, and format them as units, percentages, and more.  See all the options Intl.NumberFormat accepts in the MDN docs.

In the above call to fn, we specify a currency style. fn is locale-aware and will use the currency specified in speak-config.ts for the active locale.

// src/speak-config.ts

import type { SpeakConfig } from "qwik-speak";

export const config: SpeakConfig = {
  defaultLocale: {
    lang: "en-US",
    currency: "USD",
    timeZone: "America/Los_Angeles",
  },
  supportedLocales: [
    {
      lang: "ar-EG",
      // We can change the currency for Arabic
      // here, but we need to convert it from
      // USD in our app if we do.
      currency: "USD",
      timeZone: "Africa/Cairo",
    },
    {
      lang: "en-US",
      currency: "USD",
      timeZone: "America/Los_Angeles",
    },
  ],
  assets: ["app", "nav"],
  runtimeAssets: [],
};

🗒️ Heads up » If you specify a currency in the options to fn, e.g. fn(29.99, { style: "currency", currency: "EUR" }), it will be overridden by the currency for the active locale in speak-config.ts.

Screenshot of the Etttro demo app showing product listings with localized number formatting in English (en-US) and Arabic (ar-EG). The English section displays prices as $149.99 and $229.00, and the Arabic section displays prices as $US ١٩٩٫٠٠ and $US ٢٣٩٫٠٠. The products are a Commodore 64, a Nintendo Virtual Boy, and a Sony Walkman, each with descriptions and prices in the respective languages and formats.

🔗 Resource » See the official docs for more info about useFormatNumber.

🔗 Resource » Our Concise Guide to Number Localization goes into numeral systems, separators, and more regarding number localization.

How do I format localized dates?

Like numbers, we often overlook the importance of date and time localization. It’s important to handle date formatting carefully since dates and times are formatted differently worldwide.

Qwik Speak provides a useFormatDate function as a counterpart to useFormatNumber. Let’s use useFormatDate to localize our product dates.

🗒️ Note » Localizing dates is similar to localizing numbers, so we recommend you read the previous section before this one.

import { $, component$ } from "@builder.io/qwik";
// ...
import {
  inlinePlural,
  inlineTranslate,
+ useFormatDate,
  useFormatNumber,
  type Translation,
} from "qwik-speak";
import LocLink from "~/components/i18n/loc-link";

// ...

export default component$(() => {
  const t = inlineTranslate();
  const p = inlinePlural();
  const fn = useFormatNumber();
+ const fd = useFormatDate();
  const productsS = useProducts();

  return (
    <>
      <div class="...">
        {/* ... */}
      </div>

      <section class="...">
        {productsS.value.map((product) => (
          <LocLink
            href={`products/${product.id}`}
            key={product.id}
          >
            <article class="...">
              {/* ... */}
              <div class="...">
                <p>
                  {fn(product.priceInCents / 100.0, {
                      style: "currency",
                   })}
                </p>
                <p>
-                 // We no longer need the custom
-                 // `toShortDate$` function.
-                 {toShortDate$(product.publishedAt)}
+                 {fd(product.publishedAt, {
+                   dateStyle: "short",
+                 })}
                </p>
              </div>
              <p class="...">
                {product.description.slice(0, 65)}...
              </p>
            </article>
          </LocLink>
        ))}
      </section>
    </>
  );
});

// ...

Unsurprisingly, the fd function is built on top of Intl.DateTimeFormat, and takes a date and formatting options arguments. The options that the Intl.DateTimeFormat accepts can be passed as the second argument to fn.

🔗 Resource » See the MDN docs for Intl.DateTimeFormat for all available formatting options.

🗒️ Heads up » If we set timeZones for our locales in speak-config.ts they will automatically be used by fn.

Screenshot of the Etttro demo app showing product listings with localized date formatting in English (en-US) and Arabic (ar-EG). The English section displays dates as 6/1/24 and 6/5/24, and the Arabic section displays dates as ١/٦/٢٠٢٤ and ٥/٦/٢٠٢٤. The products are a Commodore 64, a Nintendo Virtual Boy, and a Sony Walkman, each with descriptions and prices in the respective languages and formats.

🔗 Resource » See the official docs for more info about useFormatDate.

🔗 Resource » Our Guide to Date and Time Localization covers formatting, time zones, regional calendars, and more.

Take your Qwik localization to the next level

We hope you enjoyed this guide to localizing Qwik apps with Qwik Speak.

🔗 Resource » Get the entire demo code from our GitHub repo.

When you’re all set to start the translation process, rely on the Phrase Localization Platform to handle the heavy lifting. A specialized software localization platform, the Phrase Localization Platform comes with dozens of tools designed to automate your translation process and native integrations with platforms like GitHub, GitLab, and Bitbucket.

With its user-friendly strings editor, translators can effortlessly access your content and transfer it into your target languages. Once your translations are set, easily integrate them back into your project with a single command or automated sync.

This way, you can stay focused on what you love—your code. Sign up for a free trial and see for yourself why developers appreciate using The Phrase Localization Platform for software localization.

The post Mastering Qwik Localization: A Comprehensive Guide appeared first on Phrase.

]]>
A Guide to NestJS Localization https://phrase.com/blog/posts/nestjs-localization/ Mon, 10 Jun 2024 08:59:00 +0000 https://phrase.com/?p=84321 We localize a Nest app with nestjs-i18n, working through controller, service, and database localization.

The post A Guide to NestJS Localization appeared first on Phrase.

]]>

A fast and loose framework like Express is great for small server-side apps, but as projects and teams scale, clean architecture and patterns are needed to keep things SOLID, making it easier to find and change code. Enter the Angular-inspired, architecture-focused NestJS, which sees downloads of 3 million per week. The thoughtful structure of NestJS facilitates our projects’ growth while minimizing technical debt.

While NestJS is a comprehensive framework, it leaves internationalization (i18n) up to the developer. Fortunately, Toon van Strijp created nestjs-i18n, a library that greatly simplifies NestJS localization. nestjs-i18n resolves the current request’s language, manages translation files, and formats messages, all with built-in type safety. In this guide we’ll take nestjs-i18n for a spin, building a REST API with NestJS and localizing it. Let’s dive in.

🔗 Resource » Internationalization (i18n) and localization (l10n) allow us to make our apps available in different languages and regions, often for more profit. If you’re new to i18n and l10n, check out our guide to internationalization.

🔗 Resource » If you’re interested in general Node.js or Express i18n, check out our Guide to Node.js Internationalization (I18n).

Our demo

We’ll build a fictitious jogging blog called Yogger Chicken, implementing it as a REST API with the following endpoints.

GET    /          # Root, redirects to /info
GET    /info      # Lists available endpoints
GET    /today     # Shows a daily quote
POST   /posts     # Creates a new blog post
GET    /posts     # Lists all blog posts
GET    /posts/1   # Shows a blog post with ID 1
PATCH  /posts/1   # Updates blog post with ID 1

Package versions

We’ll use the following NPM packages (versions in parentheses). Don’t worry, we’ll walk through installing them as we go.

  • typescript (5.4) — our programming language
  • @nestjs/cli (10.3) — Nest’s command-line interface, used to spin up new projects and generate code
  • @nestjs/common (10.3) — Nest (NestJS) itself
  • @nestjs/mapped-types (2.0) — allows us to derive a DTO from another DTO
  • sqlite3 (5.1) — database driver for development
  • typeorm (0.3) — our Object-Relational Mapper (ORM) for working with the database in code
  • @nestjs/typeorm (10.0) — first-party module that bridges TypeORM and Nest
  • class-transformer (0.5) — used along with class-validator for decorator-based DTO validation
  • class-validator (0.14) — used for decorator-based DTO validation
  • nestjs-i18n (10.4) — our localization library

Let’s start by installing the Nest CLI from the command line and use it to spin up a new Nest project.

$ npm install -g @nestjs/cli
$ nest new yogger-chicken

The info route

Next, we’ll add an /info route that lists our API endpoints for consumers.

$ nest generate module info
$ nest generate controller info
$ nest generate service info

Let’s add the JSON that /info will serve to our info service.

// src/info/info.service.ts

import { Injectable } from '@nestjs/common';

@Injectable()
export class InfoService {
  getInfo() {
    return {
      about: 'yogger chicken: a headless running blog',
      lastUpdated: new Date().toISOString(),
      routes: [
        {
          verb: 'GET',
          path: '/',
          description: 'Redirects to /info',
        },
        {
          verb: 'GET',
          path: '/info',
          description: 'You are here',
        },
        {
          verb: 'GET',
          path: '/today',
          description: 'Daily quote',
        },
        {
          posts: [
            {
              verb: 'GET',
              path: '/posts',
              description: 'Index of all posts',
            },
            {
              verb: 'GET',
              path: '/posts/1',
              description: 'Post with ID 1',
            },
            {
              verb: 'POST',
              path: '/posts',
              description: 'Create a new post',
            },
            {
              verb: 'PATCH',
              path: '/posts/1',
              description: 'Update post with ID 1',
            },
          ],
        },
      ],
    };
  }
}

The Nest CLI should have wired up the info module and controller when we created them. So if we run our app using npm run start:dev and hit the /info route, we should JSON output like the following.

Our REST client making a GET request to the URL /info with no query parameters. The response code is 200, and the response body contains { "about": "yogger chicken: a headless running blog" }.

Let’s update the root route (/) to redirect to /info.

// src/app.controller.ts

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

@Controller()
export class AppController {
  @Get()
  @Redirect('/info', 301)
  getRoot() {}
}

If we hit / now, we should see the same output as we did for /info.

Posts and the database

We want CRUD (Create, Read, Update, Delete) endpoints for our blog posts. To persist post data, let’s use a SQLite development database. TypeORM works well with Nest, so we’ll use it to interface with the database. Let’s install the dependencies.

 $ npm install sqlite3 typeorm @nestjs/typeorm

class-validator works out-of-the-box with Nest’s ValidationPipe, so it’s a good choice for validating our DTO (Data Transfer Objects). Let’s install it.

$ npm install class-validator class-transformer

And to make our lives a bit easier, let’s install Nest’s mapped-types package, which allows us to derive a DTO from another.

$ npm install @nestjs/mapped-types

Now we can configure TypeORM in the root AppModule.

// src/app.module.ts

  import { Module } from '@nestjs/common';
+ import { TypeOrmModule } from '@nestjs/typeorm';
  import { AppController } from './app.controller';
  import { InfoModule } from './info/info.module';
  import { TodayModule } from './today/today.module';

  @Module({
    imports: [
+     TypeOrmModule.forRoot({
+       type: 'sqlite',
+       database: 'db.sqlite',
+       entities: [__dirname + '/**/*.entity.{ts,js}'],
+       synchronize: true, // DO NOT USE IN PRODUCTION
+       logging: true,
+     }),
      InfoModule,
      TodayModule,
    ],
    controllers: [AppController],
    providers: [],
  })
  export class AppModule {}

If we run our app now, we should see an empty db.sqlite file created in the root directory of our project.

OK, onto the posts themselves. Let’s put the Nest CLI to work generating our posts boilerplate.

$ nest generate module posts
$ nest generate service posts
$ nest generate controller posts

We’ll manually add the Post entity in a new file, keeping it simple.

// src/posts/entities/post.entity.ts

import {
  Column,
  Entity,
  PrimaryGeneratedColumn,
} from 'typeorm';

@Entity()
export class Post {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  title: string;

  @Column()
  content: string;
}

This Post entity will be used in our PostsService via TypeORM’s repository pattern, which means we should import Post into our PostsModule.

// src/posts/posts.module.ts

  import { Module } from '@nestjs/common';
+ import { TypeOrmModule } from '@nestjs/typeorm';
+ import { Post } from './entities/post.entity';
  import { PostsController } from './posts.controller';
  import { PostsService } from './posts.service';

  @Module({
+   imports: [TypeOrmModule.forFeature([Post])],
    providers: [PostsService],
    controllers: [PostsController],
  })
  export class PostsModule {}

Let’s also add our create and update the post DTO.

// src/posts/dto/create-post.dto.ts

import {
  IsNotEmpty,
  IsString,
  Length,
} from 'class-validator';

export class CreatePostDto {
  @IsString()
  @IsNotEmpty()
  @Length(1, 255)
  title: string;

  @IsString()
  @IsNotEmpty()
  content: string;
}
// src/posts/dto/update-post.dto.ts

import { PartialType } from '@nestjs/mapped-types';
import { CreatePostDto } from './create-post.dto';

export class UpdatePostDto extends PartialType(
  CreatePostDto,
) {}

Weaving all these parts together is the all-important PostsService.

// src/posts/posts.service.ts

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CreatePostDto } from './dto/create-post.dto';
import { UpdatePostDto } from './dto/update-post.dto';
import { Post } from './entities/post.entity';

export interface PostSummary {
  id: number;
  title: string;
}

@Injectable()
export class PostsService {
  constructor(
    @InjectRepository(Post)
    private postRepo: Repository<Post>,
  ) {}

  async findAll(): Promise<PostSummary[]> {
    return await this.postRepo.find({
      select: ['id', 'title'],
    });
  }

  async findOne(id: number): Promise<Post | null> {
    return await this.postRepo.findOneBy({ id });
  }

  async create(
    createPostDto: CreatePostDto,
  ): Promise<Post> {
    const newPost = this.postRepo.create({
      ...createPostDto,
    });

    return this.postRepo.save(newPost);
  }

  async update(
    id: number,
    updatePostDto: UpdatePostDto,
  ): Promise<Post | null> {
    const post = await this.findOne(id);

    if (!post) {
      return null;
    }

    if (post) {
      post.title = updatePostDto.title ?? post.title;
      post.content = updatePostDto.content ?? post.content;
    }

    return await this.postRepo.save(post);
  }

  async remove(id: number): Promise<boolean> {
    const post = await this.findOne(id);

    if (!post) {
      return false;
    }

    await this.postRepo.remove(post);

    return true;
  }
}

🗒️ Note » We have strictNullChecks: true in our ./tsconfig.json file.

Our controller exposes the posts service to the outside world.

// src/posts/posts.controller.ts

import {
  Body,
  Controller,
  Delete,
  Get,
  NotFoundException,
  Param,
  Patch,
  Post,
  UsePipes,
  ValidationPipe,
} from '@nestjs/common';
import { CreatePostDto } from './dto/create-post.dto';
import { UpdatePostDto } from './dto/update-post.dto';
import { Post as PostEntity } from './entities/post.entity';
import { PostSummary, PostsService } from './posts.service';

@Controller('posts')
export class PostsController {
  constructor(
    private readonly postsService: PostsService,
  ) {}

  @Get()
  async findAll(): Promise<PostSummary[]> {
    return this.postsService.findAll();
  }

  @Get(':id')
  async findOne(
    @Param('id') id: string,
  ): Promise<PostEntity> {
    const post = await this.postsService.findOne(+id);

    if (!post) {
      this.throwNotFound(id);
    }

    return post;
  }

  @Post()
  @UsePipes(new ValidationPipe({ whitelist: true }))
  async create(
    @Body() createPostDto: CreatePostDto,
  ): Promise<PostEntity> {
    return this.postsService.create(createPostDto);
  }

  @Patch(':id')
  @UsePipes(new ValidationPipe({ whitelist: true }))
  async update(
    @Param('id') id: string,
    @Body() updatePostDto: UpdatePostDto,
  ): Promise<PostEntity> {
    const post = await this.postsService.update(
      +id,
      updatePostDto,
    );

    if (!post) {
      this.throwNotFound(id);
    }

    return post;
  }

  @Delete(':id')
  async remove(
    @Param('id') id: string,
  ): Promise<{ message: string }> {
    const ok = await this.postsService.remove(+id);

    if (!ok) {
      this.throwNotFound(id);
    }

    return { message: 'Post deleted' };
  }

  private throwNotFound(id: string): never {
    throw new NotFoundException(
      `Post with ID ${id} not found`,
    );
  }
}

🗒️ Note » We’re skipping authentication and authorization in this tutorial for brevity.

We can now create and update posts to our heart’s content.

Our REST client making a POST request to the URL /posts with form-encoded parameters title="Running in place" and content="I love my treadmill, but I will admit...". The response code is 201 Created, and the response body contains { "id": 5, "title": "Running in place", "content": "I love my treadmill, but I will admit..." }.

Our REST client making a GET request to the URL /posts with no query parameters. The response code is 200, and the response body contains a JSON object with a list of posts, displaying their ids and titles.

🔗 Resource » You can get all the starter demo code from our GitHub repo’s start branch.

How do I localize my Nest app?

OK, it’s time for the main course. This is our recipe for localizing a Nest app with nestjs-i18n:

1. Install and set up nestjs-i18n.
2. Move our hard-coded strings to translation files.
3. Load translation files using one of nestjs-i18n’s loaders.
4. Inject the i18n context and service into controllers and services, respectively, and use them to fetch strings from translation files.
5. Determine the active locale using nestjs-i18n’s resolvers.
6. Add type safety to our translation keys.
7. Handle dynamic values and localized plurals in our translations.
8. Localize the database data.
9. Localize validation messages.

We’ll go through these step-by-step.

A note on locales

A locale defines a language, a region, and sometimes more. Locales typically use IETF BCP 47 language tags, like en for English, fr for French, and es for Spanish. A region can be added with the ISO Alpha-2 code (e.g., BH for Bahrain, CN for China, US for the United States). So a locale with a region might look like en-US for English as used in the United States or zh-CN for Chinese as used in China.

🔗 Explore more language tags on Wikipedia and find country codes through the ISO’s search tool.

How do I set up nestjs-i18n?

First things first, let’s install the package.

$ npm install nestjs-i18n

Next, let’s create a src/locales directory to house our translations. We’ll work with English (en) and Arabic (ar) in this tutorial. Feel free to add any locales you want here.

// src/locales/en/common.json

{
  "about": "yogger chicken: a headless blog about"
}
// src/locales/ar/common.json

{
  "about": "دجاج يوجر: مدونة جري بلا رأس"
}

We need to add the global I18nModule from nestjs-i18n to our AppModule.

// src/app.module.ts

  import { Module } from '@nestjs/common';
  import { TypeOrmModule } from '@nestjs/typeorm';
+ import { I18nModule, QueryResolver } from 'nestjs-i18n';
+ import * as path from 'path';
  import { AppController } from './app.controller';
  import { InfoModule } from './info/info.module';
  import { PostsModule } from './posts/posts.module';
  import { TodayModule } from './today/today.module';

  @Module({
    imports: [
      TypeOrmModule.forRoot({
        type: 'sqlite',
        database: 'db.sqlite',
        entities: [__dirname + '/**/*.entity.{ts,js}'],
        synchronize: true,
        logging: true,
      }),
+     I18nModule.forRoot({
+       fallbackLanguage: 'en',
+       loaderOptions: {
+         path: path.join(__dirname, '/locales/'),
+         watch: true,
+       },
+       resolvers: [new QueryResolver(['lang'])],
+     }),
      InfoModule,
      PostsModule,
      TodayModule,
    ],
    controllers: [AppController],
    providers: [],
  })
  export class AppModule {}

nestjs-i18n uses a loader to read our translation message files, which we configure in loaderOptions. The watch option ensures that translations are reloaded into the app when translation files change.

Our app needs to have a single active locale for a request. In our app, this will be English (en) or Arabic (ar). nestjs-i18n uses one or more resolvers to determine the current request’s locale. We’re using the QueryResolver, which will look at a query param called lang to determine the active locale. For example, if our URL is http://localhost:3000?lang=ar, the active locale will resolve to Arabic (ar).

The fallbackLanguage is used when we can’t otherwise resolve the active locale for a request.

🗒️ Note » We’ll look at resolvers and loaders more closely in later sections.

The new locales directory housing our translation files won’t be automatically copied to the dist directory during builds, which will cause errors. We can fix this by explicitly telling the Nest CLI to copy the directory in the nest-cli.json config file.

// nest-cli.json

{
  "$schema": "https://json.schemastore.org/nest-cli",
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  "compilerOptions": {
    "deleteOutDir": true,
+   "assets": [
+     { "include": "locales/**/*", "watchAssets": true }
+   ]
  }
}

OK, if we run our app now we should see server logs in our terminal indicating that nestjs-i18n is watching our locales directory for changes.

NEST server log entries showing [I18nService] output. One entry reads "Checking translation changes" and the other reads "No changes detected".

Let’s use the library to localize something, shall we? We’ll do so in the following section, where we’ll look at injecting the I18nContext and I18nService into our controllers and services.

🗒️ Note » If you want to use Nest’s configuration service to set these config options using .env files, you can do so with I18nModule.forRootAsync. See the nestjs-i18n docs Module setup section for more.

🔗 Resource » Check out the I18nOptions section of the docs for a complete listing of config options.

How do I localize controllers and services?

Let’s utilize the translations we added in the previous section to create a new route, /info/about, which will return the localized name of our app. We’ll need to inject the I18nContext object into our route handler method and decorate it with @I18n.

// src/info/info.controller.ts

  import { Controller, Get } from '@nestjs/common';
+ import { I18n, I18nContext } from 'nestjs-i18n';
  import { InfoService } from './info.service';

  @Controller('info')
  export class InfoController {
    constructor(private readonly infoService: InfoService) {}

    @Get()
    getInfo() {
      return this.infoService.getInfo();
    }

+   @Get('about')
+   getAbout(@I18n() i18n: I18nContext) {
+     return i18n.t('common.about');
+   }
  }

Notice the t() method exposed by I18nContext: It takes a translation key in the file.key format and returns the corresponding translation from the active locale’s file.

.
└── src
    └── locales
        ├── en
        │   └── common<strong>.</strong>json
        │       └── "about": "yogger chicken: a headless running blog"
        └── ar
            └── common.json
                └── "about": "دجاج يوجر: مدونة جري بلا رأس"

Given the above translation structure,i18n.t('common.about') will map to the common.json file’s about translation for the active locale. Let’s see this in action by hitting the /info/about route.

Our REST client making a GET request to the URL /info/about with no query parameters. The response code is 200, and the response body contains "yogger chicken: a headless running blog".

Since we didn’t specify a locale in our request, nestjs-i18n will use the configured fallbackLanguage, which is English (en).

What if we want to set the locale in our request explicitly? Recall that we configured nestjs-i18n to use a QueryResolver, which looks for a lang query param to determine the active locale. All we need to do is provide the /?lang={locale} param when we make a request.

Our REST client making a GET request to the URL /info/about with a query parameter locale=ar. The response code is 200, and the response body contains "دجاج يوجر: مدونة جري بلا رأس".

Hitting /info/about?lang=ar sets the active locale to Arabic (ar).

That’s the basic controller translation flow with nestjs-i18n. Of course, sometimes we need to translate strings inside our services. This can be accomplished by injecting the I18nService into our own.

// src/info/info.service.ts

  import { Injectable } from '@nestjs/common';
+ import { I18nContext, I18nService } from 'nestjs-i18n';

  @Injectable()
  export class InfoService {
+   constructor(private readonly i18n: I18nService) {}

    getInfo() {
      return {
-       about: 'yogger chicken: a headless running blog',
+       about: this.i18n.translate('common.about', {
+         lang: I18nContext.current().lang,
+       }),
        lastUpdated: new Date().toISOString(),
        routes: [
          // ...
        ],
      };
    }
  }

Unlike the I18nContext object, the I18nService instance doesn’t know about the active locale; we need to explicitly provide it with a lang option via I18nContext.current().

🗒️ Note » We can call I18nContext.current().translate(key) instead of i18n.t(key).

A wrapper i18n service

It’s cumbersome to provide an I18nContext whenever we want to localize a controller or service. A simple wrapper service can reduce this friction. Let’s create one quickly.

$ nest generate module yc-i18n
$ nest generate service yc-i18n

We’re using Yc as a namespace here. In the new service, we can wrap the I18nService.translate() function with our own t().

 // src/yc-i18n/yc-i18n.service.ts

import { Injectable } from '@nestjs/common';
import { I18nContext, I18nService } from 'nestjs-i18n';

@Injectable()
export class YcI18nService {
  constructor(private readonly i18n: I18nService) {}

  t(key: string, options?: Record<string, any>) {
    const lang = I18nContext.current().lang;
    return this.i18n.translate(key, { lang, ...options });
  }
}

Let’s make sure the YcI18nModule exports its service and is made global.

// src/yc-i18n/yc-i18n.module.ts

import { Global, Module } from '@nestjs/common';
import { YcI18nService } from './yc-i18n.service';

// Make the module global so that we don't have
// to import it into every other module that needs it.
@Global()
@Module({
  providers: [YcI18nService],
  exports: [YcI18nService], // export the service
})
export class YcI18nModule {}

Now we can conveniently use constructor injection to access our YcI18nService.

// src/info/info.service.ts

  import { Injectable } from '@nestjs/common';
- import { I18nContext, I18nService } from 'nestjs-i18n';
+ import { YcI18nService } from 'src/yc-i18n/yc-i18n.service';

  @Injectable()
  export class InfoService {
-   constructor(private readonly i18n: I18nService) {}
+   constructor(private readonly i18n: YcI18nService) {}

    getInfo() {
      return {
-       about: this.i18n.translate('common.about', {
-         lang: I18nContext.current().lang,
-       }),
+       about: this.i18n.t('common.about'),
        lastUpdated: new Date().toISOString(),
        routes: [
          // ...
        ],
      };
    }
  }

We can do the same thing in our controllers, avoiding method injection and decoration.

// src/info/info.controller.ts

  import { Controller, Get } from '@nestjs/common';
- import { I18n, I18nContext } from 'nestjs-i18n';
+ import { YcI18nService } from 'src/yc-i18n/yc-i18n.service';
  import { InfoService } from './info.service';

  @Controller('info')
  export class InfoController {
   constructor(
     private readonly infoService: InfoService,
+    private readonly i18n: YcI18nService,
   ) {}

    @Get()
    getInfo() {
      return this.infoService.getInfo();
    }

    @Get('about')
-   getAbout(@I18n() i18n: I18nContext) {
+   getAbout() {
-     return i18n.t('common.about');
+     return this.i18n.t('common.about');
    }
  }

This refactor reduces the surface area we need to touch when accessing the i18n service.

How do I load translation files?

nestjs-i18n comes with two built-in loaders, one for JSON files (the default) and one for YAML. We’ll prefer the JSON loader in this article. Switching to the YAML loader is easy, however.

// src/app.module.ts

// ...
import {
  AcceptLanguageResolver,
  HeaderResolver,
  I18nModule,
+ I18nYamlLoader,
  QueryResolver,
} from 'nestjs-i18n';
import * as path from 'path';
// ...

@Module({
  imports: [
    // ...
    I18nModule.forRoot({
      fallbackLanguage: 'en',
+     loader: I18nYamlLoader,
      loaderOptions: {
        path: path.join(__dirname, '/locales/'),
        watch: true,
      },
      resolvers: [new QueryResolver(['lang'])],
    }),
    // ...
  ],
  controllers: [AppController],
  providers: [],
})
export class AppModule {}

Of course, this loader assumes our translation files are in YAML format.

# src/locales/en/common.yml

about: 'yogger chicken: a headless running blog'
# src/locales/ar/common.yml

about: 'دجاج يوجر: مدونة جري بلا رأس'

Again, we’ll stick to JSON in this article, but the choice is yours.

🗒️ Note » Check out the Loaders docs page for more info, including subfolder loading.

🗒️ Note » It seems that you can create custom loaders for nestjs-i18n. This wasn’t documented at the time of writing, however. So you might have to dig through the nestjs-i18n source code to figure out how to roll your own loaders.

How do I resolve the active locale?

For a given request, we need to set one active locale. The process of determining this locale is called locale resolution, and there are multiple strategies we can use to resolve the active locale:

  • Reading the HTTP Accept-Language header in the response is a common strategy when working with web browsers: The Accept-Language header often contains the locales preferred by the user and set in their browser settings.
  • Reading a custom HTTP header, e.g. x-lang, can be handy since it can allow REST clients to configure the header once and reuse it.
  • Using a query param is a flexible and transparent strategy. We’ve already been doing this with our ?lang param.

nestjs-i18n has several of these resolvers built-in. We’ve already used the QueryResovler. Let’s bring in another two and configure our resolvers into a little cascade.

// src/app.module.ts

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import {
+ AcceptLanguageResolver,
+ HeaderResolver,
  I18nModule,
  QueryResolver,
} from 'nestjs-i18n';
import * as path from 'path';
// ...

@Module({
  imports: [
    // ...
    I18nModule.forRoot({
      fallbackLanguage: 'en',
      loaderOptions: {
        path: path.join(__dirname, '/locales/'),
        watch: true,
      },
      resolvers: [
        new QueryResolver(['lang']),
+       AcceptLanguageResolver,
+       new HeaderResolver(['x-lang']),
      ],
    }),
    // ...
  ],
  controllers: [AppController],
  providers: [],
})
export class AppModule {}

With this configuration, nestjs-i18n will go through each resolver in order, stopping if it resolves a locale. Let’s test this by hitting http://localhost:3000?lang=ar

Our REST client making a GET request to the URL /query with query parameter lang=ar. The response code is 200, and the response body contains { "about": "دجاج يوجر: مدونة جري بلا رأس"}.

Since we provided a query param of lang=ar, the QueryResolver will catch the param and resolve the active locale to ar. No other resolver will run.

Next test: don’t provide a query param; provide an Accept-Language HTTP header instead.

Our REST client making a GET request to the URL /language with no query parameters. The request headers include an Accept-Language header set to "ar;q=1.0,en;q=0.9". The response code is 200, and the response body contains { "about": "دجاج يوجر: مدونة جري بلا رأس" }.

In this case, the QueryResolver doesn’t find a lang param, so we cascade down to the AcceptLanguageResolver, which does find our header value of ar;q=1.0,en;q=0.9. This value indicates that ar is preferred over en, so the AcceptLanguageResolver resolves to ar.

🔗 Resource » We go into locale detection and the Accept-Language header in our guide, Detecting a User’s Locale in a Web App.

As you can imagine, if we don’t supply a lang query param or an Accept-Language header but do set a custom x-lang HTTP header, our HeaderResolver will kick in.

"Our REST client making a GET request to the URL / with no query parameters. The request headers include a custom header x-lang set to "ar". The response code is 200, and the response body contains { "about": "دجاج يوجر: مدونة جري بلا رأس" }.

If our cascade falls through entirely, nestjs-i18n will use the fallbackLanguage we set in the config (en in our case).

🗒️ Note » nestjs-i18n resolvers use a best-fit algorithm, such that en, en-US, en_GB, etc. will all resolve to en.

🔗 Resource » If you want granular control over fallback behavior, check out the Fallback languages page of the docs.

🔗 Resource » Check out the Resolvers page of the nestjs-i18n docs for more info, including coverage of the CookieResolver, GraphQLWebsocketResolver, and GrpcMetadataResolver.

How do I redirect while keeping the active locale?

We currently have our / route redirecting to /info.

// src/app.controller.ts

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

@Controller()
export class AppController {
  @Get()
  @Redirect('/info', 301)
  getRoot() {}
}

While this will keep the original HTTP headers when redirecting, it won’t pass along any query parameters. This means the QueryResolver we configured in the previous section won’t get its lang param when redirecting from / to /info.

We can fix this by copying over the query params manually when redirecting.

// src/app.controller.ts

  import {
    Controller,
    Get,
+   Query,
+   Res,
  } from '@nestjs/common';
+ import { Response } from 'express';

  @Controller()
  export class AppController {
    @Get()
    getRoot(
+     @Query() query: Record<string, any>,
+     @Res() res: Response,
    ) {
+     // copy original query params
+     const queryParams = new URLSearchParams(
+       query,
+     ).toString();
+     return res.redirect(302, `/info?${queryParams}`);
    }
  }

Now if we visit http://localhost:300?lang=ar we’ll correctly see the Arabic output from /info.

Our REST client making a GET request to the URL / with a query parameter locale=ar. The response code is 200, and the response body contains { "about": "دجاج يوجر: مدونة جري بلا رأس" }.

🔗 Resource » Grab all the demo code from our GitHub repo.

How do I make my message keys type-safe?

nestjs-i18n is TypeScript-ready by default, but the keys passed into t() or translate() are not. For bullet-proof (or at least type-safe) translation keys, we must opt into the feature.

nestjs-i18n supports type-safe keys by generating the types on-the-fly when we run our app. Let’s configure this.

// src/app.module.ts

// ...

@Module({
  imports: [
    // ...
    I18nModule.forRoot({
      //...
      resolvers: [
        new QueryResolver(['lang']),
        AcceptLanguageResolver,
        new HeaderResolver(['x-lang']),
      ],
+     typesOutputPath: path.join(
+       __dirname,
+       '../src/generated/i18n.generated.ts',
+     ),
    }),
    // ...
  ],
  controllers: [AppController],
  providers: [],
})
export class AppModule {}

When we add the typesOutputPath to nestjs-i18n’s config options, the library will generate a type file at the given path when we run the app.

Let’s add the generated file to eslint ignores to prevent linting errors.

// .eslintrc.js

module.exports = {
  parser: '@typescript-eslint/parser',
  //...
  env: {
    node: true,
    jest: true,
  },
+ ignores: ['src/generated/i18n.generated.ts'],
  ignorePatterns: ['.eslintrc.js'],
  rules: {
    // ...
  },
};

If we run our app now, we should see a file appear inside src/generated

// src/generated/i18n.generated.ts

/* DO NOT EDIT, file generated by nestjs-i18n */

/* eslint-disable */
/* prettier-ignore */
import { Path } from "nestjs-i18n";
/* prettier-ignore */
export type I18nTranslations = {
    "common": {
        "about": string;
    };
};
/* prettier-ignore */
export type I18nPath = Path<I18nTranslations>;

This file will aggregate all of our translation keys across message files, generating counterpart keys under the I18nTranslations type. We can use I18nTranslations along with I18nPath to add stronger typing to our t() method.

// src/yc-i18n/yc-i18n.service.ts

  import { Injectable } from '@nestjs/common';
  import { I18nContext, I18nService } from 'nestjs-i18n';
+ import {
+   I18nPath,
+   I18nTranslations,
+ } from 'src/generated/i18n.generated';

  @Injectable()
  export class YcI18nService {
    constructor(
-     private readonly i18n: I18nService,
+     private readonly i18n: I18nService<I18nTranslations>,
    ) {}

-   t(key: string, options?: Record<string, any>) {
+   t(key: I18nPath, options?: Record<string, any>) {
      const lang = I18nContext.current().lang;
      return this.i18n.translate(key, { lang, ...options });
    }
  }

With that, when we attempt to call t() with a translation key that doesn’t exist in our message files, we’ll get a TypeScript error in our code editor.

A screenshot of a TypeScript error in an IDE. The error message states: 'Argument of type '"foo.bar"' is not assignable to parameter of type 'PathImpl2<I18nTranslations>'. ts(2345)'. The cursor is on the line containing return this.i18n.t('foo.bar'); with a red squiggly underline under 'foo.bar'.

🗒️ Note » Shut down your app before adding a new translation file and run it afterwards. Otherwise, translations in your new file won’t be added to the generated type file.

🔗 Resource » Read the Type Safety guide in the official docs.

How do I translate basic text?

Let’s quickly repeat the basic translation steps to refresh our memories before tackling advanced translation. First, we add the translations to existing or new translation files.

// src/locales/en/routes.json

{
  "posts": {
    "index": "Index of all posts"
  }
}
// src/locales/ar/routes.json

{
  "posts": {
    "index": "فهرس جميع المشاركات"
  }
}

Now we can reference the message using the file.key[.subkey...] format when using the t() function.

i18n.t('routes.posts.index');

// When active locale is `en`
// => 'Index of all posts'

// When active loclae is `ar`
// => 'فهرس جميع المشاركات'

How do I include dynamic values in translations?

We often need to inject values determined at runtime into translation strings. For example, to show the logged-in user’s name in a translated message. We can use the {variable} syntax in translation messages to achieve this.

// src/locales/en/common.json

{
  "greeting": "Hello, {username} 👋",
}
// src/locales/ar/common.json

{
  "greeting": "مرحبا {username} 👋",
}

In our code, we pass in an object as the second argument to t(). The object should house an args object with key/value pairs corresponding to the {variable}s we set in our message.


i18n.t("common.greeting", { args: { username: "Noor" } });

// When active locale is `en`
// => "Hello, Noor 👋"
// When active locale is `ar`
// => "مرحبا Noor 👋"

🔗 Resource » See the nestjs-i18n docs on Formatting for more.

How do I work with localized plurals?

Working with plurals is more than “singular and plural”. Different languages have a varying number of plural forms. English has two official plural forms, “a tree” and “many trees” — called one and other, respectively. Some languages, like Chinese, have one plural form. Arabic and Russian each have six.

🔗 Resource » The Unicode CLDR Language Plural Rules listing is a canonical source for languages’ plural forms.

nestjs-i18n provides good plural support. Let’s localize our app’s /today route to demonstrate.

🗒️ Note » Reminder that you can get the starter demo from the start branch of our GitHub repo.

First, let’s add our translation messages.

// src/locales/en/today.json

{
  "plannedRun": {
    "one": "Running a kilometer today",
    "other": "Running {count} kilometers today",
    "zero": "Resting today"
  }
}

We add the one and other plural forms for English. nestjs-i18n allows an additional zero form. Note the interpolated {count} variable in the other form: It will be replaced with an integer counter at runtime.

🗒️ Note » See the CLDR Language Plural Rules for your language’s plural forms e.g. one, other, etc. The listing places forms under the Category header.

Now for the Arabic message, which has six forms.

// src/locales/ar/today.json

{
  "plannedRun": {
    "zero": "مستريح اليوم",
    "one": "سأجري كيلومتر واحد اليوم",
    "two": "سأجري كيلومترين اليوم",
    "few": "سأجري {count} كيلومترات اليوم",
    "many": "سأجري {count} كيلومتراً اليوم",
    "other": "سأجري {count} كيلومتر اليوم"
  }
}

Now we can call i18n.t() with the parent key and a special count integer argument.

// src/today/today.controller.ts

  import { Controller, Get } from '@nestjs/common';
+ import { YcI18nService } from 'src/yc-i18n/yc-i18n.service';

  @Controller('today')
  export class TodayController {
+   constructor(private readonly i18n: YcI18nService) {}

    @Get()
    getTodayInfo() {
      return {
-       plannedRun: 'Running 4 kilometers today',
+       plannedRun: this.i18n.t('today.plannedRun', {
+         args: { count: 2 },
+       }),
      };
    }
  }

When nestjs-i18n sees the count arg, it determines that the message is a plural, and uses the value of count to determine the correct form for the active locale.

🗒️ Note » The counter arg must be called count.

Our REST client making a GET request to the URL /today with a query parameter locale=en. The response code is 200, and the response body contains { "plannedRun": "Running 2 kilometers today" }.

Our REST client making a GET request to the URL /today with a query parameter locale=ar. The response code is 200, and the response body contains { "plannedRun": "سأجري كيلومترين اليوم" }.

🗒️ Note » If nestjs-i18n doesn’t find an expected plural form in your translation message, a server-side error will be thrown.

🔗 Resource » Check out the Plurals nestjs-i18n documentation for more info.

🔗 Resource » Our quick Guide to Localizing Plurals covers ordinal plurals and other interesting plural-related tidbits.

How do I localize the database?

Our demo app has a blog posts resource with full CRUD and database persistence. However, posts currently presume a single locale. What if we wanted a post to be translated into different languages? There are two main strategies for localizing a database entity:

  • Translation columns on the main table rows e.g. instead of a title column, we have title_en, title_ar, etc.
  • Translations in a separate table: we move all translations into a separate translation table, creating a 1-n relationship between the main entity and its translations.

We’ll explore the simpler translations-columns-on-main-table strategy here. Check out the following resources if you’re interested in the 1-n solution.

🔗 Resource » What’s the Best Database Structure to Keep Multilingual Data? covers both strategies in detail.

🔗 Resource » If you want an example of the n-1 strategy implemented in Nest, we have one in our GitHub repo. Check out the tags module for the example.

Localized columns on the main model

For cases when we have a few columns and a few supported languages, it might make sense to simply put our translated fields directly into the main table. Let’s walk through how to do this for our blog posts.

// src/posts/entities/post.entity.ts

import {
  Column,
  Entity,
  PrimaryGeneratedColumn,
} from 'typeorm';

@Entity()
export class Post {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
- title: string;
+ title_en: string;

+ @Column({ nullable: true })
+ title_ar: string;

  @Column()
- content: string;
+ content_en: string;

+ @Column({ nullable: true })
+ content_ar: string;
}

We assume that English (en) is our source/default locale, and update our entity’s translatable fields with trailing locale suffixes (_en). We also add counterpart fields for each supported locale: title_ar and content_ar for our Arabic post title and body, respectively.

Since English is our source, we’ll always have an English version of a post, with an optional Arabic translation. So we’ve made our Arabic fields nullable. Let’s update our DTO validation to reflect this.

// src/posts/dto/create-post.dto.ts

  import {
    IsNotEmpty,
+   IsOptional,
    IsString,
    Length,
  } from 'class-validator';

  export class CreatePostDto {
    @IsString()
    @IsNotEmpty()
    @Length(1, 255)
-   title: string;
+   title_en: string;

+   @IsOptional()
+   @IsString()
+   @Length(1, 255)
+   title_ar: string;

    @IsString()
    @IsNotEmpty()
-   content: string;
+   content_en: string;

+   @IsOptional()
+   @IsString()
+   @Length(1, 255)
+   content_ar: string;
  }

It would be nice to retrieve post translations corresponding to the active locale. For example, a GET /posts?lang=ar request would return our posts with their Arabic titles, omitting the English ones. To achieve this, we need to update our PostsService.

First, let’s add a handy lang() method to our i18n wrapper service that returns the active locale.

// src/yc-i18n/yc-i18n.service.ts

  import { Injectable } from '@nestjs/common';
  import { I18nContext, I18nService } from 'nestjs-i18n';
  import {
    I18nPath,
    I18nTranslations,
  } from 'src/generated/i18n.generated';

+ export type SupportedLang = 'en' | 'ar';
+ export const defaultLang: SupportedLang = 'en';

  @Injectable()
  export class YcI18nService {
    constructor(
      private readonly i18n: I18nService<I18nTranslations>,
    ) {}

    t(key: I18nPath, options?: Record<string, any>) {
      return this.i18n.translate(key, {
        lang: this.lang(),
        ...options,
      });
    }

+   lang(): SupportedLang {
+     return (I18nContext.current()?.lang ||
+       defaultLang) as SupportedLang;
+   }
  }

We can now use lang() to determine the active locale and return corresponding post titles and content from our PostsService read methods.

// src/posts/posts.service.ts

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { YcI18nService } from 'src/yc-i18n/yc-i18n.service';
import { Repository } from 'typeorm';
import { CreatePostDto } from './dto/create-post.dto';
import { UpdatePostDto } from './dto/update-post.dto';
import { Post } from './entities/post.entity';

export type TranslatedPostSummary = {
  id: number;
  title: string;
};

export type TranslatedPost = {
  id: number;
  title: string;
  content: string;
};

@Injectable()
export class PostsService {
  constructor(
    @InjectRepository(Post)
    private postRepo: Repository<Post>,
    private readonly i18n: YcI18nService,
  ) {}

  async findAll(): Promise<TranslatedPostSummary[]> {
    const lang = this.i18n.lang();

    const post = await this.postRepo.find({
      select: ['id', `title_${lang}`],
    });

    return post.map((p) => ({
      id: p.id,
      title: p[`title_${lang}`],
    }));
  }

  async findOne(
    id: number,
  ): Promise<TranslatedPost | null> {
    const lang = this.i18n.lang();

    const post = await this.postRepo.findOne({
      select: ['id', `title_${lang}`, `content_${lang}`],
      where: { id },
    });

    if (!post) {
      return null;
    }

    return {
      id: post.id,
      title: post[`title_${lang}`],
      content: post[`content_${lang}`],
    };
  }

  // Our create/update/delete methods are
  // unchanged...

  create(createPostDto: CreatePostDto): Promise<Post> {
    return this.postRepo.save({ ...createPostDto });
  }

  async update(
    id: number,
    updatePostDto: UpdatePostDto,
  ): Promise<Post | null> {
    const post = await this.postRepo.findOneBy({ id });

    if (post) {
      return this.postRepo.save({
        ...post,
        ...updatePostDto,
      });
    }

    return null;
  }

  async remove(id: number): Promise<boolean> {
    const post = await this.postRepo.findOneBy({ id });

    if (!post) {
      return false;
    }

    await this.postRepo.remove(post);

    return true;
  }
}

We can create posts by providing title_en and content_en at a minimum.

Our REST client making a POST request to the URL /posts with form-encoded parameters title_en="Why I run" and content_en="There are many reasons...". The response code is 201 Created, and the response body contains { "id": 3, "title_en": "Why I run", "content": "There are many reasons...", title_ar: null, content_ar: null }.

We can PATCH our Arabic translations for a post at any time.

Our REST client making a PATCH request to the URL /posts/3 with form-encoded parameters title_ar="لماذا أركد" and content_ar="هناك العديد من الأسباب". The response code is 200, and the response body contains { "id": 3, "title_en": "Why I run", "content_en": "There are many reasons...", "title_ar": "لماذا أركد", "content_ar": "هناك العديد من الأسباب" }.

When we GET a post, we only see its text for the active locale.

Our REST client making a GET request to the URL /posts/3 with the query parameter lang=en. The response code is 200, and the response body contains { "id": 3, "title": "Why I run", "content": "There are many reasons..."}.

Our REST client making a GET request to the URL /posts/3 with the query parameter lang=ar. The response code is 200, and the response body contains { "id": 3, "title": "لماذا أركد", "content": "هناك العديد من الأسباب..."}.

This data translation strategy is relatively simple and works well for small models with a few supported locales.

🗒️ Note » If you’re working off the start branch in our Git repo, delete your db.sqlite file after making the above changes. In production, we’d use TypeORM migrations for a smooth data update, but for this demo we’re brute-forcing the update, making the new model incompatible with previous data. Deleting the db.sqlite file should cause the app to recreate it with our new schema.

How do I localize DTO validation messages?

nestjs-i18n has built-in support for DTO validation when using the class-validator package. Let’s utilize this to localize our posts’ DTO validation messages.

First, nestjs-i18n gives us a global pipe and filter that we should register in our main.ts.

// src/main.ts

  import { NestFactory } from '@nestjs/core';
+ import {
+   I18nValidationExceptionFilter,
+   I18nValidationPipe,
+ } from 'nestjs-i18n';
  import { AppModule } from './app.module';

  async function bootstrap() {
    const app = await NestFactory.create(AppModule);
+   app.useGlobalPipes(new I18nValidationPipe());
+   app.useGlobalFilters(
+     new I18nValidationExceptionFilter({
+       detailedErrors: true,
+     }),
+   );
    await app.listen(3000);
  }
  bootstrap();

🔗 Resource » See all available options for the I18nValidationExceptionFilter in the I18nValidationExceptionFilterOptions section of the docs.

Now let’s add our validation messages:

// src/locales/en/validation.json

{
  "required": "Please provide a value for `{property}`",
  "length": "`{property}` must be between {constraints.0} and {constraints.1} characters",
  "string": "`{property}` must be a string; you provided {value}"
}
// src/locales/ar/validation.json

{
  "required": "يرجى تقديم قيمة لـ `{property}`",
  "length": "يجب أن يكون عدد أحرف `{property}` بين {constraints.0} و {constraints.1}.",
  "string": "`{property}` يجب أن تكون سلسلة (string) ؛ لقد قدمت {value}"
}

{property}, value, and {constraints.index} will be replaced by the validated property name, given value, and a validation’s constraint values, respectively. We’ll see working in a moment.

🗒️ Note » {constraint.0} and {constraint.1} above are array arguments. Read more about those in the Formatting documentation.

We can now use our validation messages in DTO in two ways:

  • Passing the message key to the validation decorator directly.
  • Calling the i18nValidationMessage function provided by nestjs-i18n; We need to use this version when our messages access validation constraints.
// src/posts/dto/create-post.dto.ts

  import {
    IsNotEmpty,
    IsOptional,
    IsString,
    Length,
  } from 'class-validator';
+ import { i18nValidationMessage } from 'nestjs-i18n';

  export class CreatePostDto {
-   @IsString()
+   @IsString({ message: 'validation.string' })
-   @Length(1, 255)
+   // 1 and 255 are constraints we want passed to
+   // the validation message, so we use
+   // `i18nValidationMessage` here.
+   @Length(1, 255, {
+     message: i18nValidationMessage('validation.length'),
+   })
    title_en: string;

    @IsOptional()
-   @IsString()
+   @IsString({ message: 'validation.string' })
-   @Length(1, 255)
+   @Length(1, 255, {
+     message: i18nValidationMessage('validation.length'),
+   })
    title_ar: string;

-   @IsString()
+   @IsString({ message: 'validation.string' })
-   @IsNotEmpty()
+   @IsNotEmpty({ message: 'validation.required' })
    content_en: string;

    @IsOptional()
-   @IsString()
+   @IsString({ message: 'validation.string' })
-   @Length(1, 255)
+   @Length(1, 255, {
+     message: i18nValidationMessage('validation.length'),
+   })
    content_ar: string;
  }

Now our validation messages will be translated for the active locale in the request.

Our REST client making a POST request to the URL /posts. The request results in a 400 Bad Request status. The response body is a JSON object detailing validation errors. It includes a `statusCode` of 400, a `message` stating 'Bad Request', and an `errors` array with two objects. The first object has a `property` of `title_en`, an empty `target` object, no `children`, and `constraints` including 'isLength': '`title_en` must be between 1 and 255 characters' and 'isString': '`title_en` must be a string; you provided undefined'. The second object has a `property` of `content_en`, an empty `target` object, no `children`, and `constraints` including 'isNotEmpty': 'Please provide a value for `content_en`' and 'isString': '`content_en` must be a string; you provided undefined'.

Our REST client making a POST request to the URL /posts?lang=ar. The request results in a 400 Bad Request status. The response body is a JSON object detailing validation errors in Arabic. It includes a `statusCode` of 400, a `message` stating 'Bad Request', and an `errors` array with two objects. The first object has a `property` of `title_en`, an empty `target` object, no `children`, and `constraints` including 'isLength': 'يجب أن يكون عدد أحرف `title_en` بين 1 و 255.', and 'isString': 'يجب أن تكون سلسلة `title_en` (string); لقد قدمت undefined'. The second object has a `property` of `content_en`, an empty `target` object, no `children`, and `constraints` including 'isNotEmpty': 'يرجى تقديم قيمة ل`content_en`', and 'isString': 'يجب أن تكون سلسلة `content_en` (string); لقد قدمت undefined'.

🔗 Resource » If you want to validate your DTO manually, check out the Manual validation section of the nestjs-i18n docs.

🔗 Resource » Check out all the code for the demo on GitHub.

Upgrade your Nest localization

We hope you’ve found this guide to localizing NestJS with nestjs-i18n library helpful.

When you’re all set to start the translation process, rely on the Phrase Localization Platform to handle the heavy lifting. A specialized software localization platform, the Phrase Localization Platform comes with dozens of tools designed to automate your translation process and native integrations with platforms like GitHub, GitLab, and Bitbucket.

With its user-friendly strings editor, translators can effortlessly access your content and transfer it into your target languages. Once your translations are set, easily integrate them back into your project with a single command or automated sync.

This way, you can stay focused on what you love—your code. Sign up for a free trial and see for yourself why developers appreciate using The Phrase Localization Platform for software localization.

The post A Guide to NestJS Localization appeared first on Phrase.

]]>
Software Localization with ChatGPT https://phrase.com/blog/posts/chatgpt-localization/ Wed, 27 Mar 2024 01:10:44 +0000 https://phrase.com/?p=79623 We build and localize an app using ChatGPT to generate code while observing best prompting practices.

The post Software Localization with ChatGPT appeared first on Phrase.

]]>

ChatGPT famously reached 100 million monthly active users two months after its launch, shattering adoption records. The LLM (Large Language Model) has taken the world by storm and excels at generating certain kinds of content, code being chief among them. However, utilizing the chatbot to extract quality code takes time and effort. In this guide, we walk through localizing a React demo app with the popular i18next library, sitting side-by-side with ChatGPT as we prompt and iterate to get the best code possible.

Versions used

The demo app we’ll build in this guide uses the following NPM packages. We’ll install them as needed (some will be pre-installed in the starter project).

Package Version Notes
Vite 5.1 Build tool and bundler
React 18.2 UI library
React Router 6.22 Used for multi-page routing
Prop Types 15.8 React component props validation
Tailwind CSS 3.4.1 Styling framework
i18next 23.10 Popular i18n library
react-i18next 14.1 React extensions for i18next
i18next-http-backend 2.5 Loads translations from network
i18next-browser-languagedetector 7.2 Detects user’s preferred locales

A note on ChatGPT versions

To generate build plans, design, and code, we’ve used ChatGPT 4. ChatGPT 4 is a paid service at the time of writing, but you can work along with the free Chat GPT 3.5 if you like. However, there are significant differences between GPT 3.5 and 4, so your mileage may vary.

🗒️ Note » We used The Professional Code (Auto programming) custom GPT, which prompts ChatGPT to act like a professional software developer. The custom GPT was taken down as we published the article. You don’t have to use the custom GPT, however. Plain vanilla ChatGPT will work fine for our purposes.

Our demo

We have a little app to localize in this guide. The fictional Migrant Taco is a forum for fans of EDM (electronic dance music).

🗒️ Note » We don’t implement any CRUD functionality since we want to focus on the i18n here.

Our app before localization
Our app before localization

🗒️ Note » We scaffolded the project using the Vite React template, which installed Vite and React.

The starter app (before localization) has the following file structure.

.
├── public
│   └── assets
│       ├── hot-albums.json
│       └── trending-posts.json
└── src
    ├── components
    │   ├── AlbumCard.jsx
    │   ├── Navbar.jsx
    │   └── PostTeaser.jsx
    ├── hooks
    │   ├── use-fetch.js
    │   ├── use-hot-albums.js
    │   └── use-trending-posts.js
    ├── pages
    │   ├── HomePage.jsx
    │   ├── HotAlbums.jsx
    │   └── TrendingPosts.jsx
    ├── App.jsx
    └── main.jsx

🗒️ Note » Even the starter app was created using ChatGPT, but to focus on i18n we’re skipping the steps to build the largely straightforward starter app. ChatGPT is great at building small apps like this and excels at generating common boilerplate.

🔗 Resource »  Get the starter project from GitHub.

Hooks are used to load album and post JSON data from the network. Here are excerpts of the JSON:

// public/assets/hot-albums.json
[
  {
    "id": 1,
    "title": "Euphoric Nights",
    "artist": "DJ Pulse",
    "coverUrl": "https://picsum.photos/id/100/300/300",
    "releaseDate": "2024-02-15"
  },
  {
    "id": 2,
    "title": "Bass in the Shadows",
    "artist": "Bass Master",
    "coverUrl": "https://picsum.photos/id/101/300/300",
    "releaseDate": "2024-03-01"
  },
  // ...
]
// public/assets/trending-posts.json
[
  {
    "id": 1,
    "title": "Top 10 EDM Tracks of 2024",
    "excerpt": "Discover the tracks that are setting the stage on fire in 2024.",
    "author": "DJ Reviewer",
    "date": "2024-02-20"
  },
  {
    "id": 2,
    "title": "The Evolution of EDM Festivals",
    "excerpt": "A deep dive into how EDM festivals have transformed over the years.",
    "author": "Festival Historian",
    "date": "2024-03-05"
  },
  // ...
]

Simple hooks are used to load this JSON:

// src/hooks/use-hot-albums.js

// Handles JSON file fetching and error handling
import useFetch from "./use-fetch";

const useHotAlbums = (limit = null) => {
  const { data, loading, error } = useFetch(
    "/assets/hot-albums.json"
  );
  
  const albums =
    data && limit ? data.slice(0, limit) : data;
    
  return { albums, loading, error };
};

export default useHotAlbums;

A similar hook, useTrendingPosts, is used for trending posts. The useHotAlbums and useTrendingPosts hooks use a useFetch hook under the hood. The latter fetches JSON from the network and has some basic error handling. We’re skipping the code for useFetch here, but you can get it on GitHub.

Three pages are managed by React Router, a home page (screenshot above), a Hot Albums page, and a Trending Posts page. They use our hooks to load the JSON data and present it. Here’s the home page:

// src/pages/HomePage.jsx

import { Link } from "react-router-dom";
import AlbumCard from "../components/AlbumCard";
import PostTeaser from "../components/PostTeaser";
import useHotAlbums from "../hooks/use-hot-albums";
import useTrendingPosts from "../hooks/use-trending-posts";

const HomePage = () => {
  const {
    albums,
    loading: loadingAlbums,
    error: errorAlbums,
  } = useHotAlbums(3);

  const {
    posts,
    loading: loadingPosts,
    error: errorPosts,
  } = useTrendingPosts(4);

  if (loadingAlbums || loadingPosts)
    return <div>Loading...</div>;
    
  if (errorAlbums || errorPosts)
    return <div>Error loading data</div>;

  return (
    <div className="...">
      <section>
        <h2 className="...">
          Hot Albums
        </h2>
        <div className="...">
          {albums.map((album) => (
            <AlbumCard key={album.id} album={album} />
          ))}
        </div>
        <div className="...">
          <Link to="/hot-albums" className="...">
            View all 9 albums
          </Link>
        </div>
      </section>
      <section>
        <h2 className="...">
          Trending Posts
        </h2>
        <div className="...">
          {posts.map((post) => (
            <PostTeaser key={post.id} post={post} />
          ))}
        </div>
        <div className="...">
          <Link to="/trending-posts" className="...">
            View all 9 posts
          </Link>
        </div>
      </section>
    </div>
  );
};

export default HomePage;

The home page uses an AlbumCard component and a PostTeaser component to display its fetched data. These are presentational components, and you can get their code from GitHub.

🗒️ Note » We omit CSS styles here for brevity. These Tailwind styles are available in the full code listings on GitHub.

React Router uses the HomePage and others (all similar to the home page) in the root App component. You can find the code on GitHub.

// src/App.jsx

import {
  BrowserRouter as Router,
  Routes,
  Route,
} from "react-router-dom";
import HomePage from "./pages/HomePage";
import HotAlbums from "./pages/HotAlbums";
import TrendingPosts from "./pages/TrendingPosts";
import Navbar from "./components/Navbar";

const App = () => {
  return (
    <Router>
      <div className="...">
        <Navbar />
        <main className="...">
          <Routes>
            <Route path="/" element={<HomePage />} />
            <Route
              path="/hot-albums"
              element={<HotAlbums />}
            />
            <Route
              path="/trending-posts"
              element={<TrendingPosts />}
            />
          </Routes>
        </main>
      </div>
    </Router>
  );
};

export default App;

OK, this is our starting point. Let’s walk through how we localized it with ChatGPT.

🔗 Resource »  Get the starter project from GitHub.

The build plan

From this point, we will go through our specific experience localizing the demo app with ChatGPT.

🗒️ Note » ChatGPT is non-deterministic: It can give varying responses for the same prompt. We’ll try to focus on broad guidelines that can help you when using ChatGPT to generate software solutions.

If we jumped in head-first and asked ChatGPT to start generating code, we could go down branches of reasoning that miss important high-level information. This can be frustrating and lead to a lot of back-and-forth with the AI as we provide missing gaps in its knowledge about our specific app. It’s wise to do some high-level chain of thought prompting, getting ChatGPT to create a build plan that solves our problem before it generates any code.

The prompt

Here’s our initial prompt:

Hello, I have a small website made with React, Vite,
react-router, and Tailwind CSS. I would like to
internationalize this site and localize it to English
(en) and Arabic (ar), with the option to add more
locales in the future. I want to use the react-i18next
library to do this. What are the steps I should take?
Please outline a general plan. Don't generate any code.
Output the plan in a way that I can easily copy and paste,
please.

Note how we gave ChatGPT a good amount of context. We should be specific when prompting, or the AI will make many assumptions about our app, often derailing our efforts. So we tell which packages we’re using and what we generally want to accomplish. We could have been more specific here and will have to iterate more on the build plan in a moment.

The response

ChatGPT did a good job identifying the steps needed for localizing a React app with i18next. Here’s a summarized excerpt of its response:

To internationalize and localize a React website with
react-i18next for English and Arabic, ensuring future
scalability:

1. Install Dependencies: Include react-i18next,
i18next, and possibly i18next-browser-languagedetector for
automatic language detection.

2. Configuration: Set up a `locales` directory in `src`,
with subfolders for each language (e.g., en, ar),
containing `translation.json` files for key-value pairs.

3. Initialize react-i18next: In `src`, create `i18n.js`
to configure i18next with locale details, a fallback
language, and plugins like the language detector.

4. Integration with React: Use I18nextProvider or
useTranslation hook for translation function access.
Replace strings with translation keys using `t` function,
and change languages with `i18next.changeLanguage`.

5. RTL Layout for Arabic: Utilize Tailwind CSS's RTL
support by adding `dir="rtl"` to the root element for
Arabic.

[//]: # (omitting rest of response for brevity...)

🗒️ Note » We’re showing prompts and response as Markdown blocks here to differentiate them. The ChatGPT UI looks different, of course.

Validation and iteration

We wanted to load our translation files asynchronously from the network using the official i18next plugin (i18next-http-backend). We also wanted to localize our routes. So we told ChatGPT to update its plan:

Let's add the following to the plan:
- Loading translation files asynchronously from
  the network using the official i18next plugin
  to do so
- Localizing routes so that /en/foo loads the
  foo English page and /ar/foo load the same
  page in Arabic (foo is an example route here,
  of course)

Please output the updated plan.

ChatGPT amended the plan appropriately, adding the i18next-http-backend package to the install list in step 1. It also added a step to the build plan for localizing routes:

6. Localizing Routes: Configure react-router for routes
with language codes (e.g., /en/foo), dynamically update
i18next's language on navigation, and adjust UI and
language files accordingly.

Execution

At this point, we felt confident enough to have ChatGPT start generating specific instructions and code:

OK, let's move on to Step 1. Please walk me through
this step and show me the code.

ChatGPT generated NPM install instructions for needed packages, giving NPM and Yarn alternatives for commands. Here’s the NPM version:

npm install react-i18next i18next i18next-http-backend i18next-browser-languagedetector

The AI also explained what each package does. We asked it to continue:

Wonderful :) Let's go through step 2, please. Show me the code.

A generally good plan with code was generated. Here’s a summary:

2. Setting Up Translation Resources:

- Create a `locales` directory in your project's `src`
folder, and inside it, make subdirectories for each
language (e.g., `en`, `ar`).

- In each language subdirectory, add a `translation.json`
file containing translation key-value pairs.

- Use your IDE or file explorer to create and edit these
directories and files, ensuring key consistency across
languages for seamless translation.

ChatGPT gave us a helpful file hierarchy and example translation JSON. However, the AI made a mistake we didn’t immediately catch: It told us to create our locales directory under src. We wanted to load our translations from the network using i18next’s HTTP loader, so locales needed to go under the public directory. We asked ChatGPT to correct this and it generated an updated hierarchy:

Corrected example of the translation file hierarchy.
Corrected example of the translation file hierarchy.
ChatGPT generated helpful example translation files.
ChatGPT generated helpful example translation files.

🗒️ Note » We started prompting ChatGPT assuming we would use en-US and ar-EG for supported locales. We later changed this to en and ar, so if you see any disparity know that we landed on the latter set.

Generated code: setting up i18next

A quick prompt to move to step 3 of the build plan, setting up i18next, got ChatGPT generating its first block of production code.

// src/i18n.js

import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import HttpBackend from "i18next-http-backend";
import LanguageDetector from "i18next-browser-languagedetector";

i18n
  // Load translation using http -> see /public/locales.
  // We will serve our locales directory as a static asset
  .use(HttpBackend)
  // Detect user language
  .use(LanguageDetector)
  // Pass the i18n instance to react-i18next.
  .use(initReactI18next)
  // Initialize i18next
  .init({
    // The lng and fallbackLng options define your
    // application's default language and fallback language
    // in case the user's language can't be detected or
    // isn't supported.
    fallbackLng: "en",
    // if you're using a language detector, you can remove
    // this line
    lng: "en",
    detection: {
      // Order and from where user language should be
      // detected. Here we list possible options for
      // detection.
      order: [
        "querystring",
        "cookie",
        "localStorage",
        "sessionStorage",
        "navigator",
        "htmlTag",
        "path",
        "subdomain",
      ],
      // Keys or params to lookup language from
      lookupQuerystring: "lng",
      lookupCookie: "i18next",
      lookupLocalStorage: "i18nextLng",
      // Cache user language on
      caches: ["localStorage", "cookie"],
      // Exclude cache for specific languages
      // languages to not persist (cookie, localStorage)
      excludeCacheFor: ["cimode"],
    },
    // Path where resources get loaded from, or a function
    // returning a path:
    // function(lngs, namespaces) { return customPath; }
    // The function receives the lngs and namespaces as
    // the arguments (in case you need to load languages
    // and namespaces dynamically).The returned path will
    // interpolate lng, ns if provided like giving a static
    // path.
    backend: {
      loadPath: "/locales/{{lng}}/{{ns}}.json",
    },
    // Have a common namespace used around the full app
    ns: ["translation"],
    defaultNS: "translation",

    // Boolean or function to enable/disable adding keys
    // not found in translation to backend
    saveMissing: true,

    // Set to true if you're using a language detector
    debug: true,

    interpolation: {
      // Not needed for React as it escapes by default
      escapeValue: false,
    },
  });

export default i18n;

Interestingly, this reads like it was taken from the documentation of the various packages we’re using. We cleaned this code up, removing comments and using implicit defaults rather than writing them explicitly. We also removed the saveMissing: true option, since that would cause our back-end to write missing translations to our translation files, something we don’t support in our app and would likely lead to errors.

Here’s our cleaned-up i18n.js:

// src/i18n.js

import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import HttpBackend from "i18next-http-backend";
import LanguageDetector from "i18next-browser-languagedetector";

i18n
  .use(HttpBackend)
  .use(LanguageDetector)
  .use(initReactI18next)
  .init({
    fallbackLng: "en",
    interpolation: {
      escapeValue: false,
    },
  });

export default i18n;

ChatGPT also asked us to import this new file into our entry file, src/main.jsx.

// src/main.jsx

  import React from "react";
  import ReactDOM from "react-dom/client";
  import App from "./App.jsx";
  import "./index.css";
+ import "./i18n.js";

  ReactDOM.createRoot(document.getElementById("root")).render(
    <React.StrictMode>
      <App />
    </React.StrictMode>,
  );

Integrating i18next into components

As part of step 3, ChatGPT inadvertently gave us an example of integrating i18next into our components via the useTranslation hook. It gave us an alternative place to import our i18n.js file, the root App component.

// Example from ChatGPT

import React from 'react';
import './App.css';
import './i18n'; // Import i18n configuration
import { useTranslation } from 'react-i18next';

function App() {
  const { t } = useTranslation();

  return (
    <div className="App">
      <header className="App-header">
        {t('welcomeMessage')}
      </header>
    </div>
  );
}

export default App;

We had already imported i18n.js in main.jsx, and used the example as a guide to localize our Navbar component. We first moved all hard-coded strings in the component to our translation files:

// public/locales/en/translation.json
{
  "appName": "Migrant Taco",
  "nav": {
    "home": "Home",
    "hotAlbums": "Hot Albums",
    "trendingPosts": "Trending Posts"
  }
}
// public/locales/ar/translation.json
{
  "appName": "ميجرانت تاكو",
  "nav": {
    "home": "الرئيسية",
    "hotAlbums": "الألبومات الساخنة",
    "trendingPosts": "المنشورات الرائجة"
  }
}

Afterwards, we replaced the occurrences of these strings with calls to the t() translation function:

// src/components/Navbar.jsx
+ import { useTranslation } from "react-i18next";
  import { Link } from "react-router-dom";

  const Navbar = () => {
+   const { t } = useTranslation();

    return (
      <nav className="...">
        <div className="...">
          <h1 className="...">
-           Migrant Taco
+           {t("appName")}
          </h1>
          <ul className="...">
            <li>
              <Link to="/" className="...">
-               Home
+               {t("nav.home")}
              </Link>
            </li>
+          <!-- Localize other link text... -->
          </ul>
        </div>
      </nav>
    );
  };

  export default Navbar;

After the update, switching our locale to ar revealed our Navbar in Arabic.

English and Arabic versions of our Navbar.
English and Arabic versions of our Navbar.

🗒️ Note » To quickly test a locale when developing, set lng: "ar" in the i18n.init() options (found in the src/i18n.js file). Note that when using the language detector plugin, this will cause the set language to be stored in your local storage to be used in future visits. Deleting the local storage entry should start you fresh when testing.

🔗 Resource »  Get the full code for the Navbar component from GitHub.

The language switcher

Although not part of the build plan, we wanted to add a language switcher UI to our app. Even though the language detector plugin will try to match the user’s preferred locales (set in her browser) to our app’s locales, we often want to provide a manual override for the user.

The prompt

We prompted ChatGPT to generate the code for the switcher:

Please generate the locale switcher. Use a
`<select>` dropdown instead of buttons. Use
Tailwind CSS styles to style the `<select>`. 
Give the component a background of bg-gray-200
and text-slate-900. Keep in mind that I want to
place the localeswitcher into this navbar:

[//]: # (pasted code for Navbar component)

We asked ChatGPT to add Tailwind styles to the switcher since it’s notoriously difficult to style <select> dropdowns across browsers. (It didn’t do a perfect job styling the <select>: the dropdown looks OK on the Chromium-based Arc browser and Firefox but needs work in Safari).

The response

ChatGPT’s first take on creating the locale switcher was inlining it directly in the Navbar. We prompted it to extract the switcher to a component, and got this code back:

// LocaleSwitcher.js
import React from 'react';
import { useTranslation } from 'react-i18next';

const LocaleSwitcher = () => {
  const { i18n } = useTranslation();

  const changeLanguage = (event) => {
    i18n.changeLanguage(event.target.value);
  };

  return (
    <select
      onChange={changeLanguage}
      value={i18n.language}
      className="bg-gray-200 text-slate-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
    >
      <option value="en">English</option>
      <option value="ar">العربية</option>
    </select>
  );
};

export default LocaleSwitcher;

While generally good, the generated code used i18n.language for setting the dropdown’s value, whereas we want i18n.resolvedLanguage here. language will always be the detected user locale whereas resolvedLanguage will be a locale our app supports that’s best-matched to the detected locale. Since the value has to match one of our supported locales (en|ar) to work with the dropdown, we corrected this in the code before we added it to our app:

// src/components/LocaleSwitcher.jsx

  import { useTranslation } from "react-i18next";

  const LocaleSwitcher = () => {
    const { i18n } = useTranslation();

    const changeLanguage = (event) => {
      i18n.changeLanguage(event.target.value);
    };

    return (
      <select
        onChange={changeLanguage}
-       value={i18n.language}
+       value={i18n.resolvedLanguage}
        className="..."
      >
        <option value="en">English</option>
        <option value="ar">العربية</option>
      </select>
    );
  };

  export default LocaleSwitcher;

After placing the LocaleSwitcher into our Navbar, we got an easy UX enhancement to our app.

The locale switcher shown switching between Arabic and English versions of our site.

Interpolation and plurals

Still at step 4, we wanted to handle dynamic values in our translation messages. The “view all” buttons on our home page needed this treatment, as well as plural support.

A button labelled "View all 9 albums."

The prompt

A simple prompt that describes the problem got us started.

I have text in my app that reads "View all
9 albums". The 9 here is a dynamic value
known at runtime. How do I localize this
text?

The response

ChatGPT had a good starting skeleton to solve the problem. Here’s its response, summarized for brevity:

- Add translation keys with placeholders for dynamic values
in your JSON files (e.g., `"viewAllAlbums": "View all
{{count}} albums"`).

- Use the `t` function with interpolation to insert dynamic
values (e.g., `{t('viewAllAlbums', { count: albumCount })}`).

- For pluralization, define singular and plural keys in your
translation files. `react-i18next` automatically selects the
appropriate form based on the count.

Validation and iteration

The AI correctly identified that we’re dealing with a plural value but used i18next’s outdated singular and plural forms. Here are the translation messages it suggested:

{
  "viewAllAlbums": "View all {{count}} album",
  "viewAllAlbums_plural": "View all {{count}} albums"
}

This will work for languages with simple plurals like English, and won’t work with newer versions of i18next that no longer use simple singular/plural messages. ChatGPT identified the former problem noting “that Arabic and other languages have more complex pluralization rules, which i18next supports through its [newer] pluralization feature. You might need to add additional keys for these rules, depending on the language.”

So we immediately asked:

How do I handle Arabic pluralization in
my translations?

ChatGPT responded with the newer type of pluralization, which works for all languages, including Arabic.

{
  "viewAllAlbums_zero": "لم يتم عرض الألبومات",
  "viewAllAlbums_one": "عرض البوم واحد",
  "viewAllAlbums_two": "عرض البومان",
  "viewAllAlbums_few": "عرض {{count}} ألبومات",
  "viewAllAlbums_many": "عرض {{count}} ألبوم",
  "viewAllAlbums_other": "عرض كل {{count}} ألبوم"
}

We added the above JSON to our Arabic translation file, public/locales/en/translation.json, nesting them under the home object. We used the same format for English:

// public/locales/en/translation.json
{
  "appName": "Migrant Taco",
  //...
  "home": {
    "viewAllAlbums_one": "View {{count}} album",
    "viewAllAlbums_other": "View {{count}} albums",
  },
  // ...
}

Of course, we had to update the button text in our HomePage component to use the pluralized translations.

// src/pages/HomePage.jsx

  import { Link } from "react-router-dom";
  // ...
+ import { useTranslation } from "react-i18next";

  const HomePage = () => {
+   const { t } = useTranslation();

    // ...

    return (
      <div className="...">
        <section>
          {/* ... */}
          <div className="...">
            <Link to="/hot-albums" className="...">
-             View all 9 albums
+             {t("home.viewAllAlbums", { count: 9 })}
            </Link>
          </div>
        </section>
        {/* ... */}
      </div>
    );
  };

  export default HomePage;

When we switched our app to Arabic, we could see the correct plural form showing for any count value.

The "view all 9 albums" button translated to Arabic. The "9" is show in Western Arabic numerals.

🔗 Resource »  Our pluralization guide covers the subject in detail.

The translation messages ChatGPT outputted didn’t specify the number format for Arabic, so the Arabic translation above uses the wrong numeral system. We corrected this manually:

// public/locales/ar/translation.json
{
  "appName": "ميجرانت تاكو",
  // ...
  "home": {
    // ...
    "viewAllAlbums_zero": "لا توجد ألبومات",
    "viewAllAlbums_one": "عرض البوم واحد",
    "viewAllAlbums_two": "عرض البومان",
-   "viewAllAlbums_few": "عرض {{count}} ألبومات",
+   "viewAllAlbums_few": "عرض {{count, number}} ألبومات",
-   "viewAllAlbums_many": "عرض {{count}} ألبوم",
+   "viewAllAlbums_many": "عرض {{count, number}} ألبوم",
-   "viewAllAlbums_other": "عرض {{count}} ألبوم",
+   "viewAllAlbums_other": "عرض {{count, number}} ألبوم",
    // ...
  },
  // ...
}

With this change, our Arabic plurals showed the count variable in the correct, Eastern Arabic numeral system.

The "view all albums" button showing its Arabic label with the number "3" as the correct Easter Arabic numeral.

🔗 Resource » Our Concise Guide to Number Localization covers numeral systems, currency formatting, and other number localization goodness.

Localized routes

When we arrived at step 6 of our build plan, localizing routes, we had to iterate quite a bit to get ChatGPT to do exactly what we wanted. The AI did devise a good plan to implement the new feature. In summary:

- Prefix routes with language codes (e.g., `/en/about`).

- Update react-router to capture language codes from URLs and
set the active language in i18next.

- Use a wrapper component or hook to set i18next's language
based on the route's lang parameter.

- Ensure links include the current language code in the URL,
possibly using a helper function or custom link component.

- Provide a method for users to switch languages, updating
the URL and content to the selected language.

Here’s ChatGPT’s first take on route prefixing with react-router:

import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import HomePage from './pages/HomePage';
import AboutPage from './pages/AboutPage';
// Import other components and pages as needed

const App = () => {
  const { i18n } = useTranslation();

  return (
    <Router>
      <Routes>
        <Route path="/:lang" element={<Layout />}>
          <Route index element={<HomePage />} />
          <Route path="about" element={<AboutPage />} />
          // Define other nested routes
        </Route>
      </Routes>
    </Router>
  );
};

export default App;

This was meant as an example, but it assumed we have a Layout component in our app (we don’t). ChatGPT also didn’t factor in initializing the new :lang param if the route didn’t include a locale. We’ll address these two points shortly.

The LocalizedLink component ChatGPT offered as a drop-in replacement for react-router’s built-in Link was exactly what we needed. We used it almost as-is in our app.

// src/components/LocalizedLink.jsx

import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';

const LocalizedLink = ({ to, ...props }) => {
  const { i18n } = useTranslation();
  const lang = i18n.language;

  return <Link to={`/${lang}${to}`} {...props} />;
};

The AI also generated a LocaleWrapper component that was meant to wrap each of our pages:

// src/components/LocaleWrapper.jsx

import { useParams } from 'react-router-dom';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';

const LocaleWrapper = ({ children }) => {
  const { lang } = useParams();
  const { i18n } = useTranslation();

  useEffect(() => {
    if (lang) {
      i18n.changeLanguage(lang);
    }
  }, [lang, i18n]);

  return children;
};

export default LocaleWrapper;

We were confused about using this component in our app and didn’t have enough info to tie the solution together. We prompted further:

- I don't have a `<Layout>` element in my app; I'd like to use
something "blank" here instead
- How does the `:lang` param get initialized on first load?
- How do I use the [LocaleWrapper] component in my app?

Please provide detailed code for the localized routes
solution with accommodation for the above.

At this point, ChatGPT generated a much more robust LocaleWrapper, which checked for supported locales and used a fallback if the lang param was not supported:

// src/components/LocaleWrapper.jsx

import React, { useEffect } from "react";
import { useParams, Navigate } from "react-router-dom";
import { useTranslation } from "react-i18next";

const LocaleWrapper = ({ children }) => {
  const { lang } = useParams();
  const { i18n } = useTranslation();

  useEffect(() => {
    // If there's a lang param and it's different
    // from the current language, update it
    if (lang && i18n.language !== lang) {
      // Extend this list as needed
      const supportedLanguages = ["en", "ar"];
      const fallbackLanguage = "en";
      const newLang = supportedLanguages.includes(lang)
        ? lang
        : fallbackLanguage;
      i18n.changeLanguage(newLang);
    }
  }, [lang, i18n]);

  // Redirect to a default language route if no
  // lang param is present
  if (!lang) {
    const detectedLang = i18n.language || "en";
    return <Navigate to={`/${detectedLang}`} replace />;
  }

  return <>{children}</>;
};

export default LocaleWrapper;

The LLM also introduced a RootRedirect component for us to add to our routes. RootRedirect redirects the root / route to our default locale /en, forcing a locale on all routes.

// src/components/RootRedirect.jsx

import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";

const RootRedirect = () => {
  const navigate = useNavigate();
  const { i18n } = useTranslation();

  useEffect(() => {
    // Determine the default or detected language
    const defaultOrDetectedLang = i18n.language || "en";
    // Redirect to the route with the default or detected language
    navigate(`/${defaultOrDetectedLang}`, {
      replace: true,
    });
  }, [navigate, i18n.language]);

  return null;
};

export default RootRedirect;

We followed ChatGPT’s direction and added the new components to our root App:

// src/App.jsx

import {
  BrowserRouter as Router,
  Routes,
  Route,
} from "react-router-dom";
import HomePage from "./pages/HomePage";
import HotAlbums from "./pages/HotAlbums";
import TrendingPosts from "./pages/TrendingPosts";
import Navbar from "./components/Navbar";
import LocaleWrapper from "./components/LocaleWrapper";
import RootRedirect from "./components/RootRedirect";

const App = () => {
  return (
    <Router>
      <div className="...">
        <Navbar />
        <main className="...">
          <Routes>
           <Route path="/" element={<RootRedirect />} />

           <Route
             path="/:lang"
             element={<LocaleWrapper />}
            >
              <Route index element={<HomePage />} />
              <Route
                path="hot-albums"
                element={<HotAlbums />}
              />
              <Route
                path="trending-posts"
                element={<TrendingPosts />}
              />
            </Route>
          </Routes>
        </main>
      </div>
    </Router>
  );
};

export default App;

At this point, we discovered an error where the LocaleWrapper wasn’t rendering its children. We prompted ChatGPT about this, and it correctly realized that its generated code targeted older versions of react-router. New versions don’t use React children but an Outlet> component instead:

// src/components/LocaleWrapper.jsx

  import { useEffect } from "react";
  import {
    useParams,
    Navigate,
+   Outlet,
  } from "react-router-dom";
  import { useTranslation } from "react-i18next";
  import { supportedLngs, fallbackLng } from "../i18n";

- const LocaleWrapper = ({ children} ) => {
+ const LocaleWrapper = () => {
    const { lang } = useParams();
    const { i18n } = useTranslation();

    useEffect(() => {
      // ...
    }, [lang, i18n]);

-   // While we're at it, let's remove this redirection
-   // logic that ChatGPT generated. Our `<RootRedirect>`
-   // component is already taking care of this. 
-   if (!lang) {
-     const detectedLang = i18n.language || fallbackLng;
-     return <Navigate to={`/${detectedLang}`} replace />;
-   }

-   return <>{children}</>;
+   return <Outlet />;
  };

  export default LocaleWrapper;

This fixed the rendering error.

🗒️ Note » We had to iterate further with ChatGPT to ensure that routes without locales redirect to localized routes e.g. /trending-posts/en/trending-posts. We also wanted to address the 404 Not Found case. ChatGPT was able to help us solve these problems, and you can see the final code in our GitHub repo.

🔗 Resource » We continued with ChatGPT, localizing dates, numbers, and more. See the complete project on GitHub.

🔗 Resource » You can view the entire conversation with ChatGPT on GitHub. Go to the conversation directory and run npx serve .

An animation showing navigation between the different pages and translations of our app.
Our completed app, with Chinese added as well

🔗 Resource » We wanted to focus on working with ChatGPT in this tutorial. We have a complete tutorial for React and i18next if you want to dive deeper.

Key takeaways

Working with ChatGPT to generate code can be both frustrating and rewarding. A few important practices can increase our chances of success:

  • Know your stuff! — Think of ChatGPT as a junior friend with incredibly wide-reaching knowledge. You must know your tech well to guide it to an appropriate solution. Otherwise, you’re at the mercy of whatever it generates, for better or worse.
  • Create a plan (without code) — Before solving a complex problem, prompt ChatGPT to give you a step-by-step plan without any code. This chain of thought prompt will reveal the AI’s approach to your problem early and highlight any misunderstandings that could create problems.
  • Give appropriate context  ChatGPT knows all about React, i18next, and many other popular technologies. However, it doesn’t know your app and how you want it to work. Give the AI enough details to guide it.
  • Verify! — ChatGPT will hallucinate and make mistakes; it doesn’t always know the latest versions of libraries you’re using. Make sure to review and test the code it gives you thoroughly.
  • Iterate — When you don’t get exactly what you want, tell ChatGPT and ask it to help with your specific issues.

Wrapping up

We hope you enjoyed this adventure in coding with an LLM. And we hope you learned a thing or two. Stay tuned for more software localization tutorials. Happy coding.

The post Software Localization with ChatGPT appeared first on Phrase.

]]>
A Guide to Date and Time Localization https://phrase.com/blog/posts/date-time-localization/ Tue, 27 Feb 2024 10:59:52 +0000 https://phrase.com/?p=76145 We shed light on the intricacies of global dates and times, covering formatting, time zones, regional calendars, and more.

The post A Guide to Date and Time Localization appeared first on Phrase.

]]>

We often think of internationalization (i18n) and localization (l10n) as translating text, ignoring all-too-important i18n aspects like date and time localization. Yet dates and times are represented in very different ways across geographies, and localizing them correctly is crucial to making users feel at home in our apps. This guide aims to shed some light on the intricacies of global dates and times. We’ll cover formatting, working with time zones, understanding regional calendars, and taking a look at date and time picker UI.

🗒️ While the examples in this guide target the browser and are written in JavaScript, the guide is intentionally tech-agnostic — there should be an equivalent to each concept in your platform and programming language of choice.

🗒️ Internationalization (i18n) and localization (l10n) allow us to make our apps available in different languages and to different regions, often for more profit. If you’re new to i18n and l10n, check out our guide to internationalization.

Formatting dates and times

How do we present dates and times to someone depending on where they hail from the world? We have to answer the second question first and determine the user’s locale. This will often be their system locale: If a user sets her operating system language to French and region to Canada, the default locale of an app presented to this user is likely fr-CA (French Canada).

🗒️ A locale defines a language, a region, and sometimes more. Locales typically use IETF BCP 47 language tags, like en for English, fr for French, and es for Spanish. Adding a region with the ISO Alpha-2 code (e.g., BH for Bahrain, CN for China, US for the United States) is recommended for accurate date and number localization. So a complete locale might look like en-US for American English or zh-CN for Chinese as used in China.

🔗 Explore more language tags on Wikipedia and find country codes through the ISO’s search tool.

🗒️ Date and time formats are region-specific, so it’s important to use qualified locales when formatting dates e.g. use en-US, not en.

With our essential background in place, the straightforward approach to formatting localized dates and times involves creating a date object and then utilizing a native date formatting library. Let’s take a look at an example in the browser using the native JavaScript Intl.DateTimeFormat formatter.

const date = new Date("2024-01-27T14:00:00Z");
const formatter = new Intl.DateTimeFormat();

formatter.format(date);
// => 1/27/2024

Our date represents 2024-01-27 2:00 PM in UTC (more on time zones a bit later). We create an Intl.DateTimeFormat without specifying any options, which means we want default date formatting for the default locale.

The format() call spits out “1/27/2024” when en-US (English America) is determined to be the default locale, respecting the American default date format of Month/Day/Year.

On my machine, however, the same code outputs “2024-01-27”. This is because my operating system is set to en-CA (English Canada), and Year-Month-Day is the default Canadian date format.

🗒️ Intl.DateTimeFormat is available natively in all modern browsers and Node.js.

Default formats

When we don’t provide specific formatting options to a date formatting library, we often get the default format for a given locale. Here are some default date formats for various locales:

const date = new Date("2024-01-27T14:00:00Z");

// We use `date.toLocaleDateString("en-US")` for
// the examples below. This is a shortcut for
// `new Intl.DateTimeFormat("en-US").format(date)`
// See 🗒️ below for more info.

// English-America
date.toLocaleDateString("en-US");
// => "1/27/2024"

// Arabic-Egypt
date.toLocaleDateString("ar-EG");
// => "٢٧‏/١‏/٢٠٢٤"

// Hindi-India
date.toLocaleDateString("hi-IN");
// => "27/1/2024"

// Russian-Russia
date.toLocaleDateString("ru-RU");
// => "27.01.2024"

// Chinese-China
date.toLocaleDateString("zh-CN");
// => "2024/1/27"

// Japanese-Japan
date.toLocaleDateString("jp-JP");
// => "2024-01-27"

🗒️ Date.toLocaleDateString() uses Intl.DateTimeFormat under the hood when it’s available. Read more on the Date MDN entry.

“What about times?” you might be asking. Of course, we can often use native libraries to format localized times as well. Here are some default time formats:

// Date object with time of 2 PM UTC
const date = new Date("2024-01-27T14:00:00Z");

// The below calls to
// `date.ToLocaleTimeString(...)` are equivalent to
// `new Intl.DateTimeFormat("en-US", {
//    timeZone: "UTC",
//    timeStyle: "medium",
//  }).format(date)`
// See 🗒️ below for more info.

// English-America
date.toLocaleTimeString("en-US", { timeZone: "UTC" });
// => "2:00:00 PM"

// Arabic-Egypt
date.toLocaleTimeString("ar-EG", { timeZone: "UTC" });
// => "٢:٠٠:٠٠ م"

// Hindi-India
date.toLocaleTimeString("hi-IN", { timeZone: "UTC" });
// => "2:00:00 pm"

// Russian-Russia
date.toLocaleTimeString("ru-RU", { timeZone: "UTC" });
// => "14:00:00"

// Chinese-China
date.toLocaleTimeString("zh-CN", { timeZone: "UTC" });
// => "14:00:00"

// Japanese-Japan
date.toLocaleTimeString("jp-JP", { timeZone: "UTC" });
// => "2:00:00 p.m."

🗒️ Just like Date.toLocaleDateString(), Date.toLocaleTimeString() uses Intl.DateTimeFormat under the hood when it’s available. Read more on the Date MDN entry.

Note that in the above examples, we set the time zone to UTC using the timeZone option. This is to maintain a consistent output for demonstration purposes. If we omit the option, the system time zone will be used. I’m currently in the UTC +1 time zone, so the en-US example would output the following on my machine: “3:00:00 PM”.

🗒️ Again, we’ll tackle time zones in a bit more detail a bit later.

Preset formats

Beyond default formats, we often want to control the length of the date and time output. We can achieve this with preset formats that many native formatting libraries offer. In the JavaScript Intl.DateTimeFormat options, these are dateStyle and timeStyle. If you’re working on a different platform, there are likely equivalents in that platform’s formatting libraries.

  • dateStyle — can be "full", "long", "medium", or "short”.
  • timeStyle — can be "full", "long", "medium", or "short”.

Let’s see what these look like in action:

const date = new Date("2024-01-27T14:00:00Z");

const shortOptions = {
  timeZone: "UTC",
  dateStyle: "short",
  timeStyle: "short",
};

// English-America
new Intl.DateTimeFormat("en-US", shortOptions)
  .format(date);
// => "1/27/24, 2:00 PM"

// Arabic-Egypt
new Intl.DateTimeFormat("ar-EG", shortOptions)
  .format(date);
// => "٢٧‏/١‏/٢٠٢٤، ٢:٠٠ م"

// Chinese-China
new Intl.DateTimeFormat("zh-CN", shortOptions)
  .format(date);
// => "2024/1/27 14:00"

const fullOptions = {
  timeZone: "UTC",
  dateStyle: "full",
  timeStyle: "full",
};

// English-America
new Intl.DateTimeFormat("en-US", fullOptions)
  .format(date);
// => "Saturday, January 27, 2024 at 2:00:00 PM Coordinated Universal Time"

// Arabic-Egypt
new Intl.DateTimeFormat("ar-EG", fullOptions)
  .format(date);
// => "السبت، ٢٧ يناير ٢٠٢٤ في ٢:٠٠:٠٠ م التوقيت العالمي المنسق"

// Chinese-China
new Intl.DateTimeFormat("zh-CN", fullOptions).format(date);
// => "2024年1月27日星期六 协调世界时 14:00:00"

Preset options often conform to the conventions of the given locale. We can just set the length of the format we want and rely on the formatting library to handle the per-locale details.

Custom formats

Complete control over our date and time formatting is achieved via granular format specifiers. For example, we might want to show only the year of a given date or force 24-hour time representation. Any date formatting library worth its salt will allow us this level of customization. Here are examples using JavaScript’s Intl.DateTimeFormat:

const date = new Date("2024-01-27T14:00:00Z");

const options = {
  timeZone: "UTC",
  timeZoneName: "short",
  year: "2-digit",
  month: "narrow",
  weekday: "short",
  day: "numeric",
  hourCycle: "h24",
  hour: "numeric",
  minute: "numeric",
};

// English-America
date.toLocaleDateString("en-US", options);
// => "Sat, J 27, 24, 14:00 UTC"

// Arabic-Egypt
date.toLocaleDateString("ar-EG", options);
// => "السبت، ٢٧ ي ٢٤، ١٤:٠٠ UTC"

// Hindi-India
date.toLocaleDateString("hi-IN", options);
// => "शनि, 27 ज 24, 14:00 UTC"

// Russian-Russia
date.toLocaleDateString("ru-RU", options);
// => "сб, 27 Я 24 г., 14:00 UTC"

// Chinese-China
date.toLocaleDateString("zh-CN", options);
// => "24年1月27日周六 UTC 14:00"

// Japanese-Japan
date.toLocaleDateString("jp-JP", options);
// => "Sat, J 27, 24, 14:00 UTC"

🔗 Check out all the formatting options provided by Intl.DateTimeFormat on the MDN documentation.

🗒️ You may have noticed that localizing dates and times often relies on local number systems. The previous Arabic example uses both Eastern Arabic numerals and Arabic script, “السبت، ٢٧ ي ٢٤، ١٤:٠٠ UTC”. Modern systems and their formatting libraries often take care of all of this for us, but it’s good to bear in mind.

🔗 Our Concise Guide to Number Localization covers numeral systems, currency, and more.

🗒️ To represent written days of the week, months, etc. we of course have to use the language and script of the target locale. Again, something to be mindful of.

Time zones

We usually stick to UTC or the server’s time zone for keeping track of times on the back end. We can then convert the date during formatting to match the user’s local time zone on the front end. The key here is knowing the source and destination time zones so we can nail the conversion.

🗒️ Heads up if you’re working with full-stack frameworks like Next.js or SvelteKit — mismatched time zones between server-side rendering and client-side can lead to those pesky hydration errors. One fix is to keep your time zone consistent on both the server and client side by setting it once in a shared config.

When working with time zones, we often use a set of standards, primarily UTC, IANA, and ISO 8601. Let’s go over these briefly.

UTC

UTC (Coordinated Universal Time) is the modern standard for global civil time, refining GMT (Greenwich Mean Time) with atomic clock precision alongside Earth’s rotation. Time zones are represented as offsets from UTC. For example, UTC-05:00 is equivalent to Eastern Standard Time (EST).

🔗 Dive deeper into UTC’s specifics on its Wikipedia page.

IANA

The Internet Assigned Numbers Authority (IANA) handles crucial Internet roles like IP addressing, DNS management, and protocol IDs to keep the net running smoothly. Its Time Zone Database tracks global time zones, including daylight saving changes and UTC offsets, updated for political shifts. Time zones use an Area/Location format, like America/New_York, and have abbreviations such as EST (Eastern Standard Time).

🔗 Dive into the details on the tz database’s Wikipedia page.

🔗 The Wikipedia List of tz database time zones is an excellent lookup.

ISO 8601

We’ve been using the ISO 8601 format for dates, like 2024-02-23T15:45:00Z, which includes the date followed by T and the time in 24-hour format. Z indicates UTC, but you can specify other time zones with an offset, like +03:00 e.g. 2024-02-23T15:45:00+03:00.

🗒️ ISO 8601 is a common standard, but your source date can be formatted in a variety of ways. Make sure you know the format when parsing.

🔗 Learn more on Wikipedia’s ISO 8601 page.

Code examples

Let’s see how these standards work together when localizing dates and times. Here are examples in JavaScript using Intl.DateTimeFormat:

// Note how the `Date` object knows how to parse
// the ISO 8601 format. The following datetime
// includes 3:45 PM in the UTC +01:00 time
// zone.
const date = new Date("2024-02-27T15:45:00+01:00");

date.toLocaleString("en-US", {
  // Convert to target IANA `timeZone` when
  // formatting.
  timeZone: "America/New_York",
  dateStyle: "medium",
  timeStyle: "full",
});
// => "Feb 27, 2024, 9:45:00 AM Eastern Standard Time"

date.toLocaleString("en-US", {
  // Use the abbreviated time zone; this 
  // affects conversion as well as formatting. 
  timeZone: "EST",
  dateStyle: "medium",
  timeStyle: "full",
});
// => "Feb 27, 2024, 9:45:00 AM GMT-05:00"

// Format in Hindi-India and convert to 
// the Indian time zone.
date.toLocaleString("hi-IN", {
  timeZone: "Asia/Kolkata",
  dateStyle: "medium",
  timeStyle: "full",
});
// => "27 फ़र॰ 2024, 8:15:00 pm भारतीय मानक समय"

// Chinese-China in the Shanghai time zone.
date.toLocaleString("zh-CH", {
  timeZone: "Asia/Shanghai",
  dateStyle: "medium",
  timeStyle: "full",
})
// => "2024年2月27日 中国标准时间 22:45:00"

Calendars

While the Gregorian calendar — Jan, Feb, etc. —  is ubiquitous, there are many regional and cultural calendars used all over the world. This is important when localizing, since some calendars, like the Islamic, Persian, and Buddhist calendars are used on a day-to-day basis. Indeed, certain regions use a non-Gregorian calendar as the default.

Some of these calendars serve cultural purposes. Some are solar, others are lunar, and each has its starting year.

A listing of months in the Gregorian calendar. Source: Wikipedia.
A listing of months in the Gregorian calendar. Source: Wikipedia.
A listing of months in the Buddhist calendar. Source: Wikipedia.
A listing of months in the Buddhist calendar. Source: Wikipedia.

Here are some examples of regions that use non-Gregorian calendars as their default:

const date = new Date("2024-01-27T14:00:00Z");

// Arabic Saudi-Arabia;
// converts to the Islamic Hijri calendar.
date.toLocaleDateString("ar-SA");
// => "١٥‏/٧‏/١٤٤٥ هـ"

// We can provide the calendar as a
// manual override formatting option.
date.toLocaleDateString("en-US", { calendar: "islamic" })
// => "7/16/1445 AH"

// Persian-Iran;
// converts to the Persian calendar.
date.toLocaleDateString("fa-IR");
// => "۱۴۰۲/۱۱/۷"

date.toLocaleDateString("en-US", { calendar: "persian" });
// => "11/7/1402 AP"

// Thai-Thailand;
// converts to the Buddhist calendar.
date.toLocaleDateString("th-TH");
// => "27/1/2567"

Date and time pickers

We’ve been looking at formatting output so far. Let’s switch to input. To make our users’ lives easier, we often present them with a date picker when ask them for date input. On the web, we can use the date, time and datetime-local inputs.

<input type="date">

 

The Firefox browser date picker.
The Firefox browser date picker.

🔗 Learn more from the MDN date input entry.

<input type="time">
The Arc (Chromium-based) browser time picker.
The Arc (Chromium-based) browser time picker.

🔗 Learn more from the MDN time input entry.

To combine both date and time, can use the the datetime-local input.

<input type="datetime-local">

 

The Arc browser datetime-local picker.
The Arc browser datetime-local picker.

🔗 Learn more from the MDN datetime-local input entry.

Unfortunately, the native browser pickers seem stuck on Gregorian calendars, with no manual or automatic localization switching to other calendars. To get truly localizable date pickers in our web apps, we have to roll our own or use a library like react-dates (for React).

The react-dates date picker showing the Persian calendar.
The react-dates date picker showing the Persian calendar.

 

If you’re working on an operating system platform, like Android or iOS, you’ll find their native date pickers automatically localize their numeral systems, text labels, and layout direction per the user’s language. They might still largely stick to the Gregorian calendar, however.

The native Android date picker localized for Arabic users.
The native Android date picker localized for Arabic users.
The native Android time picker localized for Arabic users.
The native Android time picker localized for Arabic users.

🔗 How to Localize Date and Time Formats in Android is a deep dive into the subject.

Wrapping up: Key takeaways in date and time localization

As we conclude our exploration of localizing dates and times, we hope that you can see that this aspect of localization is more than a mere afterthought — it’s a cornerstone of creating truly global and inclusive applications.

Here are the key takeaways from this guide:

  • Determine the user’s locale: Identifying the user’s locale is the first step in presenting dates and times that reflect their cultural and regional standards.
  • Understand locale definitions: Recognize that a locale encompasses language, region, and sometimes additional nuances, influencing how dates and times should be formatted.
  • Use native formatting libraries: Leverage native date formatting libraries, which automatically adjust to the user’s locale.
  • Utilize default and custom formats: Whether adopting default formats or customizing them for complete control, be mindful of the locale’s conventions for representing dates and times (again libraries help a lot here).
  • Account for time zones: Ensure that dates and times are correctly adjusted for the user’s location.
  • Explore calendar diversity: Study the regions where you’re serving your app and see if they use local non-Gregorian calendars.
  • Prefer accessible date and time pickers: Choose date and time pickers that support localization, at least showing numeral systems and writing in your user’s language and region. Ideally, show them their local default calendar.

Alright, we hope you’ve enjoyed this guide to date and time localization and learned a thing or two. Happy coding.

The post A Guide to Date and Time Localization appeared first on Phrase.

]]>
Next.js Localization with Format.JS/react-intl https://phrase.com/blog/posts/next-js-l10n-format-js-react-intl/ Mon, 11 Dec 2023 12:03:18 +0000 https://phrase.com/?p=70452 Explore the ins and outs of localizing Next.js Pages Router apps with react-intl/Format.JS.

The post Next.js Localization with Format.JS/react-intl appeared first on Phrase.

]]>

Next.js Pages Router localization has become a streamlined feature of the leading full-stack React framework since the advent of Next.js 10, which brought us helpful localized routing capabilities.

Adding the robust react-intl/Format.JS i18n library takes this a step further, offering a comprehensive solution that includes production-grade translation message management, along with refined date and number formatting.

What sets react-intl/Format.JS apart from other i18n libraries is its advanced translation message extraction and compilation, allowing our apps to scale gracefully and efficiently. In this tutorial, we explore the ins and outs of localizing Next.js Pages Router apps with react-intl/Format.JS.

String Management UI visual | Phrase

Phrase Strings

Take your web or mobile app global without any hassle

Adapt your software, website, or video game for global audiences with the leanest and most realiable software localization platform.

Explore Phrase Strings

💡 Internationalization (i18n) and localization (l10n) allow us to make our apps available in different languages and to different regions, often for more profit. If you’re new to i18n and l10n, check out our guide to internationalization.

🗒️ Format.JS is a set of ICU-compliant JavaScript i18n libraries. react-intl extends Format.JS with handy React components and hooks. In this article, where we focus on React/Next.js, we will use the terms “react-intl” and “Format.JS” interchangeably.

🔗 This article is focused on the Next.js Pages Router. If you’re working with the App Router, check out our Deep Dive into Next.js App Router Localization with next-intl—and if you prefer working with the Pages Router and the popular i18next library, our Step-by-Step Guide to Next.js Internationalization has you covered.

Our demo app

We will use a simple blog to demo the i18n in this guide. Here’s what the app looks like before localization.

The blog post index page | Phrase

Single post page | Phrase

📣 Shoutout » Thanks to Scotty G for creating the fun Star Wars lorem ipsum generator, Forcem Ipsum, which we’ve used for our mock content.

After spinning up a Next.js app with the Pages Router, TypeScript, and Tailwind, we created/modified the following files to build the demo:

.
└── pages
    ├── components
    │   └── Layout.tsx  # Wraps our pages
    ├── data
    │   └── posts.ts    # Mock blog post data
    ├── posts
    │   ├── [slug].tsx  # Single blog post
    │   └── index.tsx   # Blog post listing
    └── index.tsx       # Home page

It’s all bread-and-butter Next.js, and we will get into the details of these files as we internationalize. Let’s get to it.

Package versions used

Here are the relevant NPM packages we’ve used in this tutorial.

Package Version Notes
typescript 5.2.
next 14,9
react 18.2
react-intl 6.5 Used for i18n
@formatjs/cli 6.2 Used for message extraction and compilation
babel-plugin-formatjs 10.5 Used for automatic message ID injection
nookies 2.5 Used for setting the Next.js locale cookie
accept-language-parser 1.5 Used for custom locale auto-detection
rtl-detect 1.1 Used for right-to-left (rtl) locale detection
tailwindcss 3.3 Used for styling (and out of the scope of this tutorial)

How do I localize my Pages Router app with react-intl?

Next.js Pages Router apps need to manage server-side rendering (SSR) using getStaticProps() and static site generation (SSG) with getStaticPaths(). With that in mind, here are the basic steps to localizing a Next.js Pages router with react-intl:

  1. Configure built-in Next.js i18n routing.
  2. Install and configure react-intl.
  3. Localize page/component strings using react-intl.
  4. Extract translation messages from our codebase using the Format.JS CLI.
  5. Translate messages into supported languages.
  6. Load translation messages on the server using getStaticProps().
  7. Format dates and numbers using react-intl.

In the following sections, we will cover these steps and more, including compiling messages for production, writing custom middleware for locale auto-detection, and adding a language selector for our site visitors.

Let’s work through in one step at a time.

How do I configure the built-in Next.js i18n routing?

Recent versions of Next.js come with localized routing out-of-the-box. This automatically adds our supported locales to routes, e.g. /fr/posts for our posts page in French.

Let’s see how this works by adding it to our demo. We will support English (USA) and Arabic (Egypt) here. Feel free to use any locales you want.

A note on locales

Locales typically use IETF BCP 47 language tags, like en for English, fr for French, and es for Spanish. Adding a region with the ISO Alpha-2 code (e.g., BH for Bahrain, CN for China, and US for the United States) is recommended for accurate date and number localization. So a complete locale might look like en-US for American English or zh-CN for Chinese in China.

🔗 Explore more language tags on Wikipedia and find country codes through the ISO’s search tool.

To enable localized routing, we need to add an i18n section to our next.config.js:

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
+ i18n: {
+   locales: ["en-US", "ar-EG"], // required
+   defaultLocale: "en-US",      // required
+ },
};

module.exports = nextConfig;

✋ Both locales and defaultLocale are required in Next.js. If either is missing or invalid, Next.js will trigger an error during development server runs and production builds.

Just with that, Next.js creates an /ar-EG prefix for all our routes. We started with these routes in our app:

Route Description
/ home page
/posts blog post index
/posts/foo-bar single blog post

After adding the above i18n config, we now have the following routes:

Route Active locale Description
/ English (en-US) home page
/ar-EG Arabic (ar-EG) home page
/posts English (en-US) blog post index
/ar-EG/posts Arabic (ar-EG) blog post index
/posts/foo-bar English (en-US) single blog post
/ar-EG/posts/foo-bar Arabic (ar-EG) single blog post

For every locale included in the locales array of our config, a corresponding route prefix is created. The default route (en-US in our case) doesn’t receive any prefix.

🗒️ It’s worth noting, though, that the Next.js documentation offers a workaround for redirecting unprefixed URLs, e.g. /, to prefixed URLs, such as /en-US. This approach, however, comes with its own set of challenges, such as the need to juggle a placeholder default locale in the supported locales list, and potential errors during production builds when Next.js attempts to build pages with this placeholder default locale.

Moving on, Next.js also provides access to the active locale through its router object. Let’s make use of this in our single post page to show the translated blog post.

Our hard-coded mock data is already localized:

// data/posts.ts

import type { Post } from "@/types";

export const posts: Post[] = [
  {
    slug: "marching-into-detention-area",
    date: "2023-11-10",
    translations: {
      "en-US": {
         title: "Marching into the...",
         content: "What an incredible...",
      },
      "ar-EG": {
         title: "إن الزحف إلى منطقة الاحتجاز لم...",
         content: "يا لها من رائحة مذهلة اكتشفتها...",
      },
    },
  },
  // ...
]

Using the locale route param from Next.js, we can refine this data and show the appropriate content for the active locale.

// pages/posts/[slug].tsx

import Layout from "@/components/Layout";
import { posts } from "@/data/posts";
import type { Post } from "@/types";
import type {
  GetStaticPaths,
  GetStaticProps,
  GetStaticPropsContext,
} from "next";
import Link from "next/link";
import { useRouter } from "next/router";

type SinglePostProps = {
  post: Post;
};

export const getStaticPaths: GetStaticPaths<{
  slug: string;
}> = () => {
  return {
    paths: posts.map((post) => ({
      params: { slug: post.slug },
    })),
    fallback: true,
  };
};

export const getStaticProps = (async ({
  params,
}: GetStaticPropsContext) => {
  const post = posts.find(
    (post) => post.slug === params?.slug,
  );

  if (!post) return { notFound: true };

  return { props: { post } };
}) satisfies GetStaticProps<SinglePostProps>;

export default function SinglePost({
  post,
}: SinglePostProps) {
  const router = useRouter();

+ const locale = router.locale!;
+ // => "en-US" when route is /posts/foo
+ // => "ar-EG" when route is /ar-EG/posts/foo

  if (router.isFallback) {
    return <div>Loading...</div>;
  }

  return (
    <Layout>
      <!-- ... -->

      <!-- We previously hard-coded the locale -->
-     <h1 className="...">{post.translations["en-US"].title}</h1>
+     <h1 className="...">{post.translations[locale].title}</h1>
      <p className="...">{post.date}</p>
      <div className="...">
-        <p>{post.translations["en-US"].content}</p>
+        <p>{post.translations[locale].content}</p>
      </div>
    </Layout>
  );
}

With that, we’re showing the post translated to the active locale.

When the route default to English | Phrase

The route is Arabic | Phrase

On localized dynamic routes and getStaticPaths()

For dynamic routes like [slug].tsx, when using fallback: false to generate all locale variants during the build, we need to include all locales in getStaticPaths():

 

// pages/_app.tsx

- import arMessages from "@/lang/compiled/ar-EG.json";
- import enMessages from "@/lang/compiled/en-US.json";
  import "@/styles/globals.css";
  import type { AppProps } from "next/app";
  import { useRouter } from "next/router";
  import { useEffect } from "react";
  import { IntlProvider } from "react-intl";

- const messages = {
-   "en-US": { ...enMessages },
-   "ar-EG": { ...arMessages },
- };

  export default function App({ Component, pageProps }: AppProps) {
    const { locale, defaultLocale } = useRouter();

    return (
      <IntlProvider
        locale={locale!}
        defaultLocale={defaultLocale!}
-       messages={messages[locale as keyof typeof messages]}
+       messages={pageProps.localeMessages}
      >
        <Component {...pageProps} />
      </IntlProvider>
    );
  }
export const getStaticPaths: GetStaticPaths = ({
  locales,
}: GetStaticPathsContext) => {
  return {
    paths: locales!.flatMap((locale) =>
      posts.map((post) => ({
        params: { slug: post.slug },
        locale,
      })),
    ),
    fallback: false,
  };
};

Here, locales refers to the array defined in next.config.js. However, this approach can slow down our production build due to the large number of page variants (equal to the number of slugs times the number of locales).

As an alternative, omitting locale only builds variants for the default locale, with fallback: true dynamically generating other locale variants on route visits.

🔗 Learn more in the official doc, Dynamic Routes and getStaticProps Pages.

✋ Be aware that localized routes do not work with pure static site generation (SSG), i.e., output: "export".

Localized links

Next.js automatically localizes <Link> elements. For example, <Link href="/about"> will actually point to /ar-EG/about when the active locale is ar-EG. Additionally, you can switch locales by using the locale prop on <Link>. (We will discuss locale switching using the Next.js router a bit later).

Automatic locale detection

Next.js automatically detects a user’s locale when they visit the home route / by using the HTTP Accept-Language header, which reflects the browser and operating system language settings.

For instance, if a user with Arabic (Egypt) (ar-EG) as their top browser language preference visits /, they will get redirected to /ar-EG. However, this detection isn’t loosely matched; a user with Arabic (Syria) (ar-SY) won’t be redirected to /ar-EG, despite Syrians and Egyptians sharing the same written Arabic. We will address this when we tackle custom locale detection later.

🤿 Go deeper **with Detecting a User’s Locale in a Web App.

Localized routes alone don’t fully internationalize an app; managing UI translations, dates, and numbers is also crucial. This is where react-intl comes in.

How do I install and configure react-intl in my Next.js Pages Router app?

Installing react-intl is easy enough.

npm install react-intl

To make our active locale and translation messages available to our pages and components, we need to wrap them with react-intl’s <IntlProvider>.

// pages/_app.tsx

import "@/styles/globals.css";
import type { AppProps } from "next/app";

// Import IntlProvider
import { IntlProvider } from "react-intl";

// Add translations
const messages = {
  "en-US": {
    hello: "Hello, World!",
  },
  "ar-EG": {
    hello: "مرحباً بالعالم!",
  },
};

export default function App({ Component, pageProps }: AppProps) {
  const { locale, defaultLocale } = useRouter();

  return (
    // Wrap page component with IntlProvider
    <IntlProvider
      locale={locale!}
      defaultLocale={defaultLocale!}
      messages={messages[locale as keyof typeof messages]}
    >
      <Component {...pageProps} />
    </IntlProvider>
  );
}

When configuring <IntlProvider>:

  • locale is used for date and number formatting.
  • defaultLocale is used for fallback when a message isn’t provided in the active locale (this is only for date and number formatting consistency; in fact, each message will provide its own default fallback).
  • messages are the translation messages for the active locale (it’s up to the developer to ensure this is the case).

OK, let’s take react-intl for a test run to see if it’s working.

Our home page has a heading we can replace with our new “Hello, World” translation.

// pages/index.tsx

  import Layout from "@/components/Layout";
  import type { GetStaticProps } from "next/types";
+ import { FormattedMessage } from "react-intl";

// ...

  export default function Home(...) {
    return (
      <Layout>
        <h1 className="...">
-         Hello i18n!
+         <FormattedMessage id="hello" />
        </h1>
        <p className="...">
          This is a Next.js demo of i18n with react-intl.
        </p>

        {/* ... */}

      </Layout>
    );
  }

The <FormattedMessage> component is aware of the messages provided by <IntlProvider> and can reference any of them by its id.

Header renders our new English translation | Phrase

The header renders our Arabic translation | Phrase

How do I extract and compile translation messages with the Format.JS CLI?

The key advantage of react-intl/Format.JS over other i18n libraries is its support for a message extraction and optional compilation workflow, which is particularly beneficial for large projects with multiple locales and teams, including dedicated translators. The workflow involves:

  1. Defining translation messages in the default locale (e.g., English) within the codebase.
  2. Using a CLI to extract these messages into a translation file.
  3. Sharing this file with translators, typically via an automation script.
  4. Translators then provide translations for all supported locales, often using Translation Management Software (TMS) like Phrase.
  5. Another automation script pulls the latest translations back into the codebase.
  6. Optionally, translation messages are compiled into a more efficient format for production.

This workflow streamlines the coordination of internationalization and localization efforts for larger teams. In this section, we will focus on the initial steps: defining messages, extracting them, and compiling them.

First, we need to install the Format.JS CLI and the Format.JS Babel plugin. The former will give us the extraction and compilation commands, while the latter will allow Format.JS to create IDs for our messages automatically. This saves us the need to explicitly connect messages in our components with IDs in translation files.

Let’s see this in action. First, we will install both packages as dev dependencies.

npm install --save-dev @formatjs/cli babel-plugin-formatjs

Next, we will add a .babelrc to the root of our project, which allows us to wire up the Format.JS plugin.

// .babelrc

{
  "presets": ["next/babel"],
  "plugins": [
    [
      "formatjs",
      {
        "ast": true
      }
    ]
  ]
}

For faster runtime performance, we will pre-compile our translation messages into AST (Abstract Syntax Tree) format. This format will only be used by Format.JS. Our source translation files handled by translators will be in a normal JSON key/value format.

A note on adding Babel config to a Next.js project

Next.js will automatically pick up our new .babelrc file and use it as an override for its Babel config, which is why we needed to explicitly add the next/babel plugin above. Otherwise, our Next.js environment doesn’t work at all!

Also, note that manually configuring Babel will cause Next.js to opt out of its own performant SWC compiler. One thing I noticed doesn’t work without SWC is Next.js’ automatic font optimization with next/font. Otherwise, my Next.js app continued to work fine with the Babel override.

🗒️ Format.JS does provide a SWC plugin which could, theoretically, be wired up to Next.js using the latter’s SWC plugin configuration. Since the SWC plugin config is experimental at the time of writing, we’ve chosen to go with the stable Babel override here.

Extracting messages

Now let’s add our extraction script to package.json.

// package.json

{
  // ...
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
+   "intl:extract": "formatjs extract '{pages,components,sections}/**/*.{js,jsx,ts,tsx}' --out-file lang/src/en-US.json --format simple",
  },
  // ...
}

Our new intl:extract command will tell the Format.JS CLI to look through our source files and output a translation file for our default language, en-US.

🗒️ We designate the simple format to output our translation file in simple key/value JSON. Other formats are available, however. We can even provide custom formats.

OK, let’s give our new command a spin. First, we will update our messages to remove explicit IDs and ensure each has an English defaultMessage.

import Layout from "@/components/Layout";
import { FormattedMessage } from "react-intl";

// ...

export default function Home(...) {
  return (
    <>
      <Layout>
        <h1 className="...">
-         <FormattedMessage id="hello" />
+         <FormattedMessage defaultMessage="Hello i18n!" />
        </h1>
      </Layout>
    </>
  );
}

Notice how this change makes the message much more readable. Now let’s run our new extraction command.

npm run intl:extract

If all goes well, we should have a new file in our project, lang/src/en-US.json:

// lang/src/en-US.json

{
  "RohNOo": "Hello i18n!"
}

The Format.JS CLI will automatically generate a unique ID for any message it finds in the source code that doesn’t have an explicit ID. It will also assume that the defaultMessage value is the translation in the default locale.

We can now create a copy of the English translation file for each of our other supported locales. (In production, we would probably automate this step and upload all the files to a TMS like Phrase).

// lang/src/ar-EG.json 

{
  "RohNOo": "أهلاً بالتدويل!"
}

Of course, we have to load our new message files into our app.

// pages/_app.tsx

+ import arMessages from "@/lang/src/ar-EG.json";
+ import enMessages from "@/lang/src/en-US.json";
  import "@/styles/globals.css";
  import type { AppProps } from "next/app";
  import { useRouter } from "next/router";
  import { IntlProvider } from "react-intl";

  const messages = {
-   "en-US": { ... }, // inlined messages
+   "en-US": { ...enMessages },
-   "ar-EG: { ... },  // inlined messages
+   "ar-EG": { ...arMessages },
  };

export default function App({ Component, pageProps }: AppProps) {
  const router = useRouter();
  const { locale, defaultLocale } = useRouter();

  return (
    <IntlProvider
      locale={locale!}
      defaultLocale={router.defaultLocale}
      messages={messages[locale as keyof typeof messages]}
    >
      <Component {...pageProps} />
    </IntlProvider>
  );
}

If we run our app now, we should see our English and Arabic translations.

English route header | Phrase

Arabic route header | Phrase

Behind the scenes the Format.JS Babel plugin is doing its magic, injecting the message ID in the <FormattedMessage> component. We can see this if we poke in with our React dev tools.

React dev tools browser extension shows an automatically injected ID | Phrase

Aliasing and custom components/functions

The Format.JS CLI and babel plugins will automatically work with <FormattedMessage> and the imperative intl.formatMessage (we will cover the latter a bit later). Aliasing them will not work out-of-the-box however:

// ⛔️👇 Will not work out-of-the-box. (See below).

import { FormattedMessage } from "react-intl";

export default function Aliased({ defaultMessage }) {
  return <FormattedMessage defaultMessage={defaultMessage} />;
}

// In some other component
<Aliased defaultMessage="I am a translation message" />

// ⛔️👆 Will not work! (See below).

To enable message extraction and automatic ID generation for your aliases or custom components/functions, refer to the additionalComponentNames and additionalFunctionNames options in the CLI docs and Babel plugin docs.

Compiling messages

In production, we can speed up our app with Format.JS message compilation, which does two things:

  • Compiles the translation message to a performant AST format.
  • Allows us to remove the ICU MessageFormat parser in production environments, further speeding up our app.

The Format.JS Babel plugin has been compiling our messages to AST on the fly during development. We can see this when we look at one of our message components, like <FormattedMessage>, using the React dev tools.

Our defaultMessage is compiled into an AST | Phrase

Normally, we define our translation messages using the robust ICU Message syntax. We will get into ICU a bit later. For now, just know that parsing and compiling this syntax can be a bit expensive, so pre-compiling our messages for production can make our app more performant. We do this using the Format.JS CLI.

Let’s add a new compile command to our package.json

// package.json

{
  // ...
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "intl:extract": "formatjs extract '{pages,components,sections}/**/*.{js,jsx,ts,tsx}' --out-file lang/src/en-US.json --format simple",
+   "intl:compile": "formatjs compile-folder lang/src lang/compiled --format simple --ast"
  },
  // ...
}

Format.JS’s compile-folder command will scan all message files in one folder and compile them to another folder.

When using the compiler, it’s important to specify the source message format with the --format flag. Since the simple format was used for message extraction, this same format should be provided to the compiler.

We also add the --ast flag to get compiled messages in AST format instead of the default string format.

Let’s run the command.

npm run intl:compile

If all goes well, our messages should be compiled under the lang/compiled directory:

// lang/compiled/en-US.json

{
  "RohNOo": [
    {
      "type": 0,
      "value": "Hello i18n!"
    }
  ]
}
// lang/compiled/ar-EG.json

{
  "RohNOo": [
    {
      "type": 0,
      "value": "أهلاً بالتدويل!"
    }
  ]
}

As you continue building your app and adding translation messages, you’ll notice that messages compiled into AST format retain a simplified object structure that doesn’t need to be parsed.

So we can remove the expensive ICU MessageFormat parser from our production environment:

// next.config.js

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  i18n: {
    locales: ["en-US", "ar-EG"],
    defaultLocale: "en-US",
  },
+ webpack: (config, { dev, ...other }) => {
+   if (!dev) {
+      config.resolve.alias["@formatjs/icu-messageformat-parser"] =
+       "@formatjs/icu-messageformat-parser/no-parser";
+   }
+   return config;
+ },
};

module.exports = nextConfig;

This speeds up our app in production by reducing the Format.JS bundle size by ~40%.

We can see our new config in action by swapping in our compiled messages.

// pages/_app.tsx

- import arMessages from "@/lang/src/ar-EG.json";
+ import arMessages from "@/lang/compiled/ar-EG.json";
- import enMessages from "@/lang/src/en-US.json";
+ import enMessages from "@/lang/compiled/en-US.json";

// ...

export default function App({ Component, pageProps }: AppProps) {
// ...

Running npm run build && npm start from the command line allows us to test our production environment. Looking at our browser network tab, we can see the difference in app bundle size:

Our app bundle size with the ICU MessageFormat parser bundled, weighing in at 44.9 KB
Our app bundle size with the ICU MessageFormat parser removed, dropping by 20% to 35.86 KB

🔗 Read more about pre-compiling and performance in the Format.JS Advanced Usage docs.

We’ve made our workflow and our production app leaner by using the wonderful Format.JS CLI and the Format.JS Babel plugin. We can do even better by only loading the translation file we need (instead of all of them) and doing so only on the server (instead of potentially reloading on the client). We will look at that next.

How do I load a translation file on the server?

We’re currently loading the translation message files for all our supported locales while only using the file for our active locale, which doesn’t scale well. We can solve this problem by using our pages’ getStaticProps() to load only the active locale’s translation file on the server.

We have to do this in each of our pages, so let’s write a helper function that we can reuse.

// i18n/get-locale-messages.ts

import fs from "fs/promises";
import path from "path";

export default async function getLocaleMessages(
  locale: string | undefined,
): Promise<Record<string, string>> {
  if (!locale) {
    throw new Error("Locale is missing.");
  }

  const messageFilePath = path.join(
    process.cwd(),
    "lang",
    process.env.NODE_ENV === "development"
      ? "src"
      : "compiled",
    `${locale}.json`,
  );

  const messages = await fs.readFile(
    messageFilePath,
    "utf8",
  );

  return JSON.parse(messages);
}

When we load the translation file for a given locale, we check to see if we’re in development or production. In development, we load lang/src/en-US.json (or ar-EG.json). In production, we load the compiled lang/compiled/en-US.json.

We can now use getLocaleMessage() to load our translation file in our home page.

// pages/index.tsx

+ import getLocaleMessages from "@/i18n/get-locale-messages";
  import type { 
    GetStaticProps,
    GetStaticPropsContext,
  } from "next/types";
  import { FormattedMessage } from "react-intl";

  type HomeProps = {
+   localeMessages: Record<string, string>;
    date: string;
  };

  export const getStaticProps: GetStaticProps<
    HomeProps
  > = async ({ 
+   locale
  }: GetStaticPropsContext) => {
    return {
      props: {
+       localeMessages: await getLocaleMessages(locale),
        date: new Date().toString(),
      },
    };
  };

export default function Home({ date }: HomeProps) {
  return (
    <Layout>
      <h1 className="...">
        <FormattedMessage defaultMessage="Hello i18n!" />
      </h1>

      {/* ... */}

    </Layout>
  );
}

Next.js provides a locale param with the value of the active locale to getStaticProps(). We use this param to load the translation messages corresponding to the active locale into the localeMessages page prop.

We can now use localeMessages in our root App component, passing it to react-intl’s <IntlProvider>.

// pages/_app.tsx

- import arMessages from "@/lang/compiled/ar-EG.json";
- import enMessages from "@/lang/compiled/en-US.json";
  import "@/styles/globals.css";
  import type { AppProps } from "next/app";
  import { useRouter } from "next/router";
  import { useEffect } from "react";
  import { IntlProvider } from "react-intl";

- const messages = {
-   "en-US": { ...enMessages },
-   "ar-EG": { ...arMessages },
- };

  export default function App({ Component, pageProps }: AppProps) {
    const { locale, defaultLocale } = useRouter();

    return (
      <IntlProvider
        locale={locale!}
        defaultLocale={defaultLocale!}
-       messages={messages[locale as keyof typeof messages]}
+       messages={pageProps.localeMessages}
      >
        <Component {...pageProps} />
      </IntlProvider>
    );
  }

The App component has access to each page component’s props through pageProps. So our server-loaded messages from the home page are available to App via pageProps.localeMessages.

With that, we’ve eliminated the need to load all of our translation files. We’ve also limited expensive file loading and parsing to the server by using pages’ getStaticProps().

✋ Of course, for this to work, we have to add localeMessages: await getLocaleMessages(locale) to each page in our app. Here is our posts index as another example:

// pages/posts/index.tsx

  import Layout from "@/components/Layout";
  import { posts } from "@/data/posts";
+ import getLocaleMessages from "@/i18n/get-locale-messages";
  import type { Post } from "@/types";
  import type { 
    GetStaticProps,
    GetStaticPropsContext
  } from "next";
  import Link from "next/link";

  type PostIndexProps = {
+   localeMessages: Record<string, string>;
    posts: Post[];
  };

  export const getStaticProps: GetStaticProps<
    PostIndexProps
  > = async ({ 
+   locale
  }: GetStaticPropsContext) => {
    return {
      props: {
+       localeMessages: await getLocaleMessages(locale),
        posts,
      },
    };
  };

  export default function PostIndex({
    posts,
  }: PostIndexProps) {
    // Render the posts...
  }

Loading translation messages in the App component

We only need to add a few lines per page to load our translations on the server. However, you might be wondering how we make all this DRY (Don’t Repeat Yourself). One possible solution is using the legacy getInitialProps() function in the App component.

// pages/_app.tsx

  import "@/styles/globals.css";
- import type { AppProps } from "next/app";
+ import type { AppContext, AppProps } from "next/app";
  import App from "next/app";
  import { useRouter } from "next/router";
  import { IntlProvider } from "react-intl";

- export default function App({
+ export default function MyApp({
    Component,
    pageProps,
+   messages,
- }: AppProps) {
+ }: AppProps & { messages: Record<string, string> }) {
    const { locale, defaultLocale } = useRouter();

    return (
      <IntlProvider
        locale={locale!}
        defaultLocale={defaultLocale!}
-       messages={pageProps.localeMessages}
+       messages={messages}
      >
        <Component {...pageProps} />
      </IntlProvider>
    );
  }

+ // 👇 Load the messages for the active locale
+ MyApp.getInitialProps = async (ctx: AppContext) => {
+   const { locale } = ctx.router;
+
+   const subdirectory =
+     process.env.NODE_ENV === "development"
+       ? "src"
+       : "compiled";
+
+   const messages: Record<string, string> = await import(
+     `@/lang/${subdirectory}/${locale}`
+   );
+
+   const appProps = await App.getInitialProps(ctx);
+
+   return { ...appProps, messages };
+ };

With that, we can remove all the getLocaleMessage() calls in our pages, encapsulating locale loading in the App component.

This DRYing up comes at a cost, however: using getInitialProps() on the App component causes Next.js to opt out of Automatic Static Optimization. This means that simple pages that have no getServerSideProps() or getInitialProps() will not be pre-rendered (and cached in a CDN) as usual. We can see this when we build our app.

🗒️ I added a simple About page to test here

By default, simple pages are statically rendered

 

With App component getInitialProps() translation loading, simple pages become dynamically rendered

So it’s a tradeoff either way. In this tutorial, we will stick to per-page getStaticProps translation loading, since it’s only a few lines per page for an overall performance gain. The choice is yours, of course.

🔗 If you want a closer look at the getInitialProps() solution, we have a dedicated branch in our GitHub repo that covers it. You can also see a handy diff that focuses on loading code.

🗒️ Server Components, introduced in Next.js with the App Router, can be really helpful here. We cover this in A Deep Dive into Next.js App Router Localization with next-intl. (Do note that at the time of writing, unlike react-intl, next-intl doesn’t cover message extraction/compilation).

How do I build a language switcher?

We’ve laid the plumbing of react-intl + Next.js, making sure we’re loading translations as efficiently as possible. Let’s switch gears a bit and make a locale switcher for our site visitors since we often need a way for our users to manually select their own language.

We will add a LocaleSwitcher component that takes care of this.

// components/LocaleSwitcher.tsx

import { useRouter } from "next/router";

const localeNames: Record<string, string> = {
  "en-US": "English",
  "ar-EG": "(Arabic) العربية",
};

export default function LocaleSwitcher() {
  const router = useRouter();

  // `locales` list is configured in next.config.js
  const { locale, locales } = router;

  const handleLocaleChange = (
    e: React.ChangeEvent<HTMLSelectElement>,
  ) => {
    const locale = e.target.value;
    router.push(router.pathname, router.asPath, { locale });
  };

  return (
    <div>
      <select
        value={locale}
        onChange={handleLocaleChange}
        className="..."
      >
        {locales!.map((locale) => (
          <option key={locale} value={locale}>
            {localeNames[locale]}
          </option>
        ))}
      </select>
    </div>
  );
}

Like the <Link> component, the Next.js router takes a locale option that makes the router change the locale route prefix for a given route path. For example, if we call router.push("/posts", "/posts", { locale: "ar-EG" }), we will be navigated to /ar-EG/posts.

🔗 See the router.push() docs for more info.

If we place our new <LocaleSwitcher> in the header section of our <Layout>, we can see this in action.

A language-switching UI makes it easy for our site visitors to manually select their locale

Setting the NEXT_LOCALE cookie

Right now, our user’s selected locale won’t be remembered the next time they visit our site. Recall that Next.js i18n has built-in locale detection. We can override this detection by setting a NEXT_LOCALE cookie.

Let’s add that logic to our LocaleSwitcher. We will use the popular nookies package, which allows to set cookies easily from the browser.

Let’s install it.

npm install nookies

Now can use nookies’ setCookie() in our component.

// components/LocaleSwitcher.tsx

  import { useRouter } from "next/router";
+ import { setCookie } from "nookies";

  // ...

  export default function LocaleSwitcher() {
    const router = useRouter();
    const { locale, locales } = router;

    const handleLocaleChange = (
      e: React.ChangeEvent<HTMLSelectElement>,
    ) => {
      const locale = e.target.value;
    
+     setCookie(null, "NEXT_LOCALE", locale, {
+       sameSite: "Strict",
+       path: "/",
+       // Set the lifetime of the cookie to one year
+       maxAge: 365 * 24 * 60 * 60,
+     });

      router.push(router.pathname, router.asPath, { locale });
    };

    return (
      // ...
    );
  }

Now, whenever we change the locale manually with the switcher, we can see a cookie being set in our browser dev tools.

Our browser dev tools confirm that the NEXT_LOCALE cookie is being set

With this setup, when a visitor manually selects a locale, the NEXT_LOCALE cookie stores their choice. Next.js’ locale auto-detection then prioritizes the cookie’s value over its own detected locale. This ensures that the visitor sees the site in their previously selected language during subsequent visits.

How do I automatically detect the user’s locale?

Next.js i18n has automatic locale detection built in. In fact, it’s on by default. However, as we’ve mentioned before, this detection can be a bit too precise.

For example, if a visitor sets ar-SY (Arabic as it is used in Syria) in their browser language preferences, Next.js won’t serve them the ar-EG version of our website. This is a missed opportunity because Egypt and Syria share the same written Arabic.

To achieve loose locale matching, where only the language is matched, we can roll our own locale auto-detection via custom Next.js middleware.

First, let’s disable Next’s built-in locale auto-detection.

// next.config.js

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  i18n: {
    locales: ["en-US", "ar-EG"],
    defaultLocale: "en-US",
+   localeDetection: false,
  },
  webpack: (config, { dev, ...other }) => {
    // ...
};

module.exports = nextConfig;

To make our work easier, we will use the accept-language-parser package to parse and match the user’s locale from the Accept-Language HTTP header. Let’s install the package.

npm install accept-language-parser

We can now utilize the package to provide a bestMatch() function that we will use in our new middleware momentarily.

// i18n/best-match.ts

import nextConfig from "@/next.config";
import acceptLanguageParser from "accept-language-parser";

export function bestMatch(
  acceptLanguageHeader: string | null,
): string | null {
  if (!nextConfig.i18n) {
    throw new Error(
      "Please add i18n config to next.config.js",
    );
  }

  if (!acceptLanguageHeader) {
    return nextConfig.i18n.defaultLocale;
  }

  const supportedLocales = nextConfig.i18n.locales;

  const bestMatch = acceptLanguageParser.pick(
    supportedLocales,
    acceptLanguageHeader,
    { loose: true },
  );
  return bestMatch;
}

We pull in our next.config.js i18n values and use the accept-language-parser’s pick() function to find the best matching locale.

The acceptLanguage string parameter should match the format of a standard HTTP Accept-Language header, such as "fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5". This represents a prioritized list of user-preferred locales, indicating their language preferences and environment.

🔗 Learn more about the Accept-Language header in our locale detection guide.

Given acceptLanguage and a list of app-supported locales (en-US, ar-EG in our case), pick() will try to find the best match in the supported locales list. By setting the loose option to true, we ensure that this best match is by language regardless of region, e.g. ar-SY in the Accept-Language header will match the supported ar-EG.

OK, armed with bestMatch(), let’s write our custom middleware.

// middleware.ts

import { bestMatch } from "@/i18n/best-match";
import {
  NextResponse,
  type NextRequest,
} from "next/server";

export function middleware(req: NextRequest) {
  const matchedLocale = bestMatch(
    req.headers.get("Accept-Language"),
  );

  const { locale } = req.nextUrl;

  if (locale !== matchedLocale) {
    return NextResponse.redirect(
      new URL(
        `/${matchedLocale}${req.nextUrl.pathname}`,
        req.nextUrl,
      ),
    );
  }
}

export const config = {
  // Match the root route and any other route except
  // internal Next.js routes and public files.
  matcher: [
    "/",
    "/((?!api|_next/static|_next/image|favicon.ico).*)",
  ],
};

In our middleware, we check to see if the best-matched locale is different from the current locale in the URL. If it is, we redirect to the URL with the best-matched locale swapped in.

For example, if a site visitor has pt-BR (Portuguese in Brazil) as a browser language preference, and our app supports the locale, visiting /foo or /fr-CA/foo would redirect the visitor to /pt-BR/foo.

Minding the NEXT_LOCALE cookie override

Remember the NEXT_LOCALE cookie we set in our locale switcher? The built-in Next.js locale detector will use it as an override, but we’re not doing the same in our custom middleware. This means that if a visitor manually selects a locale from our switcher, their choice will be overridden by our auto-detection.

Let’s fix this.

// middleware.ts

import {
  NextResponse,
  type NextRequest,
} from "next/server";
import { bestMatch } from "./i18n/best-match";

export function middleware(req: NextRequest) {
  const { locale } = req.nextUrl;

+ // If a locale was manually selected by the visitor
+ // during a previous visit, use *that* locale and
+ // don't attempt to auto-detect a best match.
+ const storedLocale = req.cookies.get("NEXT_LOCALE");
+
+ if (storedLocale) {
+   if (storedLocale.value !== locale) {
+     return NextResponse.redirect(
+       new URL(
+         `/${storedLocale.value}${req.nextUrl.pathname}`,
+         req.nextUrl,
+       ),
+     );
+   } else {
+     return;
+   }
+ }

  const matchedLocale = bestMatch(
    req.headers.get("Accept-Language"),
  );

  if (locale !== matchedLocale) {
    return NextResponse.redirect(
      new URL(
        `/${matchedLocale}${req.nextUrl.pathname}`,
        req.nextUrl,
      ),
    );
  }
}

export const config = {
  matcher: [
    "/",
    "/((?!api|_next/static|_next/image|favicon.ico).*)",
  ],
};

With this, we check for a previously selected locale and only auto-detect when we don’t find one.

How do I localize SSR and SSG pages?

We’ve been working with server-side rendering (SSR) and static site generation (SSG) throughout this guide, so we’ve covered most of the basics. Here’s a quick recap:

  • In the context of static generation with Next.js i18n Routing, locale information is accessible via the Next.js router. Properties available are locale (the active locale), locales (all supported locales), and defaultLocale.
  • When pre-rendering pages with getStaticProps or getServerSideProps, this locale information is provided in the context param passed to these functions.
  • Similarly, when using getStaticPaths, the supported locales and defaultLocale are included in the context parameter of the function.
  • Next.js i18n routing does not work with static exports, ie. output: 'export'.

🔗 Read How does [i18n] work with Static Generation? in the Next.js docs.

Let’s take a look at the code of the single post page to see all this in action.

// pages/posts/[slug].tsx

import Layout from "@/components/Layout";

// Our mock data
import { posts } from "@/data/posts";

// For loading our UI translation messages
import getLocaleMessages from "@/i18n/get-locale-messages";

// TypeScript types
import type { TranslatedPost } from "@/types";
import type {
  GetStaticPaths,
  GetStaticProps,
  GetStaticPropsContext,
} from "next";

import Link from "next/link";
import { useRouter } from "next/router";

// react-intl
import { FormattedMessage } from "react-intl";

type SinglePostProps = {
  localeMessages: Record<string, string>;
  post: TranslatedPost;
};

// Since we're not providing locales in our
// returned `paths`, Next.js will only 
// pre-build a version of this page translated
// to our default locale (en-US) during 
// production builds.
export const getStaticPaths: GetStaticPaths<{
  slug: string;
}> = () => {
  return {
    paths: posts.map((post) => ({
      params: { slug: post.slug },
    })),

    // Ensure that all locale translations
    // are served when requested.
    fallback: true,
  };
};

export const getStaticProps = (async ({
  params,
  locale,
}: GetStaticPropsContext) => {
  if (typeof params?.slug !== "string" || !locale) {
    return { notFound: true };
  }

  const foundPost = posts.find(
    (p) => p.slug === params.slug,
  );
  if (!foundPost) {
    return { notFound: true };
  }

	// Only get the translations for the
  // active `locale`.
  const post = {
    date: foundPost.date,
    slug: foundPost.slug,
    ...foundPost.translations[locale],
  };

  return {
    props: {
      // Load the active locale's translation
      // messages for the UI view (react-intl).
      localeMessages: await getLocaleMessages(locale),
      post,
    },
  };
}) satisfies GetStaticProps<SinglePostProps>;

export default function SinglePost({
  post,
}: SinglePostProps) {
  const router = useRouter();

  // Just a reminder that we can access
  // i18n config values here as well.
  const { locale, defaultLocale, locales } = router;

  if (router.isFallback) {
    return <div>Loading...</div>;
  }

  return (
    <Layout>
      <div className="...">
        <Link href="/posts" className="...">
          <FormattedMessage defaultMessage="Back to post index" />
        </Link>
      </div>

      <h1 className="...">{post.title}</h1>

      <p className="...">{post.date}</p>

      <div className="...">
        <p>{post.content}</p>
      </div>
    </Layout>
  );
}

🔗 Get all the code for the localized demo app from our GitHub repo.

How do localize my pages and components with react-intl?

We’ve touched on this earlier, but there’s more to localizing our views than basic messages with <FormattedMessage>. In this section, we briefly cover interpolation, plurals, and date and number formatting.

🔗 We cover all this in more detail in our Guide to Localizing React Apps with react-intl/FormatJS.

Basic translations

Translating basic strings often means adding a <FormattedMessage> with a defaultMessage.

// In a component
import { FormattedMessage } from "react-intl";

// ...

<p className="...">
  <FormattedMessage defaultMessage="Another look at Darth Vader" />
</p>

We then extract, translate, and compile to see our message in different locales.

An imperative intl.formatMessage() also exists, and it’s especially handy for localizing attributes and props.

// In a component
import { useIntl } from "react-intl";

export default function MyComponent() {
  const intl = useIntl();

  return (
    <img
			src="..."
      alt={intl.formatMessage({ 
			  defaultMessage: "Picture of Chewbacca, unimpressed."
      })}
    />
  );
}

✋ Remember that aliasing <FormattedMessage> or intl.formatMessage() won’t work with extraction without setting extra options. See the Aliasing and custom components/functions section above for more details.

Localizing page metadata

intl.formatMessage() is the only way to localize page metadata since using <FormatMessage> will throw an error when used inside Next’s <Head> component.

// pages/posts/index.tsx

import Layout from "@/components/Layout";
import Head from "next/head";
import { useIntl } from "react-intl";

// ...

export default function PostIndex({ posts }: PostIndexProps) {
  const intl = useIntl();

  return (
    <>
      <Head>
        <title>
          {intl.formatMessage({
            defaultMessage: "Posts | r.intl",
          })}
        </title>
        <meta
          name="description"
          content={intl.formatMessage({
            defaultMessage: "Our latest posts.",
          })}
        />
      </Head>>
      <Layout>
        {/* ... */}
      </Layout>
    </>
  );
}

Interpolation

To inject runtime values in our translation messages, we designate their locations with the ICU {variable} syntax, then provide named values as params.

{/* Imperative */}
<p>
  {intl.formatMessage(
    {
      defaultMessage:
        "This is a {next} demo of i18n with {reactIntl}",
    },
    {
       next: "Next.js",
       reactIntl: "react-intl",
    },
  )}
</p>

{/* Declarative */}
<p>
  <FormattedMessage
    defaultMessage="This is a {next} demo of i18n with {reactIntl}"
    values={{
      next: "Next.js",
      reactIntl: "react-intl",
    }}
  />
</p>

🗒️ react-intl/Format.JS implements the ICU (International Components for Unicode), a localization standard found in many environments. Learn more about it in The Missing Guide to the ICU Message Format.

Plurals

One of the best things about the ICU Message Format is its robust support for plurals across locales.

🤿 Different languages have significantly different pluralization rules. Our Guide to Localizing Plurals goes deeper into that subject.

// components/Plurals.tsx

const [messageCount, setMessageCount] =
    useState<number>(0);

// ...

<span>
  <FormattedMessage
    defaultMessage={`{count, plural,
      =0 {You have no messges.}
      one {You have one message.}
      other {You have # messages.}}`}
      values={{ count: messageCount }}
  />
</span>
While English has 3 plural forms, Arabic has 6

🔗 See the complete code for the above Plurals component on GitHub.

Date formatting

Under the hood, Format.JS uses the standard Intl.DateTimeFormat for its localized date formatting. This means that we can pass options to react-intl’s formatter that are used in turn by Intl.DateTimeFormat.

// components/Dates.tsx

import { FormattedDate, useIntl } from "react-intl";

// The `date` prop could be of type `Date`
// here as well: The following code would
// still work fine.
export default function Dates({ date }: { date: string }) {
  const intl = useIntl();

  return (
    <>
      <span className="...">
        {intl.formatDate(date)}
      </span>

      <span className="...">
        <FormattedDate value={date} dateStyle="long" />
      </span>

      <span className="...">
        {intl.formatDate(date, {
          year: "2-digit",
          month: "short",
          day: "2-digit",
        })}
      </span>
    </>
  );
}
Our various date formats rendered for the en-US locale
Our various date formats rendered for the ar-EG locale

✋ Safari on macOS will throw a hydration error in its developer console for the above long date in Arabic since it adds that comma in the date format on the client—and the Node.js server doesn’t. We could solve this by formatting dates on the server using getStaticProps() and passing them to pages and components as pre-formatted strings. Alternatively, we could use a library like date-fns.

🔗 Grab the full code for the above Date component from our GitHub repo.

Number formatting

Again, Format.JS uses the standard Intl.NumberFormat underneath the hood.

// components/Numbers.tsx

import { FormattedNumber, useIntl } from "react-intl";

export default function Numbers() {
  const intl = useIntl();

  return (
    <>
      <span className="...">
        <FormattedNumber value={1234.56} />
      </span>
      
      <span className="...">
        {intl.formatNumber(1234.56, {
          style: "currency",
          currency: "EUR",
        })}
      </span>
      
      <span className="...">
        {intl.formatNumber(0.98, { style: "percent" })}
      </span>
    </>
  );
}
Our number formats rendered in the en-US locale
Our number formats rendered in the ar-EG locale

🔗 Get the complete code of the above Number component from GitHub.

🤿 This article is getting a bit long, so we had to go through view localization very quickly. We invite you to look more deeply into these topics in A Guide to Localizing React Apps with react-intl/FormatJS.

How do I work with right-to-left languages?

The simplest solution to setting the document direction in the browser is through a useEffect in the App component.

// pages/_app.tsx

  import "@/styles/globals.css";
  import type { AppProps } from "next/app";
  import { useRouter } from "next/router";
+ import { useEffect } from "react";
  import { IntlProvider } from "react-intl";

  export default function App({
    Component,
    pageProps,
  }: AppProps) {
    const { locale, defaultLocale } = useRouter();

+    useEffect(() => {
+      // We set the `lang` attribute while
+      // we're at it.
+      document.documentElement.lang = locale!;
+
+      document.documentElement.dir =
+        locale === "ar-EG" ? "rtl" : "ltr";
+    }, [locale]);

     return (
       <IntlProvider
         locale={locale!}
         defaultLocale={defaultLocale}
         messages={pageProps.localeMessages}
       >
         <Component {...pageProps} />
       </IntlProvider>
     );
  }

While it may seem that calling useEffect() inside of App would make all of our pages client-side-rendered, I found that this was not the case.

However, if you’re looking for a different approach, you could use getInitialProps() in the root Document component (at pages/_document.tsx).

Alternatively, you could set document.documentElement.dir in the LocaleSwitcher.

And with that, our demo app is localized!

Our demo app pages translated into English and Arabic

🔗 Get the completed code for our demo app from our GitHub repo.

Conclusion

There’s certainly a good amount of setup to get a Next.js app localized with react-intl. Once configured, however, we get robust, efficient i18n on both server and client. With the automation options that the react-intl CLI provides, we can scale our localization with lean workflows that keep us focused on the creative code we love.

The post Next.js Localization with Format.JS/react-intl appeared first on Phrase.

]]>
Pluralization: A Guide to Localizing Plurals https://phrase.com/blog/posts/pluralization/ Mon, 30 Oct 2023 15:14:35 +0000 https://phrase.com/?p=68985 Learn more about how to get pluralization right in your multilingual app and ensure a smooth user experience for your global user base.

The post Pluralization: A Guide to Localizing Plurals appeared first on Phrase.

]]>

Pluralization in multilingual apps—a seemingly simple concept that can quickly spiral into a maze of linguistic intricacies. From the straightforward “apple” vs “apples” in English to the multifaceted plural rules of Russian and Arabic, developers and translators often grapple with the challenge of representing quantity across cultures.

In this guide, we will unravel the complexities of plural localization, showing code in JavaScript with the popular internationalization (i18n) library, i18next. For those working with other programming languages and frameworks, we’ll provide handy links to related plural i18n resources.

💡 Learn more » Internationalization (i18n) and localization (l10n) allow us to make our apps available in different languages and to different regions, often for more profit. If you’re new to i18n and l10n, check out our guide to internationalization.

String Management UI visual | Phrase

Phrase Strings

Take your web or mobile app global without any hassle

Adapt your software, website, or video game for global audiences with the leanest and most realiable software localization platform.

Explore Phrase Strings

It’s not just singular and plural

English has two plural forms: singular and plural, “one tree” and “five trees.” Many languages share this simple duality, but quite a few don’t. Chinese has one plural form, and so does Japanese. Russian has four, and Arabic has six!

🔗 Resource » The CLDR (Common Language Data Repository) Language Plural Rules listing is the canonical source for each language’s plural forms.

When localizing plurals, we often have a dynamic count integer that we use to determine which form to pick e.g. 1 → “one tree”, 2 → “two trees”.

Let’s take an example. Here’s a message for a fictional tree-planting organization.

An app message screen containing numbers | Phrase

Looking at English, we have our two forms, called one and other in localization lingo. Here we would need two versions of the message:

  • one → “We’ve planted 1 tree so far!”
  • other → “We’ve planted 20,000 trees so far!”

🗒️ Note » Normally we use the other form for the zero case in English. We’ll see how we can override this later.

What about a language like Arabic? Remember, Arabic has six plural forms. If we want accurate translations for the above message, we need six versions:

  • zero → “لم نزرع أي شجرة حتى الآن”
  • one → “لقد زرعنا شجرة ١ حتى الآن”
  • two → “لقد زرعنا شجرتين ٢ حتى الآن”
  • few → “لقد زرعنا ٣ شجرات حتى الآن”
  • many → “لقد زرعنا ١١ شجرة حتى الآن”
  • other → “لقد زرعنا ١٠٠ شجرة حتى الآن”

So there’s no one-size-fits-all answer to plural translation. We need a solution that allows selecting the correct plural form for any given language, not just “pick from singular and plural”.

🤿 Go deeper » The astute reader will have noticed that our Arabic translations above are not using Western Arabic numerals (1, 2, 3). Many Arabic regions use Eastern Arabic numerals instead (١، ٢، ٣). Read our Concise Guide to Number Localization for this and a lot more about number localization.

Use an i18n library

If we’re building a simple JavaScript app with a couple of languages, we could get away with rolling our own pluralization solution. We could even use the standard Intl.PluralRules object to make our lives easier. Prebuilt i18n libraries make this work much easier, however, especially as we support more languages.

We’ll use the immensely popular i18next JavaScript framework in this article to demonstrate. But we’ll try to stay as tech-agnostic as possible, and we’ll provide links to our other programming language and framework articles a bit later.

Assuming we’ve installed and configured i18next, we add our translation messages to it as follows:

i18next
  .init({
    // Default language
    lng: "en",
    resources: {
      // English translations
      en: {
        translation: {
          countLabel: "Count",
          messageLabel: "Message",
        },
      },
      // Arabic translations
      ar: {
        translation: {
          countLabel: "العدد",
          messageLabel: "الرسالة",
        },
      },
    },
  })

We can then use these translations in our app like this:

i18next.t("countLabel");

// => "Count" when active locale is English
// => "العدد" when active locale is Arabic

🗒️ Note » In production, we would likely house each language’s translations in a separate JSON file and load the file when needed. We’re skipping this here to keep our focus on plurals.

So how do we add a translation message with plural forms? Well, any i18n library worth using supports plurals out of the box, and i18next is no exception.

i18next uses a suffix naming convention for plurals: Each plural form for a message called foo would get its own entry e.g. foo_one, foo_other.

Let’s revisit our above tree-planting example; say we wanted to give its translation message a key of message. We’d add the plural forms to our translations as follows. (Remember, English has two plural forms and Arabic has six).

i18next
  .init({
    lng: "en",
    debug: true,
    resources: {
      en: {
        translation: {
          countLabel: "Count",
          messageLabel: "Message",
+         message_one: "🌳 We've planted {{count, number}} tree so far!",
+         message_other: "🌳 We've planted {{count, number}} trees so far!",
        },
      },
      ar: {
        translation: {
          countLabel: "العدد",
          messageLabel: "الرسالة",
+         message_zero: "🌳 لم نزرع أي شجرة حتى الآن!",
+         message_one: "🌳 لقد زرعنا شجرة {{count, number}} حتى الآن!",
+         message_two: "🌳 لقد زرعنا شجرتين {{count, number}} حتى الآن!",
+         message_few: "🌳 لقد زرعنا {{count, number}} شجرات حتى الآن!",
+         message_many: "🌳 لقد زرعنا {{count, number}} شجرة حتى الآن!",
+         message_other: "🌳 لقد زرعنا {{count, number}} شجرة حتى الآن!",
        },
      },
    },
  })

Heads up » Generally speaking, the other form is always required.

To use these plural forms, we provide the message key without any suffix, and a count variable:

i18next.t("message", { count: 3 })

// => (en) "🌳 We've planted 3 trees so far!"
// => (ar) "🌳 لقد زرعنا ٣ شجرات حتى الآن!"

Of course, the count can be dynamic and provided at runtime.

Heads up » The plural counter variable must be called count.

🗒️ Note » i18next uses a {{variable}} syntax to interpolate runtime values into a message. We’re making use of this above — note the {{count, number}} — to format the count as a number with proper localized formatting. Read more about interpolation and formatting in the i18next docs.

With that in place, we have a localized plural solution that adapts to any language.

Demo app screen displaying a localized plural solution | Phrase
Our plural messages shown in English and Arabic

🔗 Resource » Get all the code for this demo app from our GitHub repository.

i18next provides a special _zero case for all languages: It overrides the language’s normal plural form resolution. We could use it to provide a special zero message in English.

i18next
  .init({
    lng: "en",
    debug: true,
    resources: {
      en: {
        translation: {
          countLabel: "Count",
          messageLabel: "Message",
+         message_zero: "🌳 We haven't planted any trees yet.",
          message_one: "🌳 We've planted {{count, number}} tree so far!",
          message_other: "🌳 We've planted {{count, number}} trees so far!",
        },
      },
      ar: {
        // ...
      },
    },
  })
App screen displaying a count of zero | Phrase
Our zero override shown when the count is zero

Use the ICU message format

ICU (International Components for Unicode) is a set of portable, widely used i18n libraries. i18next itself has an ICU plugin, which we’ll demo in a moment. Many other i18n libraries across programming languages have built-in ICU support. One of the most important parts of the ICU is its translation message format, which has excellent plural support.

🤿 Go deeper » We’ve written extensively about ICU in The Missing Guide to the ICU Message Format.

An ICU message is a string, much like our translation strings above, with special syntaxes for interpolating runtime values, plurals, and more. We’ll focus on plurals here, of course.

Assuming the official i18next ICU plugin is installed and set up, here’s how we can refactor our messages to the ICU message format.

i18next
  .use(window.i18nextICU)
  .init({
    lng: "en",
    debug: true,
    resources: {
      en: {
        translation: {
          countLabel: "Count",
          messageLabel: "Message",
          message: `
            {count, plural,
              one {🌳 We've planted one tree so far!}
              other {🌳 We've planted # trees so far!}
            }`,
        },
      },
      ar: {
        translation: {
          countLabel: "العدد",
          messageLabel: "الرسالة",
          message: `{count, plural,
                zero {🌳 لم نزرع أي شجرة حتى الآن!}
                one {🌳 لقد زرعنا شجرة # حتى الآن!}
                two {🌳 لقد زرعنا شجرتين # حتى الآن!}
                few {🌳 لقد زرعنا # شجرات حتى الآن!}
                many {🌳 لقد زرعنا # شجرة حتى الآن!}
                other {🌳 لقد زرعنا # شجرة حتى الآن!}
            }`,
        },
      },
    },
  })

ICU plurals are all part of the same message. The syntax is basically:

{countVariable, plural,
  firstPluralForm {content}
  secondPluralForm {content}
  ... 
}

The CLDR plural forms are used here as before (one, other, etc.). You can find the forms for your language in the CLDR Language Plural Rules listing.

🗒️ Note » In fact the {count, plural, ...} segment could be embedded in a longer message. For example, We planted {count, plural, ...}!. However, it’s considered good practice to keep each plural form separate: It’s easier to maintain the message that way.

Heads up » Generally speaking, we can’t mix and match non-ICU plurals, interpolation, etc. with ICU ones when using i18next. It’s a good idea to choose one kind of format and stick to it.

The special # character above will be replaced by the count variable when we resolve the message.

i18next.t("message", { count: 12 })

// => (en) "🌳 We've planted 12 trees so far!"
// => (ar) "🌳 لقد زرعنا ١٢ شجرة حتى الآن!"

🗒️ Note » Unlike regular i18next messages, we could have called count anything we wanted here, as long as we kept it consistent in our messages.

Instead of #, we can use the variable name itself along with ICU interpolation syntax ({variable}) to inject the counter in a message:

{count, plural,
  one {🌳 We've planted {count} tree so far!}
  other {🌳 We've planted {count} trees so far!}
}

Note that while # respects the number formatting of the current language, {count} won’t necessarily. For example, using {count} in Arabic messages results in Western Arabic numerals used (1, 2, 3), which we don’t want.

Arabic translation screen with a Western Arabic “1” embedded | Phrase
Our Arabic translation shown with a Western Arabic “1” embedded

To correct this, we can use the ICU number format.

{count, plural,
  zero {🌳 لم نزرع أي شجرة حتى الآن!}
  one {🌳 لقد زرعنا شجرة {count, number} حتى الآن!}
  two {🌳 لقد زرعنا شجرتين {count, number} حتى الآن!}
  ...
}

This ensures that the active language’s number formatting is respected.

Arabic translation screen with Eastern Arabic “١” embedded | Phrase
Our Arabic translation shown with the appropriate Eastern Arabic “١” embedded

🔗 Resource » To get the ICU message code in this article, check out the ICU branch in our GitHub repo.

ICU plurals allow us to override language plural rules for specific numbers. Unlike regular i18next plurals, the ICU format works for any number, not just zero. We just need to use the =n {} syntax for the override we want, where n is the specific count we’re overriding.

{count, plural,
  =0 {🌳 We haven't planted any trees yet!}
  one {🌳 We've planted one tree so far!}
  =2 {🌳 We've planted a couple of trees so far!}
  other {🌳 We've planted # trees so far!}
}
Zero and two number overrides displayed in an English message screen | Phrase
The zero and two number overrides displayed in our English message

🔗 Resource » I find the Online ICU Message Editor handy when I’m formatting my ICU messages.

Ordinals

An ordinal is a word that represents the rank of a number, e.g. first, second, third. Some languages, like English, have special representations for ordinals, e.g. 1st, 2nd, 3rd, 4th.

The plural forms we’ve covered so far in this article are cardinal plurals, relating to the natural numbers represented in a language. Note, however, that a language can have different forms for its cardinals and ordinals. As we’ve mentioned earlier, English, for example, has two forms for its cardinals: one and other. Yet it has four forms for its ordinals: one, two, few, and other.

The ICU message format has a syntax just for ordinals, using the selectordinal keyword. Here it is for English:

Yay! Our
{count, selectordinal,
  one {#st}
  two {#nd}
  few {#rd}
  other {#th}
}
tree!

Arabic has alphabetic ordinals (أول، ثاني، ثالث). Its numerical representation of ordinals is written just as the cardinal number, something like, “We’ve planted the tree 33”. So ICU affords Arabic the single other form to display these:

رائع! شجرتنا الـ
{count, selectordinal, other {#}}
!
Ordinal message screen | Phrase
Our ordinal messages

🔗 Resource » Check the CLDR Language Plural Rules listing for all languages’ supported ordinals.

Framework resources

We’ve covered i18next and JavaScript above. However, many programming languages and libraries implement the ICU message format. Environments that have no ICU support almost certainly have an alternative that solves complex plural localization.

We think you’ll find a good plural localization solution for your framework in one of these articles (we link to the plurals section directly where we can):

React and Next.js

Vue

Other web frameworks

Mobile

Server

Game engines

Pluralization doesn’t need to be a hassle

That about does it for this guide on localizing plurals. By following developer-tested best practices, you can ensure your multilingual app smoothly handles plurals in different languages, delivering a seamless UX to your global user base. We hope you’ve picked up some valuable insights and enjoyed yourself.

The post Pluralization: A Guide to Localizing Plurals appeared first on Phrase.

]]>
Next.js 13/14 App Router Localization with next-intl https://phrase.com/blog/posts/next-js-app-router-localization-next-intl/ Tue, 24 Oct 2023 06:37:10 +0000 https://phrase.com/?p=68190 Take a deep dive into Next.js localization using an App Router demo and next-intl. We'll explore routing, Server and Client Components, and much more.

The post Next.js 13/14 App Router Localization with next-intl appeared first on Phrase.

]]>

Next.js by Vercel has become the React framework. Next.js’ downloads have surpassed those of create-react-app and the official React documentation recommends the framework for spinning up new React projects. Little wonder: Next.js solves previously headache-inducing problems like routing and server-side rendering with elegance, making modern React development fun again.

The introduction of the App Router in Next.js 13 added the flexibility and convenience of React Server Components (RSC)—it also complicated Next.js internationalization (i18n). Thankfully, the next-intl library by Jan Amann simplifies Next.js 13/14 App Router i18n, offering robust i18n solutions, including translation management, localized formatting, and excellent support for server components.

🔗 Learn more » Internationalization (i18n) and localization (l10n) allow us to make our apps available in different languages and regions, often for more profit. If you’re new to i18n and l10n, check out our guide to internationalization.

🔗 Resource » For localizing a Next.js app using the Pages Router and the i18next library, refer to our step-by-step guide to Next.js internationalization.

🔗 Resource » If you want to use the Format.js/react-intl library with the Pages Router instead, check out Next.js Localization with Format.JS/react-intl.

Our demo app

We’ll build a minimalistic mock weather app to help us work through our i18n problems, creatively named Next.js Weather.

Our home page shown before localization, titled 'Next.js Weather'. The application displays today's weather as 'Sunny 22°C' for 'Monday April 15 2024'. The interface has a dark background with a sun icon and large white text presenting the temperature. At the top of the interface are navigation tabs labeled 'This week' and 'About'. The browser window shows 'localhost:3000' in the address bar.
Our home page before localization.

Let’s build this app and localize it step-by-step.

Package versions used

We use the following NPM packages in this guide.

Library Version used Description
typescript 5.4 Our programming language of choice.
next 14.2 The full-stack React framework.
react 18.2 A somewhat popular UI library.
next-intl 3.11 Our i18n library.
rtl-detect 1.1 For detecting right-to-left languages.
tailwindcss 3.4 For styling; largely optional for our purposes.
clsx 2.1 For dynamic CSS class assignment; largely optional for our purposes.

🔗 Resource » Get the complete code for our demo from GitHub.

Building the demo

With that out of the way, let’s get building. We’ll spin up a fresh Next.js app from the command line:

npx create-next-app@latest

The usual setup Q&A follows. Here’s what I entered:

  • Would you like to use TypeScript? Yes
  • Would you like to use ESLint? Yes
  • Would you like to use Tailwind CSS? Yes
  • Would you like to use src/ directory? No
  • Would you like to use App Router? (recommended) Yes
  • Would you like to customize the default import alias (@/*)? No

After our new app has spun up, let’s override the default Next.js boilerplate:

// app/layout.tsx

import Header from "@/app/_components/Header";
import type { Metadata } from "next";
import "./globals.css";

export const metadata: Metadata = {
  title: "Next.js Weather",
  description:
    "A weather app built with Next.js and next-intl",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className="...">
        <Header />
        {children}
      </body>
    </html>
  );
}

🗒️ Note » We’re omitting styles here for brevity (unless they directly pertain to i18n). You can get complete code listings from our GitHub repo (including styles).

 The simple Header in our RootLayout is a navbar that tops all our pages.

// app/_components/Header.tsx

import Link from "next/link";

export default function Header() {
  return (
    <header className="...">
      <nav>
        <ul className="...">
          <li>
            <Link href="/" className="...">
              Next.js Weather
            </Link>
          </li>
          <li>
            <Link href="/week" className="...">
              This week
            </Link>
          </li>
          <li>
            <Link href="/about" className="...">
              About
            </Link>
          </li>
        </ul>
      </nav>
    </header>
  );
}

Navigation header with links titled 'Next.js Weather', 'This week', and 'About', on a dark blue background.

Our home page lists some mock hard-coded data for “today’s weather”.

// app/page.tsx

export default function Home() {
  return (
    <main>
      <h1 className="...">
        Today&apos;s weather
      </h1>
      <h2 className="...">Monday April 15 2024</h2>

      <section>
        <div className="...">
          <p className="...">☀️</p>
          <p className="...">Sunny</p>
          <p className="...">22°C</p>
        </div>
      </section>
    </main>
  );
}

A screenshot of our home page showing sunny weather with a sun icon and a temperature of 22°C for Monday, April 15, 2024, on a dark background.

Weekly weather

To simulate an async server component, let’s build a page at the /week route that loads some mock JSON data: daily weather forecasts for the week. Here’s what our JSON looks like:

// app/_data/week.json

{
  "weeklyWeather": [
    {
      "dateTime": "2024-04-15T12:00:00Z",
      "condition": "sunny",
      "conditionIcon": "☀️",
      "temperature": {
        "celsius": 22,
        "fahrenheit": 71.6
      }
    },
    {
      "dateTime": "2024-04-16T12:00:00Z",
      "condition": "partlyCloudy",
      "conditionIcon": "⛅️",
      "temperature": {
        "celsius": 20,
        "fahrenheit": 68
      }
    },
    // ...
  ]
}

🔗 Resource » Get the entire JSON file from GitHub.  

Of course, we’ll need to declare some TypeScript types for it:

// types.ts

export interface WeeklyWeatherRoot {
  weeklyWeather: WeeklyWeather[];
}

export interface WeeklyWeather {
  dateTime: string;
  condition: string;
  conditionIcon: string;
  temperature: Temperature;
}

export interface Temperature {
  celsius: number;
  fahrenheit: number;
}

We can now create a page component that pulls and displays this data.

// app/week/page.tsx

import type { WeeklyWeatherRoot } from "@/types";
import { promises as fs } from "fs";

export default async function Week() {
  const fileContents = await fs.readFile(
    `${process.cwd()}/app/_data/week.json`,
    "utf-8",
  );
  const { weeklyWeather } = JSON.parse(
    fileContents,
  ) as WeeklyWeatherRoot;

  return (
    <main>
      <h1 className="...">
        This week&apos;s weather
      </h1>
      <div className="...">
        {weeklyWeather.map((day) => (
          <section key={day.dateTime} className="...">
            <h2 className="...">
              {new Date(day.dateTime).toString()}
            </h2>
            <div>
              <div className="...">
                <p className="...">
                  {day.conditionIcon}
                </p>
                <p className="...">
                  {day.condition}
                </p>
                <p className="...">
                  {day.temperature.celsius}°C
                </p>
              </div>
            </div>
          </section>
        ))}
      </div>
    </main>
  );
}

Weekly weather forecast on our site, showing sunny 22°C on Monday, partly cloudy 20°C on Tuesday, cloudy 18°C on Wednesday, and rainy 16°C on Thursday. Each entry includes the date and time with a GMT+0100 timezone. The site is accessed via a local server at 'localhost:3000/week' (shown in browser address bar).

The formatting could be better here; we’ll improve it as we localize the page. Speaking of which, let’s get to localization!

🔗 Resource » Get the code for our entire starter app (before localization) from GitHub.

How do I localize my Next.js app with next-intl?

Let’s look at the recipe for localizing a Next.js app with next-intl:

1. Install and set up next-intl.
2. Use t() for component string translations.
3. Configure localized routing.
4. Build a language switcher.
5. Localize Client Components.
6. Localize Async components.
7. Enable static rendering.
8. Apply localized formatters for dates/numbers.
9. Localize page metadata.

We’ll work through these steps one at a time.

How do I install and configure next-intl?

First things first, let’s install the next-intl NPM package from the command line:

npm install next-intl

Adding the locale route segment

Next, we’ll move all our pages under a [locale] dynamic route segment. This will allow us to turn a given /foo route to localized routes like /en-us/foo (English USA) and /ar-eg/foo (Arabic Egypt).

🗒️ Note » A locale indicates a language and a region, represented by a code like fr-ca for French as used in Canada. While “language” and “locale” are sometimes used interchangeably, it’s important to know the difference. Here’s a handy list of locale codes.

This is the relevant project hierarchy before the [locale] segment:

.
└── app
    ├── about
    │   └── page.tsx
    ├── layout.tsx
    ├── page.tsx
    └── week
        └── page.tsx

Let’s create a new subdirectory called [locale] under app (or src/app if you’re using a src directory) and place our routed pages there. Here is our hierarchy after the [locale] route segment:

.
└── app
    └── [locale]
        ├── about
        │   └── page.tsx
        ├── layout.tsx
        ├── page.tsx
        └── week
            └── page.tsx

🗒️ Note » Be sure to update your module imports if need be.

Adding translation message files

We’ll need to pull our hard-coded strings out of our components and into translation files, one for each locale we want to support. Let’s add a new directory called locales under our project root with two new JSON files under it:

.
└── locales
    ├── ar-eg.json
    └── en-us.json

🗒️ Note » In this tutorial, we’re supporting en-us (English USA) and ar-eg (Arabic Egypt). Feel free to add any locales you want here.

Let’s start small and translate our app’s title in the Header component. We need to add a key/value pair for each translation message, making sure we use the same keys across locale files:

// locales/en-us.json

{
  "Header": {
    "appTitle": "Next.js Weather"
  }
}
// locales/ar-eg.json

{
  "Header": {
    "appTitle": "تقص نكست چى إس",
  }
}

🗒️ Note » We’ve namespaced our appTitle translation under Header to associate it with the Header component. This message structure is recommended by next-intl but is not necessary. Read the Structuring messages section of the next-intl documentation to learn more.

We’re on the right track, but there’s still a bit to do before we can use our new translations. First up, we need to integrate next-intl with our Next.js app.

Setting up configuration files

We need to create a few small setup files to get next-intl working smoothly. This includes adding a plugin to our next.config.mjs file.

// next.config.mjs

+ import createNextIntlPlugin from "next-intl/plugin";
+ const withNextIntl = createNextIntlPlugin();

  /** @type {import('next').NextConfig} */
  const nextConfig = {};

- export default nextConfig;
+ export default withNextIntl(nextConfig);

We’ll often refer to our app’s supported locales, so it’s wise to configure them in a single, central file. Let’s create a new file called i18n.config.ts at the root of our project to house our config.

// i18n.config.ts

export const locales = ["en-us", "ar-eg"] as const;
export type Locale = (typeof locales)[number];

next-intl uses a special i18n.ts configuration file to load translations—not to be confused with the previous i18n.config.ts. By default, this file must be in the project route and called i18n.ts, although this is configurable.

// i18n.ts

import { getRequestConfig } from "next-intl/server";
import { notFound } from "next/navigation";
import { locales, type Locale } from "./i18n.config";

// Load the translation file for the active locale
// on each request and make it available to our
// pages, components, etc.
export default getRequestConfig(async ({ locale }) => {
  if (!locales.includes(locale as Locale)) {
    return notFound();
  }

  return {
    messages: (await import(`./locales/${locale}.json`))
      .default,
  };
});

One last boilerplate is wiring up a Next.js middleware that next-intl provides. Let’s create a middleware.ts file at the root of our project and set it up with some starter config.

// middleware.ts

import createMiddleware from "next-intl/middleware";
import { locales } from "./i18n.config";

export default createMiddleware({
  // Use this locale when we can't match
  // another with our user's preferred locales
  // and when no locale is explicitly set.
  defaultLocale: "en-us",

  // List all supported locales (en-us, ar-eg).
  locales,

  // Automatic locale detection is enabled by
  // default. We're disabling it to keep things
  // simple for now. We'll enable it later when
  // we cover locale detection.
  localeDetection: false,
});

// Our middleware only applies to routes that
// match the following:
export const config = {
  matcher: [
    // Match all pathnames except for
    // - … if they start with `/api`, `/_next` or `/_vercel`
    // - … the ones containing a dot (e.g. `favicon.ico`)
    "/((?!api|_next|_vercel|.*\\..*).*)",
  ],
};

🗒️ Note » I used a different middleware route matcher than the next-intl recommended "/(en-us|ar-eg)/:path*". This approach avoids duplicating the locale list and works well in general. Just take care to add a specific matcher for any localized routes containing a dot (.).

🗒️ Note » If you’re using other middleware, check out the next-intl documentation on Composing other middlewares.

A note on the NEXT_LOCALE cookie

Despite disabling locale detection, next-intl uses a NEXT_LOCALE cookie to store the current locale, ensuring users consistently see their preferred language on subsequent visits. This is useful for maintaining user preferences but can lead to confusion during development. Consider deleting the NEXT_LOCALE cookie via your browser’s dev tools when you test initial route access.

A basic test

Phew! That was a good amount of configuration. It’s well worth the effort, however, considering how much custom code and headaches next-intl saves us. Let’s put the library to the test and use the translation files we added above. Here they are again to refresh our memory:

// locales/en-us.json

{
  "Header": {
    "appTitle": "Next.js Weather"
  }
}
// locales/ar-eg.json

{
  "Header": {
    "appTitle": "تقص نكست چى إس",
  }
}

The simplest way to add these translations to our components is via next-intl’s useTranslations() hook:

// app/_components/Header.tsx

+ import { useTranslations } from "next-intl";
  import Link from "next/link";

  export default function Header() {
+   // t will be scoped to "Header"...
+   const t = useTranslations("Header");

    return (
      <header className="...">
        <nav>
          <ul className="...">
            <li>
              <Link
                href="/"
                className="..."
              >
-               Next.js Weather
+               {/* ...because we're scoped to "Header", this
+                   returns "Header.appTitle" from the active
+                   locale's translation file. */}
+               {t("appTitle")}
             </Link>
            </li>
            {/* ... */}
          </ul>
        </nav>
      </header>
    );
  }

When we visit /en-us, our app should look exactly as it did before. If we visit /ar-eg, however, we should see our app title in Arabic.

Website header with the navigation tabs 'This week' and 'About' in English, and the site title in Arabic script ‘تقص نكست چى إس’ which translates to 'Next JS Weather' on a dark background.

Accessing the locale param

next-intl ensures that our routes are always localized under a /[locale] route param. We can access this param as usual in our layouts and pages. Let’s use the param to make our <html lang> attribute reflect the active locale.

// app/[locale]/layout.tsx

  import Header from "@/app/_components/Header";
  import "@/app/globals.css";
  import type { Metadata } from "next";

  export const metadata: Metadata = {
    title: "Next.js Weather",
    description:
      "A weather app built with Next.js and next-intl",
  };

+ // Rename for clarity
- export default function RootLayout({
+ export default function LocaleLayout({
    children,
+   params: { locale },
  }: Readonly<{
    children: React.ReactNode;
+   params: { locale: string };
  }>) {
    return (
-     <html lang="en">
+     <html lang={locale}>
        <body className="...">
          <Header />
          {children}
        </body>
      </html>
    );
  }

When we run our app now and visit /, we’ll be redirected to /en-us. If we open our browser dev tools, we should see the <html lang> element reflecting the active locale in the route.

The browser dev tools inspector showing <html lang=”en-us”>.

If we visit /ar-eg we should see our <html lang> update accordingly.

The browser dev tools inspector showing <html lang=”ar-eg”>.

How do I translate basic text?

We covered this when we configured and set up next-intl. Let’s go over it one more time, however. Our Header component needs its navigation links localized anyway.

// locales/en-us.json

 // next-intl incentivizes structuring our translation
 // keys so that they’re namespaced by component or page.
 {
   "Header": {
     "appTitle": "Next.js Weather",
+    "navLinks": {
+      "week": "This week",
+      "about": "About"
+    }
   }
 }
// locales/ar-eg.json

 {
   "Header": {
     "appTitle": "Next.js Weather",
+    "navLinks": {
+      "week": "هذا الأسبوع",
+      "about": "نبذة عنا"
+    }
   }
 }

The useTranslations hook allows to pull translations for the active locale using its returned t() function.

// app/_components/Header.tsx

+ // We added this before; higlighting for clarity.
+ import { useTranslations } from "next-intl";
  import Link from "next/link";

  export default function Header() {
+   // We added this before; higlighting for clarity.
+   const t = useTranslations("Header");

    return (
      <header className="...">
        <nav>
          <ul className="...">
            <li>
              <Link href="/" className="...">
                {t("appTitle")}
              </Link>
            </li>
            <li>
              <Link href="/week" className="...">
-               This week
+               {/* Note that we can refine into our
+                   translation objects with `.` */}
+               {t("navLinks.week")}
              </Link>
            </li>
            <li>
              <Link href="/about" className="...">
-               About
+               {t("navLinks.about")}
              </Link>
            </li>
          </ul>
        </nav>
      </header>
    );
  }

There, now the navbar in our header is completely localized.

Our webpage header with navigation links reading 'Next.js Weather', 'This week', and 'About' on a dark blue background.
When we visit /en-us we see the nav links in English.
A simple webpage header with navigation links reading (in Arabic) 'Next.js Weather', 'This week', and 'About' on a dark blue background.
When we visit /ar-eg we see the nav links in Arabic.

🗒️ Note » We can access all translations in a file by calling useTranslations() with no params. If we did so in the above example, we would have to prefix the keys given t() with "Header." e.g. t("Header.navLinks.week").

How do I configure localized routing?

next-intl’s middleware defaults to managing localized routing with a prefixed-based strategy, where routes are prefixed with the active locale, like /en-us/about.

🗒️ Note » next-intl provides a domain-based routing strategy that is beyond the scope of this article.

Configuring the locale prefix

Our default setting uses a forced locale prefix, meaning routes like /about automatically redirect to /en-us/about. This makes localized routing consistent, but we can adjust it. By changing the setting to as-needed, the default locale won’t have a prefix, but all others will.

// middleware.ts

import createMiddleware from "next-intl/middleware";
import { locales } from "./i18n.config";

export default createMiddleware({
  defaultLocale: "en-us",
  locales,
  localeDetection: false,
+ localePrefix: "as-needed",
});

export const config = {
  matcher: [
    "/((?!api|_next|_vercel|.*\\..*).*)",
  ],
};

With the as-needed setting, visiting / or /week directly shows content in the default English locale without the /en-us prefix. To access content in Arabic, such as the weekly weather, we must explicitly use the ar-eg prefix e.g. /ar-eg/week.

For this tutorial, we’ll revert the prefix setting to the forced "always" mode:

// middleware.ts

import createMiddleware from "next-intl/middleware";
import { locales } from "./i18n.config";

export default createMiddleware({
  defaultLocale: "en-us",
  locales,
  localeDetection: false,
- localePrefix: "as-needed",
+ // `"always"` is the default; we can
+ // omit the `localePrefix` option
+ // entirely and get the same result.
+ localePrefix: "always"
});

export const config = {
  matcher: [
    "/((?!api|_next|_vercel|.*\\..*).*)",
  ],
};

🗒️ Note » We can also set localePrefix: "never" which disables prefixes entirely. This option relies on a cookie for determining the locale. See the Never using a prefix section of the next-intl docs for more information.

🗒️ Note » For SEO or usability, some consider localizing their pathnames, such as translating /ar-eg/about to /ar-eg/نبذة-عنا. For details on how to set this up and ensure proper navigation, check out the Localizing pathnames section in the next-intl documentation.

Alternate links

next-intl automatically generates alternate URLs, which are crucial for SEO as they inform search engines about available page translations. These appear in the link header in HTTP response headers.

A screenshot of HTTP response headers, displaying various server responses such as 'Cache-Control', 'Content-Encoding', and 'Content-Type'. Notably, there's a 'link' header specifying alternate versions of the page for English US ('en-us') and Arabic Egypt ('ar-eg') locales. A 'set-cookie' header sets the 'NEXT_LOCALE' to 'en-us'. The response also includes 'X-Powered-By: Next.js' indicating the technology used.
An HTTP response from our app, showing an alternate ar-eg links in the link header.

🔗 Resource » You can disable these alternate links, or customize them using your own middleware logic. The Alternate links section of the next-intl docs can guide you here.

How do I use localized links?

Our Next.js Link components don’t automatically include locale prefixes like /ar-eg/week for Arabic pages—they link directly to /week. Although next-intl middleware corrects this by redirecting based on the active locale, links copied directly from our pages and shared lose their locale context. For consistent navigation and better SEO, it’s best to include locale prefixes in URLs explicitly.

Luckily, next-intl makes this easy by providing a drop-in replacement to Next.js’ Link. We can expose it from our i18n.config.ts file and use it in our app’s components.

// i18n.config.ts
+ import { createSharedPathnamesNavigation } from "next-intl/navigation";

  export const locales = ["en-us", "ar-eg"] as const;
  export type Locale = (typeof locales)[number];

+ export const { Link } =
+  createSharedPathnamesNavigation({ locales });

All it takes to use the next-intl-provided Link is changing our module imports in our components.

// app/_components/Header.tsx

  import { useTranslations } from "next-intl";
- import Link from "next/link";
+ import { Link } from "@/i18n.config";

  export default function Header() {
    const t = useTranslations("Header");

    // ...
  }

That’s it! Now when we’re on an Arabic page, our Links will explicitly point to the Arabic version of our pages.

The image shows a section of the rendered HTML of our site header with navigation links. The header contains a navigation bar with a list of three links. Each link is wrapped in list item tags. The first link is to the homepage with Arabic text indicating "Home." The second and third links are to the "Week" and "About Us" sections of the site, also in Arabic. All links are prefixed with the "/ar-eg/" locale.
Our nav links when we’re on an Arabic page.

Of course, when we’re on an English page, our Links explicitly point to the /en-us/foo versions of our pages.

🗒️ Note » If you’re using localized pathnames that vary by locale (like /en-us/about vs. /ar-eg/نبذة-عنا), switch to using the createLocalizedPathnamesNavigation function for accurate navigation. For more details, check the next-intl documentation on Localized pathnames.

How do I build a language switcher?

It’s useful to allow users to manually select their locale (even though we’ll attempt to automatically detect the best match from browser settings a bit later). In this section, we’ll create a Client Component with a <select> dropdown as a locale switcher.

We’ll use next-intl’s programmatic navigation functions in our locale switcher. Let’s start by exposing these functions from our i18n.config.ts file.

// i18n.config.ts

  import { createSharedPathnamesNavigation } from "next-intl/navigation";

  export const locales = ["en-us", "ar-eg"] as const;
  export type Locale = (typeof locales)[number];

- export const { Link } =
+ export const { Link, usePathname, useRouter } =
    createSharedPathnamesNavigation({ locales });

While we’re in i18n.config.ts let’s write a little map containing human-friendly names for our locales; we’ll use this in our <select> dropdown.

// i18n.config.ts

  import { createSharedPathnamesNavigation } from "next-intl/navigation";

  export const locales = ["en-us", "ar-eg"] as const;
  export type Locale = (typeof locales)[number];

+ export const localeNames: Record<Locale, string> = {
+   "en-us": "English",
+   "ar-eg": "العربية (Arabic)",
+ };

export const { Link, usePathname, useRouter } =
  createSharedPathnamesNavigation({ locales });

Alright, we’re ready to write our LocaleSwitcher.

// app/_components/LocaleSwitcher.tsx

"use client";

import {
  localeNames,
  locales,
  usePathname,
  useRouter,
  type Locale,
} from "@/i18n.config";

export default function LocaleSwitcher({
  locale,
}: {
  locale: Locale;
}) {
  // `pathname` will contain the current
  // route without the locale e.g. `/about`...
  const pathname = usePathname();
  const router = useRouter();

  const changeLocale = (
    event: React.ChangeEvent<HTMLSelectElement>,
  ) => {
    const newLocale = event.target.value as Locale;

    // ...if the user chose Arabic ("ar-eg"),
    // router.replace() will prefix the pathname
    // with this `newLocale`, effectively changing
    // languages by navigating to `/ar-eg/about`.
    router.replace(pathname, { locale: newLocale });
  };

  return (
    <div>
      <select
        value={locale}
        onChange={changeLocale}
        className="..."
      >
        {locales.map((loc) => (
          <option key={loc} value={loc}>
            {localeNames[loc]}
          </option>
        ))}
      </select>
    </div>
  );
}

🔗 Resource » Check out next-intl’s navigation options on the official docs.

Let’s add our new LocaleSwitcher to the Header component.

// app/_components/Header.tsx

- import { Link } from "@/i18n.config";
+ import { Link, type Locale } from "@/i18n.config";
- import { useTranslations } from "next-intl";
+ import { useLocale, useTranslations } from "next-intl";
+ import LocaleSwitcher from "./LocaleSwitcher";

  export default function Header() {
    const t = useTranslations("Header");

+   // Retrieves the active locale.
+   const locale = useLocale() as Locale;

    return (
      <header className="...">
        <nav>
          {/* ... */}
        </nav>
+       <LocaleSwitcher locale={locale} />
      </header>
    );
  }

We can’t call next-intl’s useLocale() hook directly from a Client Component, so we’re passing the locale as a prop from the Header component. We’ll cover Client Components in greater detail a bit later.

An animation showing our website on the “http://localhost:3000/en-us/about” page, showing the About page in English with a new language switcher dropdown in the header. The mouse cursor clicks on the dropdown and changes the selection from “English” to “Arabic”, causing the browser URL to change from “localhost:3000/en-us/about” to “localhost:3000/ar-eg/about” and showing the About page in Arabic. The mouse cursor then selects “English” from the dropdown, ad infinitum.

🗒️ Note » Remember that NEXT_LOCALE cookie? It’s still being set by next-intl, updating to reflect the user’s selected locale. This ensures if a user selects Arabic (ar-eg) from the dropdown, they will be automatically directed to the Arabic version of the site on future visits.

How do I automatically detect the user’s locale?

Modern browsers allow us to select a list of languages we prefer to see our web pages displayed in.

The Firefox’s dialog box for Webpage Language Settings. It explains that web pages can be offered in multiple languages and allows users to choose their preferred languages for displaying web pages. Two languages are listed: "English (Canada) [en-ca]" is highlighted, indicating it is the top preference, followed by "Arabic (Egypt) [ar-eg]." On the right side, there are buttons to "Move Up," "Move Down," or "Remove" the selected language. Below the language list is a button to "Select a language to add..." and at the bottom, there are "Cancel" and "OK" buttons, with "OK" highlighted in blue, suggesting it is the default action button.
Firefox’s language preferences dialog.
These languages are included in the Accept-Language header sent with every HTTP request. next-intl utilizes this to automatically detect the visitor’s locale, a feature we previously disabled to simplify our reasoning about localized locales and navigation. Let’s re-enable it now.

// middleware.ts

import createMiddleware from "next-intl/middleware";
import { locales } from "./i18n.config";

export default createMiddleware({
  defaultLocale: "en-us",
  locales,
+ // This is the default. We can omit the
+ // option entirely and get the same result.
+ localeDetection: true,
});

export const config = {
  // ...
};

🔗 Resource »  We cover locale detection in our dedicated guide, Detecting a User’s Locale in a Web App.

next-intl uses a cascade to determine the active locale, stopping when it resolves the locale at any step:

1. Locale prefix in the request URI (e.g., /ar-eg/about).
2. The NEXT_LOCALE cookie, if it exists.
3. A locale matched from the Accept-Language header (we just enabled this).
4. The defaultLocale configured in the middleware.

When localeDetection is active, next-intl tries to match the browser’s language preferences with our configured locales, optimizing for the closest linguistic and regional fit. For example, English variants match to en-us, and Arabic preferences to ar-eg, ensuring users see the most relevant language version of the site.

Notes and resources

  • Under the hood, next-intl uses the @formatjs/intl-localematcher best-fit algorithm.
  • When testing automatic locale matching remember to delete the NEXT_LOCALE cookie, since it will take precedence over user browser preferences when resolving the locale.
  • Read more about Locale detection in the next-intl docs.

How do I localize Client Components?

Next.js’ App Router typically uses React Server Components for server rendering, boosting performance and security. When components need browser-specific features like DOM events or React state, they can be designated as Client Components, which includes them in the client bundle and forces them to render in the browser.

While next-intl supports Server Components by default, it also offers ways to localize Client Components. Let’s add a mock weather alert Client Component to our home page to demonstrate. The component will have an accordion folding/unfolding UI that needs React state, which is only available in Client Components.

🗒️ Note » We made the language switcher we built a Client Component since we needed to listen to the DOM change event firing from its <select>.

// app/_components/WeatherAlerts.tsx

// Tell Next.js that this is a Client
// Component.
"use client";

// ...
import { useState } from "react";

export default function WeatherAlerts() {
  // We can only use React state in Client
  // Components.
  const [isOpen, setIsOpen] = useState(false);
  const toggleAlerts = () => setIsOpen(!isOpen);

  return (
    <div>
      <div
        className="..."
        onClick={toggleAlerts}
      >
        Weather Alerts
        <span className="...">
          &#9660; {/* Chevron down icon */}
        </span>
      </div>
      {isOpen && (
        <div className="...>
          <p className="...">
            🌩️ Severe Thunderstorm Warning until 09:00 PM
          </p>
          <p className="...">
            🌨️ Blizzard Warning in effect from 01:00 AM
          </p>
          <p className="...">
            🌊 Coastal Flood Advisory from noon today to
            10:00 PM
          </p>
        </div>
      )}
    </div>
  );
}

Let’s drop our new WeatherAlerts component into our home page.

// app/[locale]/page.tsx

+ import WeatherAlerts from "../_components/WeatherAlerts";

  export default function Home() {
    return (
      <main>
         {/* ... */}

        <section className="...">
           <div className="...">
             <p className="...">☀️</p>
             <p className="...">Sunny</p>
             <p className="...">22°C</p>
           </div>
         </section>

+      <WeatherAlerts />
     </main>
   );
 }

Looping animation of the weather alerts opening and closing, displaying the alert details in its open state.

Since useTranslations pulls translation messages on the server, we can’t use it in our Client Components. Fortunately, next-intl provides alternative ways for making our translations available to Client Components:

1. Passing translations to Client Components
2. Moving state to the server side
3. Providing individual messages
4. Providing all messages

We’ll focus on 1. passing translations directly, which allows Client Components to receive props from Server Components. This approach works well for our WeatherAlerts component, where translations are fetched server-side and all interactive elements are managed client-side.

First, let’s rename our WeatherAlerts component to ClientWeatherAlerts and make it a presentational component.

// app/_components/WeatherAlerts/ClientWeatherAlerts.tsx

 "use client";

 // ...
 import { useState } from "react";

- export default function WeatherAlerts() {
+ export default function ClientWeatherAlerts({
+   title,
+   children,
+ }: Readonly<{ title: string; children: React.ReactNode }>) {
    const [isOpen, setIsOpen] = useState(false);
    const toggleIsOpen = () => setIsOpen(!isOpen);

    return (
      <div>
        <div
          className="..."
          onClick={toggleIsOpen}
        >
-         Weather Alerts
+         {title}
          <span className="...">
            &#9660; {/* Chevron down icon */}
          </span>
        </div>
        {isOpen && (
          <div className="...">
-           <p className="...">
-             🌩️ Severe Thunderstorm Warning until 09:00 PM
-           </p>
-           // ...

+           {children}
          </div>
        )}
      </div>
    );
  }

Now we can create a new ServerWeatherAlerts component that injects our server-side translations, and mock fetched alerts, into our Client Component.

// app/_components/WeatherAlerts/ServerWeatherAlerts.tsx

import { type Locale } from "@/i18n.config";
import { useLocale, useTranslations } from "next-intl";
import ClientWeatherAlerts from "./ClientWeatherAlerts";

// In a production app, we would likely
// be fetching these from some service.
const mockWeatherAlerts = {
  "en-us": [
    "🌩️ Severe Thunderstorm Warning until 09:00 PM",
    "🌨️ Blizzard Warning in effect from 01:00 AM",
    "🌊 Coastal Flood Advisory from noon today to 10:00 PM",
  ],
  "ar-eg": [
    "🌩️ تحذير من عاصفة رعدية شديدة حتى الساعة 09:00 مساءً",
    "🌨️ تحذير من عاصفة ثلجية قائمة بدءًا من الساعة 01:00 صباحًا",
    "🌊 تنبيه من فيضان ساحلي من الظهيرة اليوم حتى الساعة 10:00 مساءً",
  ],
};

export default function ServerWeatherAlerts() {
  const t = useTranslations("WeatherAlerts");

  const locale = useLocale() as Locale;
  const alerts = mockWeatherAlerts[locale];

  return (
    // Pass the translation message as a prop.
    <ClientWeatherAlerts title={t("title")}>
      {/* Inject alerts as children. */}
      {alerts.map((alert) => (
        <p className="..." key={alert}>
          {alert}
        </p>
      ))}
    </ClientWeatherAlerts>
  );
}

We can use the useTranslations and useLocale hooks as normal in our Server Component, allowing us to grab our translations and pass them as props to our Client Component. Here are the new translation messages:

// locales/en-us.json
{
  // ...
+ "WeatherAlerts": {
+   "title": "Weather Alerts"
+ }
}

// locales/ar-eg.json
{
  // ...
+ "WeatherAlerts": {
+   "title": "تنبيهات الطقس"
+ }
}

Let’s swap our new Server Component into our home page to see our changes in action.

// app/[locale]/page.tsx

- import WeatherAlerts from "../_components/WeatherAlerts";
+ import ServerWeatherAlerts from "../_components/WeatherAlerts/ServerWeatherAlerts";

  export default function Home() {
    return (
      <main>
        {/* ... */}

        <section className="...">
          {/* ... */}
        </section>
-        <WeatherAlerts />
+        <ServerWeatherAlerts />
      </main>
    );
  }

Looping animation showing the weather alerts component in English opening to reveal the individual alerts. The app language is switched to Arabic to show the Arabic version of the weather alerts as it opens, ad infinitum.

This composition pattern of wrapping Client Components inside Server Components allows our translations to load only on the server. The i18n library is never added to the client bundle, making our initial app load and client bundle as performant as possible.

🔗 Resource » Again, there are other ways next-intl provides for localizing Client Components. The Using internationalization in Client Components section of the next-intl docs covers them in detail.

🔗 Resource » Reminder that you can get all the app code we cover here from our GitHub repo.

How do I localize async components?

Up to this point, we’ve localized synchronous Server Components, which work out-of-the-box with next-intl. However, our weekly forecast page component needs to fetch data, which makes it a special case: an async component.

 // app/[locale]/week/page.tsx

import type { WeeklyWeatherRoot } from "@/types";
import { promises as fs } from "fs";

// Note the `async` keyword here.
export default async function Week() {
  // We `await` our file read.
  const fileContents = await fs.readFile(
    `${process.cwd()}/app/_data/week.json`,
    "utf-8",
  );
  const { weeklyWeather } = JSON.parse(
    fileContents,
  ) as WeeklyWeatherRoot;

  return (
    <main>
      <h1 className="...">
        This week&apos;s weather
      </h1>
      <div className="...">
        {weeklyWeather.map((day) => (
          // Display the day data
        ))}
      </div>
    </main>
  );
}

Async components like this throw an error if we call the useTranslations hook from within them.

⨯ Internal error: Error: Expected a suspended thenable.
This is a bug in React. Please file an issue.

Easy fix, however: next-intl provides an async drop-in replacement for useTranslations called getTranslations. We’ll use this function to localize our weekly weather page.

First, let’s add our new translations:

// locales/en-us.json

  {
    // ...
+   "Week": {
+     "title": "This week's weather",
+     "sunny": "Sunny",
+     "cloudy": "Cloudy",
+     "rainy": "Rainy",
+     "partlyCloudy": "Partly Cloudy",
+     "showers": "Showers",
+     "thunderstorms": "Thunderstorms"
    }
  }
// locales/ar-eg.json

  {
    // ...
+   "Week": {
+     "title": "طقص الأسبوع",
+     "sunny": "مشمس",
+     "cloudy": "غائم",
+     "rainy": "ممطر",
+     "partlyCloudy": "غائم جزئيا",
+     "showers": "زخات مطرية",
+     "thunderstorms": "عواصف رعدية"
    }
  }

Now we need to import the async getTranslations function and use it like useTranslations.

// app/[locale]/week/page.tsx

  import type { WeeklyWeatherRoot } from "@/types";
  import { promises as fs } from "fs";
+ import { getTranslations } from "next-intl/server";

  export default async function Week() {
    const fileContents = await fs.readFile(
      `${process.cwd()}/app/_data/week.json`,
      "utf-8",
    );
    const { weeklyWeather } = JSON.parse(
      fileContents,
    ) as WeeklyWeatherRoot;

+  // We have to `await` here.
+  const t = await getTranslations("Week");

    return (
      <main>
        <h1 className="...">
-         This week&apos;s weather
+         {t("title")}
        </h1>
        <div className="...">
          {weeklyWeather.map((day) => (
            <section key={day.dateTime} className="...">
              <h2 className="...">
                {new Date(day.dateTime).toString()}
              </h2>
              <div>
                <div className="...">
                  <p className="...">
                    {day.conditionIcon}
                  </p>
                  <p className="...">
-                   day.condition
+                   {/* "sunny" | "partlyCloudy" | ... */}
+                   {t(day.condition)}
                  </p>
                  <p className="...">
                    {day.temperature.celsius}°C
                  </p>
                </div>
              </div>
            </section>
          ))}
        </div>
      </main>
    );
  }

🗒️ Note » We need to use getTranslations in all async components, whether they’re page or shared components.

The English version weekly weather page, showing its title, “This week’s weather” and daily conditions “sunny, partly cloudy, etc.” in English.
Our weekly weather in English.
The Arabic version weekly weather page, showing its title, “طقص الأسبوع” and daily conditions “مشمس, غائم جزئيا, etc.” in Arabic.
Our weekly weather in Arabic.

🔗 Resource » The next-intl Async components documentation section covers other async server functions like getLocale and getFormatter.

How do I ensure static rendering?

Next.js defaults Server Components to static rendering. We can see this if we build our site before it was localized by next-intl (use the start branch of our GitHub repo if you want to try this).

From the command line:

npm run build
This image displays a terminal output showing the route size report for a web application. The output includes a table with columns labeled "Route (app)", "Size", and "First Load JS". The table lists routes for "/", "/_not-found", "/about", and "/week", each with sizes ranging from 145 B to 871 B and first load JavaScript sizes all at approximately 87 kB. Additionally, there's a section titled "+ First Load JS shared by all" with entries for chunks such as "23-3032740b29323cf3.js" and "fd9d1056-2821b0fcab0c3d8bd.js" with sizes of 31.3 kB and 53.6 kB respectively, and "other shared chunks (total)" of 1.86 kB. Below the table, a line reads "o (Static) prerendered as static content", indicating that the listed routes are statically generated.
Next.js build report in the terminal.

All our routes are statically prerendered, which means they can be cached on the server, speeding up our page loads and saving compute time on the server.

If we build the site in its current state, after localization with next-intl, we see a different story. (Use the main branch of the GitHub repo if you want to try this yourself).

This image shows a build report with marked routes: "/_not-found" is static, while "/[locale]", "/[locale]/about", and "/[locale]/week" are dynamic. Sizes vary from 137 B to 1.12 kB, with shared JavaScript chunks contributing to first load performance. Middleware is also listed, adding to the complexity of the build.

Due to next-intl APIs loading translations, our routes implicitly opt-in to dynamic rendering for each request. The next-intl team plans to address this in future updates, but they’ve provided a temporary workaround. Let’s implement it.

First, we need to address the [locale] dynamic route param. Next.js doesn’t know how to fill values in that route segment during building unless we tell via the generateStaticParams function. Let’s add this function to our layout.

// app/[locale]/layout.tsx

  import Header from "@/app/_components/Header";
  import "@/app/globals.css";
- import { type Locale } from "@/i18n.config";
+ import { type Locale, locales } from "@/i18n.config";
  import type { Metadata } from "next";

+ export function generateStaticParams() {
+   return locales.map((locale) => ({ locale }));
+ }

  export const metadata: Metadata = {
    // ...
  };

  export default function LocaleLayout({
    children,
    params: { locale },
  }: Readonly<{
    children: React.ReactNode;
    params: { locale: Locale };
  }>) {
    return (
      // ...
    );
  }

Next, we need to call next-intl’s workaround unstable_setRequestLocale function, which makes the current locale available to all its APIs.

// app/[locale]/layout.tsx

  import Header from "@/app/_components/Header";
  import "@/app/globals.css";
  import { type Locale, locales } from "@/i18n.config";
  import type { Metadata } from "next";
+ import { unstable_setRequestLocale } from "next-intl/server";

  export function generateStaticParams() {
    return locales.map((locale) => ({ locale }));
  }

  export const metadata: Metadata = {
    // ...
  };

  export default function LocaleLayout({
    children,
    params: { locale },
  }: Readonly<{
    children: React.ReactNode;
    params: { locale: Locale };
  }>) {
+   unstable_setRequestLocale(locale);
    return (
      // ...
    );
  }

Because Next.js can render layouts separately from pages, we need to call unstable_setRequestLocale in the layout and all pages.

// app/[locale]/page.tsx

+ import { unstable_setRequestLocale } from "next-intl/server";
  import WeatherAlerts from "../_components/WeatherAlerts/WeatherAlerts";

- export default function Home() {
+ export default function Home({
+   params: { locale },
+ }: Readonly<{ params: { locale: string } }>) {

+   unstable_setRequestLocale(locale);

    return (
      <main>
        {/* ... */}
      </main>
    );
  }

Repeat the above for each page in your app, adding the locale param and passing it to unstable_setRequestedLocale.

🗒️ Note » You need to call unstable_setRequestedLocale before using any next-intl API like useTranslations or you’ll get errors when you build.

With this in place, running npm run build should reveal that we’re getting static rendering again.

This image displays a build report with routes using Static Site Generation (SSG). The "/[locale]", "/[locale]/about", and "/[locale]/week" routes, along with their English and Arabic versions, use SSG for optimized performance. Details on script sizes for initial loads and shared chunks are also included.

Some notes on static rendering

  • The unstable_setRequestLocale is a temporary solution due to Next.js limitations: Server Components can’t directly access route parameters like locale. Future next-intl updates may eliminate this requirement. Find out more in the next-intl Static rendering documentation.
  • Using generateStaticParams to specify every supported locale can impact build performance, especially for sites with many locales. It’s often more efficient to specify only the default locale and dynamically generate others as needed.
  • Statically exporting your site with next-intl has some important limitations. Refer to the Usage without middleware (static export) section in the next-intl documentation for guidance.

How do I work with right-to-left languages?

The Arabic version of our site has been looking awkward in its left-to-right (ltr) orientation so far. Arabic, Hebrew, Maldivian, and other languages are laid out right-to-left (rtl). Let’s accommodate rtl languages in our app. We’ll do so via a simple custom hook.

First, let’s grab the rtl-detect NPM package, which we’ll use in our hook.

npm install rtl-detect
npm install --save-dev @types/rtl-detect

On to our custom hook:

// app/_hooks/useTextDirection.ts

import { useLocale } from "next-intl";
import { isRtlLang } from "rtl-detect";

export type TextDirection = "ltr" | "rtl";

export default function useTextDirection(): TextDirection {
  const locale = useLocale();
  return isRtlLang(locale) ? "rtl" : "ltr";
}

We can now use our new hook in our app layout to ensure the <html dir> attribute matches the active locale.

// app/[locale]/layout.tsx

  // ...
  import type { Metadata } from "next";
  import { unstable_setRequestLocale } from "next-intl/server";
+ import useTextDirection from "../_hooks/useTextDirection";

  // ...

  export default function LocaleLayout(
    // ...
  ) {
    unstable_setRequestLocale(locale);
+   // Make sure this comes after the
+   // unstable_setRequestLocale call
+   // to avoid build errors.
+   const dir = useTextDirection();

    return (
-     <html lang={locale}>
+     <html lang={locale} dir={dir}>
        <body className="...">
          <Header />
          {children}
        </body>
      </html>
    );
  }

With this in place, the html tag will have dir="ltr" when we’re on an English route and dir="rtl" for Arabic routes.

Our home page flowing in a right-to-left orientation.
Browsers automatically flow the page right-to-left when dir=”rtl”.

🔗 Resource » There’s more to rtl than just setting the <html dir> attribute. We often need to consider which CSS selectors to use for a locale or text direction and how they affect our layouts. Our CSS Localization guide goes into detail about this and more.

How do I work with dynamic values in translation messages?

So far we’ve used static text in our translation messages. To inject runtime values into a translation we can use the {variable} interpolation syntax. Let’s add a mock user greeting to our home page to demonstrate.

// locales/en-us.json

{
  // ...
  "WeatherAlerts": {
    "title": "Weather Alerts"
  },
+ "Home": {
+   // `{name}` will be replaced at runtime
+   "userGreeting": "👋 Welcome, {name}!"
+ },
  // ...
 }
// locales/ar-eg.json

{
  // ...
  "WeatherAlerts": {
    "title": "تنبيهات الطقس"
  },
+ "Home": {
+   "userGreeting": "👋 مرحباً {name}"
+ },
  // ...
 }
// app/[locale]/page.tsx

+ import { useTranslations } from "next-intl";
  import { unstable_setRequestLocale } from "next-intl/server";
  import WeatherAlerts from "../_components/WeatherAlerts/WeatherAlerts";

  export default function Home({
    params: { locale },
  }: Readonly<{ params: { locale: string } }>) {
    unstable_setRequestLocale(locale);

+   const t = useTranslations("Home");

    return (
      <main>
+       <p className="...">
+         // We supply a key/value map of
+         // dynamic values we want to replace.
+         {t("userGreeting", { name: "Noor" })}
+       </p>

        {/* ... */}
      </main>
    );
  }

We can have as many {variable}s in our message as we desire. We just need to make sure that we have an equivalent {variable: "Value"} in the second param passed to t() or our message won’t render correctly.

Our English home page, showing the new user greeting reading “Welcome, Noor!”

Our Arabic home page, showing the new user greeting reading, “مرحباً Noor”.🔗 Resource » Reminder that you can get all the code for our demo app from our GitHub repo.

How do I work with localized plurals?

Plurals often need special treatment in translation messages. We need to provide the different plural forms (”one message”, “three messages”) and an integer count that is used to select the correct form. next-intl wisely implements plurals with the flexible ICU Message Format, which looks like the following:

"messageCount":
  "{count, plural,
    one {One message}
    other {# messages}
  }"

English has two plural forms: one and other, which we specify above. We can then utilize the above English message with a call like t("messageCount", { count: 4 }). next-intl would then select the other form and render “4 messages”. Note that the count variable will replace the special # character in our messages.

🗒️ Note » We can’t have line breaks in our JSON files. The above line breaks were added to clarify the formatting. We’ll see a message like this in JSON in a moment.

For a concrete example, let’s add a weather alert counter to our weekly weather page. First, we’ll add the plural translation messages.

// locales/en-us.json

{
  // ...
  "Home": {
    "userGreeting": "👋 Welcome, {name}!"
  },
  "Week": {
+   "alertCount": "{count, plural, =0 {No alerts} one {One alert!} other {# alerts!}}",
    "sunny": "Sunny",
    "cloudy": "Cloudy",
    // ...
  }
}

We can use a special =n plural form where n is an integer override, allowing us to target a specific count value not covered by the language’s built-in plural forms. The =0 form above is a special zero count message that will override the other form when count === 0.

Now for the Arabic message. Unlike English, Arabic has six plural forms!

// locales/ar-eg.json

{
  // ...
  "Home": {
    "userGreeting": "👋 مرحباً {name}"
  },
  "Week": {
+   "alertCount": "{count, plural, zero {لا توجد تنبيهات} one {يوجد تنبيه واحد} two {يوجد تنبيهان} few {يوجد {count, number} تنبيهات} many {يوجد {count, number} تنبيه} other {يوجد {count, number} تنبيه}}",
    "sunny": "مشمس",
    // ...
  }
}

Note how we’re using {count, number} instead of # for count interpolation in our Arabic messages. This practice ensures our numbers are rendered in the correct numeral system for the active locale.

🔗 Resource » The canonical source for languages’ plural forms is the CLDR Language Plural Rules chart.

🔗 Resource » This Online ICU Message Editor is handy when formatting plural forms.

Alright, let’s add the weekly alert counter to utilize these messages.

// app/[locale]/week/page.tsx

// ...

export default async function Week({
  params: { locale },
}: Readonly<{ params: { locale: string } }>) {
  // ...

  const t = await getTranslations("Week");

  return (
    <main>
      <div className="...">
        <h1 className="...">{t("title")}</h1>
+       <p className="...">
+         {t("alertCount", { count: 3 })}
+       </p>
      </div>
      <div className="...">
        {/* ... */}
      </div>
    </main>
  );
}

Given the value of count and the active locale, we’ll see our new alert counter rendered with the correct plural form.

The image illustrates the pluralization rules for both Arabic and English. Arabic has six categories for plurals—zero, one, two, few, many, and other—each with its own unique phrase for 'alerts'. English, in contrast, has three categories—zero (no alerts), one (one alert), and other (used for numbers greater than one, e.g., three alerts).

Notes and resources

How do I localize numbers?

Not all regions use Western Arabic numerals (1, 2, 3). For instance, Tamil employs the Tamil numeral system (௦, ௧, ௨, ௩). Symbols for currency and large number separators vary by locale: In India, numbers are often separated by lakhs and crores (1,00,000 and 1,00,00,000) instead of thousands and millions (1,000 and 1,000,000).

🗒️ Note » Numbers and dates are often region-specific not language-specific, so use region-specific locale codes. Use en-us, not en, for example.

next-intl provides two main ways to format numbers: standalone and in messages. Standalone numbers are formatted using the format.number() function. Let’s use this on our home page to localize the day’s temperature.

//app/[locale]/page.tsx

- import { useTranslations } from "next-intl";
+ // Import the `useFormatter` hook
+ import { useFormatter, useTranslations } from "next-intl";
  // ...

  export default function Home({
    params: { locale },
  }: Readonly<{ params: { locale: string } }>) {
    const t = useTranslations("Home");
+   const format = useFormatter();

    unstable_setRequestLocale(locale);

   return (
    <main>
      {/* ... */}

      <section className="...">
        <div className="...">
          <p className="...">☀️</p>
          <p className="...">
            {t("sunny")}
          </p>
-         <p className="...">22°C</p>
+         <p className="...">
+           {format.number(22, {
+             style: "unit",
+             unit: "celsius",
+           })}
+         </p>
        </div>
      </section>

      <WeatherAlerts />
    </main>
  );
}

🗒️ Note » In async components, we need to use getFormatter instead of useFormatter.

Under the hood next-intl uses the standard Intl.NumberFormat to format our numbers, and it passes any options in the second param to format.number() along to Intl.NumberFormat.

🔗 Resource » See the Intl.NumberFormat constructor Parameters section of the MDN docs for a listing of all available number formatting options.

An excerpt of our Arabic home page displays a weather icon with a sun and, adjacent to the Arabic word for 'sunny.' The temperature is shown as "٢٢°م" within a highlighted box.
The Arabic temperature shown in the Eastern Arabic numerals native to Arabic.
An excerpt of the home page displaying a weather icon of a sun, accompanied by the word "Sunny" and the temperature "22°C" within a outlined box.
English numbers retain their formatting in Western Arabic numerals.

We sometimes need our numbers embedded in translation messages, and next-intl allows us to do this via ICU skeletons. These are just formatting patterns that start with ::. Let’s see them in action as we add a mock “we’ve been live for this long” message to our About page.

First, the messages:

// locales/en-us.json

{
  // ...
  "Week": {
    // ...
    "showers": "Showers",
    "thunderstorms": "Thunderstorms"
  },
  "About": {
+   "liveDuration": "We've been live for {duration, number, ::precision-integer} seconds."
  }
}

Note the {variableName, number [, ::skeleton]} syntax. The ::precision-integer skeleton causes the number to be rounded to the nearest whole number.

// locales/ar-eg.json

{
  // ...
  "Week": {
    // ...
    "showers": "زخات مطرية",
    "thunderstorms": "عواصف رعدية"
  },
  "About": {
+   "liveDuration": "لقد كنا على الهواء مباشرة لمدة {duration, number, ::precision-integer} ثانية."
  }
}

Let’s use these messages on our About page.

// app/[locale]/about/page.tsx

import { useTranslations } from "next-intl";
import { unstable_setRequestLocale } from "next-intl/server";

export default function About({
  params: { locale },
}: Readonly<{ params: { locale: string } }>) {
  unstable_setRequestLocale(locale);

  const t = useTranslations("About");

  return (
    <main>
      {/* ... */}
      <p className="...">
+       {t("liveDuration", { duration: 17280000.45 })}
      </p>
    </main>
  );
}

A screenshot of a portion of our About page reading  "لقد كنا على الموعد مباشرةً لمدة ٢٣٬٣٤٦ دقيقة”, which is the Arabic translation of our new message. The number in the message is shown in Eastern Arabic numerals and a whole number (no fractional decimals).

A screenshot of a portion of our English About page reading "We’ve been live for 17,280,000 seconds.” The number in the message is shown in Western Arabic numerals and a whole number (no fractional decimals).

ICU skeletons offer a lot of convenience and flexibility. Prebuilt skeletons like ::currency/USD or ::percent will use appropriate formatting for the active locale. In addition, ICU skeletons can offer granular control over number formats. For example, the ::.##/@##r skeleton will format a number with at most 2 fraction digits, but guarantee at least 3 significant digits.

Notes and resources

  • See the Number Skeletons page in the ICU documentation for available skeletons. Heads up, however: While many ICU skeletons work with next-intl, not all do. Be sure to test out the skeletons to ensure they work.
  • next-intl allows us to define global custom formats for reuse across our app. Check out the Formats documentation for more details.
  • Our Concise Guide to Number Localization covers numeral systems, separators, currency, and more.

How do I localize dates and times?

Date formatting also varies across regions. For instance, the format often used in the United States is MM/DD/YYYY, whereas many European countries use DD/MM/YYYY.

🗒️ Note » Date and time localization is similar to number localization in many ways, so we encourage you to read the previous section if you haven’t already.

Much like numbers, next-intl gives us two main ways of formatting dates and times: standalone and in messages. We’ll cover standalone formatting first. Let’s localize the date on our home page as we do.

// app/[locale]/page.tsx

import { useFormatter, useTranslations } from "next-intl";
// ...

export default function Home({
  params: { locale },
}: Readonly<{ params: { locale: string } }>) {
  const t = useTranslations("Home");
+ // We use the same `userFormatter`
+ // hook that used did for numbers.
  const format = useFormatter();

  unstable_setRequestLocale(locale);

  return (
    <main>
      {/* ... */}

      <h1 className="...">{t("title")}</h1>
      <h2 className="...">
-       Monday April 15 2024
+       {format.dateTime(new Date("2024-04-15"), {
+         dateStyle: "full",
+       })}
      </h2>

      {/* ... */}
    </main>
  );
}

The format.dateTime function is locale-aware, and will format its given Date object per the rules of the active locale. Under the hood, next-intl uses the standard Intl.DateTimeFormat, passing it any options we give it as the second param to t().

🔗 Resource » See the Intl.DateTimeFormat constructor Parameters section of the MDN docs for a listing of available datetime formatting options.

A screenshot of part of our English home page showing the text “Today’s weather” with the formatted date underneath reading “Monday, April 15, 2024.”
The full datetime format for English United States.
A screenshot of part of our Arabic home page showing the Arabic text meaning “Today’s weather” with the formatted date underneath reading “الاثنين، ١٥ أبريل ٢٠٢٤” (the Arabic date formatted in full).
The full datetime format for Arabic Egypt.

We can embed dates in our translation messages. Let’s add a message to our weekly forecast page that displays the day’s date to demonstrate.

// locales/en-us.json

{
  // ...
  "Week": {
    "title": "This week's weather",
+   "dayDate": "{dayDate, date, ::EEEE}",
    // ...
  },
  // ...
}
// locales/ar-eg.json

{
  // ...
  "Week": {
    "title": "الطقس لهذا الأسبوع",
+   "dayDate": "{dayDate, date, ::EEEE}",
    // ...
  },
  // ...
}

We can use the ICU {variable, date [, ::skeleton]} syntax to format dates in our message. The ::EEEE ICU datetime skeleton above should display the date as a long weekday e.g. “Thursday”.

🔗 Resource » next-intl only supports a subset of ICU datetime skeletons. See the Dates and times within messages section of the documentation for a listing of supported skeletons.

Let’s use our new messages on our weekly forecast page.

// app/[locale]/week/page.tsx

  import type { WeeklyWeatherRoot } from "@/types";
  import {
+   getFormatter,
    getTranslations,
    unstable_setRequestLocale,
  } from "next-intl/server";

  export default async function Week({
    params: { locale },
  }: Readonly<{ params: { locale: string } }>) {
    // ...

    const t = await getTranslations("Week");

+   // Remember to `await` here.
+   const format = await getFormatter();

    return (
      <main>
        {/* ... */}
        <div className="...">
          {weeklyWeather.map((day) => (
            <section key={day.dateTime} className="...">
              <h2 className="...">
-               {new Date(day.dateTime).toString()}
+               {t("dayDate", {
+                 dayDate: new Date(day.dateTime),
+               })}
              </h2>
              {/* ... */}
          </section>
        ))}
      </div>
    </main>
  );
}

Recall that we’ll get an error if we call the useFormatter hook within our async Week component. We have to use the async getFormatter instead.

A screenshot of our /en-us/week page showing the first days of the week with their dates formatted as “Mon”, 
”Tue”, and “Wed”.

A screenshot of our /ar-eg/week page showing the first days of the week with their dates formatted as “الاثنين”,
”الثاثاء”, and “الأربعاء”, which are the Arabic words for “Monday”, “Tuesday” and “Wednesday”, respectively.

Notes and resources

  • The eagle-eyed reader will have noticed that the English format above does not show the full day of the week (”Monday”), but the short version instead (”Mon”). This seems to be an issue with next-intl at the time of writing. We were able to work around it by using a custom global format.
  • By default, the server’s time zone is used when formatting dates. See the Time zone documentation under Global configuration if you want to use a different time zone.
  • In addition to absolute datetime formatting, next-intl provides options for formatting relative times and date ranges.
  • Our Guide to Date and Time Localization covers the subject in detail.

How do I include HTML in my translation messages?

Different languages have different grammar, so it’s often wise to leave internal links or style emphases (italics, bold) for translators to place in messages. However, we don’t want translators to worry about the intricacies of HTML.

next-intl solves this with the t.rich function. Let’s use the function to localize the description text in our About page. We can include a link in the description message for each locale:

// locales/en-us.json

{
  // ...
  "About": {
    "title": "About",
+   "description": "This is a minimalistic mock weather app built with <linkToNext>Next.js</linkToNext>.",
    // ...
  }
}

// locales/ar-eg.json

{
  // ...
  "About": {
    "title": "نبذة عنا",
+       "description": "هذا تطبيق طقس وهمي بسيط تم إنشاؤه باستخدام <linkToNext>نكست چى أس</linkToNext>.",
    // ...
  }
}

Note that our messages use a custom <linkToNext> tag to indicate the text we’re linking. We can call this tag whatever we want. We can also use as many tags as we want in a message as long as we resolve them when we call t.rich.

// app/[locale]/about/page.tsx

import { useTranslations } from "next-intl";
import { unstable_setRequestLocale } from "next-intl/server";

export default function About({
  params: { locale },
}: Readonly<{ params: { locale: string } }>) {
  unstable_setRequestLocale(locale);

  const t = useTranslations("About");

  return (
    <main>
      <h1 className="...">{t("title")}</h1>
      <p className="...">
-       This is a minimalistic mock weather app...
+       {t.rich("description", {
+         linkToNext: (chunks) => (
+           <a
+             href="https://nextjs.org"
+             className="text-sky-200 underline"
+           >
+             {chunks}
+           </a>
+         ),
+       })}
      </p>
      {/* ... */}
    </main>
  );
}

t.rich takes two parameters: the key of our translation message and a map of tags to resolvers. Each resolver is a simple function that takes as a chunks param, a string of the inner contents between <tag> and </tag> in the translation message. This allows using any React component when resolving our custom linkToNext tag. Here we’re using simple JSX that outputs an <a> tag.

A section of our English About page showing the header, reading "About," followed by the sentence "This is a minimalistic mock weather app built with Next.js." The word "Next.js" is presented in blue and underlined.

A section of our Arabic About page showing the header, reading "نذة عنا," (”About”) followed by the Arabic translation for "This is a minimalistic mock weather app built with Next.js." The word "Next.js" is presented in blue and underlined.

🔗 Resource » The Rich text section of the next-intl documentation covers tag reuse, self-closing tags, and more.

🔗 Resource » Sometimes we need to output a raw string; we can achieve this with the t.markup function. Find out more in the HTML markup section of the next-intl docs.

How do I localize page metadata?

Since metadata is managed outside of component rendering, we must utilize next-intl’s async getTranslator function to localize it. We’ll need to use Next.js’ async generateMetadata function as well.

Let’s localize our layout’s metadata to demonstrate. First, our messages:

// locales/en-us.json

{
  // ...
+ "Layout": {
+   "metaData": {
+     "title": "Next.js Weather",
+     "description": "A weather app built with Next.js and next-intl"
+   }
+ },
  // ...
}
// locales/ar-eg.json

{
  // ...
+ "Layout": {
+   "metaData": {
+     "title": "تقص نكست چى إس",
+     "description": "تطبيق طقس وهمي بسيط تم إنشاؤه باستخدام Next.js و next-intl."
+   }
+ },
  // ...
}

Now we can use these messages to localize our layout’s metadata.

// app/[locale]/layout.tsx

 // ...
 import { Locale, locales } from "@/i18n.config";
 import {
+  getTranslations,
   unstable_setRequestLocale,
 } from "next-intl/server";
 import useTextDirection from "../_hooks/useTextDirection";

 export function generateStaticParams() {
   return locales.map((locale) => ({ locale }));
 }

- export const metadata: Metadata = {
-   title: "Next.js Weather",
-   description:
-     "A weather app built with Next.js and next-intl",
- };

+ // We pull in the current locale
+ // generated from `generateStaticParms`
+ // or the current request route.
+ export async function generateMetadata({
+   params: { locale },
+ }: {
+   params: { locale: Locale };
+ }) {
+   const t = await getTranslations({
+     locale,
+     namespace: "Layout.metaData",
+   });
+
+   return {
+     title: t("title"),
+     description: t("description"),
+   };
+ }

 export default function LocaleLayout(
  // ...
 ) {
   // ...
 }

Now we can see our site’s title and description translated to the active locale.

A screenshot of the rendered HTML of our home page, showing the <title> and <meta name=”description”> attributes with inner values of “Next.js weather” and “A weather app built with Next.js and next-intl”, respectively.
Rendered HTML of our English home page.
A screenshot of the rendered HTML of our home page, showing the <title> and <meta name=”description”> attributes with inner values of our Arabic translations, “تقص نكست چى إس” and “تطبيق طقس وهمي بسيط تم إنشاؤه باستخدام Next.js و next-intl.”, respectively.
Rendered HTML of our Arabic home page.

Of course, we can override the layout values by applying the above recipe to any of our page components.

🔗 Resource » The Metadata & Route Handlers documentation covers metadata files (like OpenGraph images), route handlers, and more.

How do I make my message keys type-safe?

next-intl supports TypeScript out-of-the-box, but that doesn’t cover our app-specific message keys. By default, these are treated as a string type. We can tighten this up and make t() only accept keys we’ve defined in our default translation file. To accomplish this we need to redeclare next-intl’s IntlMessages type.

// types.ts

+ import enUSMessages from "./locales/en-us.json";

+ type Messages = typeof enUSMessages;
+ declare global {
+   interface IntlMessages extends Messages {}
+ }

  export interface WeeklyWeatherRoot {
    weeklyWeather: WeeklyWeather[];
  }

  // ...

Our particular app feeds weather condition keys to t() to map a key like "sunny" to its translation, "Sunny" (en-us) or "مشمس" (ar-eg). This means we need to update our types.ts file so that we’re always passing a compatible key type to t().

  import enus from "./locales/en-us.json";

  type Messages = typeof enus;
  declare global {
    interface IntlMessages extends Messages {}
  }

  export interface WeeklyWeatherRoot {
    weeklyWeather: WeeklyWeather[];
  }

+ export type Condition =
+   | "sunny"
+   | "cloudy"
+   | "rainy"
+   | "thunderstorms"
+   | "showers";

  export interface WeeklyWeather {
    dateTime: string;
-   condition: string;
+   condition: Condition;
    conditionIcon: string;
    temperature: Temperature;
  }

// ...

With this in place, if we try to call t() with a key not defined in our English message file, TypeScript will give us a type error.

A screenshot of VS Code, showing a call of t(”foo.bar”) resulting in a TypeScript error reading “Argument of type '"foo.bar"' is not assignable to parameter of type 'MessageKeys<{ metaData: { title: string; description: string; }; title: string;…”

That will do it for this tutorial. Here’s a look at our final localized app.

Looping animations of all the pages in our app localized in English and Arabic.

🔗 Resource » You can get all the code of the demo app from our GitHub repo.

Push the boundaries of Next.js localization

We hope you’ve found this guide to localizing Next.js with the App Router and next-intl library helpful.

When you’re all set to start the translation process, rely on the Phrase Localization Platform to handle the heavy lifting. A specialized software localization platform, the Phrase Localization Platform comes with dozens of tools designed to automate your translation process and native integrations with platforms like GitHub, GitLab, and Bitbucket.

With its user-friendly strings editor, translators can effortlessly access your content and transfer it into your target languages. Once your translations are set, easily integrate them back into your project with a single command or automated sync.

This way, you can stay focused on what you love—your code. Sign up for a free trial and see for yourself why developers appreciate using The Phrase Localization Platform for software localization.

The post Next.js 13/14 App Router Localization with next-intl appeared first on Phrase.

]]>
How to Localize SolidJS Applications with i18next https://phrase.com/blog/posts/solidjs-localization-i18next/ Mon, 23 Oct 2023 07:10:39 +0000 https://phrase.com/blog/?p=15220 Master the art of tailoring SolidJS applications for international users by leveraging the powerful i18next library.

The post How to Localize SolidJS Applications with i18next appeared first on Phrase.

]]>

SolidJS is a declarative JavaScript library for building user interfaces. It’s fast, since it doesn’t need a virtual DOM, and it only updates when needed. It also benefits from a relatively simple API, making it potentially faster to develop with than other libraries.

When it comes to JavaScript localization with SolidJS, we can use the solid-i18next library on top of the popular i18next library to get the job done effectively. This guide will walk you through localizing your SolidJS apps with solid-i18next.

We will quickly build a weather app and localize it: translating simple messages, adding a language switcher, formatting dates, and more. Let’s start already!

Translation memory visual | Phrase

The right translation software for your needs

Drive global growth by automating, managing, and translating all your content with the world’s most powerful, connective, and customizable translation software.

Explore Suite

 

 

Our demo app: SolidJS Weather

Throughout this tutorial, we will make a weather app named “SolidJS Weather”.

Our app will show you a variety of information related to the weather | Phrase

Our weather app in action showing the weather data in different languages | Phrase

To build this weather app, we will need the following packages.

Library Version used Description
SolidJS 1.7.6 Our main UI library.
i18next 22.5.1 Used to localize our app.
solid-i18next 1.2.2 Used to localize our app.
i18next-http-backend 2.2.1 Used to load translation files asynchronously.
i18next-browser-languagedetector 7.1.0 Used to automatically detect the user’s browser language.
tailwindcss 3.3.2 For styling; optional for our purposes.

Let’s use the SolidJS Vite template to kickstart a new project. From the command line:

npx degit solidjs/templates/js weather-app

Let’s quickly go over the components and starter code we need to have to get a working weather app. Our root App.js looks like this:

// /src/app.js

import WeatherForm from "./components/WeatherForm";

function App() {
  return (
   {/* CSS classes removed for brevity. */}
    <div>
      <h1>SolidJS Weather App</h1>
      <WeatherForm />
    </div>
  );
}

export default App;

The WeatherForm component contains the following code:

// /src/components/WeatherForm.jsx

import { createSignal } from "solid-js";

const mockWeatherData = [
  {
    city: "San Francisco",
    country: "USA",
    temperatureCelcius: 18,
    temperatureFahrenheit: 64.4,
    feelsLike: 15,
    humidity: 0.7,
    date: "10/04/2023",
    weatherStations: 11,
  },
  {
    city: "Paris",
    country: "France",
    temperatureCelcius: 5,
    temperatureFahrenheit: 41,
    feelsLike: 3,
    humidity: 0.8,
    date: "10/04/2023",
    weatherStations: 10,
  },
  // ...
];

const WeatherForm = () => {
  const [city, setCity] = createSignal("");
  const [weatherData, setWeatherData] = createSignal(null);

  const handleSubmit = (event) => {
    event.preventDefault();

    const cityWeather = mockWeatherData.find(
      (weather) => weather.city.toLowerCase() === city().toLowerCase()
    );

    if (cityWeather) {
      setWeatherData(cityWeather);
    } else {
      setWeatherData({
        error: `Could not find weather for ${city()}`,
      });
    }
  };

  return (
    <div>
      <form onSubmit={handleSubmit}>
        <input
          type="text"
          placeholder="Enter a city"
          value={city()}
          onInput={(event) => setCity(event.target.value)}
         />

        <button type="submit">
          Get Weather
        </button>
      </form>

      {weatherData() && !weatherData().error && (
        <div>
          <h2>
            📍 {weatherData().city}, {weatherData().country}
          </h2>
          <div>
            <p>
              Temperature is: {weatherData().temperatureCelcius}°C
              <br />
              Or: {weatherData().temperatureFahrenheit}°F
            </p>
          </div>
          <div>
            <p>
              Feels like: {weatherData().feelsLike}°C
            </p>
          </div>
          <div>
            <p>Humidity:</p>
            <p>{weatherData().humidity}%</p>
          </div>
          <div>
            <p>Data recorded on:</p>
            <p>{weatherData().date}</p>
          </div>
          <div>
            <p>Number of Weather Stations:</p>
            <p>
              {weatherData().weatherStations}
            </p>
          </div>
        </div>
      )}
      {weatherData() && weatherData().error && (
        <div>
          <p>{weatherData().error}</p>
        </div>
      )}
    </div>
  );
};

export default WeatherForm;

Our weather component in the demo app | Phrase

We’re mocking and hard-coding our weather data in the component, which uses the text <input> value to filter by city. If a city is found then all its weather is displayed, otherwise we show an error message.

🗒️ Note » You can find the starter project with all the app code before localization on GitHub.

The solid-i18next library

To add i18n functionality to our weather app, we will use the solid-i18next library. solid-i18next provides utilities and components for integrating i18n functionality into SolidJS applications using the very popular i18next library. It’s straightforward to integrate into your app, and supports various localization features like changing the language, interpolation, pluralization, and more.

📣 Shout-out » Martynas Barzda created and continues to maintain solid-i18next.

How do I set up solid-i18next?

We will start by installing the required dependencies from the command line:

npm install @mbarzda/solid-i18next i18next --save

Next, we need to wrap our app’s root component with solid-i18next’s <TransProvider />:

// src/index.js

// ...

import App from "./App";
import { TransProvider } from "@mbarzda/solid-i18next";

// ...

render(
  () => (
    <TransProvider lng="en-US">
      <App />
    </TransProvider>
  ),
  root
);

We incorporate the <TransProvider> component from solid-i18next which initializes i18next under the hood by executing i18next.init(). It creates a translation context in our SolidJS app and is responsible for providing the i18n functionality throughout our app components. We can also provide the lng prop to specify the default locale to use in the app.

How do I setup and configure i18next?

We will support 4 languages in this tutorial: English (default), Spanish, Russian and Arabic.

Let’s initialize i18next. Inside App.jsx, we create a new effect call to initialize i18next:

// /src/App.jsx

import { createEffect } from "solid-js";
// ...

createEffect(() => {
    i18next
      .init({
				// Default language.
        lng: "en-US",
				// Logging i18next information to the console.
        debug: true,
				// Interpolation option to escape passed in
        // values to avoid XSS injection.
        interpolation: {
          escapeValue: true,
        },
				// Language to use if translations in the
        // active language are not available.
        fallbackLng: false,
				// Resources i18next will use to retrieve
        // the appropriate translations based on
        // the active locale.
				resources: {
          "en-US": {
            translation: {
              app_name: "SolidJS Weather App",
              get_weather_button_label: "Get Weather",
              city_input_placeholder: "Enter a city",
              // ...
            },
          },
          "es-ES": {
            translation: {
              app_name: "Aplicación meteorológica SolidJS",
              get_weather_button_label: "Obtener el clima",
              city_input_placeholder: "Introduce una ciudad",
              // ...
            },
          },
          "ru-RU": {
            translation: {
              app_name: "Погодное приложение SolidJS",
              get_weather_button_label: "Получить погоду",
              city_input_placeholder: "Введите город",
              // ...
            },
          },
          "ar-EG": {
            translation: {
              app_name: "تطبيق الطقس سولَد چاي إس",
              get_weather_button_label: "الطقس",
              city_input_placeholder: "أدخل مدينة",
              // ...
            },
          },
        },
      })
  });

  return (
      <h1>SolidJS Weather App</h1>
      <WeatherForm />
  );
}

We are initializing an i18next instance inside the createEffect() to ensure we only initialize it once, instead of on every render.

How do I work with translation files?

Having all our translations inline in resources like we do above is not very scalable. Let’s move our translations into separate files, one per locale, and only load in the one we need.

🗒️ Note » A locale defines linguistic conventions for data display, with a locale code identifying a specific locale and region e.g. en-US for English as spoken in the United States.

Solid-i18next uses the i18next library and follows its format for adding translation files in folders that follow the naming convention, locales/{{lng}}/{{ns}}.json. {{lng}} is the locale and {{ns}} is the namespace (translation by default). For example, our Spanish translations would sit in locales/es-ES/translation.json.

🗒️ Note » Namespaces give us a bit of structure, so we can break up our translations into logical sections if need be (e.g. admin, login).

Let’s create a locales folder at the root of our project with locale folders inside e.g. locales/en-US, locales/es-ES.

Next, we will add a translations.json file inside each locale folder.

// /locales/en-US/translation.json

{
  "app_name": "SolidJS Weather App",
  "get_weather_button_label": "Get Weather",
  "city_input_placeholder": "Enter a city",
  // ...
}
// /locales/es-ES/translation.json

{
  "app_name": "Aplicación meteorológica SolidJS",
  "get_weather_button_label": "Obtener el clima",
  "city_input_placeholder": "Introduce una ciudad",
  // ...
}
// /locales/ru-RU/translation.json

{
 "app_name": "Погодное приложение SolidJS",
 "get_weather_button_label": "Получить погоду",
 "city_input_placeholder": "Введите город",
 // ...
}
// /locales/ar-EG/translation.json

{
  "app_name": "تطبيق الطقس سولَد چاي إس",
  "get_weather_button_label": "الطقس",
  "city_input_placeholder": "أدخل مدينة",
  // ...
}

As we progress throughout the article, we will add new translation keys. But first, we need to load these translation files into i18next.

How do I load translation files asynchronosly?

i18next supports lazy loading of translations through the i18next-http-backend plugin, which we will install in our app.

npm install i18next-http-backend

And now, use it in our App.jsx so our modified initialization code becomes this:

// /src/App.jsx

import { Show, createEffect, createSignal } from "solid-js";

import Backend from "i18next-http-backend";

function App() {
	const [isReady, setIsReady] = createSignal(false);
	
	createEffect(() => {
		i18next
		  .use(Backend)
		  .init({
		    lng: "en-US",
		    debug: true,
		    interpolation: {
		      escapeValue: true
		    },
		    fallbackLng: false,
		    // The default namespace to load when none
        // are specified explicitly.
        // "translation" is the default value here,
        // so we can can remove the `ns` option here
        // entirely if we wanted.
		    ns: "translation",
		    backend: {
		      loadPath: "../locales/{{lng}}/{{ns}}.json"
		    }
		  })
		  .then(() => setIsReady(true))
		  .catch((err) => console.error(err));
	});

return (
    <Show when={isReady()} fallback={<div>Loading...</div>}>
      <h1>SolidJS Weather App</h1>
      <WeatherForm />
    </Show>
  );
}

Since we’re loading our translation file asynchronously, we use a SolidJS <Show> component to display a loading message.

A note on the dev language and fallback

You might get an error like the following in your browser console:

GET http://localhost:3000/locales/dev/translation.json
net::ERR ABORTED 404 (Not Found).

This is normal, and just indicates that i18next is falling back to the developer language, dev. You can set fallbackLng to false during development to disable the error if you like, although according to the official docs the dev language does have its uses.

How do I retrieve the active locale?

We can use i18next’s language property to get the currently active locale.

import i18next from "i18next";

console.log("Active locale:", i18next.language);

How do I work with basic translation messages?

Now that we set up our translations in the supported locales, we can start using solid-i18next to translate our app. The library provides a useTransContext() hook, which provides us with a translation t function. Given a translation key, t returns the associated translation in the active locale.

// /src/App.jsx

import { useTransContext } from "@mbarzda/solid-i18next";

function App() {
  const [t] = useTransContext();

  return (
    <Show when={isReady()} fallback={<div>Loading...</div>}>
      <div>
        <h1>{t("app_name")}</h1>
      </div>
    </Show>
  );
}

export default App;

Try changing the lng value in the i18next config from en-US to es-ES; you will see the heading now translated into Spanish. The same goes for the other locales, ru-RU and ar-EG.Our app name translated to the es-ES locale | Phrase

🗒️ Note » solid-i18next library also has a <Trans> component for translating messages. You can Read more about <Trans> the official docs.

Now let’s switch our attention to the weather form component and translate its strings:

// /src/components/WeatherForm.jsx

import { useTransContext } from "@mbarzda/solid-i18next";

const WeatherForm = () => {
  const [t] = useTransContext();
  
  // ...

  return (
    <div>
      <form
        onSubmit={handleSubmit}>
        <input
          type="text"
          placeholder={t("placeholder")}
          value={city()}
          onInput={(event) => setCity(event.target.value)} />
        <button type="submit">
          {t("button_label")}
        </button>
      </form>

      {weatherData() && !weatherData().error && (
        <div>
          // ...
          <div>
            <p>{t("humidity")}</p>
            <p>{weatherData().humidity}%</p>
          </div>
          <div>
            <p>{t("date_label")}</p>
            <p>{weatherData().date}</p>
          </div>
          <div>
            <p>{t("weather_stations_label")}</p>
            <p>
              {weatherData().weatherStations}
            </p>
          </div>
        </div>
      )}
      // ...
    </div>
  );
};

export default WeatherForm;

But what if a translation value is missing from your translation files? In that case, the translation key will act as a fallback. For example, if we do this:

<p>{t("section_heading")}</p>

And if you haven’t defined section_heading in your translation files, you will get “section_heading” in our browser.

Our weather app with the fallback translation key | Phrase

How do I add a language switcher?

Let’s give our users a nice little drop-down to select their language of choice. We will create a new component for this: it will comprise a basic <select> element for choosing from the supported languages in our app:

// /src/components/LanguageSwitcher.jsx

import { useTransContext } from "@mbarzda/solid-i18next";
import i18next from "i18next";

const LanguageSwitcher = () => {
  const [t, { changeLanguage }] = useTransContext();

  function handleLanguageChange(event) {
    changeLanguage(event.target.value);
  }

  return (
   <div>
       <select
					value={i18next.language} 
					onChange={handleLanguageChange}
       >
         <option value="en-US">{t("english_label")}</option>
         <option value="es-ES">{t("spanish_label")}</option>
         <option value="ru-RU">{t("russian_label")}</option>
         <option value="ar-EG">{t("arabic_label")}</option>
       </select>
    </div>
  );
};

export default LanguageSwitcher;

i18next provides a changeLanguage function which updates the active locale in the i18next instance. When we use the changeLanguage to update the active locale, solid-i18next ensures that all of our t() calls are re-rendered.

Our app’s language switcher in action | Phrase

How do I add dynamic values in translation messages?

Sometimes we need to include variables in our translation messages at runtime. In our app, we show an error message to the user when they search for a city we don’t have weather data for.

Our app’s error message when there’s no weather data for a particular city | Phrase

When translating this message, we’d like to interpolate the city name at runtime. We can do this using i18next’s {{variable}} format.

// /locales/en-US/translation.json

{
  "error_message": "Could not find weather for {{city}}",
}
// /locales/es-ES/translation.json

{
  "error_message": "No se pudo encontrar el clima para {{city}}",
}
// /locales/ru-RU/translation.json

{
  "error_message": "Не удалось найти погоду для {{city}}",
}
// /locales/ar-EG/translation.json

{
   "error_message": "لا يمكن العثور على الطقس ل{{city}}",
}

Inside the WeatherComponent, instead of hardcoding the error string, we can replace it with the t function, passing in the correct key and passing city as the second parameter:

// /src/components/WeatherForm.jsx

// ...

		if (cityWeather) {
			setWeatherData(cityWeather);
		} else {
			setWeatherData({
			  error: t("error_message", {
			    city: city(),
			  }),
			});
		}

// ...

Our app’s error message when there’s no weather data for a particular city in es-ES locale | Phrase

How do I work with plurals in translation messages?

The languages our app supports have different plural forms. English has 2 forms (one and other), Spanish has 3 (onemany and other), Russian has 4 (onefewmany and other), and Arabic has 6 (zeroonetwofewmany and other).

🗒️ Note » i18next uses the Intl.PluralRules API under the hood to determine which plural form to use for a message in the active locale.

In our app, we need the number of weather stations for a city, a pluralized message:

// /src/components/WeatherForm.jsx

// ...

return (
    <div>
      // ...
      {weatherData() && !weatherData().error && (
        <div>
          // ...
          <div>
            <p>{t("weather_stations_label")}</p>
            <p>
              {t("station", { count: weatherData().weatherStations })}
            </p>
          </div>
        </div>
      )}
      // ...
    </div>
  );
};

Note the count variable here: this will be used by i18next to select the appropriate plural form.

Heads up » There will be no fallback for a plural message if count is not provided.

Now let’s add the corresponding translations. For plurals, we use separate keys to mark the plural forms of a message, adding a plural form suffix to each. For example, the station message plural forms would have keys station_one, station_many, etc.

🔗 Resource » The CLDR Language Plural Rules chart is a canonical source for languages’ plural forms.

To differentiate between normal and plural form keys, i18next relies on the count option passed to t(). If it sees the count, it will look for plural form keys.

// /locales/en-US/translation.json

{
  // ...
  "station_one": "{{count}} weather station",
  "station_other": "{{count}} weather stations"
}
// /locales/es-ES/translation.json

{
  // ...
  "station_one": "{{count}} estación",
  "station_many": "{{count}} estaciones",
  "station_other": "{{count}} estaciones"
}
// /locales/ru-RU/translation.json

{
  // ...
  "station_one": "{{count}} метеостанция",
  "station_few": "{{count}} метеостанции",
  "station_many": "{{count}} Много станций",
  "station_other": "{{count}} станций"
}
// /locales/ar-EG/translation.json

{
  // ...
  "station_one": "محطة {{count}}",
  "station_two": "محطتان {{count}}",
  "station_few": "{{count}} محطات",
  "station_many": "{{count}} محطة",
  "station_other": "{{count}} محطة"
}

Number of weather stations based on the count value in en-US locale | Phrase

Number of weather stations based on the count value in ru-RU locale | Phrase

🔗 Resource » See the official i18next documenation on Plurals for more information.

Working with interval plurals

Interval plurals help to define phrases expressing the number of items that lie within a range. If you want to use interval plurals in your application where, for instance, you want to pluralize the number of likes of a post, then you will need to use a post-processor called i18next-intervalplural-postprocessor.

We first install it in our app:

npm install i18next-intervalplural-postprocessor

Then add it to our i18next initialization code:

// /src/App.jsx

import i18next from 'i18next';
import intervalPlural from 'i18next-intervalplural-postprocessor';

// ...

i18next
 .use(intervalPlural)
 .init({
   // ...
 })
// ...

Interval plurals have a special syntax in i18next translation messages:

// /locales/es-ES/translation.json

"likes_interval": "(0)[No me gusta];(1-100)[1+ gustos];(100-999)[100+ gustos];(1000-9999)[1k+ gustos];(10000-99999)[10k+ gustos];"

With this, we can use these keys in our app like this:

// /src/App.jsx

<div>
    <p>Number of likes in this post: 
      {t("likes_interval", {
        postProcess: "interval",
        count: weatherData().likes,
      })}
    </p>
  </div>

Interval plurals rendering number of likes in a post in en-US locale | Phrase

Interval plurals rendering number of likes in a post in es-ES locale I Phrase

How do I localize numbers?

We need to format the numbers we see for the temperature, “feels like”, and humidity values. Unlike English, Spanish and Russian, Arabic doesn’t use Western Arabic numerals (1, 2, 3). Instead, it uses Eastern Arabic numerals (١,٢,٣). Each locale can also have its own large number separation rules (e.g. 1,000,000), percentage symbol, currency formatting, and more.

🤿 Go deeper » Our Concise Guide to Number Localization covers the topic in much more detail.

So we need a way to localize our numbers. We can format numbers in our translation messages using the {{val, number}} syntax. The number keyword here is essential: It tells i18next to format the value as a number in the active locale.

🗒️ Note » i18next uses JavaScript’s built-in Intl.NumberFormat ****object for number formatting.

Let’s update all the instances of number formatting in our translation files so we get the following:

// /locales/en-US/translation.json

{
  // ...
  "temperature_celcius": "Temperature is: {{val, number}}",
  "temperature_fahrenheit": "Or: {{val, number}}",
  "feels_like": "Feels like: {{val, number}}",
  "humidity_number": "{{val, number}}",
  // ...
}
// /locales/es-ES/translation.json

{
  // ...
  "temperature_celcius": "La temperatura es: {{val, number}}",
  "temperature_fahrenheit": "O: {{val, number}}",
  "feels_like": "Se siente como: {{val, number}}",
  "humidity_number": "{{val, number}}",
  // ...
}
// /locales/ru-RU/translation.json

{
  // ...
  "temperature_celcius": "Температура: {{val, number}}",
  "temperature_fahrenheit": "Или: {{val, number}}",
  "feels_like": "Как будто: {{val, number}}",
  "humidity_number": "{{val, number}}",
  // ...
}
// /locales/ar-EG/translation.json

{
  // ...
  "temperature_celcius": "درجة الحرارة: {{val, number}} درجة مئوية",
  "temperature_fahrenheit": "أو: {{val, number}} درجة فهرنهايت",
  "feels_like": "تبدو مثل: {{val, number}} درجة مئوية",
  "humidity_number": "{{val, number}}",
  // ...
}

In our WeatherForm component, we pass the val property in the t function. This property’s value should be the corresponding weather data we need:

// /src/components/WeatherForm.jsx
// ...
{weatherData() && !weatherData().error && (<div>
  <h2>
    📍 {
    weatherData().city
  }, {
    weatherData().country
  } </h2>
  <div>
    <p> {
      t("temperature_celcius", {
        val: weatherData().temperatureCelcius,
        formatParams: {
          val: {
            style: "unit",
            unit: "celsius"
          }
        }
      })
    }
      <br/> {
      t("temperature_fahrenheit", {
        val: weatherData().temperatureFahrenheit,
        formatParams: {
          val: {
            style: "unit",
            unit: "fahrenheit"
          }
        }
      })
    } </p>
  </div>
  <div>
    <p> {
      t("feels_like", {
        val: weatherData().feelsLike,
        formatParams: {
          val: {
            style: "unit",
            unit: "celsius"
          }
        }
      })
    } </p>
  </div>
  <div>
    <p> {
      t("humidity")
    }</p>
    <p> {
      t("humidity_number", {
        val: weatherData().humidity,
        formatParams: {
          val: {
            style: "percent"
          }
        }
      })
    } </p>
  </div>
  // ...
</div>)
}

Note how we are also passing additional formatting options with the formatParams property to the t function. We add the nested val property along with two sub style and unit properties so that our unit of measurement for temperature values shows as expected.

Weather app showing localized numbers in en-US locale | Phrase

Weather app showing localized numbers in ru-RU locale | Phrase

How do I localize dates?

We’re being transparent and showing our users the date our weather data was recorded. Let’s localize this date. Similar to number formatting, for date formatting, we need to provide the following syntax in our translation value: {{val, datetime}}. This indicates to i18next to format the value as a DateTime.

🗒️ Note » Under the hood i18next uses the Intl.DateTimeFormat object to format localized dates.

Let’s add this DateTime to our translation files:

{
  // ...
  "data_recorded_on_date": "{{val, datetime}}",
  // ...
}

And then pass on the date property from our data in the weather component:

// /src/components/WeatherForm.jsx

// ...
<div>
  <p>{t("date_label")}</p>
  <p>{t("data_recorded_on_date", { val: weatherData().date })}</p>
</div>

Weather app showing the localized date in en-US locale | Phrase

Localized date in ru-RU locale | Phrase

If you need to modify the default date format, use the formatParams property of t function and pass the relevant values:

{t("data_recorded_on_date", {
	val: weatherData().date,
  formatParams: {
	  val: {
	    year: "2-digit",
      month: "short",
      day: "numeric",
    },
  },
})}

This results in a date that is a number, but the month name is shortened and the year is of 2 digits:

Localized date with custom formatting in en-US locale | Phrase

Localized date with custom formatting in es-ES locale | Phrase

🔗 Resource » Learn all the date formatting options i18next prodivides in the official docs.

How do I work with text direction (LTR, RTL)?

Arabic, among other languages, is laid out right-to-left. To accommodate this, we need to switch the <html dir> attribute so that it becomes <html dir="rtl"> when the active locale is Arabic.

// /src/App.jsx

import {createSignal, createEffect} from "solid-js";


function App() {
	//...
  const [locale, setLocale] = createSignal('en-US');

  //...

  const handleLocaleChange = (newLocale) => {
    setLocale(newLocale);
  };

  // Update the document text direction when 
  // the locale is changed.
  createEffect(() => {
    if (locale().startsWith('ar-EG')) {
      document.documentElement.setAttribute('dir', 'rtl');
    } else {
      document.documentElement.setAttribute('dir', 'ltr');
    }
  });

  //...
}

export default App;

Finally, we need to modify the language switcher drop-down to incorporate the locale() and onLocaleChange params:

// /src/components/LanguageSwitcher.jsx

const LanguageSwitcher = ({ locale, onLocaleChange }) => {
  const [t, { changeLanguage }] = useTransContext();

  function handleLanguageChange(event) {
    const newLocale = event.target.value;
    changeLanguage(newLocale);
    onLocaleChange(newLocale);
  }

  return (
    <div>
      <select
        value={locale}
        onChange={handleLanguageChange}
      >
        <option value="en-US">{t("english_label")}</option>
        <option value="es-ES">{t("spanish_label")}</option>
        <option value="ru-RU">{t("russian_label")}</option>
        <option value="ar-EG">{t("arabic_label")}</option>
      </select>
    </div>
  );
};

export default LanguageSwitcher;

Then make sure we also add these props to our App component as well:

// /src/App.jsx

import LanguageSwitcher from "./components/LanguageSwitcher";

// ...
function App() {
  // ...

  return (
    <Show when={isReady()} fallback={<div>Loading...</div>}>
      <div>
        <h1>{t("app_name")}</h1>
        <LanguageSwitcher
          locale={locale()}
          onLocaleChange={handleLocaleChange}
        />
        <WeatherForm />
      </div>
    </Show>
  );
}
export default App;

With these changes, your language switcher should work normally, and we now get our document in the RTL direction when in the ar-EG locale:

RTL direction when in the ar-EG locale | Phrase

How do I automatically detect the user’s language?

To let i18next automatically detect locale in our app based on the user’s preferred language, we can use the i18next-browser-languagedetector plugin. It detects the language in the following order as stated in its docs:

order: ['querystring', 'cookie', 'localStorage', 'sessionStorage', 'navigator', 'htmlTag', 'path', 'subdomain'],

Let’s install it:

npm install i18next-browser-languagedetector

Then we can use() it in our i18next code:

// /src/App.jsx

import LanguageDetector from "i18next-browser-languagedetector";

i18next
 .use(LanguageDetector)
 .init({
   // ...
 })
 .then(() => setIsReady(true))
 .catch((err) => console.error(err));

If a user’s browser is set to a preferred language, say fr-FR, and our app only supports fr-CA, i18next will still match the language part (fr) and use fr-CA for translations.

In cases where none of the user’s preferred locales match our app’s locales, i18next will resort to the fallbackLng set during the library’s configuration.

But how does automatic language detection interplay with our manual LanuageSwitcher? Here, if the changeLanguage method of i18next is called without any arguments, it defaults to what the i18next-browser-languagedetector plugin determines. However, if a user decides to manually select a language via the LanguageSwitcher, this will override the automatic language detection.

🗒️ Note » You can find the final project with all the component code on GitHub.

Step up your game in JavaScript localization

In this tutorial, we’ve delved into internationalizing SolidJS applications with the powerful solid-i18next and i18next libraries. We’ve covered translating messages, implementing a language switcher, and mastering interpolation and plurals with number and date formatting.

Now that your application is ready to go global, it’s time to make the translation process simpler. Enter Phrase Strings, our dedicated software localization solution, designed to streamline string translation management from start to finish.

With its robust API for automating translation workflows and seamless integrations with platforms like GitHub, GitLab, and Bitbucket, Phrase Strings takes care of the heavy lifting, giving you back time to focus on your code.

String Management UI visual | Phrase

Phrase Strings

Take your web or mobile app global without any hassle

Adapt your software, website, or video game for global audiences with the leanest and most realiable software localization platform.

Explore Phrase Strings

Phrase Strings offers a comprehensive strings editor that empowers translators to pick up content for translation with ease. Once they’ve completed their work, you can automatically integrate the translated content back into your project in the preferred file format.

Check out all Phrase features for developers and see for yourself how they can streamline your software localization workflows right from the start.

The post How to Localize SolidJS Applications with i18next appeared first on Phrase.

]]>