How does server side internationalization (i18n) look like?
You may already know how to properly internationalize a client side application, like described in this React based tutorial, this Angular based tutorial or this Vue based tutorial.
In this blog post we will shed light on the server side.
Why do I need to handle i18n in my application's backend?
Think of all user faced content not directly rendered in your browser...
- For example you're building a command line interface (CLI)?
- You're sending some emails?
- Or you're using server side rendering (SSR)?
- etc.
Let's check that out...
We will show some examples that uses i18next as i18n framework. If you're curious to know why we suggest i18next, have a look at this page.
Command line interface (CLI)
Let's start with something simple: a very small CLI app. For this example let's use commander, originally created by TJ Holowaychuk.
We are defining a sayhi command with optional language and name parameters that should respond with a salutation in the appropriate language.
#!/usr/bin/env node
const program = require('commander')
program
.command('sayhi')
/* Lines 56-71 omitted */
})
program.parse(process.argv)
if (!process.argv.slice(2).length) {
program.outputHelp()
}Ok, now let's create a new i18n.js file and setup i18next accordingly:
const i18next = require('i18next')
// if no language parameter is passed, let's try to use the node.js system's locale
const systemLocale = Intl.DateTimeFormat().resolvedOptions().locale
i18next
.init({/* Lines 90-99 omitted */})
module.exports = (lng) => i18next.getFixedT(lng || systemLocale)And also our translation resources:
// locales/en/translations.json
{
"salutation": "Hello World!",
"salutationWithName": "Hello {{name}}!"
}
// locales/de/translations.json
{
"salutation": "Hallo Welt!",
"salutationWithName": "Hallo {{name}}!"
}Now we can use the i18n.js export like that:
#!/usr/bin/env node
const program = require('commander')
const i18n = require('../i18n.js')
program
.command('sayhi')
/* Lines 130-148 omitted */
})
program.parse(process.argv)
if (!process.argv.slice(2).length) {
program.outputHelp()
}Ok, what's the result?
## if we execute the cli command without any parameters...
mycli sayhi
## result: Hello World!
## if we execute the cli command with a language parameter...
mycli sayhi --language de
## result: Hallo Welt!
## if we execute the cli command with a language parameter and a name parameter...
mycli sayhi --language de --name John
## result: Hallo John!Easy, isn't it?
If you don't bundle your CLI app in a single executable, for example by using pkg, you can also i.e. use the i18next-fs-backend to dynamically load your translations, for example like this:
const i18next = require('i18next')
const Backend = require('i18next-fs-backend')
const { join } = require('path')
const { readdirSync, lstatSync } = require('fs')
// if no language parameter is passed, let's try to use the node.js system's locale
const systemLocale = Intl.DateTimeFormat().resolvedOptions().locale
const localesFolder = join(__dirname, './locales')
i18next
.use(Backend)
/* Lines 190-200 omitted */
})
module.exports = (lng) => i18next.getFixedT(lng || systemLocale)🧑💻 A code example can be found here.
A possible next step...
A possible next step could be to professionalize the translation management. This means the translations would be "managed" (add new languages, new translations etc...) in a translation management system (TMS), like Locize and synchronized with your code. To see how this could look like, check out Step 1 in this tutorial.
Generate Emails
Another typical server side use case that requires internationalization is the generation of emails.
To achieve this goal, you usually need to transform some raw data to html content (or text) to be shown in the user's preferred language.
In this example we will use pug (formerly known as "Jade", and also originally created by TJ Holowaychuk) to define some templates that should be filled with the data needed in the email, and mjml to actually design the email content.
Let's create a new mail.js file, which we can use, to accomplish this.
import pug from 'pug'
import mjml2html from 'mjml'
export default (data) => {
// first let's compile and render the mail template that will include the data needed to show in the mail content
/* Lines 229-236 omitted */
return html
}The mailTemplate.pug could look like this:
mjml
mj-body(background-color='#F4F4F4' color='#55575d' font-family='Arial, sans-serif')Now let's define some translations...
// locales/en/translations.json
{
"greeting": "Hi {{name}}!",
/* Lines 272-273 omitted */
"ending": "Internationalized with"
}
// locales/de/translations.json
{
"greeting": "Hallo {{name}}!",
/* Lines 279-280 omitted */
"ending": "Internationalisiert mit"
}...and use them in an i18n.js file:
import { dirname, join } from 'path'
import { readdirSync, lstatSync } from 'fs'
import { fileURLToPath } from 'url'
import i18next from 'i18next'
import Backend from 'i18next-fs-backend'
const __dirname = dirname(fileURLToPath(import.meta.url))
const localesFolder = join(__dirname, './locales')
i18next
.use(Backend) // you can also use any other i18next backend, like i18next-http-backend or i18next-locize-backend
/* Lines 298-309 omitted */
})
export default i18nextSo finally, all the above can be used like that:
import mail from './mail.js'
import i18next from './i18n.js'
const html = mail({
t: i18next.t,
name: 'John'
})
// that html now can be sent via some mail provider...This is how the resulting html could look like:

