Localize your push notifications in Node.js

Being a backend developer, there's an unusual thing with push notifications. Devices display what I send. The clients don't have a say in it; they can't remodel and transform them. Meaning, I basically have to do some UI (User Interface).

Mainly dealing with data all day, this was a bit unusual to me. I tried to wiggle my way out of it; get the clients to receive silent data notifications, generate the strings, handle the whole formatting and localization, then re-emit local notifications.

But the mobile developers made some pretty good points (namely that we might want to be able to send unplanned notifications, or A/B test different formats), and I had to face the fact I'd have to generate, format and localize a few user-facing strings. Damn it. How am I supposed to localize text properly in Node.js?

"You've got to be kidding me" - Photo by Lauren McConachie / Unsplash

Having a few years of iOS development experience, I knew localization is a somewhat complex matter (ever heard of arabic plurals? Well, have a look. Also, here's an awesome list of things we know for sure but just aren't so), that I shouldn't just wing it if I didn't want it to come bite me in the ass later on, and that there had to be some pretty standard way to deal with it. Also, having used POEditor as a mobile dev, I  knew that, implemented correctly on my side, strings translation and localization could later be delegated to the product or business people on the team.

And so I went, looking for how to implement strings localization in my backend.

Most of npm's localization and internationalization modules seem to be either heavily tailored for specific front-end frameworks, abandonned (like most of my own open source code; sorry about that), or to rely on exotic formats.

One of them seems absolutely great, albeit too heavy for the time I had to implement this feature. There also appears to be a fine one, but I didn't find it at the time. Finding and evaluating good libraries is hard.

In the end, here's what I came up with.

Specifications

What is our ideal outcome?

All notifications' titles and bodies should be localized in the user's preferred language. Quantities and all other language specificities should be properly handled. Strings should be updatable and localizable with as little developer involvement as possible.

What will we do?

We will implement a somewhat standard way of handling strings localization, inspired from my iOS development experience. It will be both scalable and maintainable. The system should be generic enough to handle any language and it's possibly numerous specificities.

How will we do it?

... didn't I just answer that one?

What will we use to get it done?

It will be done in Typescript, using the i18next library (it seems both well maintained and backed), relying on key-value json strings localization files to hold our strings, and updating them using POEditor.

Now that we have the recipe outlined, let's get cooking!

Implementation

Photo by Maarten van den Heuvel / Unsplash

Original code

Let's say we're building a banking app, where we want to notify users on their device(s) whenever a transaction occurs. We currently have two languages: French and English.

const strings = {
	FR: (transactionAmount: number, remainingBalance: number) => ({
    	title: `Votre compte a été débité de ${transactionAmount} ${transactionAmount > 1 ? euros : euro}`,
        body: `Il reste ${remainingBalance} ${remainingBalance > 1 ? euros : euro} sur votre compte`
    }),
    EN: (transactionAmount: number, remainingBalance: number) => ({
    	title: `${transactionAmount} ${transactionAmount > 1 ? euros : euro} charged to your account`,
        body: `Remaining balance: ${remainingBalance} ${transactionAmount > 1 ? euros : euro}`
    }),
}

private chargeEventToNotification = (transactionAmount: number, remainingBalance: number, language: string) => {
	const stringsLanguage = Object.keys(strings).includes(language) ?  language : "default";
    return strings[language](transactionAmount: number, remainingBalance: number);
}

It seems... okayish, right? But see how we handle plurals? Who's to say some language don't require a much different treatment? Also, do you really want to have to update your code whenever someone decides the text should be clarified or refined? I wouldn't. How do we get out of this?

Store your users' locale preference

First, you need to know the user's language. For that, you'll want the client to send it to you. Preferably rely on something standard or common, such as IETF language tags (i.e. 'en-US' for American English, 'en-GB' for British English). Store these along with your users' preferences in your favorite database (usually DynamoDB for me, but I'm working with Firestore at the moment).

Create a key-value json localization file

Name it, say... strings.json! How original, yes! Also, remember to define where your dynamized text should appear in your strings. In our case, the parameter is named count because it also serves to determine wether or not the framework should use plurals.

{
    en: {
        notifications: {
            transactions: {
                title: "Votre compte a été débité de {{count}} euro",
                title_plural: "Votre compte a été débité de {{count}} euros",
                body: "Il reste {{count}} euro sur votre compte"
                body_plural: "Il reste {{count}} euros sur votre compte"
            }
        }
    }, 
    fr: {
        notifications: {
            transactions: {
                title: "{{count}} euro charged to your account",
                title_plural: "{{count}} euros charged to your account",
                body: "Account balance: {{count}} euro",
                body_plural: "Account balance: {{count}} euros"
            }
        }
    }
}

Update your code

Import i18next, set it up, internationalize your dynamized strings, and pass the proper count parameter so i18next can know wether or not to use plurals.


import i18next from 'i18next';
import strings from "./strings.json"

private chargeEventToNotification = (transactionAmount: number, remainingBalance: number, language: string) => {
   return i18next.init({ lng: language.replace("_", "-"), resources: strings })
       .then( () => ({
				title: i18next.t('notifications.transactions.title', { count: transactionAmount }),
				body: i18next.t('notifications.transactions.body', { count: remainingBalance }),
        }))
}

Sure, it now returns a Promise instead of an object. But I haven't managed to figure out another way to properly make sure i18next was setup before calling the function. Please do tell if you do have something better :-)

Going the extra mile

There are many other things to consider when it comes to localization. Even when only trying to properly manage plurals. If you want to see how to handle languages with multiple plurals, or interval plurals, check out the i18next documentation!

Optionaly, you could implement POEditor. It should probably work like a charm if you already have CI / CD. A client I worked for some time ago had it all set up for their mobile apps, linking POEditor and their source repositories, auto-updating the strings on the fly. So it shouldn't be too much of a pain to get working, but I haven't gotten to that point yet, so I can't say. I'll definitely update this post once I get there.


Done!

Enjoy your new-found localization powers! If you have any issue with this code, or any feedback whatsoever, let me know!

Languages! So many languages! - Photo by Jason Leung / Unsplash