nico.fyi
    Published on

    Simple Internationalization for Next.js with Plurals Support

    New update!

    Authors

    After releasing my simple-i18n-next tool last week, Václav Hodek from Localazy asked if it supported pluralization. I hadn't thought about it before, but I thought it would be a good idea to add it.

    So I started by first researching how other tools support pluralization. It turned out that it's not as simple as handling "an apple" and "two apples." It's more complicated than that. For example, in German, you can have "ein Apfel" or "zwei Äpfel," just like in English. Meanwhile, in Japanese, there are no different forms for "one apple" and "two apples," so you have to use the same form for both: リンゴ1個 and リンゴ2個. Other languages like Arabic even have 6 different plural forms!

    The complexity doesn't stop there. There are also different plural forms for the ordinal numbers! For example, in English, you can have "1st apple," "2nd apple," "3rd apple," and "4th apple," which indicates that there are four plural forms. But in German, you just need to use the same form for the ordinal numbers: 1. Apfel, 2. Apfel, 3. Apfel, 4. Apfel.

    To solve this, I decided to follow the agreed convention as documented in the Unicode Common Locale Data Repository (CLDR), which defines 6 categories of the plural forms: zero, one, two, few, many, and other.

    These categories are merely mnemonics—their names don't strictly indicate the category's exact contents. For instance, in both English and French, the number 1 falls under the category one (singular). In English, all other numbers are classified as plural and given the category other. In French, however, the number 0 is also categorized as one, not other or zero, because units qualified by 0 are treated as singular.

    According to the document, a common misconception is that one applies only to the number 1. In reality, one is a category for any number that behaves like 1. In some languages, for example, one includes numbers ending in "1" (such as 1, 21, 151) but excludes numbers ending in 11 (like 11, 111, 10311).

    Thankfully, I don't need to write the code to determine the rules of which numbers go into which categories by myself. JavaScript has already a built-in API to do that: Intl.PluralRules. I just need to pass the language code to it, and it will return the rules for that language.

    const pr = new Intl.PluralRules()
    pr.select(0) // 'other' if in US English locale
    pr.select(1) // 'one' if in US English locale
    pr.select(2) // 'other' if in US English locale
    

    There is also an API to get the plural categories for a given language:

    let categories = new Intl.PluralRules('en').resolvedOptions().pluralCategories
    console.log(categories) // ['one', 'other']
    categories = new Intl.PluralRules('en', { type: 'ordinal' }).resolvedOptions().pluralCategories
    console.log(categories) // [ 'few', 'one', 'two', 'other' ]
    

    Using these APIs, I added the pluralization support to simple-i18n-next. You just need to add one of the following suffixes to let the script know that you want to use plurals: _one, _two, _few, _many, _other, or _zero for cardinal numbers, and _ordinal_one, _ordinal_two, _ordinal_few, _ordinal_many, _ordinal_other, or _ordinal_zero for ordinal numbers.

    For example, you can create a locales/en/messages.json file that contains the following content:

    locales/en/messages.json
    {
      "book_one": "One book",
      "book_other": "{{count}} books",
      "movie_ordinal_one": "1st movie",
      "movie_ordinal_two": "2nd movie",
      "movie_ordinal_few": "3rd movie",
      "movie_ordinal_other": "{{count}}th movie"
    }
    

    and a locales/de/messages.json file that contains the following content:

    locales/de/messages.json
    {
      "book_one": "Ein Buch",
      "book_other": "{{count}} Bücher",
      "movie_ordinal_other": "{{count}}. Film"
    }
    

    Then in the RSC component like page.tsx, you can use the generated function like this:

    page.tsx
    import {
      SupportedLanguage,
      bookWithCount,
      movieWithOrdinalCount,
    } from "@/locales/.generated/server";
    export default function Home({
      params: { lang },
    }: Readonly<{ params: { lang: SupportedLanguage } }>) {
      return (
        <main>
          <div>
            <p>{movieWithOrdinalCount(lang, 1)}</p>
            <p>{movieWithOrdinalCount(lang, 2)}</p>
            <p>{movieWithOrdinalCount(lang, 3)}</p>
            <p>{movieWithOrdinalCount(lang, 4)}</p>
            <p>{movieWithOrdinalCount(lang, 5)}</p>
          </div>
          <div>
            <p>{bookWithCount(lang, 1)}</p>
            <p>{bookWithCount(lang, 2)}</p>
            <p>{bookWithCount(lang, 3)}</p>
            <p>{bookWithCount(lang, 4)}</p>
            <p>{bookWithCount(lang, 5)}</p>
          </div>
        </main>
      )
    }
    

    which will render the following HTML when the language is German (de):

    <main>
      <div>
        <p>1. Film</p>
        <p>2. Film</p>
        <p>3. Film</p>
        <p>4. Film</p>
        <p>5. Film</p>
      </div>
      <div>
        <p>1 Buch</p>
        <p>2 Bücher</p>
        <p>3 Bücher</p>
        <p>4 Bücher</p>
        <p>5 Bücher</p>
      </div>
    </main>
    

    and when the language is English (en):

    <main>
      <div>
        <p>1st movie</p>
        <p>2nd movie</p>
        <p>3rd movie</p>
        <p>4th movie</p>
        <p>5th movie</p>
      </div>
      <div>
        <p>One book</p>
        <p>2 books</p>
        <p>3 books</p>
        <p>4 books</p>
        <p>5 books</p>
      </div>
    </main>
    

    In a client component, you can use the generated function like this:

    client/page.tsx
    "use client";
    
    import { useStrings } from "@/locales/.generated/client/hooks";
    
    export default function ClientComponent() {
      const lang = useSelectedLanguageFromPathname();
      const [, plurals] = useStrings(
        [
          "bookWithCount",
          "movieWithOrdinalCount",
        ],
        lang
      );
      if (!plurals) return null;
      return (
        <div>
          <div>
            <p>{plurals.bookWithCount(1)}</p>
            <p>{plurals.bookWithCount(2)}</p>
            <p>{plurals.bookWithCount(3)}</p>
            <p>{plurals.bookWithCount(4)}</p>
            <p>{plurals.bookWithCount(5)}</p>
          </div>
    
          <div>
            <p>{plurals.movieWithOrdinalCount(1)}</p>
            <p>{plurals.movieWithOrdinalCount(2)}</p>
            <p>{plurals.movieWithOrdinalCount(3)}</p>
            <p>{plurals.movieWithOrdinalCount(4)}</p>
            <p>{plurals.movieWithOrdinalCount(5)}</p>
          </div>
        </div>
      )
    }
    

    One thing to note is that even with the pluralization support, the strings being sent to the client are still only the required strings. For example, the movieWithOrdinalCount function above only contains the strings for the selected language. If the selected language is English, it will only send down the English strings defined in movie_ordinal_one, movie_ordinal_two, movie_ordinal_few, and movie_ordinal_other in locales/en/messages.json. If the selected language is German, it will only send down the German strings defined in movie_ordinal_other in locales/de/messages.json. Nothing more!

    It has been a fascinating learning experience to add pluralization support to simple-i18n-next. I hope you find it useful and that it helps you in your projects.

    Give it a try and let me know if you have any issues! You can also check an example of a Next.js project that uses the generated code here.


    By the way, I'm making a book about Pull Requests Best Practices. Check it out!