🧑💻 A code example can be found here.
Server Side Rendering (SSR)
We will try 2 different SSR examples, a classic one using Fastify with pug and a more trendy one using Next.js.
Fastify with Pug example
For this example we will use my favorite http framework Fastify (created by Matteo Collina and Tomas Della Vedova), but any other framework will also work.
This time we will use a different i18next module, i18next-http-middleware. It can be used for all Node.js web frameworks, like express or Fastify, but also for Deno web frameworks, like abc or ServestJS.
As already said, here we will use Fastify, my favorite 😉.
Let's again start with the i18n.js file:
import { dirname, join } from 'path'
import { readdirSync, lstatSync } from 'fs'
import { fileURLToPath } from 'url'
import i18next from 'i18next'
import Backend from 'i18next-fs-backend'
import i18nextMiddleware from 'i18next-http-middleware'
const __dirname = dirname(fileURLToPath(import.meta.url))
const localesFolder = join(__dirname, '../locales')
i18next
.use(i18nextMiddleware.LanguageDetector) // the language detector, will automatically detect the users language, by some criteria... like the query parameter ?lng=en or http header, etc...
/* Lines 363-374 omitted */
})
export { i18next, i18nextPlugin: i18nextMiddleware.plugin }And our translation resources...
// locales/en/translations.json
{
"home": {/* Lines 385-389 omitted */}
}
// locales/de/translations.json
{
"home": {/* Lines 395-399 omitted */}
}
// locales/it/translations.json
{
"home": {/* Lines 405-409 omitted */}
}A simple pug template:
html
headOur "main" file app.js:
import fastify from 'fastify'
import pov from 'point-of-view'
import pug from 'pug'
import { i18next, i18nextPlugin } from './lib/i18n.js'
const port = process.env.PORT || 8080
const app = fastify()
app.register(pov, { engine: { pug } })
app.register(i18nextPlugin, { i18next })
app.get('/raw', (request, reply) => {
reply.send(request.t('home.title'))
})
app.get('/', (request, reply) => {
reply.view('/views/index.pug')
})
app.listen(port, (err) => {
if (err) return console.error(err)
/* Lines 453-456 omitted */
console.log(i18next.t('server.started', { port, lng: 'it' }))
})Now start the app and check what language you're seeing...
If you check the console output you'll also see something like this:
node app.js
## Server is listening on port 8080.
## Der server lauscht auf dem Port 8080.
## Il server sta aspettando sul port 8080.Yes, if you like, you can also internationalize your log statements 😁
🧑💻 A code example can be found here.
Next.js example
Now it's time for Next.js...
When it comes to internationalization of Next.js apps one of the most popular choices is next-i18next. It is based on react-i18next and users of next-i18next by default simply need to include their translation content as JSON files and don't have to worry about much else.
Here you'll find a simple example.
You just need a next-i18next.config.js file that provides the configuration for next-i18next and wrapping your app with the appWithTranslation function, which allows to use the t (translate) function in your components via hooks.
// _app.js
import { appWithTranslation } from 'next-i18next'
const MyApp = ({ Component, pageProps }) => <Component {...pageProps} />
export default appWithTranslation(MyApp)// index.js
import { useTranslation } from 'next-i18next'
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
// This is an async function that you need to include on your page-level components, via either getStaticProps or getServerSideProps (depending on your use case)
const Homepage = () => {
const { t } = useTranslation('common')
/* Lines 566-575 omitted */
)
}
export const getStaticProps = async ({ locale }) => ({
props: {/* Lines 580-582 omitted */},
})
export default HomepageBy default, next-i18next expects your translations to be organized as such:
.
└── public
└── localesA demo of how such an app looks like when it is deployed, can be found here.
This looks really simple, right?
Manage the translations outside of the code
To best manage the translations there are 3 different approaches:
POSSIBILITY 1: live translation download
When using Locize, you can configure your next-i18next project to load the translations from the CDN (on server and client side).
Such a configuration could look like this:
// next-i18next.config.js
module.exports = {
i18n: {
defaultLocale: 'en',
locales: ['en', 'de'],
},
backend: {
projectId: 'd3b405cf-2532-46ae-adb8-99e88d876733',
// apiKey: 'myApiKey', // to not add the api-key in production, used for saveMissing feature
referenceLng: 'en'
},
use: [
require('i18next-locize-backend/cjs')
],
ns: ['common', 'footer', 'second-page'], // the namespaces needs to be listed here, to make sure they got preloaded
serializeConfig: false, // because of the custom use i18next plugin
// debug: true,
// saveMissing: true, // to not saveMissing to true for production
}Here you'll find more information and an example on how this looks like.
There is also the possibility to cache the translations locally thanks to i18next-chained-backend. Here you can find more information about this option.
If you're deploying your Next.js app in a serverless environment, consider to use the second possibility... More information about the reason for this can be found here.
POSSIBILITY 2: bundle translations and keep in sync
If you're not sure, choose this way.
This option will not change the configuration of your "normal" next-i18next project:
// next-i18next.config.js
module.exports = {
i18n: {
defaultLocale: 'en',
locales: ['en', 'de'],
}
}Just download or sync your local translations before "deploying" your app.
Here you'll find more information and an example on how this looks like.
You can, for example, run an npm script (or similar), which will use the cli to download the translations from Locize into the appropriate folder next-i18next is looking in to (i.e. ./public/locales). This way the translations are bundled in your app and you will not generate any CDN downloads during runtime.
i.e. locize download --project-id=d3b405cf-2532-46ae-adb8-99e88d876733 --ver=latest --clean=true --path=./public/locales
Best approach: optimized for server and client side
Here you'll find a blog post on how to best use next-i18next with client side translation download and SEO optimization.
There's also an i18next crash course video.
🎉🥳 Conclusion 🎊🎁
As you see i18n is also important on server side.
I hope you’ve learned a few new things about server side internationalization and modern localization workflows.
So if you want to take your i18n topic to the next level, it's worth to try i18next and also Locize.
👍